DDL이 코드 밖에서 온다면, 테스트 DB 구성을 빌드 안에 선언한다

DDL이 코드 밖에 있었다
전제 하나부터 분명히 하겠습니다. 저희 조직에서 스키마의 진실은 애플리케이션 코드가 아닙니다. Hibernate가 엔티티를 보고 테이블을 파생시키는 것이 아니라, Liquibase changelog가 스키마를 결정합니다. 그래서 테스트에서 ddl-auto로 스키마를 만들어 쓰는 길은 애초에 맞지 않습니다. 지금 이 시점의 전체 스키마가 무엇인지는 코드 어디에도 통째로 적혀 있지 않고, changelog를 다 재생해야만 나옵니다.
그런데 더 큰 문제는 출처가 코드가 아니라는 사실 자체가 아니었습니다. 문제는 그 DDL이 세우는 테스트 데이터베이스의 구성이 빌드 어디에도 적혀 있지 않다는 점이었습니다. 코드는 A 모듈이 필요하면 A 모듈을 의존성으로 선언하고, 그 선언은 빌드에 남습니다. 하지만 스키마는 흔히 한 덩어리 마이그레이션 폴더나 공유 데이터베이스, 테스트 초기화 스크립트 어딘가에서 옵니다. 그러면 어떤 테스트 데이터베이스가 어떤 스키마들로 서 있어야 하는지, B 모듈의 DDL이 A 모듈의 테이블을 전제로 한다는 사실이 빌드 어디에도 드러나지 않습니다. 테스트 데이터베이스의 구성은 사람의 기억과 실행 순서 관례에만 남습니다.
이 관례가 어긋나는 순간은 생각보다 평범하게 옵니다. B 모듈 테스트를 단독으로 돌리면 relation payment_account does not exist로 깨집니다. A 스키마를 먼저 올려야 한다는 사실이 코드 어디에도 없기 때문입니다. 그 지식은 위키 한 구석이나 먼저 당해본 사람의 머릿속에만 있습니다. CI가 프로젝트를 병렬로 돌리다 순서가 흔들리면 간헐적으로만 실패하고, 그마저 flaky 재시도로 덮여 원인이 오래 숨습니다.
지금 저희가 도착한 답은 그 DDL이 세우는 테스트 데이터베이스의 구성을 빌드가 알게 만드는 것입니다. 하지만 이 답은 처음부터 있던 게 아니라, 몇 번의 전환을 거쳐 나온 자리였습니다. 저희가 어떻게 여기까지 왔는지를 시간순으로 짚으면, 왜 이 구조가 필요했는지가 더 분명해집니다.

