PCG.dev
Blog
FlywaySpring BootJPADatabase

ddl-auto=update에서 Flyway로: 개발·운영 DB 스키마 불일치를 정리하고 안전하게 전환하기

Hibernate ddl-auto=update로 달라진 개발·운영 DB 스키마를 비교하고, 백업·복구 리허설과 baseline migration을 거쳐 Flyway와 ddl-auto=validate로 안전하게 전환한 과정을 정리합니다.

·28분 읽기

들어가며

Spring Boot와 JPA를 사용하면 Hibernate의 ddl-auto=update 설정으로 엔티티 변경을 데이터베이스에 자동 반영할 수 있습니다.

spring:
  jpa:
    hibernate:
      ddl-auto: update

개발 초기에는 편리합니다.

엔티티에 필드 추가
→ 애플리케이션 실행
→ Hibernate가 컬럼 추가

하지만 서비스가 개발 서버와 운영 서버로 나뉘고, 서로 다른 시점에 배포되기 시작하면 문제가 생깁니다.

Hibernate는 현재 엔티티를 보고 필요한 DDL을 실행하지만 다음 내용은 Git에 남기지 않습니다.

어떤 SQL이 실행되었는가
언제 실행되었는가
어느 서버까지 적용되었는가
환경별로 같은 순서로 실행되었는가

실제로 개발 DB와 운영 DB의 스키마를 덤프해서 비교해 보니 컬럼 타입, NULL 허용 여부, UNIQUE 제약조건과 사용하지 않는 잔존 컬럼이 서로 달랐습니다.

처음에는 개발 DB를 운영 DB와 한 번 맞춘 뒤 계속 ddl-auto=update를 사용해도 되지 않을까 생각했습니다.

하지만 그렇게 하면 현재 차이만 없어질 뿐, 다음 엔티티 변경부터 같은 문제가 다시 발생합니다.

이번 글에서는 다음 과정을 정리합니다.

개발·운영 DB 스키마 비교
→ 데이터 백업 및 복구 리허설
→ 기존 스키마 정합화
→ 운영 DB 기준 V1 baseline 작성
→ V2·V3 migration 작성
→ Flyway 도입
→ Hibernate ddl-auto=validate 전환
→ 개발 서버 최초 배포 및 검증

운영 서버에는 아직 Flyway를 적용하지 않았으며, 개발 서버에서 먼저 도입과 검증을 마친 상태입니다.


1. 기존 스키마 관리 방식

기존에는 개발·운영 환경에서 Hibernate가 엔티티를 기준으로 데이터베이스를 변경했습니다.

애플리케이션 시작
  ↓
Hibernate가 엔티티와 DB 비교
  ↓
필요하다고 판단한 DDL 실행
  ↓
애플리케이션 시작

이 방식에는 별도의 migration 파일이나 변경 이력 테이블이 없습니다.

예를 들어 개발 서버에 다음 엔티티 변경이 먼저 배포됐다고 가정해 보겠습니다.

@Column(name = "evaluation_data_deleted_at")
private LocalDateTime evaluationDataDeletedAt;

개발 DB에는 컬럼이 추가되지만 운영 서버에 해당 코드가 배포되지 않았다면 운영 DB에는 컬럼이 없습니다.

개발 DB: evaluation_data_deleted_at 존재
운영 DB: evaluation_data_deleted_at 없음

이 차이 자체는 개발이 운영보다 앞서 있기 때문에 자연스러울 수 있습니다.

문제는 어떤 차이가 의도된 변경이고, 어떤 차이가 과거 자동 DDL이나 수동 작업으로 남은 것인지 확인하기 어렵다는 점입니다.

의도된 차이인가?
운영 배포가 누락된 것인가?
삭제된 엔티티 필드의 잔존 컬럼인가?
누군가 DB 콘솔에서 직접 변경한 것인가?

Git에는 엔티티의 현재 모습만 있고, DB가 그 상태에 도달한 과정은 남아 있지 않았습니다.


2. 개발 DB와 운영 DB를 직접 비교해 보기

두 DB 모두 외부에서 바로 접근할 수 없었기 때문에 AWS SSM 포트포워딩을 사용했습니다.

