스키마 변경은 코드 배포와 성격이 다릅니다. 코드는 이전 버전으로 되돌리면 그만이지만, 이미 지워진 컬럼은 되돌릴 수 없습니다. Flyway는 스키마 변경을 버전 관리하는 도구인데, 처음부터 쓰는 프로젝트보다 이미 운영 중인 DB에 도입하는 경우가 훨씬 까다롭습니다. 여기서는 기존 DB에 얹는 방법, 되돌릴 수 없는 변경을 다루는 순서, 그리고 무중단 배포와 함께 갈 때의 규칙을 정리합니다.
Flyway가 하는 일
동작은 단순합니다. 정해진 위치의 SQL 파일을 버전 순서대로 한 번씩만 실행하고, 무엇을 실행했는지 DB에 기록합니다.
src/main/resources/db/migration/
V1__create_member.sql
V2__create_order.sql
V3__add_order_status_index.sql
기록은 flyway_schema_history 테이블에 남습니다.
| 컬럼 | 의미 |
|---|---|
version |
V 뒤의 버전 번호 |
checksum |
파일 내용의 해시 — 이미 실행된 파일이 바뀌면 감지 |
success |
성공 여부. 실패로 남으면 다음 실행이 막힌다 |
installed_on |
적용 시각 |
여기서 checksum이 실무의 첫 번째 함정입니다. 이미 적용된 마이그레이션 파일을 나중에 고치면 체크섬이 달라져 애플리케이션이 기동에 실패합니다.
FlywayException: Validate failed:
Migration checksum mismatch for migration version 3
규칙은 하나입니다. 적용된 파일은 절대 수정하지 않는다. 잘못 만들었으면 고치지 말고 새 버전을 추가해 바로잡습니다.
이미 운영 중인 DB에 도입하기
테이블이 이미 수십 개 있는 DB에 Flyway를 붙이면, Flyway는 “아무것도 적용된 적 없는 DB”로 보고 V1부터 실행하려 합니다. 당연히 Table already exists로 실패합니다.
이때 쓰는 것이 baseline입니다. “여기까지는 이미 되어 있는 걸로 치자”고 선언하는 기능입니다.
spring:
flyway:
enabled: true
baseline-on-migrate: true # 히스토리 테이블이 없으면 baseline 을 먼저 찍는다
baseline-version: 1 # 현재 상태를 V1 으로 간주
baseline-description: "existing schema"
이 설정으로 첫 기동을 하면 flyway_schema_history에 baseline 레코드 하나만 생기고, 실제 SQL은 실행되지 않습니다. 이후 추가하는 V2부터 정상 적용됩니다.
도입 순서는 이렇게 잡으면 안전합니다.
- 운영 DB의 현재 스키마를 덤프해
V1__baseline.sql로 저장 (실행용이 아니라 기록용) baseline-on-migrate: true로 설정하고 스테이징에서 먼저 기동flyway_schema_history가 생기고 baseline 레코드만 있는지 확인- 이후 변경부터
V2로 추가
1번을 빠뜨리기 쉬운데, 나중에 새 환경을 구축할 때 스키마를 처음부터 만들 방법이 없어집니다. baseline SQL은 반드시 저장소에 남겨 두십시오.
되돌릴 수 없는 변경을 다루는 순서
Flyway 무료판에는 롤백(undo) 기능이 없습니다. 있더라도 DROP COLUMN을 되돌리면 데이터는 돌아오지 않습니다. 그래서 실무에서는 애초에 되돌릴 필요가 없도록 순서를 나눕니다. 이를 확장-축소(expand-contract) 패턴이라고 합니다.
예시 — 컬럼 이름을 바꿀 때
user_name을 nickname으로 바꾸고 싶다고 해 봅시다. 한 번에 하면 이렇게 됩니다.
-- ✗ 배포 중간에 구버전 애플리케이션이 user_name 을 찾다가 죽는다
ALTER TABLE member CHANGE user_name nickname VARCHAR(50);
무중단 배포 중에는 구버전과 신버전이 동시에 떠 있는 구간이 반드시 있습니다. 그 순간 구버전이 없어진 컬럼을 찾습니다. 그래서 세 번의 배포로 나눕니다.
| 단계 | 스키마 | 애플리케이션 |
|---|---|---|
| ① 확장 | nickname 컬럼 추가 (nullable) |
두 컬럼에 모두 쓰고, 읽기는 user_name |
| ② 이행 | 기존 데이터 복사 | 읽기를 nickname으로 전환 |
| ③ 축소 | user_name 삭제 |
nickname만 사용 |
각 단계 사이에 배포가 한 번씩 들어갑니다. 번거로워 보이지만 어느 시점에 중단해도 서비스가 살아 있습니다. 무중단 배포 구조에서는 선택이 아니라 필수입니다. 배포 중 두 버전이 공존하는 구조는 무중단 배포 Nginx vs HAProxy에서 정리했습니다.
NOT NULL 추가도 같은 문제
-- ✗ 기존 행에 NULL 이 있으면 실패하고, 성공해도 구버전이 INSERT 하다 죽는다
ALTER TABLE orders ADD COLUMN channel VARCHAR(20) NOT NULL;
-- ✓ nullable 로 추가 → 기본값 채우기 → 나중에 NOT NULL 로 전환
ALTER TABLE orders ADD COLUMN channel VARCHAR(20) NULL;
UPDATE orders SET channel = 'WEB' WHERE channel IS NULL;
-- (애플리케이션이 항상 값을 넣게 된 뒤, 다음 버전에서)
ALTER TABLE orders MODIFY channel VARCHAR(20) NOT NULL;
큰 테이블에 ALTER를 걸 때
수백만 행 테이블에 ALTER TABLE을 걸면 그 시간 동안 테이블이 잠길 수 있습니다. MySQL 8.0은 상당수 작업을 온라인 DDL로 처리하지만, 전부는 아닙니다.
| 작업 | MySQL 8.0 | 비고 |
|---|---|---|
| 인덱스 추가 | 온라인 가능 | ALGORITHM=INPLACE, LOCK=NONE |
| 컬럼 추가 (끝에) | 온라인 가능 | INSTANT 알고리즘 |
| 컬럼 타입 변경 | 테이블 복사 | 크기에 비례해 오래 걸림 |
| 컬럼 순서 변경 | 테이블 복사 | 피하는 편이 낫다 |
복사가 일어나는 작업은 마이그레이션에 넣지 말고 별도 작업으로 분리하는 편이 안전합니다. Flyway 마이그레이션은 애플리케이션 기동 시점에 실행되므로, 오래 걸리면 배포 자체가 멈춥니다. 기동 타임아웃에 걸려 배포가 실패하는 일도 생깁니다.
인덱스를 추가할 때는 순서도 함께 고려하십시오. 잘못된 순서의 복합 인덱스는 추가해도 쓰이지 않습니다. 기준은 복합 인덱스는 순서가 전부다에 정리했습니다.
운영에서 지킬 설정
spring:
flyway:
enabled: true
baseline-on-migrate: true
validate-on-migrate: true # 체크섬 검증 — 끄지 말 것
clean-disabled: true # ★ 운영에서 반드시 true
out-of-order: false # 버전 건너뛴 적용 금지
clean-disabled는 반드시 true로 두십시오. flyway.clean()은 스키마의 모든 객체를 삭제합니다. 운영 DB를 향해 실행되면 복구 수단은 백업뿐입니다. Flyway 9부터 기본값이 true로 바뀌었지만, 명시해 두는 편이 안전합니다.
out-of-order도 기본값 false를 유지하는 게 좋습니다. 두 사람이 각자 V5를 만드는 충돌은 배포 전에 발견되는 편이 낫습니다. 버전 번호에 타임스탬프를 쓰면(V20260919103000__...) 충돌 자체가 거의 사라집니다.
실패했을 때
마이그레이션이 중간에 실패하면 히스토리에 success = 0 레코드가 남고, 이후 모든 기동이 막힙니다.
FlywayException: Validate failed:
Detected failed migration to version 7
해결 순서는 이렇습니다.
- DB의 실제 상태를 먼저 확인합니다. MySQL은 DDL이 트랜잭션으로 묶이지 않아, 실패한 마이그레이션이 절반만 적용됐을 수 있습니다.
- 절반 적용됐다면 수동으로 되돌리거나 마저 적용해 일관된 상태로 만듭니다.
- 그다음
flyway repair로 실패 레코드를 정리합니다.
./gradlew flywayRepair
순서를 바꿔 repair부터 하면 “실패 기록만 지우고 실제 스키마는 어중간한” 상태가 됩니다. DB 상태 확인이 항상 먼저입니다. PostgreSQL은 DDL도 트랜잭션에 묶이므로 이 문제가 덜합니다.
자주 묻는 질문
Q1. ddl-auto: update 를 쓰면 안 되나요?
로컬 개발에서는 편하지만 운영에서는 쓰면 안 됩니다. 이유가 셋입니다. 무엇이 실행될지 예측할 수 없고, 컬럼 삭제나 타입 축소는 반영되지 않아 스키마가 조용히 어긋나며, 변경 이력이 남지 않습니다. 운영은 ddl-auto: validate로 두고 스키마 변경은 마이그레이션으로만 하는 것이 원칙입니다.
Q2. 여러 인스턴스가 동시에 뜨면 마이그레이션이 중복 실행되나요?
아닙니다. Flyway는 마이그레이션 전에 DB 레벨 잠금을 잡습니다. 먼저 잡은 인스턴스가 실행하고 나머지는 대기했다가 완료된 상태를 확인하고 넘어갑니다. 다만 마이그레이션이 오래 걸리면 대기하던 인스턴스들이 기동 타임아웃에 걸릴 수 있으므로, 긴 작업은 배포와 분리하는 편이 안전합니다.
Q3. 테스트에서도 Flyway를 돌려야 하나요?
돌리는 편이 낫습니다. ddl-auto: create로 만든 스키마는 인덱스나 제약이 운영과 달라, 정작 검증해야 할 것을 놓칩니다. 컨테이너로 실제 DB를 띄우고 마이그레이션을 그대로 실행하면 마이그레이션 스크립트 자체가 CI에서 검증되는 이점도 생깁니다. 앞 글에서 다룬 Testcontainers 구성과 자연스럽게 맞물립니다.
Q4. 데이터 이행 스크립트도 Flyway로 넣어도 되나요?
양이 적으면 괜찮지만, 수백만 건 UPDATE는 넣지 마십시오. 앞서 말한 대로 기동이 그만큼 멈추고, 긴 트랜잭션이 다른 쿼리와 락 경합을 일으킵니다. 대량 이행은 마이그레이션으로 컬럼만 만들어 두고, 배치로 나눠서 별도 실행하는 구조가 맞습니다.
마무리
Flyway 도입에서 중요한 것은 도구 사용법이 아니라 순서를 나누는 습관입니다. 기존 DB에는 baseline으로 얹고, 적용된 파일은 절대 고치지 않고, 되돌릴 수 없는 변경은 확장-축소로 나눕니다.
규칙 하나만 기억한다면 이것입니다. 스키마 변경은 항상 구버전 애플리케이션이 살아 있는 상태에서도 안전해야 한다. 이 조건을 만족시키면 배포 중 장애도, 롤백 불가 상황도 대부분 사라집니다.
함께 보면 좋은 글
- 무중단 배포 Nginx vs HAProxy — 구버전과 신버전이 공존하는 구간
- 복합 인덱스는 순서가 전부다 — 인덱스를 추가하기 전에 정할 것
- MySQL 쿼리 지연 해결 — 스키마를 바꾸기 전에 볼 것