JPA ddl-auto 대신 Flyway로 DB 스키마 관리해보기
ddl-auto
JPA 구현체인 Hibernate가 테이블 생성/검증/수정에 얼마나 관여할지 정하는 설정.
ddl-auto 설정 종류
create - 애플리케이션 시작 시 기존 테이블 등의 스키마 객체를 삭제하고, Entity를 기준으로 다시 생성한다.
create-drop - 애플리케이션 시작 시 기존 스키마 객체를 삭제하고 다시 생성하며, 애플리케이션이 정상 종료될 때 다시 삭제한다.
update - Entity 매핑을 기준으로 DB 스키마 변경을 시도한다. 변경 이력이 남지 않으며, 모든 변경을 안전하게 처리한다고 보장할 수는 없다.
validate - Entity 매핑에 필요한 테이블과 컬럼 등이 DB 구조와 호환되는지 검증한다. 문제가 있으면 애플리케이션 실행에 실패한다.
none - Hibernate가 DB 스키마를 생성하거나 변경하거나 검증하지 않는다.
ddl-auto만 사용했을 때 생긴 문제
팀원이 Entity를 변경할 때마다 어떤 테이블과 컬럼이 변경되었는지 별도로 공유해야 했다.
전달이 누락되거나 각자의 코드 상태가 다르면 로컬 DB 구조도 서로 달라질 수 있었다.
ERD와 채팅 기록은 변경 내용을 참고하는 데에는 도움이 되었지만, 실제 DB에 그대로 실행할 수 있는 변경 이력은 아니었다.
여기서 두 가지 불편함을 느꼈다.
- DB 구조의 어떤 부분이 변경되었는가?
- 현재 내 로컬 DB에는 변경 사항이 어디까지 적용되었는가?
Flyway
참고: https://ywoosang.tistory.com/18
Flyway는 데이터베이스 변경 사항을 SQL 파일로 관리하게 해주는 데이터베이스 마이그레이션 도구다.
데이터베이스 마이그레이션은 테이블, 컬럼, 인덱스 같은 DB 구조를 변경하거나 데이터를 변경하는 과정을 말한다.
그리고 마이그레이션 도구를 사용하면 이런 변경 사항을 버전으로 관리하고 이력을 추적할 수 있다.
기존에는 Entity 변경 후 DB가 어떻게 바뀌었는지 직접 확인해야 했다면,
Flyway를 사용하면 변경 내용을 SQL 파일로 남길 수 있다.
예를 들어 다음과 같은 파일로 DB 변경을 관리한다.
V1__init_schema.sql
V2__add_unique_constraints.sql
V3__add_index_to_ranking.sql
Flyway 도입 방향
이번 글에서는 Flyway의 모든 기능을 다루기보다,
DB 스키마 변경 사항을 SQL 파일로 남기고 버전 관리하는 기본 흐름에 집중한다.
기존에는 Entity 변경과 `ddl-auto` 설정에 의존해 DB 구조를 맞췄다면,
이제는 테이블 생성과 제약조건 추가 같은 변경 사항을 마이그레이션 파일로 관리해보려 한다.
즉, JPA Entity는 애플리케이션에서 사용할 객체 매핑 역할을 맡고,
DB 구조 변경 이력은 Flyway SQL 파일로 남기는 방향이다.
1. Flyway 의존성 추가
build.gradle의 dependencies에 아래 두 줄 추가
implementation 'org.flywaydb:flyway-core'
implementation 'org.flywaydb:flyway-mysql'
flyway-core는 Flyway의 핵심 기능을 제공하고,
flyway-mysql은 MySQL 환경에서 Flyway가 동작하도록 추가한 의존성이다.
2. 파일 생성
파일명 규칙
V버전__설명.sql
예: V1__init_schema.sql
- V로 시작한다.
- 버전과 설명 사이에는 언더바 두 개를 사용한다.
- 확장자는 .sql을 사용한다.
- 뒤에 실행되어야 하는 파일일수록 더 큰 버전을 사용한다.
V1__init_schema.sql
Flyway는 기본적으로 src/main/resources/db/migration 경로에서 마이그레이션 파일을 찾는다.

