website: Flyway 도입으로 ddl-auto:update가 삼키던 DDL 실패를 잡다
stage `members` 테이블에 `student_id` 컬럼이 몇 시간째 빠진 채로 서비스가 떠 있던 사고(#133)를 계기로, `ddl-auto: update` 대신 Flyway로 스키마 관리 방식을 바꾼 PR이다.
사고 원인부터 확인했다
student_id 컬럼이 stage에 없다는 걸 발견하고 로그를 뒤져보니, ALTER TABLE로 UNIQUE 컬럼을 추가하려던 게 SQLite에서 애초에 지원 안 되는 작업이었다. 문제는 그 실패가 조용히 넘어갔다는 것. ddl-auto: update는 DDL 실행이 실패해도 그걸 fatal로 취급하지 않아서, 앱은 아무 일 없다는 듯 계속 떠 있었고 몇 시간이 지나도록 아무도 몰랐다. 스키마 드리프트가 나는데 그걸 알아챌 방법이 애초에 없는 구조였다.
이 문제를 근본적으로 해결하려면 스키마 변경을 Hibernate의 자동 DDL에 맡기는 방식 자체를 그만둬야 했다. 그래서 Flyway를 도입하기로 했다.
baseline을 어떻게 만들지가 첫 고민이었다
Flyway를 붙이려면 지금 stage/prod가 실제로 어떤 스키마인지를 정확히 아는 마이그레이션 파일(V1)이 있어야 한다. 손으로 스키마를 다시 적으면 실제 DB와 미묘하게 어긋날 위험이 있어서, ddl-auto=create로 빈 DB를 하나 띄워 Hibernate가 실제로 내는 DDL을 그대로 캡처하는 방법을 택했다. 이게 지금 엔티티 매핑이 실제로 만드는 스키마와 100% 일치한다는 걸 보장하는 가장 확실한 방법이었다.
캡처한 뒤에는 로컬에서 stage DB를 scp로 받아와 그 사본에 대고 ddl-auto=validate로 직접 기동해봤다. 여기서 통과가 나와야 이 baseline이 실제 stage/prod 상태와 같다고 믿을 수 있었다.
ddl-auto를 none이 아니라 validate로 바꾼 이유
사전에 합의돼 있던 infra/db-migration.md 문서는 ddl-auto: none을 전제로 쓰여 있었다. 그런데 실제로 작업하면서 다시 생각해보니 none은 이번 사고를 그대로 반복할 수 있는 설정이었다. none은 마이그레이션이 틀려도 앱 기동 자체는 성공하고, 실제 요청이 그 컬럼을 건드리는 순간에야 터진다 — 결국 #133이 지적한 "DDL 실패가 조용히 묻힌다"는 문제의 다른 버전일 뿐이다.
validate는 다르다. 기동 시점에 엔티티 매핑과 실제 DB 스키마를 대조해서, 안 맞으면 앱이 아예 못 뜬다. CD 파이프라인의 헬스체크가 실패하면 자동 롤백으로 이어지니, 문제가 있는 배포가 조용히 살아남는 일을 원천 차단할 수 있었다. 찬욱님과 상의해서 문서상의 none을 뒤집고 validate로 확정했다.
validate로 바꾸자마자 또 다른 버그를 만났다
빈 DB에 baseline을 적용하고 validate로 재기동하는 검증을 돌리는데, 이번엔 방금 Flyway로 만든 테이블조차 자기 검증에 실패했다. 원인을 파고들어 보니 org.hibernate.community.dialect.SQLiteDialect 자체의 문제였다. @GeneratedValue(IDENTITY) PK를 실제로 생성할 땐 SQLite의 rowid 별칭 규칙에 맞춰 integer 타입으로 만들면서, 검증할 때는 그걸 모르고 일반 규칙(Long → bigint)으로 비교하고 있었다. dialect가 자기가 만든 걸 자기가 못 알아보는 구조였다.
우회 방법은 PK 필드에는 @Column(columnDefinition = "integer")를, 그 PK를 참조하는 FK 필드에는 반대로 실제 물리 상태에 맞춰 columnDefinition = "bigint"를 명시하는 것이었다. SQLite는 PK가 아닌 컬럼이라면 integer와 bigint가 이름만 다를 뿐 기능적으로 차이가 없다는 걸 이용한 방식이다. 14개 엔티티의 PK와 그걸 참조하는 FK 8곳에 전부 이 패턴을 적용했다.
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(columnDefinition = "integer")
private Long id;
@Column(name = "member_id", columnDefinition = "bigint", nullable = false)
private Long memberId;
빈 DB 검증과 실제 운영 DB 검증은 다른 층위라는 걸 정리해야 했다
여기서 헷갈리기 쉬운 지점이 있었다. "빈 DB에서 검증이 통과했다"는 사실이 커버하는 범위와 "V1 baseline이 실제 stage/prod와 같다"는 사실이 커버하는 범위가 다르다는 것.
앞으로 추가될 V2 이후 마이그레이션은 stage/prod에서도 baseline 없이 그대로 실행되는 파일이라, 빈 DB에서 통과하면 운영에서도 같은 SQL이 그대로 먹힌다는 보장이 된다. 그래서 이 부분은 SchemaMigrationConsistencyIntegrationTest라는 자동 테스트로 만들어서 PR마다 CI가 잡도록 했다. 빈 DB에 Flyway로 전체 마이그레이션을 실제 적용한 뒤 ddl-auto=validate로 대조하는 방식인데, 검증 로직 자체는 따로 없고 컨텍스트가 뜨는 것 자체가 성공 조건이다.
@DynamicPropertySource
static void overrideForRealMigrationCheck(DynamicPropertyRegistry registry) {
registry.add("spring.datasource.url", () -> "jdbc:sqlite:" + tempDir.resolve("schema-check.db"));
registry.add("spring.flyway.enabled", () -> "true");
registry.add("spring.jpa.hibernate.ddl-auto", () -> "validate");
}
@Test
void entityMappingsMatchActualMigrationFiles() {
// 검증할 게 없다 — 컨텍스트가 이 설정으로 뜨는 것 자체가 검증이다.
}
반면 V1(baseline) 자체가 지금 stage/prod의 실제 상태와 같다는 건 이 자동 테스트가 커버하지 못한다. baseline은 실행 없이 "이미 적용됨"으로만 기록되는 지점이라서, 원천적으로 자동 재검증 대상이 될 수 없다. 이건 앞서 말한 대로 실제 stage DB 사본을 받아서 수동으로 1회성 검증했다. 그리고 최종적으로는 실제 배포 때마다 앱이 운영 DB를 상대로 다시 기동 검증을 하니까, 세 층위(PR 시점 빈 DB / baseline 수동 검증 / 배포 시점 실제 DB)가 서로 다른 걸 나눠서 커버하는 구조로 정리됐다.
테스트 환경은 이 변화와 충돌하지 않게 그대로 뒀다. src/test/resources/application.yml은 create-drop + flyway.enabled: false 조합을 유지해서, 매 테스트마다 격리된 :memory: DB를 새로 만드는 기존 방식과 Flyway가 부딪히지 않도록 했다.
다음에 같은 실수를 안 하도록 절차를 문서로 남겼다
이번 사고의 핵심은 결국 "스키마 변경 시 사람이 뭘 챙겨야 하는지가 문서화돼 있지 않았다"는 것이기도 했다. 그래서 backend/.claude/skills/db-man/에 앞으로 팀원이 엔티티를 바꿀 때 따를 절차를 스킬로 정리했다. 마이그레이션 파일 작성 규칙, SQLite의 ALTER TABLE 제약을 우회하는 재생성 패턴, PK/FK columnDefinition 짝 맞추기, 그리고 로컬에서 SchemaMigrationConsistencyIntegrationTest로 미리 검증하는 방법까지 담았다.
작성하고 나서 리뷰하다 보니 빠진 게 몇 가지 더 보였다. enum 값을 추가하거나 바꾸는 경우는 validate도, 새로 만든 테스트도 못 잡는다는 점이다. @Enumerated(EnumType.STRING) 필드는 CHECK (col IN (...)) 제약으로 SQL에 박히는데, Hibernate의 스키마 검증은 컬럼의 존재와 타입만 보고 CHECK 제약 안의 값 목록까지는 안 본다. 그래서 enum에 새 값을 추가하고 CHECK 갱신 마이그레이션을 빠뜨려도 앱은 멀쩡히 뜨고, 실제로 그 값으로 INSERT/UPDATE가 일어나는 순간에야 터진다. 이건 자동으로 못 잡는 구멍이라 스킬 문서에 수동으로 챙겨야 한다고 명시해뒀다.
또 하나는 Flyway 마이그레이션이 CD의 자동 롤백으로 안 되돌아간다는 점이다. 롤백은 컨테이너 이미지를 이전 걸로 교체하는 것뿐이라, 이미 실행된 테이블/컬럼 변경은 DB에 그대로 남는다. 그래서 가능하면 컬럼을 바로 지우지 않고 확장(새 컬럼 추가) → 전환 → 나중에 정리(옛 컬럼 제거) 순서로 나누는 expand-contract 패턴을 권장하는 걸로 정리했고, 파괴적 변경은 되돌릴 SQL을 PR에 별도로 적어두게 했다.
머지되기 전에 이미 한 번 사고가 있었다
이 PR을 준비하는 과정에서 별개의 사건이 하나 있었다. prod가 dev보다 스키마·기능이 많이 뒤처져 있어서, Flyway를 붙이기 전에 dev→main 승격(#161)으로 먼저 맞추는 작업을 했는데, 그 과정에서 members.student_id ALTER가 prod에서도 똑같이 실패해 실제 배포 사고가 한 번 났다. 수동으로 복구했고, 이때 CD 롤백 마커의 타이밍 버그(#162)도 같이 발견해서 고쳤다. 이 두 건은 이 PR과는 독립적으로 이미 머지된 상태였다.
검증하고 머지까지
전체 테스트 스위트(신규 테스트 포함 37개)가 통과했고, stage DB 사본에 대한 실측 검증, 빈 DB 왕복 검증(create로 만들고 validate로 재기동), baseline-on-migrate 동작 확인(빈 DB는 V1 실제 실행, 기존 스키마가 있는 DB는 baseline만 기록)까지 마친 뒤 2026-07-23에 올려서 다음 날 dev로 머지됐다. 머지 후에는 실제 dev CD 런에서 Flyway가 stage에 baseline만 기록하고(V1 재실행 없이) 앱이 정상 기동하는지, 그리고 스모크 테스트 4개 엔드포인트가 정상 통과하는지를 후속으로 확인하기로 했다.
돌아보면 이 작업에서 제일 시간이 든 부분은 Flyway 자체를 붙이는 것보다, validate로 전환하면서 튀어나온 dialect 버그를 재현하고 우회하는 쪽이었다. 자동화된 스키마 관리 도구를 붙일 때는 그 도구가 기본 가정하는 규칙(여기서는 identity PK 타입 규칙)이 실제 DB 방언과 안 맞는 지점이 있을 수 있다는 걸 다시 확인한 셈이다.