로컬 PC
  ↓ localhost 포트
AWS SSM Session
  ↓
EC2 또는 사설 DB
  ↓
MariaDB 3306

MariaDB 클라이언트를 로컬에 설치하는 대신 Docker의 MariaDB 이미지를 사용해 스키마만 덤프했습니다.

mariadb-dump \
  --host=host.docker.internal \
  --port=<포워딩 포트> \
  --user=<DB 사용자> \
  --password \
  --no-data \
  --skip-triggers \
  --skip-comments \
  --skip-dump-date \
  --skip-add-drop-table \
  --skip-lock-tables \
  <DB 이름>

운영과 개발의 덤프 파일은 반드시 서로 다른 디렉터리와 파일명으로 저장했습니다.

schema-compare/
├─ production/
│  └─ schema.sql
└─ development/
   └─ schema.sql

환경별 디렉터리를 먼저 분리하고, 파일 생성 후 크기와 해시까지 확인했습니다.

Get-Item .\production\schema.sql
Get-FileHash .\production\schema.sql -Algorithm SHA256

두 파일은 git diff --no-index로 비교했습니다.

git diff --no-index -- .\production\schema.sql .\development\schema.sql

3. 실제로 발견한 스키마 불일치

비교 결과 단순한 AUTO_INCREMENT 현재값 차이뿐 아니라 실제 기능에 영향을 줄 수 있는 차이가 있었습니다.

대상운영 DB개발 DB
application_files.s3_keyUNIQUE 존재UNIQUE 누락
post.contentLONGTEXT NOT NULLTINYTEXT NOT NULL인 DB 존재
post.urlTEXT다른 문자열 타입
terms.contentLONGTEXT NOT NULLNULL 또는 타입 차이 존재
scholarship_cycles.review_phase없음과거 컬럼 잔존
scholarship_cycles.evaluation_data_deleted_at없음존재

review_phase는 엔티티와 코드에서 이미 제거됐지만 개발 DB에는 남아 있었습니다.

반면 evaluation_data_deleted_at은 현재 코드에서 사용하는 필드였기 때문에 앞으로 운영 DB에도 추가되어야 했습니다.

여기서 모든 차이를 무조건 운영 DB와 똑같이 만드는 것은 올바르지 않습니다.

각 차이를 다음처럼 분류해야 했습니다.

운영 DB 정의가 올바른 항목
→ 개발 DB를 운영 기준으로 정합화
 
현재 코드에 필요하지만 운영에 아직 없는 항목
→ 새로운 migration으로 운영까지 전달
 
코드에서 제거됐지만 DB에 남은 항목
→ 새로운 migration으로 제거
 
AUTO_INCREMENT 현재값과 같은 데이터 상태 차이
→ 스키마 불일치에서 제외

4. UNIQUE 제약조건보다 먼저 데이터를 확인해야 했다

application_files.s3_key에 UNIQUE 제약조건을 추가하기 전에 중복 데이터를 확인했습니다.

SELECT s3_key, COUNT(*) AS duplicate_count
FROM application_files
GROUP BY s3_key
HAVING COUNT(*) > 1;

개발 과정에서 DB 데이터를 직접 복사해 테스트 파일을 만들면서 동일한 S3 key가 여러 행에 저장된 상태였습니다.

이 상태에서 바로 UNIQUE 제약조건을 추가하면 DDL이 실패합니다.

중복 데이터 존재
→ UNIQUE 제약조건 추가 시도
→ Duplicate entry 오류

따라서 필요한 파일만 남도록 S3와 DB 데이터를 정리한 뒤 중복이 사라졌는지 다시 확인했습니다.

SELECT s3_key, COUNT(*)
FROM application_files
GROUP BY s3_key
HAVING COUNT(*) > 1;

결과가 없어진 후에 제약조건을 적용했습니다.

ALTER TABLE application_files
    ADD CONSTRAINT UKq8pqe7k4n85n3faa6b7a14354
    UNIQUE (s3_key);

스키마 변경은 DDL만 올바르다고 끝나는 것이 아니었습니다.

변경 전 데이터가 새 제약조건을 만족하는가?
NULL 데이터가 타입 변경을 방해하지 않는가?
기존 데이터 길이가 새 타입에 들어가는가?
외래 키 관계에 영향을 주지 않는가?

