← 개발 로그 목록

website: Flyway 스키마 변경 가이드(db-man) 문서를 실제 사고 경험대로 다시 정리하다

/ 7분 분량 / 개발 로그

Flyway 도입(#163) 이후 엔티티만 고쳐서 스키마를 못 바꾸게 된 상황을 팀원들이 헷갈려 해서, db-man 스킬 문서와 관련 가이드를 실제 겪은 일 기준으로 다시 정리한 PR이다.

Flyway를 붙이기 전엔 ddl-auto: update라서 Hibernate가 알아서 테이블·컬럼을 맞춰줬다. 그런데 이 방식이 SQLite ALTER TABLE 실패(UNIQUE 컬럼 추가 같은 경우)를 조용히 삼켜버려서, stage members 테이블에 컬럼 하나가 몇 시간째 빠진 채로 떠 있던 사고(#133)가 있었다. 그래서 ddl-auto: validate로 바꿨는데, 이건 엔티티랑 실제 DB가 안 맞으면 앱이 아예 안 뜨는 방식이다. 안전해진 대신 절차가 하나 늘었다 — 엔티티를 고치면 SQL 마이그레이션 파일을 사람이 직접 추가해야 한다. 이 절차를 팀원들이 매번 헤매지 않도록 문서화하고, 로컬에서 미리 검증할 수 있는 방법까지 정리하는 게 이 PR의 목적이었다.

작업 도중에 예상 못 한 문제가 하나 터졌다. #133 PR을 머지하자마자 dev→stage CD가 실패한 것이다(다행히 자동 롤백돼서 데이터 영향은 없었다). 원인을 확인해보니, Spring Boot 3.5.16이 끌어오는 Flyway 10.x에서 spring.flyway.clean-on-validation-error(= SPRING_FLYWAY_CLEAN_ON_VALIDATION_ERROR env var) 프로퍼티 자체가 제거돼 있었다. 값을 설정하기만 해도 Flyway 인스턴스 생성 단계에서 "has been removed" 예외가 난 것이다.

Flyway를 9버전으로 내려서 우회하는 방법도 잠깐 생각했지만 하지 않았다. Spring Boot 3.5.x는 Flyway 10.x대에 맞춰 auto-configuration이 짜여 있어서, 억지로 버전을 내리면 이 프로퍼티는 살아나도 다른 연동 지점이 검증 안 된 조합으로 깨질 위험이 있었다. 대신 같은 동작(체크섬 불일치 시 DB 전체 날리고 처음부터 재적용)을 코드로 재구현하기로 했다.

@Bean
@Profile("stage")
public FlywayMigrationStrategy stageCleanOnValidationErrorStrategy() {
    return flyway -> {
        try {
            flyway.migrate();
        } catch (FlywayValidateException e) {
            flyway.clean();
            flyway.migrate();
        }
    };
}

이렇게 옮기고 나니 env var 방식보다 오히려 더 안전해진 부분이 있었다. env var 방식은 이름을 실수로 .env.prod에 복붙하면 prod도 자동 clean 대상이 될 수 있는 구조였는데, @Profile("stage")로 분리하면 그 실수 자체가 구조적으로 불가능해진다 — prod엔 이 빈이 아예 안 생긴다. 서버 쪽 조치는 .env.stage에서 해당 env var 줄을 지우는 것뿐이었고, .env.prod는 원래부터 이 변수가 없어서 그대로 안전했다.

이 사고를 겪고 나서 첫 커밋으로 db-migration.md와 db-man 스킬 문서를 FlywayConfig.java 반영 내용으로 갱신했다. 그런데 문서를 다시 팀원들에게 공유하니 질문이 따라왔다. 왜 PK/FK 타입을 저렇게 지정해야 하는지, SchemaMigrationConsistencyIntegrationTest가 정확히 뭘 검증하는지, CHECK 제약이 뭔지 같은 것들이었다. 그래서 두 번째 커밋에서는 이 "왜"들을 채워 넣었다.

예를 들어 PK/FK 타입 규칙은 원래 "이렇게 하라"는 결과만 적혀 있었는데, 실제로는 SQLite에서 자동증가 PK가 동작하려면 컬럼이 정확히 INTEGER PRIMARY KEY여야 한다는 SQLite 자체 규칙이 배경에 있다. 그런데 지금 쓰는 SQLiteDialect가 테이블을 만들 때와 검증할 때 서로 다른 타입 이름 규칙을 써서, 자기가 만든 테이블조차 자기 검증에 실패하는 버그가 있다. 그래서 PK 필드엔 columnDefinition = "integer", 그 PK를 참조하는 FK 필드엔 실제 물리 타입에 맞춰 columnDefinition = "bigint"를 명시해 우회한다 — SQLite는 PK가 아닌 컬럼에서 integer/bigint 타입 이름 차이가 기능적으로 없다는 점을 이용한 것이다. 이유를 안다고 새로 유도하려 하면 오히려 틀리기 쉬워서, 문서에는 "기존 엔티티(Member, Post 등)를 그대로 복사해서 쓸 것"이라고 못 박았다.

SchemaMigrationConsistencyIntegrationTest도 마찬가지였다. 이 테스트가 하는 일은 빈 DB에 db/migration/의 SQL을 전부 순서대로 적용한 뒤 그 결과 DB에 대고 ddl-auto=validate로 앱을 실제 기동시켜보는 것이다. 원래 "엔티티만 고치고 마이그레이션 파일을 빠뜨리는" 실수는 stage/prod 배포 시점에야 발견됐다(헬스체크 실패 → CD 자동 롤백). 이 테스트는 그 발견 시점을 배포 전, PR 단계로 앞당기는 것뿐이라는 걸 문서에 명확히 적었다.

CHECK 제약 부분도 자동 검증이 못 잡는 구멍이라는 걸 강조해서 다시 썼다. @Enumerated(EnumType.STRING) 필드는 SQL에서 CHECK (col IN ('A','B',...))로 허용값이 박히는데, validate는 컬럼 존재·타입만 보지 CHECK 안의 값 목록은 안 본다. 그래서 enum에 새 값을 추가하고 CHECK 갱신을 빠뜨려도 컴파일·validate·앱 기동까지 전부 멀쩡히 통과한다. 실제로 그 값을 저장하려는 순간에야 DB가 거부한다 — 자동 검증 어디에도 안 걸리고 실사용 시점에야 드러나는 케이스라서 문서에 따로 힘줘서 적었다.

로컬 개발 DB 관련 문구도 하나 정정했다. 예전 문구는 지워야 할 로컬 DB 파일 경로를 backend/data/*.db처럼 근거 없이 단정하고 있었는데, 실제로는 backend/.env.example의 DB_PATH가 빈 값이라 진짜 경로는 각자 로컬 .env(git-ignore 대상)에 개인이 지정한 값이다. 확인 안 된 걸 확정적으로 써놓은 셈이라 이번에 고쳤다. 이 외에 "막히면" 항목에서 "혼자 판단해서 밀어붙이지 않는다"는 다소 감정적인 표현도 빼고, 리뷰 요청하라는 지시만 남겼다.

PR 자체는 코드 변경 없이 문서만 고친 것이라 커밋도 두 개짜리 문서 수정과 dev 브랜치 머지 커밋 하나로 끝났다. 올릴 때부터 바로 머지하지 않고 팀원들이 볼 시간을 갖고 코멘트 남긴 뒤에 머지하기로 해뒀고, 실제로 7월 24일에 올려서 7월 26일에 머지됐다.

문서를 쓰면서 새삼 느낀 건, "이렇게 하면 됩니다"만 적어두면 다음에 똑같은 질문이 반복된다는 점이다. 왜 그렇게 해야 하는지(SQLiteDialect 버그, CHECK 제약이 자동 검증 밖이라는 점, DB_PATH가 고정 경로가 아니라는 점)까지 적어야 팀원이 스스로 판단할 수 있는 지점과, 그냥 기존 걸 복사해야 하는 지점을 구분할 수 있다. 특히 PK/FK 타입 지정처럼 "이유를 알아도 직접 유도하면 틀리는" 케이스는, 문서에 배경 설명과 함께 "그래도 복사해서 쓸 것"이라는 지침을 같이 적어두는 게 나았다.