스프링 테스트 코드로 마이그레이션 코드 생성하는 방법

스프링 테스트 코드로 마이그레이션 코드 생성하는 방법
dev-tips/hibernate.hbm2ddl.auto 위험 헷지.md at master · HomoEfficio/dev-tips
개발하다 마주쳤던 작은 문제들과 해결 방법 정리. Contribute to HomoEfficio/dev-tips development by creating an account on GitHub.

스프링에서 spring.jpa.hibernate.ddl-auto=update 설정으로 Entity 객체의 변화에 따른 데이터베이스 스키마를 반영시키고 있습니다.

spring:
  jpa:
    # TODO: 실제 배포할 때 잘 설정해주자.
    database: postgresql
    ...
    hibernate:
      # TODO: 당분간은..
      ddl-auto: update
    ...
application-production.yaml

spring.jpa.hibernate.ddl-auto=update설정의 위험성은 서비스 개발 초기 단계에서 인지 됐습니다. migration 기능에 필요성을 느꼈지만, 스프링 migration 경험 부족으로 서비스 오픈 이후에 챙기기로 했습니다. 서비스가 오픈되고 관련 기능을 찾아보았지만, 만족스러운 결과를 얻지 못했습니다.

자동으로 migration 코드가 생성되는 기능이 필요했습니다. 기존에 회사에서 운영하던 서비스는 django로 개발되었고 grails를 사용해본 경험을 기반으로 스프링에도 비슷한 기능이 있으리라 생각했지만 아쉽게 그러한 기능은 찾을 수 없었습니다. 직접 쿼리를 작성하고 Flyway로 migration을 해도 되지만 migration 코드가 올바른지 테스트 할 수 있는 방법이 필요했고 기본 spring jpa naming convention을 사용하고 싶었습니다.

https://www.baeldung.com/spring-data-jpa-generate-db-schema

스키마 생성 쿼리 파일을 만드는 기능을 활용해 문제를 해결하기로 했습니다. 해당 기능을 테스트해 보며 디버거로 내부 로직을 보면 힌트를 얻을 수 있을 것 같았습니다.

@DataJpaTest(
    showSql = true,
    properties = [
        "spring.jpa.properties.javax.persistence.schema-generation.scripts.action=create",
        "spring.jpa.properties.javax.persistence.schema-generation.scripts.create-target=create.sql",
        "spring.jpa.properties.javax.persistence.schema-generation.scripts.create-source=metadata",
   ],
)
@ContextConfiguration(classes = [BookConfiguration::class])
class SchemaGenerateTests {

    @Test
    fun `test generate create sql`() {

    }

}
src/test/kotlin/com/example/book/SchemaGenerateTests.kt
@EntityScan
@EnableJpaRepositories
@Configuration
class BookConfiguration
src/main/kotlin/com/example/book/BookConfiguration.kt
@Entity
class Book(
    var title: String,

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    var id: Long = 0L
)
src/main/kotlin/com/example/book/Book.kt
create table book (id  bigserial not null, title varchar(255), primary key (id))
create.sql

스키마 생성 파일이 정상적으로 생성되었습니다. 컬럼을 추가 할 수 있는지 테스트해 보기 위해 Book 엔티티에 author를 추가하고 테스트를 실행했습니다.

@Entity
class Book(
    var title: String,

    var author: String,

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    var id: Long = 0L
)
src/main/kotlin/com/example/book/Book.kt
create table book (id  bigserial not null, title varchar(255), primary key (id))
create table book (id  bigserial not null, author varchar(255), title varchar(255), primary key (id))
create.sql

컬럼 추가 기능은 지원하지 않는 것 같습니다. spring.jpa.show-sql=true 일 때 create table.., alter table.. 쿼리가 로그에 출력되는 것을 보면 hibernate에서 쿼리문을 생성하고 반영을 하는 것으로 추측되어 내부 코드를 찾아보았습니다. 쿼리문을 생성하는 기능은 Dialect 인터페이스 구현체에서 하기에 PostgreSQL81Dialect.java 코드를 찾아보았습니다. 새로운 컬럼이 생기는 로직을 탐색하기 위해 PostgreSQL81Dialect.java::getAddColumnString 함수에 브레이크포인트를 찍고 디버그 모드로 테스트 코드를 실행했습니다.

PostgreSQL81Dialect.java::getAddColumnString 함수 호출 스택을 탐색하고  SchemaManagementToolCoordinator.java를 찾을 수 있었습니다.