이 조건을 먼저 확인해야 안전하게 적용할 수 있습니다.


5. 실제 개발 DB가 아니라 복구 DB에서 먼저 실행하기

개발 데이터도 유지해야 했기 때문에 실제 개발 DB에 바로 ALTER TABLE을 실행하지 않았습니다.

먼저 전체 데이터를 포함한 dump를 만들었습니다.

dev-ssuport-full.sql

그다음 로컬 Docker에 별도의 MariaDB 10.11 컨테이너를 실행하고 백업 파일을 복원했습니다.

개발 DB 전체 dump
  ↓
로컬 MariaDB 10.11 복원
  ↓
테이블·외래 키·데이터 확인
  ↓
ALTER TABLE 실행
  ↓
스키마 재덤프
  ↓
운영 스키마와 다시 비교

복구 결과는 다음과 같았습니다.

복구 종료 코드: 0
애플리케이션 테이블: 18개
외래 키: 19개

복구 DB에서 다음 항목도 확인했습니다.

s3_key 중복 여부
terms.content NULL 여부
scholarship_cycles의 기존 값 존재 여부

리허설 DB에서 DDL과 스키마 비교가 끝난 후에만 동일한 SQL을 실제 개발 DB에 적용했습니다.

이 과정의 목적은 단순히 백업 파일을 보유하는 것이 아니었습니다.

백업 파일이 실제로 복구되는가?
복구된 데이터에서 DDL이 성공하는가?
변경 후 목표 스키마와 일치하는가?

복구 가능성을 확인한 백업이어야 실제 복구 지점으로 사용할 수 있습니다.


6. Flyway baseline 설계

기존 운영 중인 DB에는 이미 테이블과 데이터가 있습니다.

따라서 Flyway를 처음 도입한다고 해서 V1의 CREATE TABLE을 기존 DB에서 다시 실행할 수는 없습니다.

기존 DB에 테이블 존재
→ V1 CREATE TABLE 실행
→ Table already exists 오류

이 문제를 해결하기 위해 운영 DB의 현재 스키마를 기준으로 V1을 작성하고, 기존 DB에는 V1을 실행하는 대신 baseline으로 등록하기로 했습니다.

기존 DB
→ V1을 이미 적용된 버전으로 등록
→ V2부터 실제 실행
 
새로운 빈 DB
→ V1부터 실제 실행
→ V2, V3 순서대로 실행

V1 파일은 다음 위치에 생성했습니다.

src/main/resources/db/migration/V1__baseline_schema.sql

스키마 dump를 그대로 V1로 사용하지는 않았습니다.

다음처럼 환경이나 dump 시점에 종속되는 내용을 제거했습니다.

dump session 설정문
현재 AUTO_INCREMENT 카운터
dump 생성 시각 및 주석
DROP TABLE 문

반면 컬럼의 실제 AUTO_INCREMENT 속성, 인덱스, UNIQUE 제약조건과 외래 키는 유지했습니다.

테이블 생성 순서와 외래 키 참조 순서 때문에 V1 실행 중에는 외래 키 검사를 일시적으로 비활성화했습니다.

SET FOREIGN_KEY_CHECKS = 0;
 
-- CREATE TABLE ...
 
SET FOREIGN_KEY_CHECKS = 1;

빈 MariaDB 10.11에서 V1을 직접 실행한 결과 18개 테이블과 19개 외래 키가 정상적으로 생성됐습니다.


7. V2와 V3로 baseline 이후 변경을 표현하기

V1은 Flyway 도입 시점의 운영 DB 기준입니다.

그 이후 현재 애플리케이션이 필요로 하는 변경은 V2부터 별도로 작성했습니다.

V2: 장학금 회차 스키마 갱신

ALTER TABLE scholarship_cycles
    ADD COLUMN IF NOT EXISTS evaluation_data_deleted_at
    TIMESTAMP(6) NULL DEFAULT NULL;
 
ALTER TABLE scholarship_cycles
    DROP COLUMN IF EXISTS review_phase;

파일명은 다음과 같습니다.

V2__update_scholarship_cycles.sql

