1. 개요
3년 동안 운영 DB 스키마는 Hibernate ddl-auto=update 가 만들었다. 엔티티를 고치면 컬럼이 생겼고, 그게 편했다. 2026년 8월 13일, Java enum 에 상수 하나를 추가하고 배포했더니 화면에 “입력하신 내용을 다시 확인해주세요” 한 줄이 떴다. 이 글은 그날부터 Flyway 로 갈아타고 validate 로 내리기까지의 기록이다.
2. 핵심 내용
2-1. 사고: update 는 컬럼을 추가하지만 타입은 바꾸지 않는다
MySQL 은 enum 컬럼을 네이티브 ENUM('A','B','C') 타입으로 만들 수 있고, Hibernate 는 @Enumerated(STRING) 을 그렇게 매핑한다. Java 쪽에 D 를 추가해도 ddl-auto=update 는 기존 컬럼의 타입 정의를 건드리지 않는다. 새 컬럼은 만들어 주지만, 있는 컬럼의 ENUM(...) 목록은 그대로다. D 를 INSERT 하면 MySQL 이 거부하고, 앱은 400 으로 번역한다.
더 큰 문제는 이걸 계기로 운영 스키마를 실측했을 때 나왔다. 운영 DB 에는 68개 테이블 586개 컬럼이 있는데 엔티티는 63개 테이블 566개 컬럼이었다. 3년 동안 지운 엔티티의 잔재가 DB 에는 그대로 남아 있었다. update 는 지우지 않는다. 그리고 Hibernate validate 는 DB 에만 있는 여분 컬럼을 문제 삼지 않는다. 여분 컬럼에 NOT NULL 이 걸려 있으면 런타임 INSERT 에서만 터진다.
2-2. 결정: baseline 은 “이상적 스키마” 가 아니라 “운영 덤프 그대로”
Flyway 를 도입할 때 첫 마이그레이션 V1 을 무엇으로 잡을지가 갈림길이었다.
- 엔티티에서 생성한 깨끗한 DDL 을 V1 으로 → 운영 DB 와 처음부터 어긋난다. 운영에는 없는 인덱스 이름, 운영에만 있는 컬럼.
- 운영 덤프의 스키마 부분을 그대로 V1 으로 → 못생겼지만 현실과 일치한다.
두 번째를 택했다. baseline-on-migrate 로 운영 DB 에는 V1 을 “이미 적용됨” 으로 기록하고, 새 환경은 V1 부터 실행한다. 드리프트(엔티티 vs DB)는 information_schema 를 컬럼 단위로 대조해 문서로 남기고, 이후 마이그레이션에서 하나씩 정리했다.
네이티브 ENUM 은 세 번의 마이그레이션으로 22개 → 0개. 전부 VARCHAR 로 바꿨다. 그리고 테스트 하나를 추가했다. information_schema 에서 ENUM 타입 컬럼을 세어 0 이 아니면 실패한다. 화이트리스트 없이. 누가 다시 네이티브 ENUM 을 만들면 CI 가 막는다.
2-3. validate 로 내리기
2026년 8월 31일에 staging 과 prod 모두 SPRING_JPA_HIBERNATE_DDL_AUTO=validate 로 바꿨다. application.yml 에는 여전히 update 가 적혀 있고, k8s 매니페스트의 env 가 덮는다. 코드만 보면 update 로 오독하기 쉬운데, 로컬 개발 편의를 위해 남겨 둔 것이다.
Spring Boot 는 Flyway 를 JPA 초기화보다 먼저 돌린다. 그래서 validate 로 내려도 순서는 “마이그레이션 → 검증” 이고, 마이그레이션이 실패하면 파드가 뜨지 않는다. 이게 의도다. 스키마가 어긋난 채로 트래픽을 받는 것보다 안 뜨는 게 낫다.
2-4. 컬럼 하나 지우는 데 릴리스 두 번
replicas 가 1 이라도 RollingUpdate 는 구 파드와 신 파드가 겹치는 창이 있다. 이 창에서 신 파드의 마이그레이션이 컬럼을 DROP 하면, 구 파드가 Unknown column 으로 죽는다.
그래서 contract 변경은 둘로 쪼갠다.
- 릴리스 N: 컬럼에 DEFAULT 를 주거나 nullable 로 바꾸고, 코드에서 참조를 제거한다. 배포 후 구 파드가 다 내려간 것을 확인.
- 릴리스 N+1: 물리 DROP.
한 번에 하면 배포 창에서 짧은 장애가 난다. 두 번에 나누면 안 난다. 귀찮지만 규칙으로 못박았다.
2-5. 마이그레이션 번호는 머지 직전에 다시 센다
Flyway 는 out-of-order 를 끄고 쓴다. 그러면 여러 사람이 동시에 PR 을 열 때 번호가 충돌한다. 실제로 V27 이 두 개 머지된 적이 있다. PR CI 에 “순서 검증” 이 있었는데도.
이유는 PR CI 가 PR 을 만든 시점에 돈다는 것이다. 그 뒤 다른 PR 이 먼저 머지되면 내 PR 의 초록불은 낡은 초록불이다. GitHub 은 base 가 바뀌어도 CI 를 다시 돌리지 않는다.
지금은 네 시점에서 번호를 다시 센다.
착수 시 git ls-tree origin/main -- src/main/resources/db/migration | tail -1
푸시 전 (같은 명령)
PR 생성 CI 순서 검증
머지 직전 (같은 명령) ← 이게 빠지면 위 셋이 무의미
그리고 main push 에서도 순서를 재검증하는 워크플로를 따로 둬서, 중복 번호가 들어오면 이미지가 안 만들어지고 릴리스가 멈춘다. 2편에서 말한 “빌드가 없으면 릴리스가 멈춘다” 가 여기서 안전장치로 쓰인다.
빈 번호는 채우지 않는다. V37 이 결번이면 그대로 둔다. 운영 DB 가 이미 V38 을 적용했으면 V37 을 새로 넣은 순간 기동에서 막힌다. 배포된 마이그레이션 파일은 주석 하나도 고치지 않는다. 체크섬이 바뀌면 validate-on-migrate 가 거부한다.
2-6. 마이그레이션이 만든 스키마와 엔티티가 맞는지는 누가 보나
Flyway 로 만든 DB 위에 엔티티를 얹어 validate 를 통과하는지 보는 통합테스트가 하나 있다. Testcontainers 로 MySQL 을 띄우고 V1 부터 끝까지 적용한 뒤 컨텍스트를 올린다. 마이그레이션 PR 의 유일한 스키마 게이트다.
이 테스트는 별도 태스크(integrationTest)로 분리돼 있어 ./gradlew test 로는 돌지 않는다. 그리고 Gradle 은 입력이 안 바뀌면 UP-TO-DATE 로 0건 실행하고 BUILD SUCCESSFUL 을 준다. 이 얘기는 7편에서.
3. 마무리
요약
ddl-auto=update는 컬럼을 추가하지만 타입을 바꾸지 않고, 지우지도 않는다. 3년이면 드리프트가 쌓인다.- baseline 은 운영 덤프 그대로. 드리프트는 실측해서 문서로 남기고 마이그레이션으로 정리한다.
validate는 마이그레이션 실패 시 파드를 안 띄우는 것이 의도다.- 컬럼 삭제는 릴리스 두 번. 번호는 머지 직전에 다시 센다. 결번은 채우지 않는다.
다음은 시크릿과 네트워크다.