3. 초기 스키마 작성
이제 V1__init_schema.sql 파일 안에 1차 MVP에서 사용할 초기 테이블을 작성했다.
초기 스키마에는 다음 테이블들을 포함했다.
users
ticketing_events
event_seat_grades
seats
seat_selections
처음에는 이벤트, 좌석, 선택 기록 정도만 생각했지만 좌석 등급별 점수와 이벤트별 좌석 구성을 분리하기 위해 event_seat_grades 테이블도 함께 두었다.
그리고 테이블 생성뿐 아니라 UNIQUE, CHECK, FOREIGN KEY 같은 제약조건도 함께 작성했다.
사용자
→ 이메일 중복 금지
→ 닉네임 중복 금지
이벤트
→ 같은 시작 시각 중복 금지
→ 정각 시작만 허용
→ 종료 시각은 시작 시각보다 뒤
→ 상태와 타입은 지정된 값만 허용
좌석 등급
→ 이벤트 내 같은 등급 코드 중복 금지
→ 음수 점수 금지
좌석
→ 이벤트 내 같은 좌표 중복 금지
→ 좌표는 1 이상
→ 좌석과 등급은 같은 이벤트에 소속
좌석 선택
→ 사용자당 이벤트에서 한 번만 선택
→ 좌석당 이벤트에서 한 번만 선택
→ 선택한 좌석은 해당 이벤트 소속이어야 함
→ 음수 점수 금지
이렇게 작성하면 DB 구조와 제약조건이 Entity 코드에만 의존하지 않고, V1__init_schema.sql 파일에 명시적으로 남는다.
즉, 초기 DB 구조를 하나의 SQL 마이그레이션 파일로 관리할 수 있게 된다.
CREATE TABLE users (
id BIGINT NOT NULL AUTO_INCREMENT,
email VARCHAR(255) NOT NULL,
password_hash VARCHAR(255) NOT NULL,
nickname VARCHAR(50) NOT NULL,
created_at DATETIME(6) NOT NULL,
updated_at DATETIME(6) NOT NULL,
PRIMARY KEY (id),
UNIQUE KEY uk_users_email (email),
UNIQUE KEY uk_users_nickname (nickname)
);
CREATE TABLE ticketing_events (
id BIGINT NOT NULL AUTO_INCREMENT,
title VARCHAR(100) NOT NULL,
event_type VARCHAR(50) NOT NULL DEFAULT 'DAILY_PRACTICE',
grid_width INT NOT NULL,
grid_height INT NOT NULL,
open_at DATETIME(6) NOT NULL,
close_at DATETIME(6) NULL,
status VARCHAR(20) NOT NULL DEFAULT 'SCHEDULED',
created_at DATETIME(6) NOT NULL,
updated_at DATETIME(6) NOT NULL,
PRIMARY KEY (id),
CONSTRAINT chk_ticketing_events_type
CHECK (event_type IN ('DAILY_PRACTICE')),
CONSTRAINT chk_ticketing_events_status
CHECK (status IN ('SCHEDULED', 'OPEN', 'CLOSED', 'CANCELED')),
CONSTRAINT chk_ticketing_events_grid_size
CHECK (grid_width > 0 AND grid_height > 0),
CONSTRAINT chk_ticketing_events_time
CHECK (close_at IS NULL OR close_at > open_at),
UNIQUE KEY uk_ticketing_events_open_at (open_at),
CONSTRAINT chk_ticketing_events_open_at_hour
CHECK (
MINUTE(open_at) = 0
AND SECOND(open_at) = 0
AND MICROSECOND(open_at) = 0
)
);
CREATE TABLE event_seat_grades (
id BIGINT NOT NULL AUTO_INCREMENT,
event_id BIGINT NOT NULL,
grade VARCHAR(20) NOT NULL,
name VARCHAR(50) NOT NULL,
score INT NOT NULL,
created_at DATETIME(6) NOT NULL,
updated_at DATETIME(6) NOT NULL,
PRIMARY KEY (id),
UNIQUE KEY uk_event_seat_grades_event_grade (
event_id,
grade
),
UNIQUE KEY uk_event_seat_grades_event_id_id (
event_id,
id
),
CONSTRAINT fk_event_seat_grades_ticketing_events
FOREIGN KEY (event_id)
REFERENCES ticketing_events (id),
CONSTRAINT chk_event_seat_grades_score
CHECK (score >= 0)
);
CREATE TABLE seats (
id BIGINT NOT NULL AUTO_INCREMENT,
event_id BIGINT NOT NULL,
event_seat_grade_id BIGINT NOT NULL,
grid_x INT NOT NULL,
grid_y INT NOT NULL,
created_at DATETIME(6) NOT NULL,
updated_at DATETIME(6) NOT NULL,
PRIMARY KEY (id),
UNIQUE KEY uk_seats_event_position (
event_id,
grid_x,
grid_y
),
UNIQUE KEY uk_seats_event_id_id (
event_id,
id
),
CONSTRAINT fk_seats_ticketing_events
FOREIGN KEY (event_id)
REFERENCES ticketing_events (id),
CONSTRAINT fk_seats_event_seat_grades
FOREIGN KEY (event_id, event_seat_grade_id)
REFERENCES event_seat_grades (event_id, id),
CONSTRAINT chk_seats_grid_position
CHECK (grid_x > 0 AND grid_y > 0)
);
CREATE TABLE seat_selections (
id BIGINT NOT NULL AUTO_INCREMENT,
event_id BIGINT NOT NULL,
seat_id BIGINT NOT NULL,
user_id BIGINT NOT NULL,
score INT NOT NULL,
selected_at DATETIME(6) NOT NULL,
PRIMARY KEY (id),
UNIQUE KEY uk_seat_selections_event_user (
event_id,
user_id
),
UNIQUE KEY uk_seat_selections_event_seat (
event_id,
seat_id
),
CONSTRAINT fk_seat_selections_ticketing_events
FOREIGN KEY (event_id)
REFERENCES ticketing_events (id),
CONSTRAINT fk_seat_selections_users
FOREIGN KEY (user_id)
REFERENCES users (id),
CONSTRAINT fk_seat_selections_seats
FOREIGN KEY (event_id, seat_id)
REFERENCES seats (event_id, id),
CONSTRAINT chk_seat_selections_score
CHECK (score >= 0)
);
4. MySQL 실행 환경 준비
Spring Boot 애플리케이션이 DB에 연결하려면 먼저 MySQL 서버가 실행 중이어야 한다.
이번 프로젝트에서는 MySQL을 Docker로 실행하기 위해 프로젝트 루트에 docker-compose.yml 파일을 작성했다.
services:
mysql:
image: mysql:8.0
container_name: everyday-ticketing-mysql
ports:
- "3307:3306"
environment:
MYSQL_ROOT_PASSWORD: root
MYSQL_DATABASE: everyday_ticketing
MYSQL_USER: everyday
MYSQL_PASSWORD: everyday
volumes:
- everyday-ticketing-mysql-data:/var/lib/mysql
command:
- --character-set-server=utf8mb4
- --collation-server=utf8mb4_unicode_ci
volumes:
everyday-ticketing-mysql-data:
작성한 설정으로 MySQL 컨테이너를 실행했다.
docker compose up -d
docker-compose.yml은 MySQL 컨테이너를 어떤 설정으로 실행할지 적어둔 파일이고,
실제 실행은 docker compose up -d 명령어로 이루어진다.
docker ps
5. Spring Boot DB 연결 설정
이제 Spring Boot가 Docker로 실행한 MySQL에 연결할 수 있도록 application.yml을 설정했다.
spring:
application:
name: everyday-ticketing
datasource:
url: jdbc:mysql://localhost:3307/everyday_ticketing?serverTimezone=Asia/Seoul&characterEncoding=UTF-8
username: everyday
password: everyday
driver-class-name: com.mysql.cj.jdbc.Driver
jpa:
hibernate:
ddl-auto: validate
properties:
hibernate:
format_sql: true
flyway:
enabled: true
Docker MySQL은 내 로컬 기준으로 localhost:3307에 연결된다.
localhost:3307
DB 이름, 사용자명, 비밀번호는 docker-compose.yml에서 설정한 값과 맞춰주었다.
DB 이름: everyday_ticketing
username: everyday
password: everyday
ddl-auto는 validate로 설정했다.
ddl-auto: validate
이후 Entity를 추가했을 때 Hibernate가 테이블을 자동으로 생성하거나 수정하지 않고, Entity 매핑에 필요한 테이블과 컬럼 등이 Flyway로 생성한 DB 구조와 호환되는지 검증하도록 validate로 설정했다.
테이블 생성과 변경 이력 관리는 Flyway가 담당하고, Hibernate는 Entity 매핑에 필요한 주요 구조를 검증하는 역할로 둔다.
6. Spring Boot 실행으로 Flyway 적용 확인
이제 Spring Boot 애플리케이션을 실행했다.
./gradlew bootRun
bootRun은 Spring Boot 애플리케이션의 main() 메서드를 실행하는 Gradle 명령어다.
실행 흐름은 다음과 같다.
Spring Boot 실행
→ application.yml의 정보로 MySQL 연결
→ Flyway가 기존 마이그레이션 이력과 체크섬 검증
→ 아직 적용되지 않은 마이그레이션 실행
→ Hibernate가 Entity 매핑과 DB 구조 검증
→ 애플리케이션 실행 완료
현재는 V1__init_schema.sql 파일이 있으므로, Spring Boot 실행 과정에서 Flyway가 해당 SQL 파일을 읽고 DB에 적용한다.
실행 로그에서 다음 문구를 확인했다.
Started EverydayTicketingApplication
이 로그가 보이면 Spring Boot 애플리케이션이 정상적으로 실행된 것이다.
7. 테이블 생성 확인
Spring Boot 실행 후, MySQL에 접속해서 테이블이 생성되었는지 확인했다.
docker exec -it everyday-ticketing-mysql mysql -ueveryday -p everyday_ticketing
MySQL 콘솔에 접속한 뒤 다음 명령어를 실행했다.
SHOW TABLES;
결과는 다음과 같았다.