evaluation_data_deleted_at은 현재 코드에서 사용하는 컬럼이므로 추가했고, review_phase는 코드에서 제거된 잔존 컬럼이므로 삭제했습니다.

V3: 게시글 본문 타입 정합화

개발 서버 최초 배포에서 Hibernate validate가 실제 대상 DB의 post.content가 아직 TINYTEXT라는 사실을 발견했습니다.

이미 V1과 V2가 개발 DB의 Flyway 이력에 등록된 뒤였기 때문에 기존 파일을 수정하지 않았습니다.

새로운 V3를 추가했습니다.

ALTER TABLE post
    MODIFY COLUMN content LONGTEXT NOT NULL;
V3__post_content_type.sql

한 번 적용된 migration 파일은 내용뿐 아니라 공백이나 포맷도 수정하지 않는 것이 원칙입니다.


8. Spring Boot에 Flyway 추가하기

MariaDB를 사용하고 있으므로 Gradle에 다음 의존성을 추가했습니다.

implementation 'org.flywaydb:flyway-core'
implementation 'org.flywaydb:flyway-mysql'

설정은 다음과 같이 변경했습니다.

spring:
  jpa:
    hibernate:
      ddl-auto: validate
    properties:
      hibernate:
        format_sql: true
    open-in-view: false
 
  flyway:
    enabled: true
    locations: classpath:db/migration
    baseline-version: 1
    baseline-on-migrate: ${FLYWAY_BASELINE_ON_MIGRATE:false}
    validate-on-migrate: true
    out-of-order: false
    clean-disabled: true

각 설정의 역할은 다음과 같습니다.

설정역할
enabled애플리케이션 시작 시 Flyway 실행
locationsmigration 파일 위치 지정
baseline-version기존 DB를 등록할 기준 버전
baseline-on-migrate이력이 없는 기존 DB의 일회성 baseline 허용
validate-on-migrate적용 이력과 migration 파일 checksum 검증
out-of-order버전을 건너뛰어 적용하는 동작 방지
clean-disabledFlyway를 통한 전체 스키마 삭제 방지

ddl-auto=validate는 DB를 변경하지 않습니다.

Flyway
→ 아직 적용되지 않은 migration 실행
 
Hibernate validate
→ migration 적용 결과와 엔티티가 일치하는지 검사

두 도구의 역할을 분리했습니다.


9. 빈 DB에서 전체 migration 검증하기

기존 DB에서 baseline만 성공했다고 V1이 올바른 것은 아닙니다.

baseline은 V1 SQL을 실행하거나 기존 DB와 비교하지 않고 다음처럼 기록하기 때문입니다.

이 DB는 이미 V1 상태라고 간주한다

따라서 별도의 빈 MariaDB 10.11에서 V1과 이후 migration을 모두 실행했습니다.

빈 MariaDB
→ V1 실행
→ V2 실행
→ Hibernate validate
→ 애플리케이션 기동

초기 실행에서는 Hibernate가 @Lob String 필드를 LONGTEXT로 기대하지만 엔티티 정의가 명확하지 않은 문제가 있었습니다.

Post.content와 Term.content에 실제 목표 스키마를 명시했습니다.

@Lob
@Column(
    name = "content",
    nullable = false,
    columnDefinition = "LONGTEXT"
)
private String content;

수정 후 다음 내용을 확인했습니다.

Flyway migration 검증 성공
현재 스키마 버전 확인
Hibernate EntityManagerFactory 초기화
Tomcat 실행
Spring Boot 애플리케이션 기동

10. 개발 서버 최초 배포

기존 개발 DB에는 테이블이 있지만 flyway_schema_history가 없었습니다.

최초 배포에서만 다음 값을 사용했습니다.

baseline-on-migrate: true

배포 시 실행 흐름은 다음과 같습니다.

기존 개발 DB 확인
→ V1을 BASELINE으로 등록
→ V2 실행
→ Hibernate validate

Flyway 이력은 다음처럼 생성됐습니다.

1 | 1 | << Flyway Baseline >>       | BASELINE | 성공
2 | 2 | update scholarship cycles   | SQL      | 성공

하지만 애플리케이션은 처음에 기동하지 못했습니다.