Liquibase는 원래 스키마 관리 도구였다
시작은 테스트가 아니었습니다. Liquibase는 저희에게 오래전부터 운영 마이그레이션 도구였습니다. changelog를 하나씩 쌓아 운영 데이터베이스의 스키마를 앞으로 밀고 나가는 용도였습니다. 이 changelog 더미는 몇 해에 걸쳐 두꺼워졌습니다.
이 과정에서 자연스럽게 굳은 전제가 하나 있습니다. 스키마의 진실은 엔티티가 아니라 changelog에 있다는 것입니다. 어떤 테이블이 지금 어떤 모양인지 알고 싶으면 엔티티 클래스를 보는 게 아니라 changelog를 따라가야 했습니다. 엔티티는 그 스키마를 자바 쪽에서 바라보는 한쪽 창일 뿐이고, 실제로 데이터베이스에 무엇이 서 있는지는 changelog가 정합니다.
이 시절에 테스트는 이 진실과 거의 무관하게 돌았습니다. 스키마 관리와 테스트는 별개의 세계였고, 둘을 잇는 다리는 없었습니다. 그 다리를 처음 놓아보려 한 것이 다음 이야기입니다.
첫 전환, changelog를 테스트로 가져오다
계기는 단순했습니다. 통합 테스트를 changelog가 만든 실제 스키마 위에서 돌리고 싶었습니다. 엔티티에서 파생한 스키마가 아니라, 운영이 쓰는 그 changelog에서 온 스키마여야 테스트가 의미를 가졌습니다.
그래서 처음 만든 것은 여러 모듈의 changelog를 그러모아 하나의 DDL 파일로 합치는 Gradle 플러그인이었습니다. 다른 모듈의 changelog를 의존성으로 끌어와, 그것들을 이어 붙여 schema.sql 하나를 만드는 방식이었습니다. 그 첫 시도의 뼈대가 이렇게 생겼습니다.
dependencies {
liquibaseChangelog(project(":my-domain")) // 다른 모듈의 changelog를 끌어온다
}
tasks.register<FileMergeTask>("mergeSchema") {
// 끌어온 changelog들을 이어 붙여 schema.sql 하나로 만든다
output = layout.buildDirectory.file("schema.sql")
}liquibaseChangelog(project(":my-domain"))으로 다른 모듈의 changelog를 산출물처럼 끌어오고, FileMergeTask로 그것들을 이어 붙여 정적인 schema.sql을 만듭니다. 그러면 Spring 통합 테스트가 그 schema.sql을 읽어 스키마를 세웁니다. Testcontainers도, 실제 마이그레이션 실행도 없었습니다. 정적인 SQL 파일 하나로 테스트의 스키마를 세우는 것, 그게 첫 시도였습니다.
이걸로 얻은 게 분명히 있었습니다. 테스트가 보는 스키마의 출처가 드디어 엔티티가 아니라 changelog가 되었습니다. 하지만 한계도 그만큼 분명했습니다. 테스트가 실제로 읽는 것은 여전히 합쳐진 정적 SQL 파일이었습니다. 실제 데이터베이스 엔진 위에서 실제 마이그레이션 경로를 밟아본 것이 아니라, 그 경로의 결과물을 한 번 요약해 옮겨 적은 파일을 읽는 것이었습니다. changelog가 순서대로 적용되며 만드는 중간 상태나 제약조건 검사, 엔진 고유의 반응은 이 방식으로 재현되지 않았습니다.
두 번째 전환, 실제 DB에 적용하다
그다음 한 것은 이 간극을 메우는 일이었습니다. 정적인 schema.sql을 읽는 대신, 프로덕션과 같은 엔진을 Testcontainers로 매번 띄우고, 그 안의 실제 데이터베이스에 Liquibase로 DDL을 적용했습니다.
바뀐 것은 스키마를 세우는 경로였습니다. 이전에는 합쳐진 SQL 파일을 한 번에 읽어 스키마를 세웠다면, 이제는 컨테이너 안 실제 엔진에 changelog를 순서대로 적용해 스키마를 세웁니다. 테스트가 밟는 길이 프로덕션이 밟는 길과 같아진 것입니다. 엔진 고유의 예약어나 컬럼 기본값, 마이그레이션이 순서대로 적용되며 걸리는 제약조건 검사까지, 이전 방식이 놓치던 것들을 실제로 검증할 수 있게 되었습니다.
이 전환의 한계는 방향에 있지 않았습니다. 방향은 오히려 맞았습니다. 문제는 비용이었습니다. 컨테이너를 매번 띄우고, 그 안에서 마이그레이션 전체를 매번 처음부터 재생하는 데 시간이 들었습니다. 다만 이 시점에서는 그 비용이 아직 참을 만했습니다. 테스트가 그렇게 많지 않았기 때문입니다. 비용이 진짜 문제로 드러나는 것은 그다음이었습니다.
정확해진 테스트가 많아지자 비용이 문제가 됐다
여기서 흔한 오해를 하나 짚고 싶습니다. 저희가 Testcontainers 방식을 버린 것이 아닙니다. 오히려 반대입니다. 실제 엔진에 실제 마이그레이션을 적용하는 이 방식이 정확했기 때문에, 그 위에 쌓는 데이터베이스 테스트가 계속 늘었습니다.
효과가 있으니 사람들이 더 썼고, 더 쓰니 테스트가 많아졌습니다. 그리고 테스트가 많아지자, 그동안 참을 만하던 비용이 병목으로 바뀌었습니다. 테스트 하나하나가 컨테이너를 띄우고 마이그레이션 전체를 재생하는 시간을 물고 있는데, 그런 테스트가 수백 개가 되면 로컬에서 한 번 돌리는 것도, CI 한 회차도 느려집니다. 컨테이너 기동만 테스트당 몇 초씩 들고, changelog가 수백 개면 매번 그 전체를 다시 재생하는 것이 가장 큰 병목이 됩니다.
전환점은 여기였습니다. 비용이 문제가 됐다는 사실은 이 방식이 틀렸다는 신호가 아니라, 이 방식이 옳아서 널리 쓰이게 됐다는 신호였습니다. 그러니 저희가 풀어야 할 문제는 방향을 바꾸는 것이 아니라, 이 정확한 테스트를 감당 가능한 비용으로 계속 굴리는 것이었습니다. 그 답으로 구조를 다시 짠 것이 지금의 v2입니다.
v2, 테스트 DB 구성을 빌드 안에 선언하고 격리한다
v2에서 저희가 세운 축은 앞에서 말한 그 한 문장입니다. 어떤 테스트 데이터베이스를 어떤 스키마들로 세울지를 빌드가 직접 말하게 한다. 위키나 초기화 스크립트에 흩어져 있던 그 구성을 root 빌드의 선언 하나로 끌어올린 것입니다.
그래서 v2에서는 root 빌드가 테스트용 데이터베이스 variant를 정의하고, 그 variant가 자신을 어떤 스키마 프로젝트들로 세울지 나열합니다.
실제 DSL은 아직 다듬는 중이라, 뼈대만 의사코드로 옮기면 이렇습니다.
# root 빌드
testDatabase("testdb") {
schemas = [ project(":payment"), project(":order") ] // 이 테스트 DB를 어떤 스키마로 세울지
engine = mysql
init = createDatabaseAndUser("testdb") // 이름, 계정, 권한
}v1과 v2는 둘 다 스키마 모듈을 project(...)로 가리킵니다. 겉으로 비슷해 보이지만 다루는 층위가 다릅니다. v1은 소비 모듈이 다른 모듈의 changelog를 자기 의존성으로 가져오는 선언이었습니다. v2는 root에 정의된 테스트 DB variant가 자신을 구성할 스키마 프로젝트를 나열하는 선언입니다. v1이 '모듈이 무엇을 가져오나'였다면 v2는 '이 테스트 DB를 무엇으로 세우나'입니다.
여기서 중요한 것은 :order가 :payment를 의존성으로 끌어온다는 점이 아닙니다. testdb라는 테스트용 데이터베이스가 :payment와 :order 스키마로 구성된다고 root 빌드가 직접 말한다는 점입니다. 데이터베이스 이름, 초기화, 계정과 권한, 어떤 스키마 프로젝트를 올릴지까지 한 variant 안에 모입니다. 그래서 테스트 DB 구성은 테스트 코드나 위키나 공유 DB 상태에 흩어지지 않고, 빌드에서 검토 가능한 선언으로 남습니다.

