JPA를 쓰는 애플리케이션에서 테이블을 만들고 바꾸는 방법은 두 가지다. Hibernate가 엔티티를 보고 스키마를 직접 고치게 하거나(ddl-auto), 마이그레이션 도구(Flyway)가 버전별 SQL 파일로 스키마를 관리하게 하는 것이다. 운영 DB에서는 보통 Flyway가 스키마를 만들고, Hibernate는 엔티티와 테이블이 맞는지 검사만 하게 둔다.

ddl-auto

spring.jpa.hibernate.ddl-auto는 애플리케이션이 시작할 때 Hibernate가 스키마에 무엇을 할지 정한다.

값하는 일
none아무것도 하지 않음
validate엔티티와 테이블이 맞는지 검사만 함; 안 맞으면 시작 실패
update엔티티에 맞춰 테이블을 고침(컬럼 추가 등)
create시작할 때마다 테이블을 지우고 새로 만듦
create-dropcreate에 더해 종료할 때 테이블도 지움

Spring Boot의 기본값은 H2 같은 내장 DB이면서 Flyway나 Liquibase가 없을 때 create-drop이고, 그 밖에는 none이다. MySQL을 쓰면 적지 않아도 none이라 Flyway와 충돌하지 않는다.

update와 create는 운영 DB에 쓰지 않는다. update는 컬럼을 추가할 뿐 이름 변경이나 삭제를 정확히 반영하지 못하고, 어떤 DDL이 실행됐는지 기록도 남지 않는다. create는 데이터를 지운다.

validate를 켜는 이유

yaml
spring:
  jpa:
    hibernate:
      ddl-auto: validate

none과 validate는 둘 다 스키마를 바꾸지 않는다. validate를 쓰면 두 가지가 더 생긴다.

  • 설정만 봐도 '스키마는 다른 도구가 관리하고 Hibernate는 검사만 한다'는 의도가 드러난다.
  • 엔티티와 테이블이 어긋나면 첫 쿼리 때가 아니라 애플리케이션 시작 시점에 실패한다. 예를 들어 엔티티에 @Table(name = "bookings")를 빠뜨리면 Schema-validation: missing table [booking]으로 바로 멈춘다.

검사 범위에는 한계가 있다. 테이블과 컬럼이 있는지, 타입이 맞는지는 보지만, MySQL ENUM 컬럼의 값 목록과 Kotlin enum 이름의 철자가 같은지는 보지 않는다. 이런 문제는 저장하고 다시 읽는 리포지토리 테스트로 잡는다. JPA 엔티티 매핑에 정리했다.

Flyway

Flyway는 SQL 파일에 버전을 붙여 차례대로 적용하고, 어디까지 적용했는지 DB의 flyway_schema_history 테이블에 기록하는 마이그레이션 도구다. Spring Boot에서는 의존성만 넣으면 애플리케이션 시작 시점에 아직 적용하지 않은 파일을 실행한다.

  • 위치: src/main/resources/db/migration/
  • 파일 이름: V번호__설명.sql(밑줄 두 개). 예: V3__add_channel_to_bookings.sql
  • 이미 적용된 파일은 고치지 않는다. Flyway가 적용한 파일의 체크섬을 기록해 두었다가, 내용이 바뀌면 검증 단계에서 시작을 막는다. 고칠 것이 있으면 새 버전 파일을 추가한다.
  • 아직 어느 DB에도 적용되지 않은 파일은 고쳐도 된다. 로컬 DB에 한 번이라도 적용되면 그때부터는 고정이다.

설명 부분은 기록에 그대로 남는다. 다른 파일을 복사해 만들면서 이름을 안 바꾸면, 적용 로그와 flyway_schema_history에 엉뚱한 설명이 영구히 남는다.

적용 여부는 시작 로그로 확인한다.

Successfully validated 3 migrations
Migrating schema `app` to version "3 - add channel to bookings"
Successfully applied 1 migration to schema `app`, now at version v3

이미 다 적용된 상태면 Schema ... is up to date. No migration necessary.가 찍힌다.

패턴 1: 행이 있는 테이블에 NOT NULL 컬럼 추가

sql
-- 기존 행을 'WEB'으로 채우며 컬럼을 추가함
ALTER TABLE bookings
    ADD COLUMN channel VARCHAR(10) NOT NULL DEFAULT 'WEB';
 
-- 기본값은 바로 제거함
ALTER TABLE bookings
    ALTER COLUMN channel DROP DEFAULT;

MySQL은 행이 있는 테이블에 NOT NULL 컬럼을 기본값 없이 추가해도 실패하지 않는다. 기존 행에는 그 타입의 암묵적 기본값이 조용히 들어간다. 문자열 컬럼이면 빈 문자열 '', 숫자 컬럼이면 0이다. MySQL 8.4에서 기본값 없이 추가해 보면 기존 행이 모두 ''로 채워진다. 의미 없는 값이 에러 없이 들어가므로, 기존 행에 넣을 값을 DEFAULT로 직접 정한다. PostgreSQL은 같은 상황에서 에러를 낸다.

두 번째 문장에서 기본값을 지우는 이유는 이후의 INSERT 때문이다. 기본값이 남아 있으면 코드가 값을 빠뜨려도 조용히 'WEB'이 들어간다. 지우면 값을 빠뜨린 INSERT가 Field 'channel' doesn't have a default value 에러로 바로 드러난다. 엔티티 필드도 val channel: String처럼 기본값 없이 추가해서, 저장하는 코드가 매번 값을 정하게 한다.

패턴 2: NOT NULL 컬럼을 nullable로 바꾸기

sql
ALTER TABLE bookings
    MODIFY COLUMN memo TEXT;
  • MODIFY COLUMN은 컬럼 정의를 통째로 다시 쓴다. 타입도 다시 적어야 하고, MODIFY COLUMN memo NULL처럼 타입을 빼면 문법 오류가 난다.
  • NOT NULL을 적지 않으면 nullable이다. MODIFY COLUMN memo TEXT NOT NULL은 목표와 반대다.
  • 엔티티 필드도 val memo: String?으로 함께 바꾼다. DB는 NULL을 허용하는데 코드는 항상 값을 요구하는 상태가 되지 않게 한다.

확인 방법

sql
DESC bookings;   -- 타입, NULL 허용, 기본값
SELECT version, description, success FROM flyway_schema_history;

컬럼 타입과 제약 조건은 MySQL 컬럼 타입과 제약 조건에, 테스트에서 실제 MySQL에 마이그레이션을 적용하는 방법은 Spring Boot에서 Testcontainers 쓰기에 정리했다. 다른 ORM의 마이그레이션은 MikroORM 마이그레이션과 TypeORM 마이그레이션에 있다.