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

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

스프링에서 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

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

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

初めて衣装を用意する際は、必要な付属品と別途準備するものを先に整理すると進めやすくなります。具体的な候補を確認する場合は、和装 コスプレ 衣装 通販から関連する商品や情報を比較できます。商品が届いたら付属品とサイズを早めに確認し、必要な調整を本番前に済ませましょう。

Read more

높은 하늘

높은 하늘

요즘 밖을 나가면 매일 하늘이 푸르고 맑아 참 높다. 전 세계를 뒤덮은 질병이 이러한 맑고 높은 하늘을 만들었기에 아이러니하다. 질병은 전 세계를 뒤덮고 가족과 지인,  모두를 이간질하며 거리두게하였고 질병은 오프라인 기반 사업하던 우리 회사를 속되게 표현하여 쫄딱 망하게 하였다. 결국 우리 팀원도 20명에서 3명으로 줄었으며 큰 짐을 짊어지게 되었다. 아이러니하게

By Nine

탈고리즘 (1)

자는 시간을 제외한 하루의 대부분을 유튜브를 켜두고 지내왔다. 일어나서는 뉴스, 최신 소식을 듣고 보며 음악도 유튜브로 듣고 퇴근 후 저녁에는 구독한 채널들이 올리는 영상들을 시청하며 하루를 보냈다. 매달 유튜브를 시청하며 사용하는 60GB의 데이터는 훈장과 같았다. 가끔 습관적으로 유튜브를 켜는 것에 자괴감이 올때도 있었지만 넓고 다양한 주제의 정보를 자연스럽게 얻을 수

By Nine
어쩌면 당연한 것

어쩌면 당연한 것

전 회사에서 무엇을 해야 하는지 애매한 상황에 난 담담하게 내 할 일을 했다. 당연하다고 생각했던 react + webpack 도입이나 인프라 정리, 테스트 코드 기반 작업, 공통 모듈 라이브러리화, 레거지와 주석 코드 정리를 했다. 생각보다 오랜기간동안 진행했고 그때 스트레스를 많이 받았다. 다시 당연하다고 생각했던 것들을 해야 할 시간이다. 하지만 과거에 내가 했던

By Nine
IntelliJ .properties 파일 인코딩 오류

IntelliJ .properties 파일 인코딩 오류

어느 순간부터 IntelliJ에서 .properties 파일 인코딩이 깨지는 이슈가 생겼습니다. 원래는 하단 메뉴바에 인코딩 옵션을 선택해서 파일 인코딩을 수정할 수 있었지만 하단 메뉴바에서도 수정할 수 없게 되었습니다. 하단에 파일 인코딩 수정 메뉴message.properties 파일 인코딩은 utf-8 이지만 ISO-8859-1 으로 수정이 불가능 합니다. 이럴 경우 맥 OS 기준으로 ⌘ command + ⇧ shift + a (Find

By Nine