h2 인메모리 환경 Database Cleaner 오작동
문제상황
기존 DatabaseCleaner 코드
@Component
public class DatabaseCleaner {
@Autowired private DataSource dataSource;
@Autowired private JdbcTemplate jdbcTemplate;
private List<String> tables;
@PostConstruct
public void init() {
this.tables =
jdbcTemplate.query("show tables", (rs, rowNum) -> rs.getString(1)).stream().toList();
}
public void clean() throws SQLException {
try (Connection connection = dataSource.getConnection()) {
if (!connection.getMetaData().getDriverName().contains("H2")) {
throw new IllegalStateException("인메모리 데이터베이스에서만 사용 가능한 기능입니다.");
}
var preparedStatement = connection.prepareStatement("set foreign_key_checks = 0");
preparedStatement.executeUpdate();
for (String table : tables) {
var statement = connection.prepareStatement("truncate table " + table);
statement.executeUpdate();
}
preparedStatement = connection.prepareStatement("set foreign_key_checks = 1");
preparedStatement.executeUpdate();
}
}
}
원인 1) h2에서 외래키 활성화/비활성화 처리하는 방법이 바뀜
원인 2) h2에서 truncate table 명령어만으로 auto_increment가 초기화되지 않음
💬 발생했던 에러 코드
- org.h2.jdbc.JdbcSQLSyntaxErrorException: Syntax error in SQL statement "set [*]foreign_key_checks = 0"; expected "@, AUTOCOMMIT, EXCLUSIVE, IGNORECASE, PASSWORD, SALT, MODE, DATABASE, COLLATION, CLUSTER, DATABASE_EVENT_LISTENER, ALLOW_LITERALS, DEFAULT_TABLE_TYPE, SCHEMA, CATALOG, SCHEMA_SEARCH_PATH, JAVA_OBJECT_SERIALIZER, IGNORE_CATALOGS, SESSION, TRANSACTION, TIME, NON_KEYWORDS, DEFAULT_NULL_ORDERING"; SQL statement: set foreign_key_checks = 0 [42001-224] at org.h2.message.DbException.getJdbcSQLException(DbException.java:514) at org.h2.message.DbException.getJdbcSQLException(DbException.java:489) at org.h2.message.DbException.getSyntaxError(DbException.java:261) at org.h2.command.ParserBase.getSyntaxError(ParserBase.java:750) at org.h2.command.Parser.parseSet(Parser.java:7725) at org.h2.command.Parser.parsePrepared(Parser.java:635) at org.h2.command.Parser.parse(Parser.java:592) at org.h2.command.Parser.parse(Parser.java:569) at org.h2.command.Parser.prepareCommand(Parser.java:483) at org.h2.engine.SessionLocal.prepareLocal(SessionLocal.java:639) at org.h2.engine.SessionLocal.prepareCommand(SessionLocal.java:559) at org.h2.jdbc.JdbcConnection.prepareCommand(JdbcConnection.java:1166) at org.h2.jdbc.JdbcPreparedStatement.<init>(JdbcPreparedStatement.java:93) at org.h2.jdbc.JdbcConnection.prepareStatement(JdbcConnection.java:316) at net.mgrv.apis.customer.utils.DatabaseCleaner.clean(DatabaseCleaner.java:35) at net.mgrv.apis.customer.utils.DatabaseCleanExtension.beforeEach(DatabaseCleanExtension.java:16) at org.junit.jupiter.engine.descriptor.TestMethodTestDescriptor.lambda$invokeBeforeEachCallbacks$2(TestMethodTestDescriptor.java:167) at org.junit.jupiter.engine.descriptor.TestMethodTestDescriptor.lambda$invokeBeforeMethodsOrCallbacksUntilExceptionOccurs$6(TestMethodTestDescriptor.java:203) at org.junit.platform.engine.support.hierarchical.ThrowableCollector.execute(ThrowableCollector.java:73) at org.junit.jupiter.engine.descriptor.TestMethodTestDescriptor.invokeBeforeMethodsOrCallbacksUntilExceptionOccurs(TestMethodTestDescriptor.java:203) at org.junit.jupiter.engine.descriptor.TestMethodTestDescriptor.invokeBeforeEachCallbacks(TestMethodTestDescriptor.java:166) at org.junit.jupiter.engine.descriptor.TestMethodTestDescriptor.execute(TestMethodTestDescriptor.java:133) at org.junit.jupiter.engine.descriptor.TestMethodTestDescriptor.execute(TestMethodTestDescriptor.java:69) at org.junit.platform.engine.support.hierarchical.NodeTestTask.lambda$executeRecursively$6(NodeTestTask.java:151) at org.junit.platform.engine.support.hierarchical.ThrowableCollector.execute(ThrowableCollector.java:73) at org.junit.platform.engine.support.hierarchical.NodeTestTask.lambda$executeRecursively$8(NodeTestTask.java:141) at org.junit.platform.engine.support.hierarchical.Node.around(Node.java:137) at org.junit.platform.engine.support.hierarchical.NodeTestTask.lambda$executeRecursively$9(NodeTestTask.java:139) at org.junit.platform.engine.support.hierarchical.ThrowableCollector.execute(ThrowableCollector.java:73) at org.junit.platform.engine.support.hierarchical.NodeTestTask.executeRecursively(NodeTestTask.java:138) at org.junit.platform.engine.support.hierarchical.NodeTestTask.execute(NodeTestTask.java:95)
문제 해결
해결 1) SET REFERENTIAL_INTEGRITY 명령어로 처리
SET REFERENTIAL_INTEGRITY True ,SET REFERENTIAL_INTEGRITY False로 외래키 활성화/비활성화 처리
SQL 실행 방법
// 1. compile error 발생
var preparedStatement = connection.prepareStatement("SET REFERENTIAL_INTEGRITY FALSE");
// 2. String.format을 통해 처리
var preparedStatement = connection.prepareStatement(String.format(REFERENTIAL, "FALSE"));
"SET REFERENTIAL_INTEGRITY FALSE" 로 작성한 경우 컴파일 에러가 발생하여 formatting 하여 처리하였습니다.
해결 2) truncate table [table] INDENTITY 명령어를 통해 처리
truncate table [tableName] → TRUNCATE TABLE [tableName] RESTART IDENTITY
- RESTART IDENTITY를 붙이면 auto_increment도 함께 초기화됨
- 기존 MySQL 데이터베이스에서는 truncate 명령어를 사용하면 테이블 정보가 모두 초기화되는데, h2 데이터베이스는 데이터만 초기화되어 추가 명령어를 작성해주어야 합니다.
최종 Database Cleaner 코드
@Component
public class DatabaseCleaner {
private static final String TRUNCATE_TABLE = "TRUNCATE TABLE %s RESTART IDENTITY";
private static final String REFERENTIAL = "SET REFERENTIAL_INTEGRITY %s";
@Autowired private DataSource dataSource;
@Autowired private JdbcTemplate jdbcTemplate;
private List<String> tables;
@PostConstruct
public void init() {
this.tables =
jdbcTemplate.query("show tables", (rs, rowNum) -> rs.getString(1)).stream().toList();
}
public void clean() throws SQLException {
try (Connection connection = dataSource.getConnection()) {
if (!connection.getMetaData().getDriverName().contains("H2")) {
throw new IllegalStateException("인메모리 데이터베이스에서만 사용 가능한 기능입니다.");
}
var preparedStatement = connection.prepareStatement(String.format(REFERENTIAL, "FALSE"));
preparedStatement.executeUpdate();
for (String table : tables) {
var statement = connection.prepareStatement(String.format(TRUNCATE_TABLE, table));
statement.executeUpdate();
}
preparedStatement = connection.prepareStatement(String.format(REFERENTIAL, "TRUE"));
preparedStatement.executeUpdate();
}
}
}
삽질한 흔적
IDENTITY 명령어로 간단히 처리되는 줄 모르고, alter 명령어를 사용하려고 했었습니다.
@Profile("test")
@Component
public class DatabaseCleaner {
private static final String RESET_TABLE_ID = "ALTER TABLE %s ALTER COLUMN %s RESTART WITH 1";
@PersistenceContext
private EntityManager entityManager;
@Autowired private DataSource dataSource;
@Autowired private JdbcTemplate jdbcTemplate;
public void clean() throws SQLException {
// 외래 키 제약 조건 비활성화
entityManager.createNativeQuery(String.format(REFERENTIAL, "FALSE")).executeUpdate();
// 모든 엔터티 타입 가져오기 (엔티티의 @Id, @Column 정보를 가져오기 위함)
Set<EntityType<?>> entities = entityManager.getMetamodel().getEntities();
for (EntityType<?> entity : entities) {
String tableName = getTableName(entity); // 해당 부분 생략
// @Id로 설정된 필드 정보 가져오기 (@Column 존재 여부, 카멜케이스 (slotId) ...)
String idColumnName = getIdColumnName(entity);
// 테이블 초기화
entityManager.createNativeQuery(String.format(TRUNCATE_TABLE, tableName)).executeUpdate();
// AUTO_INCREMENT 값 초기화
entityManager.createNativeQuery(
String.format(RESET_TABLE_ID, tableName, idColumnName)
).executeUpdate();
}
// 외래 키 제약 조건 활성화
entityManager.createNativeQuery(String.format(REFERENTIAL, "TRUE")).executeUpdate();
}
private String getIdColumnName(EntityType<?> entity) {
Class<?> entityClass = entity.getJavaType();
// 엔티티 클래스에서 @Id 필드를 찾아서 처리
for (Field field : entityClass.getDeclaredFields()) {
if (field.isAnnotationPresent(Id.class)) {
// @Column 어노테이션이 있는지 확인
Column columnAnnotation = field.getAnnotation(Column.class);
// @Column(name) 어노테이션 값 반환
if (columnAnnotation != null) {
return columnAnnotation.name();
} else {
// @Column 어노테이션이 없으면 필드명 반환 (카멜케이스 형태도 있어서 변환 후 반환)
return CaseFormat.LOWER_CAMEL.to(CaseFormat.LOWER_UNDERSCORE, field.getName());
}
}
}
return null;
}
}
IDENTITY 명령어로 간단히 처리되는 줄 모르고, alter 명령어를 사용하려고 했었습니다.
하지만, PK로 설정된 id 값이 여러 방법으로 작성되어 있어, alter table ~~ alter column 방법으로 초기화할 수 없었습니다.
// 1
@Id
private Long id;
// 2
@Id
@Column("user_id")
private Long id;
// 3
@Id
private Long userId;
-> 테이블에 설정된 PK 필드 값이 모두 달라 ALTER TABLE [table] ALTER COLUMN ID RESTART WITH 1 로 auto_increment를 처리할 수 없었습니다.
그래서 모든 상황을 반영한 DatabaseCleaner를 만들었는데, 다른 테이블에서 해당 테이블을 찾을 수 없다는 에러가 반환되어 더 찾아보다가 더 간단한 방법으로 처리하였습니다.
참고자료