조립과 격리는 선언된 variant에서 나온다
variant가 나열한 스키마 조합을 실제로 세우는 것이 조립입니다. 통합 테스트가 여러 모듈을 함께 검증해야 하면, 플러그인이 variant에 나열된 각 스키마 프로젝트의 changelog를 하나의 master changelog로 엮어 그 전용 데이터베이스에 적용합니다. 헥사고날에서 여러 모듈의 스키마가 결국 한 물리 데이터베이스에 합쳐지는 현실을 그대로 반영합니다.
격리는 그 조립 결과를 놓는 위치에서 얻습니다. variant마다 전용 데이터베이스를 쓰고, 데이터베이스 이름은 variant가 정합니다. 예를 들어 :order의 통합 조합은 그것만의 전용 데이터베이스를 받습니다. 한 컨테이너를 공유하더라도 데이터베이스가 다르니 서로의 DDL 이력, 테이블, 테스트 데이터가 섞이지 않습니다. 그래서 앞서 병목이던 Gradle 프로젝트 단위 병렬 실행도 이제 안전하게 됩니다.

반복 재생 비용은 덤프 캐시로 줄인다
조립과 격리를 갖추고 나면, 남는 문제는 처음에 저희를 여기까지 밀어붙인 그 비용입니다. 매번 variant에 나열된 changelog를 처음부터 재생하면 정확하지만 느립니다. 그래서 저희는 실제 데이터베이스에 changelog를 적용한 결과를 엔진의 덤프 형식으로 고정하고, 그 덤프를 Gradle 빌드 캐시에 넣습니다.
이 덤프는 손으로 만든 스냅샷이 아닙니다. variant가 나열한 스키마 조합을 실제 엔진에 적용한 결과입니다. changelog나 베이스 이미지, 초기화 SQL처럼 스키마에 영향을 주는 입력이 바뀌면 다시 적용하고, 바뀌지 않으면 검증된 덤프를 복원합니다. 그래서 느린 경로와 빠른 경로가 같은 스키마로 수렴합니다.
수렴이 성립하려면 덤프 텍스트가 결정적이어야 합니다. 같은 스키마라도 덤프를 두 번 뜨면 헤더의 생성 시각이나 시퀀스의 현재값, 오브젝트 출력 순서 같은 것들이 미묘하게 달라집니다. 이걸 걷어내지 않으면 스키마가 같아도 텍스트가 달라져 캐시가 빗나갑니다. 그래서 이 비결정 요소들을 정규화하는 처리는 편의 기능이 아니라, 저 수렴을 지키기 위한 설계 결정입니다.
그래서 우회로와 무엇이 달랐나
지금까지의 길을 짚고 나면, 다른 조직이 흔히 택하는 우회로들이 저희 경로 위 어디쯤에 놓이는지도 같은 지도 위에서 보입니다. 몇몇은 저희가 실제로 지나온 단계이기도 합니다.
가장 흔한 것은 공유 데이터베이스에 트랜잭션을 열고 끝에 롤백하는 방식입니다. 다시 시딩할 필요가 없어 빠릅니다. 하지만 롤백은 데이터 변경만 되돌리고 DDL 작업은 대개 그 범위 밖입니다. 저희처럼 새 DDL 자체를 검증해야 하는 상황에서는 애초에 대상이 아니었습니다. 이건 저희가 지나온 단계도 아닙니다.
두 번째는 테스트에서만 ddl-auto를 켜거나 인메모리 데이터베이스로 바꿔 끼워 스키마를 만드는 방식입니다. 이건 저희 전제와 정면으로 부딪힙니다. 저희 스키마는 코드에서 나오지 않으니, 테스트에서도 코드로 스키마를 만들 수 없습니다. 선택지에 오르지 못한 길입니다.
세 번째는 테스트마다 Testcontainers를 띄워 실제 마이그레이션을 돌리는 방식입니다. 이건 저희가 실제로 거쳐온 그 단계입니다. 방향은 옳고, 저희도 그 위에 서 있었습니다. 다만 테스트가 많아지며 매번 전체 재생하는 비용이 드러났고, 그 비용을 풀려고 v2로 넘어왔습니다. 정적인 schema.sql로 스키마를 세우던 첫 시도 역시 저희 v1과 대응합니다. 남들이 손으로 뜨는 스냅샷과 달리 저희 것은 Liquibase가 만든 산출물이었으니 출처는 나았지만, 결국 실제 마이그레이션 경로를 밟은 게 아니라는 같은 한계를 안고 있었습니다.
그리고 컨테이너 방식이든 스냅샷 방식이든, 어떤 테스트 데이터베이스를 어떤 스키마들로 세울지는 대개 사람의 관례에 맡깁니다. Liquibase 멀티모듈을 다루는 표준 안내조차, 의존이 있으면 master changelog에서 선행 모듈을 먼저 include하도록 사람이 손으로 맞추라고 정리합니다. 무엇을 어떤 순서로 넣을지가 결국 사람 손에 남는 것입니다. 저희가 다르게 한 지점이 바로 여기, 그 구성을 root 빌드의 variant 선언에 맡긴 데 있습니다.
세 우회로가 각자 무엇을 내주는지는 다르지만 공통점이 하나 있습니다. 어느 것도 테스트 데이터베이스의 구성을 빌드에 남기지 못한다는 점입니다. 롤백은 공유 데이터베이스의 스키마를 그냥 전제하고, ddl-auto는 스키마를 코드에서 다시 만들며, 컨테이너 방식도 조립 대상을 관례에 맡깁니다.

정리하면 이렇습니다. 저희 스키마는 코드에서 나오지 않습니다. 그래서 저희는 스키마를 코드에서 억지로 생성하지 않았습니다. 대신 Liquibase changelog를 테스트 DB를 세우는 입력으로 삼고, v2에서는 그 입력의 조합을 root의 DB variant에 선언했습니다. 덤프 캐시와 전용 데이터베이스 격리는 이 선언된 variant를 빠르고 결정적으로 실행하기 위한 장치였습니다.
🚀플렉스팀 채용페이지 바로가기
☕flex Private Talk 신청하기