← 개발 로그 목록

website: ddl-auto:update가 삼킨 DDL 실패, Flyway로 막기

/ 5분 분량 / 개발 로그

stage members 테이블에 student_id 컬럼이 몇 시간째 없이 떠 있었다. ddl-auto: update가 SQLite의 ALTER TABLE 실패를 조용히 삼켜서 생긴 일이었고, 이걸 계기로 Flyway를 도입했다.

원래 Flyway 도입은 실제 운영을 시작하기 전으로 미뤄둔 상태였다. prod가 사실상 비어있는 지금이 베이스라인 부담이 제일 적은 타이밍이고, main에 실데이터가 쌓이기 시작하면 stage처럼 베이스라인 처리가 필요해지니 이 창을 넘기지 않는 게 중요하다고 판단했었다. 그런데 stage에서 student_id 컬럼이 통째로 빠진 채 몇 시간을 돌고 있었던 사고가 터지면서 일정이 앞당겨졌다. 원인은 단순했다. SQLite는 ALTER TABLE로 UNIQUE나 CHECK 제약이 붙은 컬럼을 추가하는 걸 못 하는데, ddl-auto: update는 이 실패를 그냥 넘겨버리고 앱을 정상 기동시켰다. 실제 요청이 그 컬럼을 건드리기 전까지는 아무도 눈치챌 수 없는 구조였다.

막상 도입에 들어가 보니 dev와 main(prod)이 그 사이 꽤 벌어져 있었다. Flyway baseline을 뜨려면 두 환경의 스키마가 실질적으로 같아야 하는데 그렇지 않아서, 먼저 dev→main 승격(#161)부터 처리해야 했다. 그리고 이 승격 과정에서 정확히 같은 student_id ALTER 실패가 prod에서도 재현됐다 — 실제 배포 사고였다. 수동 롤백 후 패치했고, 이 와중에 CD 자동 롤백의 마커 타이밍 버그(#162)도 같이 발견해서 고쳤다. 결과적으로 Flyway 도입 전에 이미 그 문제를 실제 배포에서 한 번 겪고 넘어간 셈이다.

ddl-auto를 어디로 맞출지도 다시 판단해야 했다. 초안 문서에는 none으로 적어뒀었는데, 생각해보니 none은 마이그레이션이 엔티티와 안 맞아도 기동 자체는 성공하고 실제 요청이 그 컬럼을 건드릴 때에야 터진다. 그러면 #133이 지적한 "DDL 실패가 조용히 묻힌다"는 문제를 이름만 바꿔서 그대로 남기는 꼴이었다. 그래서 validate로 올렸다. validate는 기동 시점에 엔티티 매핑과 실제 스키마를 대조해서, 안 맞으면 기동 자체를 실패시킨다. CD 헬스체크 실패로 이어져 자동 롤백이 걸리니 최소한 문제가 조용히 넘어가는 일은 없다.

validate로 검증을 돌리다가 SQLiteDialect 자체의 버그를 하나 발견했다. @GeneratedValue(IDENTITY) PK를 생성할 때는 SQLite의 rowid 별칭 규칙에 맞춰 integer로 만들어놓고, 검증할 때는 그 규칙을 무시하고 일반 규칙(Long → bigint)으로 비교한다. 그러니까 자기가 방금 만든 테이블조차 자기 검증에 실패하는 상황이었다. 빈 DB로 재현해서 확인한 뒤, PK 필드에는 @Column(columnDefinition = "integer"), 그 PK를 참조하는 FK 필드에는 실제 물리 타입에 맞춰 columnDefinition = "bigint"를 명시하는 방식으로 우회했다. SQLite는 PK가 아닌 컬럼에서 integer/bigint 타입 이름 차이가 기능적으로 없다는 점을 이용한 것이다. 엔티티 14개의 PK와 FK 8곳에 이 패턴을 적용했다.

baseline SQL(V1__baseline.sql)은 눈으로 diff를 뜨는 대신 실측으로 확인하고 싶었다. 그래서 stage DB 사본을 로컬로 받아 ddl-auto=validate로 직접 띄워보고 통과하는 걸 확인한 다음 확정했다. stage·prod는 이 파일을 실행하지 않고 baseline-on-migrate로 "이미 V1까지 적용됨"만 표시되고, 새로 뜨는 빈 DB(로컬 개발 등)만 실제로 이 SQL이 실행된다.

그리고 이번 사고의 핵심이었던 "빠뜨림"을 배포 시점이 아니라 PR 시점에서 잡고 싶어서 SchemaMigrationConsistencyIntegrationTest를 추가했다. 다른 테스트는 create-drop + Flyway 비활성 조합으로 빠른 :memory: DB를 쓰지만, 이 테스트 하나만 실제 배포 환경과 동일한 조합(Flyway가 db/migration의 SQL로 스키마를 실제로 만들고 ddl-auto=validate로 대조)으로 컨텍스트를 띄운다. 검증할 코드가 따로 없다 — 컨텍스트 기동이 성공하는 것 자체가 마이그레이션 파일과 엔티티 매핑이 일치한다는 증거다.

마지막으로 이번에 겪은 걸 db-man 스킬 문서로 정리했다. 엔티티를 고치면 db/migration에 SQL을 같이 추가하는 게 절차의 전부라는 것, SQLite가 ALTER로 못 하는 것들(DROP COLUMN, UNIQUE/CHECK 추가)은 새 테이블 생성 → 데이터 복사 → 기존 테이블 DROP → RENAME 패턴을 쓴다는 것, enum 값 추가는 validate도 로컬 테스트도 못 잡으니 CHECK 제약 갱신을 수동으로 챙겨야 한다는 것을 적어뒀다. 특히 강조해둔 건 Flyway 마이그레이션은 CD 자동 롤백으로 되돌아가지 않는다는 점이다. 코드는 이전 이미지로 롤백되지만 이미 실행된 마이그레이션은 DB에 그대로 남는다. 그래서 가능하면 컬럼 삭제 대신 확장 먼저(nullable 컬럼 추가 → 코드 전환 → 한참 뒤 옛 컬럼 제거) 하는 patterns을 쓰기로 했고, 파괴적 변경은 되돌릴 SQL을 PR에 별도로 적어두기로 했다.