Schema-validation:
wrong column type encountered in column [content] in table [post]
found [tinytext]
expecting [longtext]

이 오류는 Flyway 도입 실패가 아니었습니다.

ddl-auto=update였다면 Hibernate가 스키마를 자동으로 바꾸거나 문제를 숨겼을 수 있지만, validate는 실제 대상 DB가 엔티티와 다르다는 사실을 정확하게 보여줬습니다.

V3를 추가해 다시 배포한 뒤 다음 결과를 확인했습니다.

V3 post content type 적용 성공
Hibernate ddl-auto=validate 통과
JPA EntityManagerFactory 초기화 성공
Tomcat 8080 기동 성공
애플리케이션 정상 시작

최종 개발 DB 이력은 다음과 같습니다.

1 | 1 | << Flyway Baseline >>       | BASELINE | 1
2 | 2 | update scholarship cycles   | SQL      | 1
3 | 3 | post content type           | SQL      | 1

V1 baseline 등록이 확인된 후에는 설정을 다시 변경했습니다.

baseline-on-migrate: false

11. 왜 baseline-on-migrate를 다시 false로 바꾸는가

baseline-on-migrate=true는 기존 DB를 Flyway에 처음 등록할 때 편리합니다.

하지만 계속 활성화하면 이력이 없는 다른 DB에 잘못 연결됐을 때도 Flyway가 해당 DB를 정상적인 V1 상태로 간주할 수 있습니다.

잘못된 DB 주소 설정
또는 flyway_schema_history가 없는 다른 스키마 연결
  ↓
baseline-on-migrate=true
  ↓
해당 DB를 V1 상태로 자동 등록할 가능성

반대로 false라면 비어 있지 않은 미등록 DB에서 오류를 발생시켜 배포를 중단합니다.

true
→ 기존 DB 최초 등록을 위한 일회성 옵션
 
false
→ 등록 완료 후 유지하는 안전한 기본값

설정 파일에는 다음처럼 환경변수를 사용할 수 있습니다.

baseline-on-migrate: ${FLYWAY_BASELINE_ON_MIGRATE:false}

최초 등록 시에만 환경변수로 true를 전달하고, 성공 후 제거하면 기본값 false로 돌아갑니다.


12. 기존 로컬 DB는 어떻게 처리할까

팀원들의 로컬 DB에도 기존 테이블과 데이터가 있습니다.

로컬 DB 상태에 따라 세 가지 흐름으로 나눴습니다.

빈 로컬 DB

FLYWAY_BASELINE_ON_MIGRATE 미설정
→ 기본값 false
→ V1, V2, V3 실제 실행
→ Hibernate validate

기존 테이블이 있고 Flyway 이력이 없는 DB

최초 실행에만 다음 환경변수를 지정합니다.

FLYWAY_BASELINE_ON_MIGRATE=true
V1 baseline 등록
→ V2, V3 실행
→ Hibernate validate

정상 실행 후 환경변수를 삭제합니다.

기존 로컬 스키마가 V1과 다른 DB

실제로 기존 로컬 DB에서는 terms.content가 TINYTEXT라서 Hibernate 검증이 실패했습니다.

로컬 DB: TINYTEXT NOT NULL
엔티티·V1: LONGTEXT NOT NULL

로컬 데이터가 필요하지 않으면 빈 DB를 새로 만드는 것이 가장 안전합니다.

데이터가 중요하다면 먼저 백업하고, V1과 다른 부분을 일회성으로 정합화할 수 있습니다.

SELECT COUNT(*)
FROM terms
WHERE content IS NULL;
 
ALTER TABLE terms
    MODIFY COLUMN content LONGTEXT NOT NULL;

로컬의 과거 스키마만 맞추기 위한 공용 migration은 추가하지 않았습니다.

제품 스키마의 새로운 변경이 아니라, 기존 로컬 DB가 baseline 기준과 달랐던 문제이기 때문입니다.

불일치가 계속 발견된다면 하나씩 수정하기보다 새 빈 DB에서 V1부터 실행하는 것이 낫습니다.


13. 기존과 Flyway 도입 후 배포 흐름 비교

기존 방식