performDatabaseAction에서 스키마가 생성 및 반영되는 것을 확인하고 performStringAction 함수와  spring.jpa.properties.javax.persistence.schema-generation.scripts.action=create가 연관되었다는 것을 알 수 있었습니다. 추가로 spring.jpa.properties.javax.persistence.schema-generation.scripts.action=create으로 설정하게 되면 hibernate ddl auto 기능이 작동하지 않는다는 것도 SchemaManagementToolCoordinator.java에서 확인할 수 있었습니다. 스키마 반영을 위해서는 spring.jpa.properties.javax.persistence.schema-generation.database.action 값도 설정해줘야 합니다.  ...<script|database>.action=에는 create뿐만 아니라 update, validate도 있다는 것을 확인하고 테스트 코드를 수정했습니다.

String HBM2DDL_DATABASE_ACTION = "javax.persistence.schema-generation.database.action";
org.hibernate.cfg.AvailableSettings.java
@DataJpaTest(
    showSql = true,
    properties = [
        "spring.jpa.properties.javax.persistence.schema-generation.database.action=update",
        "spring.jpa.properties.javax.persistence.schema-generation.scripts.action=update",
        "spring.jpa.properties.javax.persistence.schema-generation.scripts.create-target=update.sql",
        "spring.jpa.properties.javax.persistence.schema-generation.scripts.create-source=metadata",
        "spring.jpa.properties.hibernate.hbm2ddl.delimiter=;",
        "spring.jpa.database=postgresql",
   ],
)
@AutoConfigureTestDatabase(replace = NONE)
@ContextConfiguration(
    classes = [BookConfiguration::class],
)
class SchemaGenerateTests {
    ...
}
src/test/kotlin/com/example/book/SchemaGenerateTests.kt
alter table if exists book add column author varchar(255);

테스트를 실행하니 컬럼 추가 쿼리가 생성되었습니다. migration 테스트를 위해 Testcontainer를 설정 후 spring.jpa.properties.javax.persistence.schema-generation.database.action 설정을 validate로 변경하고 다시 테스트를 실행했습니다.

@Testcontainers
@DataJpaTest(
    showSql = true,
    properties = [
        "spring.jpa.properties.javax.persistence.schema-generation.database.action=validate",
        "spring.jpa.properties.javax.persistence.schema-generation.scripts.action=update",
        "spring.jpa.properties.javax.persistence.schema-generation.scripts.create-target=src/main/resources/db/migration/update.sql",
        "spring.jpa.properties.javax.persistence.schema-generation.scripts.create-source=metadata",
        "spring.jpa.properties.hibernate.hbm2ddl.delimiter=;",
        "spring.jpa.database=postgresql",
        "spring.flyway.enabled=true",
   ],
)
@AutoConfigureTestDatabase(replace = NONE)
@ContextConfiguration(
    classes = [BookConfiguration::class],
    initializers = [SchemaGenerateTests.Initializer::class],
)
class SchemaGenerateTests {