users,
ticketing_events,
event_seat_grades,
seats,
seat_selections는 V1__init_schema.sql에 작성했던 테이블이다.
즉, Flyway가 초기 스키마 SQL 파일을 실행해서 실제 DB에 테이블을 생성한 것이다.
여기서 flyway_schema_history 테이블도 함께 생성된 것을 볼 수 있다.
이 테이블은 내가 직접 만든 테이블이 아니라, Flyway가 마이그레이션 적용 이력을 관리하기 위해 자동으로 생성한 테이블이다.
8. Flyway 적용 이력 확인
Flyway가 어떤 마이그레이션 파일을 적용했는지 확인하기 위해 flyway_schema_history 테이블을 조회했다.
SELECT installed_rank, version, description, type, script, success
FROM flyway_schema_history;
결과는 다음과 같았다.

이 결과를 통해 다음 내용을 확인할 수 있다.
installed_rank: 1
→ 첫 번째로 적용된 마이그레이션
version: 1
description: init schema
type: SQL
→ SQL 파일로 실행된 마이그레이션
script: V1__init_schema.sql
success: 1
즉, Flyway가 V1__init_schema.sql 파일을 정상적으로 실행했고, 그 결과를 flyway_schema_history 테이블에 기록한 것이다.
이 테이블을 보면 현재 DB에 어떤 마이그레이션이 어디까지 적용되었는지 확인할 수 있다.
다만 flyway_schema_history는 변경 내용을 자세히 설명하는 테이블은 아니다.
어디까지 적용되었는지는 flyway_schema_history로 확인하고, 실제로 무엇이 변경되었는지는 각 버전의 SQL 파일을 확인해야 한다.
flyway_schema_history에는 이 외에도 여러 실행 정보가 기록되지만, 이번 글에서는 주요 항목만 간단히 다룬다
9. 스키마 변경이 필요하다면
스키마 변경이 필요할 때는 이미 적용된 V1__init_schema.sql 파일을 수정하는 방식으로 진행하지 않는다.
대신 새로운 버전의 마이그레이션 파일을 추가한다.
Flyway는 적용된 SQL 파일의 내용을 기준으로 체크섬이라는 값을 기록한다. 이미 적용된 파일을 나중에 수정하면 이전에 기록한 값과 달라지므로 검증 오류가 발생할 수 있다.
예를 들어 랭킹 조회 성능 개선을 위해 인덱스가 필요해졌다면 다음과 같은 파일을 새로 만든다.
V2__add_ranking_index.sql
그리고 해당 파일 안에 필요한 SQL을 작성한다.
예를 들면 다음과 같은 형태가 될 수 있다.
CREATE INDEX idx_seat_selections_event_score
ON seat_selections (event_id, score DESC, selected_at ASC);
이후 Spring Boot 애플리케이션을 다시 실행하면 Flyway는 flyway_schema_history를 확인한다.
DB에는 V1까지만 적용되어 있음
db/migration 폴더에는 V1, V2가 있음
→ V2는 아직 적용되지 않았다고 판단
→ V2__add_ranking_index.sql 실행
→ flyway_schema_history에 V2 기록
즉, Flyway는 이미 적용된 마이그레이션을 다시 실행하지 않고, 아직 적용되지 않은 새 버전만 순서대로 실행한다.
다시 이력을 조회하면 다음과 같이 V2가 추가된 것을 확인할 수 있다.
SELECT installed_rank, version, description, type, script, success
FROM flyway_schema_history;
예상된 조회 결과 중 핵심 값만 정리하면 다음과 같다.
version | description | script | success
1 | init schema | V1__init_schema.sql | 1
2 | add ranking index | V2__add_ranking_index.sql | 1
이렇게 Flyway를 사용하면 DB 스키마 변경 사항을 SQL 파일로 남기고, 어떤 변경이 어디까지 적용되었는지도 DB 이력으로 확인할 수 있다.
10. 정리
이번 과정에서는 Flyway를 이용해 초기 DB 스키마를 관리하는 흐름을 확인했다.
흐름을 정리하면 다음과 같다.
1. Flyway 의존성 추가
2. src/main/resources/db/migration 경로에 V1__init_schema.sql 작성
3. MySQL 실행 환경 준비
4. Spring Boot에서 DB 연결 설정
5. Spring Boot 실행
6. Flyway가 V1 SQL 파일 적용
7. SHOW TABLES로 테이블 생성 확인
8. flyway_schema_history로 적용 이력 확인
기존에는 Entity 변경과 ddl-auto 설정에 의존해 DB 구조를 맞췄다면, 이제는 DB 변경 사항을 SQL 파일로 명시하고 버전으로 관리할 수 있게 되었다.
현재는 V1__init_schema.sql 하나만 적용된 상태지만, 이후 스키마 변경이 필요하면 V2, V3 파일을 추가하는 방식으로 변경 이력을 쌓아갈 수 있다.
11. 다른 선택지: Liquibase
이번에는 SQL 파일을 이용해 DB 변경 이력을 관리하는 기본 흐름을 경험하기 위해 Flyway를 사용했다.
현재 프로젝트에는 Flyway만으로도 충분하지만, 이후 더 복잡한 변경 관리나 롤백 같은 기능이 필요해진다면 다른 데이터베이스 마이그레이션 도구인 Liquibase도 별도로 알아볼 수 있을 것 같다.