엔티티 수정
→ develop 배포
→ Hibernate가 개발 DB 자동 변경
→ main 배포
→ Hibernate가 운영 DB 자동 변경
→ 실제로 어떤 DDL이 실행됐는지 이력 없음

Flyway 도입 후

엔티티 수정
+ 새로운 migration 작성
→ 같은 PR에서 검토
→ 빈 로컬 DB migration 검증
→ 기존 데이터가 있는 DB 검증
→ 개발 서버 배포
→ flyway_schema_history 확인
→ Hibernate validate 확인
→ 동일한 커밋을 운영 배포
→ 운영 migration 이력과 서비스 상태 확인

환경마다 다른 SQL을 만드는 것이 아닙니다.

로컬: V1 → V2 → V3 → V4 ...
개발: V1 → V2 → V3 → V4 ...
운영: V1 → V2 → V3 → V4 ...

동일한 파일을 사용하되, 각 DB의 flyway_schema_history를 기준으로 아직 적용되지 않은 버전만 실행합니다.


14. 개선 전후 비교

항목기존 ddl-auto=updateFlyway + ddl-auto=validate
스키마 변경 주체Hibernate 자동 판단명시적인 SQL migration
변경 이력없음flyway_schema_history
Git 코드 리뷰엔티티 위주엔티티와 DDL 함께 검토
적용 순서환경별 실행 시점에 의존버전 순서 보장
개발·운영 차이 확인별도 dump 비교 필요적용 버전과 이력 확인 가능
잘못된 컬럼 타입자동 변경되거나 뒤늦게 발견시작 시 검증 실패
기존 DB 최초 도입별도 개념 없음V1 baseline 등록
빈 DB 생성Hibernate 현재 엔티티 기준V1부터 전체 이력 재생

Flyway를 도입했다고 모든 문제가 자동으로 해결되는 것은 아닙니다.

Flyway는 작성된 migration을 순서대로 실행하고 이력을 보장합니다.

잘못된 SQL을 작성하면
→ 잘못된 SQL도 정확한 순서로 실행됨

따라서 다음 검증은 여전히 필요합니다.

변경 전 데이터 사전 조건 확인
백업 및 복구 리허설
빈 DB 전체 migration 실행
기존 데이터 DB에서 신규 migration 실행
Hibernate validate
개발 서버 선배포

마치며

이번 작업은 단순히 Gradle에 Flyway 의존성을 추가하는 작업이 아니었습니다.

먼저 현재 개발 DB와 운영 DB가 어떤 상태인지 알아야 했습니다.

스키마 dump
→ diff
→ 불일치 분류
→ 데이터 사전 조건 확인
→ 백업 복구 리허설
→ 정합화

그다음 기존 DB를 V1 baseline으로 등록하고, 기준 시점 이후의 변경을 V2와 V3로 표현했습니다.

V1: 운영 DB 기준 초기 스키마
V2: scholarship_cycles 변경
V3: post.content 타입 정합화

가장 인상적인 부분은 ddl-auto=validate가 개발 서버 배포 과정에서 남아 있던 TINYTEXT와 LONGTEXT 불일치를 실제로 잡아낸 순간이었습니다.

애플리케이션이 시작되지 않은 것은 배포 실패처럼 보였지만, 실제로는 잘못된 스키마 상태에서 서비스를 시작하지 않도록 막아준 결과였습니다.

기존:
Hibernate가 DB 구조를 자동으로 변경
 
개선:
Flyway가 명시된 변경만 실행
+ Hibernate가 결과를 검증

Flyway의 핵심은 SQL 파일을 자동 실행해 주는 데만 있지 않습니다.

스키마 변경을 코드처럼 버전 관리하고
동일한 변경을 모든 환경에 같은 순서로 적용하며
누가 보더라도 DB가 현재 상태에 도달한 과정을 확인할 수 있게 하는 것

이번 전환을 통해 편리한 자동 변경보다, 명시적인 변경 이력과 실패할 때 안전하게 멈추는 구조가 운영 환경에서는 더 중요하다는 점을 배울 수 있었습니다.

DB 스키마도 애플리케이션 코드와 마찬가지로 변경 이유, 적용 순서와 검증 결과가 함께 관리되어야 한다.