    companion object {
        @JvmStatic
        @Container
        val container: PostgreSQLContainer<Nothing> = PostgreSQLContainer<Nothing>("postgres:latest")
    }
src/test/kotlin/com/example/book/SchemaGenerateTests.kt
Caused by: org.hibernate.tool.schema.spi.SchemaManagementException: Schema-validation: missing table [book]
	at org.hibernate.tool.schema.internal.AbstractSchemaValidator.validateTable(AbstractSchemaValidator.java:121)
	at org.hibernate.tool.schema.internal.GroupedSchemaValidatorImpl.validateTables(GroupedSchemaValidatorImpl.java:42)
	at org.hibernate.tool.schema.internal.AbstractSchemaValidator.performValidation(AbstractSchemaValidator.java:89)
	at org.hibernate.tool.schema.internal.AbstractSchemaValidator.doValidation(AbstractSchemaValidator.java:68)
	at org.hibernate.tool.schema.spi.SchemaManagementToolCoordinator.performDatabaseAction(SchemaManagementToolCoordinator.java:192)
	at org.hibernate.tool.schema.spi.SchemaManagementToolCoordinator.process(SchemaManagementToolCoordinator.java:73)
	at org.hibernate.internal.SessionFactoryImpl.<init>(SessionFactoryImpl.java:318)
	at org.hibernate.boot.internal.SessionFactoryBuilderImpl.build(SessionFactoryBuilderImpl.java:468)
	at org.hibernate.jpa.boot.internal.EntityManagerFactoryBuilderImpl.build(EntityManagerFactoryBuilderImpl.java:1259)
	at org.springframework.orm.jpa.vendor.SpringHibernateJpaPersistenceProvider.createContainerEntityManagerFactory(SpringHibernateJpaPersistenceProvider.java:58)
	at org.springframework.orm.jpa.LocalContainerEntityManagerFactoryBean.createNativeEntityManagerFactory(LocalContainerEntityManagerFactoryBean.java:365)
	at org.springframework.orm.jpa.AbstractEntityManagerFactoryBean.buildNativeEntityManagerFactory(AbstractEntityManagerFactoryBean.java:409)
	... 112 more
테스트 실패

예상한 대로 hibernate validate 과정에서 오류가 발생했습니다. 생성된 update.sql 파일명을 flyway migration 파일명 형식으로 변경해주었습니다.

hibernate validate까지 완료 후 테스트에 통과하였습니다. 다시 Book 엔티티에 author 컬럼을 추가하고 테스트를 실행했습니다.

Caused by: org.hibernate.tool.schema.spi.SchemaManagementException: Schema-validation: missing column [author] in table [book]
	at org.hibernate.tool.schema.internal.AbstractSchemaValidator.validateTable(AbstractSchemaValidator.java:136)
	at org.hibernate.tool.schema.internal.GroupedSchemaValidatorImpl.validateTables(GroupedSchemaValidatorImpl.java:42)
	at org.hibernate.tool.schema.internal.AbstractSchemaValidator.performValidation(AbstractSchemaValidator.java:89)
	at org.hibernate.tool.schema.internal.AbstractSchemaValidator.doValidation(AbstractSchemaValidator.java:68)
	at org.hibernate.tool.schema.spi.SchemaManagementToolCoordinator.performDatabaseAction(SchemaManagementToolCoordinator.java:192)
	at org.hibernate.tool.schema.spi.SchemaManagementToolCoordinator.process(SchemaManagementToolCoordinator.java:73)
	at org.hibernate.internal.SessionFactoryImpl.<init>(SessionFactoryImpl.java:318)
	at org.hibernate.boot.internal.SessionFactoryBuilderImpl.build(SessionFactoryBuilderImpl.java:468)
	at org.hibernate.jpa.boot.internal.EntityManagerFactoryBuilderImpl.build(EntityManagerFactoryBuilderImpl.java:1259)
	at org.springframework.orm.jpa.vendor.SpringHibernateJpaPersistenceProvider.createContainerEntityManagerFactory(SpringHibernateJpaPersistenceProvider.java:58)
	at org.springframework.orm.jpa.LocalContainerEntityManagerFactoryBean.createNativeEntityManagerFactory(LocalContainerEntityManagerFactoryBean.java:365)
	at org.springframework.orm.jpa.AbstractEntityManagerFactoryBean.buildNativeEntityManagerFactory(AbstractEntityManagerFactoryBean.java:409)
	... 112 more
alter table if exists book add column author varchar(255);
src/main/resources/db/migration/update.sql

hibernate validate에서 오류가 발생해 테스트는 통과하지 못했지만 update.sql가 생성되었습니다. 새로 생성된 update.sql도 flyway migration 파일명 형식으로 변환하고 테스트를 실행하니 정상적으로 통과되었습니다.

.
└── src
    ├── main
    │   ├── kotlin
    │   │   └── com
    │   │       └── example
    │   │           └── book
    │   │               ├── Book.kt
    │   │               └── BookConfiguration.kt
    │   └── resources
    │       ├── application.properties
    │       └── db
    │           └── migration
    │               ├── V1__create_table_book.sql
    │               └── V2__alter_table_book_add_column_author.sql
    └── test
        └── kotlin
            └── com
                └── example
                    └── book
                        └── SchemaGenerateTests.kt
tree
GitHub - Park9eon/hibernate-with-flyway
Contribute to Park9eon/hibernate-with-flyway development by creating an account on GitHub.

테스트를 실행하면 변경사항과 상관없이 update.sql 파일이 생성되는 이슈가 있긴 합니다. 처음에는 annotation processing을 이용해 migration 파일을 생성해주는 gradle 플러그인을 만들어야 하나, 번거롭지만 sql을 매번 작성해야 하나 고민이 깊었습니다. 하지만 만족스러운 방법으로 안전한 migration 파일을 생성할 수 있게 되어 기쁩니다. 정석적인 방법은 아닌 것 같지만 제가 찾아봤을 때 자동으로 migration 코드를 생성하는 기능은 찾을 수 없었습니다. 그래서  Django migration기능만 이용할까도 고민했습니다.

자바는 IntelliJ 디버거로 내부 코드를 탐색하기 편리하게 되어 있어 늘 궁금한 기능이 있다면 디버거로 탐색하는 것을 즐깁니다. 그래서 이번 시도도 무척 흥미롭고 즐거웠습니다. 더 좋은 방법이 있다면 알려주세요.