Entity에 필드를 하나 추가한다고 하자.
@Column(name = "phone_number", nullable = false)
private String phoneNumber;
이걸로 끝인가, 아니면 어딘가에 ALTER TABLE을 하나 더 써야 하는가. 정한 적이 없다.
「아직 정하지 않은 것」 장부를 뒤져보니 그 자리가 그대로 적혀 있었다. Flyway도 Liquibase도 ddl-auto도 표준 전체에서 0건이다. 그런데 DB 시간 저장 표준은 TIMESTAMP(6)과 세션 UTC 초기화를 강제한다. 그걸 보장할 DDL이 어디 사는지가 없었다.
선택지는 이렇게 갈린다.
// 안 A — Entity가 스키마를 만든다 (ddl-auto)
@Column(name = "created_at", columnDefinition =
"TIMESTAMP(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6)")
private Instant createdAt;
-- 안 B — 마이그레이션 파일이 스키마를 만든다
created_at TIMESTAMP(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
updated_at TIMESTAMP(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6)
ON UPDATE CURRENT_TIMESTAMP(6)
걸려 있는 건 컬럼 하나가 아니다. 스키마가 애플리케이션 안에 사는가 밖에 사는가이고, 그 답이 테스트가 무엇을 검증하는지까지 바꾼다.
먼저: 정한 적이 없는 줄 알았는데 이미 좁혀져 있었다
장부에는 “0건”이라 적혀 있었다. 그래서 백지에서 시작하는 줄 알았는데, 기존 표준을 읽어보니 네 가지가 이미 답을 좁혀놓고 있었다.
하나 — DB가 컬럼 기본값을 소유하기로 이미 정했다. DB 시간 저장 표준 원칙 5가 세 안 중 “DB 기본값과 자동 갱신으로 관리한다”를 골랐다. Entity 쪽은 insertable = false, updatable = false로 읽기만 한다. 즉 이 컬럼의 동작을 정의하는 것은 Entity가 아니라 DDL이다.
둘 — 그래서 ddl-auto가 만들 수 있는 스키마가 아니다. Hibernate는 @ColumnDefault로 DEFAULT까지는 표현한다. 그런데 ON UPDATE CURRENT_TIMESTAMP(6)는 MariaDB 고유 컬럼 속성이라 columnDefinition에 SQL 문자열을 직접 적어야 나온다. 그 순간 DDL은 Entity 어노테이션 안으로 들어가고, 위 안 A의 모양이 된다.
셋 — MyBatis 경로는 Entity에 보이지 않는다. Repository 설계 표준이 동적 검색을 MyBatis로 갈랐고, 대량 연산도 MyBatis다. ddl-auto는 JPA Entity만 보므로 MyBatis만 건드리는 테이블과 컬럼은 스키마에 아예 안 생긴다.
넷 — 이게 제일 크다. 테스트가 실제 MariaDB를 쓰기로 이미 정했다. DB 시간 저장 표준 원칙 6이 Testcontainers로 “운영과 같은 DB·드라이버 변환을 검증한다”고 못 박았다. 그런데 ddl-auto로 테스트 스키마를 만들면 검증하는 스키마와 운영 스키마가 서로 다른 물건이 된다. 표준의 증거로 삼겠다던 테스트가 증거가 못 된다.
넷이 같은 방향을 가리켰다. ddl-auto는 이 저장소에서 이미 죽어 있었고, 장부가 그걸 몰랐을 뿐이다.
원칙 1. 스키마는 Flyway 마이그레이션 파일이 소유한다
정해야 했던 건 이거였다 — 애플리케이션 밖으로 옮긴다면 무엇으로 옮기는가.
안 A — ddl-auto: update로 Hibernate가 만들고 고친다. 얻는 것은 파일을 따로 안 써도 된다는 점이다. 버린 이유는 위 넷 전부다. 특히 Hibernate는 컬럼을 지우거나 타입을 좁히지 않는다 — 안 쓰는 컬럼이 계속 쌓이고, 스키마의 현재 상태를 아무도 한 곳에서 읽을 수 없게 된다.
안 B — Liquibase changelog로 관리한다. 얻는 것은 XML·YAML로 적으면 DB 종류가 바뀌어도 같은 changelog가 돈다는 점이다. 버린 이유는 우리가 이미 MariaDB에 묶여 있다는 것이다. DB 시간 저장 표준이 최소 버전을 MariaDB 11.8 LTS로 고정했고 ON UPDATE CURRENT_TIMESTAMP(6)을 쓴다. 이식성을 위해 SQL을 한 겹 추상화하는 값을 치르는데, 그 이식성을 쓸 일이 없다.
안 C — Flyway로 SQL 파일을 그대로 쌓는다. 얻는 것은 위 DDL을 한 글자도 안 바꾸고 그대로 옮길 수 있다는 점이다. 표준이 이미 SQL로 적혀 있으니 번역 단계가 없다.
골랐다 — 안 C. 안 A는 표준이 요구하는 DDL을 만들지 못하고, 안 B는 만들 수는 있지만 SQL을 한 번 번역하게 한다. 번역이 없으면 번역 실수도 없다.
대신 되돌리기를 포기한다. Flyway Community 판에는 undo가 없다. 잘못 나간 마이그레이션은 되돌리는 게 아니라 앞으로 가는 마이그레이션을 하나 더 써서 고친다. 이건 선택이 아니라 도구가 정한 제약이라 전제로 깔고 간다.
원칙 2. ddl-auto는 validate로 두고 어긋나면 부팅을 막는다
Hibernate가 스키마를 만들지 않기로 했으니 남은 값은 둘이다 — none이냐 validate냐.
정해야 했던 건 이거였다 — Entity에 필드를 추가하고 마이그레이션 쓰는 걸 잊었을 때 언제 알아차릴 것인가.
안 A — none. Hibernate가 스키마에 아무 말도 안 한다. 얻는 것은 부팅이 어떤 이유로도 막히지 않는다는 점이다. 버린 이유는 그 컬럼을 건드리는 첫 쿼리가 실행될 때까지 아무도 모른다는 것이다. 그게 언제인지는 어느 API를 먼저 호출하느냐에 달렸고, 운영에서 처음 호출될 수도 있다.
안 B — validate. 부팅할 때 Entity 매핑과 실제 스키마를 대조하고 어긋나면 애플리케이션이 뜨지 않는다.
Schema-validation: missing column [phone_number] in table [orders]
골랐다 — 안 B. 안 A가 런타임으로 미루는 실패를 부팅으로 당긴다. 사람이 “마이그레이션을 썼던가”를 기억해야만 지켜지던 규칙이, 기억 안 해도 지켜지는 규칙이 된다.
대가가 둘 있다.
하나, validate는 Entity → DB 방향만 본다. Entity가 요구하는 컬럼이 DB에 있는지만 확인하고, DB에만 있는 컬럼은 통과시킨다. 그래서 MyBatis만 쓰는 테이블은 검사 대상이 아예 아니다. 어긋남 검사가 전 구간이 아니라 JPA 경로에만 걸린다는 걸 알고 써야 한다.
둘, 멀쩡한 스키마에서도 부팅이 막힐 수 있다. 타입 해석이 어긋나면 그렇다. TIMESTAMP(6)을 쓰기로 했으므로 여기서 걸릴 여지가 있고, 그때는 마이그레이션이 아니라 Entity 매핑을 맞춘다.
원칙 3. 마이그레이션 파일 이름은 일련번호가 아니라 타임스탬프로 짓는다
Flyway는 파일명으로 적용 순서를 정한다. 그 번호를 무엇으로 매길 것인가.
안 A — 일련번호. V1__init.sql, V2__add_order_phone.sql. 얻는 것은 순서가 한눈에 보인다는 점이다. 파일 목록만 봐도 몇 번째 변경인지 안다.
버린 이유는 둘이 동시에 만들면 같은 번호가 나온다는 것이다. 각자 V3__을 만들면 합칠 때 충돌하고, Flyway는 같은 버전 둘을 거부한다. 그때 파일명을 고쳐야 하는데 한쪽 DB에는 이미 V3이 적용된 뒤일 수 있다. 적용 기록과 파일이 어긋난 상태가 된다.
안 B — 타임스탬프. V202608031430__init.sql. 얻는 것은 그 충돌이 구조적으로 안 난다는 점이다. 분 단위까지 같을 일이 없다.
골랐다 — 안 B. 처음엔 안 A로 가려 했다. 혼자 작업하니 번호 충돌은 “아직 없는 문제”이고, 없는 문제를 위해 매일 읽기 어려운 파일명을 감수하는 건 손해라고 봤다.
그 전제가 틀렸다. 마이그레이션을 만드는 주체가 사람만이 아니다. AI 에이전트가 함께 돌면서 병렬로 작업한다. 사람 하나짜리 프로젝트여도 같은 시점에 여러 갈래에서 마이그레이션이 생긴다. 번호 충돌은 미래의 문제가 아니라 지금의 문제였다.
대신 순서를 눈으로 읽는 편의를 포기한다. 파일명이 길어지고, 몇 번째 변경인지는 세어봐야 안다.
원칙 4. 테스트는 운영과 같은 마이그레이션을 실행한다
Testcontainers로 띄운 MariaDB는 테이블이 하나도 없는 빈 DB다. 테스트가 돌기 전에 누군가 만들어야 한다.
정해야 했던 건 이거였다 — 그 스키마를 어디서 가져올 것인가.
안 A — 테스트 전용 schema.sql을 따로 둔다. 얻는 것은 마이그레이션이 쌓여도 테스트가 느려지지 않는다는 점이다. 버린 이유는 같은 DDL을 두 벌 적게 된다는 것이고, 어긋나는 방향이 둘이라 그중 하나가 특히 나쁘다.
| 어디가 틀렸나 | 결과 |
|---|---|
테스트용 스크립트에서 ON UPDATE를 빠뜨렸다 |
테스트가 실패한다. 시간을 쓰지만 결국 찾는다 |
운영 마이그레이션에서 ON UPDATE를 빠뜨렸다 |
테스트는 통과한다. 운영에서만 updated_at이 안 늘어난다 |
아래쪽이면 검증을 통과했다는 사실이 아무것도 보장하지 않는다.
안 B — 테스트에서만 ddl-auto: create-drop을 쓴다. 얻는 것은 설정 한 줄로 끝난다는 점이다. 버린 이유는 안 A보다 나쁘다 — Hibernate가 만든 테이블에는 DEFAULT CURRENT_TIMESTAMP(6)도 ON UPDATE도 없다. 애플리케이션도 안 넣고 DB도 안 넣으니 NOT NULL 위반으로 INSERT부터 실패한다. 원칙 5를 검증하려던 테스트가 원칙 5가 없는 스키마 위에서 돈다.
안 C — 운영에서 쓸 마이그레이션을 그대로 실행한다. 실제로 할 일은 거의 없다. Flyway가 클래스패스에 있으면 테스트의 Spring 컨텍스트도 부팅하면서 마이그레이션을 돌린다. “끄지 않는다”가 전부다.
골랐다 — 안 C. 안 A와 안 B가 만드는 “검증한 스키마 ≠ 배포되는 스키마”를 구조적으로 불가능하게 만든다. 두 벌이 아니라 한 벌이라서다.
덤이 하나 있다. 테스트를 돌릴 때마다 마이그레이션이 실행되므로 마이그레이션 파일 자체가 매번 검증된다. 문법이 틀렸거나 순서가 꼬인 파일은 운영에 나가기 전에 테스트에서 죽는다.
대가는 속도다. 파일이 쌓일수록 컨테이너를 띄울 때마다 전부 다시 실행한다. 수십 개가 되어 테스트가 느려지는 시점이 이 결정을 다시 볼 자리다.
원칙 5. 인덱스는 마이그레이션이 선언한다. @Index를 쓰지 않는다
인덱스도 스키마다. 그래서 원칙 1이 그대로 적용되지만, JPA에는 인덱스를 선언하는 자리가 따로 있어서 명시해둔다.
// 이렇게 하지 않는다
@Table(name = "orders", indexes = @Index(columnList = "user_id, created_at"))
-- 이렇게 한다
CREATE INDEX idx_orders_user_created ON orders (user_id, created_at);
버리는 이유가 취향이 아니다. ddl-auto가 validate면 Hibernate는 아무것도 만들지 않는다. 그러면 @Index는 적어두면 인덱스가 생기는 것처럼 보이는데 실제로는 아무 일도 안 하는 선언이 된다. 게다가 Hibernate 검증기는 테이블과 컬럼을 대조하지 인덱스를 대조하지 않는다. 없는데 있다고 믿게 만드는 자리라, 원칙 2가 막으려던 어긋남이 여기서 조용히 생긴다.
언제 넣는가는 두 갈래로 간다.
- 사전 — 테이블을 만들 때 같이 넣는다. 조회 조건으로 쓸 것이 이미 보이는 컬럼은 처음부터 인덱스를 건다
- 사후 — 느려진 쿼리에 대응해 넣는다
사후 규칙은 지금 발동 조건이 없다. “느리다”의 기준, 즉 슬로우 쿼리 임계값을 정한 적이 없어서다. 그건 장부에 열린 항목으로 남아 있고, 그것을 정할 때 이 규칙이 완성된다.
그리고 복합 인덱스의 컬럼 순서는 이 글에서 정하지 않는다. 정하려다 막혔고, 왜 막혔는지는 아래에 적는다.
판단 기준 정리
| 질문 | 답 | 결론 |
|---|---|---|
| 스키마를 누가 소유하나 | Flyway 마이그레이션 파일 | ddl-auto로 만들지 않는다 |
| Hibernate는 스키마에 무엇을 하나 | 대조만 한다 | ddl-auto: validate |
| 마이그레이션 파일 이름은 | V{타임스탬프}__{설명}.sql |
병렬로 만들어도 안 부딪힌다 |
| 되돌리려면 | 앞으로 가는 마이그레이션을 하나 더 쓴다 | Community 판에 undo가 없다 |
| 테스트 스키마는 어디서 오나 | 운영과 같은 마이그레이션 | 검증한 것이 곧 배포되는 것 |
| 인덱스는 어디에 선언하나 | 마이그레이션 | @Index는 아무것도 안 만든다 |
| 인덱스를 언제 넣나 | 만들 때 미리 + 느려지면 사후 | 사후 기준은 아직 비어 있다 |
이 표준을 정하기까지
장부에 “0건”이라 적혀 있어서 백지인 줄 알았다. Flyway·Liquibase·ddl-auto 전부 표준에 한 번도 안 나왔으니 처음부터 정해야 하는 자리로 보였다. 그런데 기존 표준을 읽어보니 이미 답이 거의 정해져 있었다. DB 시간 저장 표준이 컬럼 기본값을 DB에 맡겼고, 테스트를 실제 MariaDB에서 돌리기로 했고, 동적 검색을 MyBatis로 갈라놨다. 셋 다 ddl-auto를 조금씩 죽이고 있었다. 정한 적이 없는 게 아니라, 정해놓고 인지하지 못한 상태였다.
그래서 도구 선택은 생각보다 빨리 끝났다. MariaDB에 이미 묶여 있으니 Liquibase의 이식성은 쓸 데가 없었고, 표준이 이미 SQL로 적혀 있으니 Flyway가 그걸 그대로 받았다. 번역 단계가 없다는 것이 결정적이었다.
파일 이름에서 한 번 틀렸다. 일련번호가 읽기 쉬우니 안 A로 가려 했다. 근거는 “혼자 작업하니 번호 충돌은 아직 없는 문제”였다. 그 전제가 깨졌다 — 마이그레이션을 만드는 주체가 사람만이 아니었다. AI 에이전트가 병렬로 도는 환경에서는 같은 시점에 여러 갈래가 생긴다. 팀 규모로 판단할 문제가 아니었다.
테스트 스키마에서 기준이 드러났다. 안을 셋 놓고 비교하다 보니 버리는 이유가 전부 같은 문장이었다 — 같은 것을 두 벌 적으면 언젠가 어긋난다. 그리고 어긋남에는 방향이 있는데, “테스트는 통과하는데 운영이 깨지는” 쪽이 반대보다 훨씬 나쁘다. 앞의 것은 시간을 쓰고 결국 찾지만, 뒤의 것은 검증이 검증이 아니게 된다.
그 기준이 이 글에서 처음 나온 게 아니라는 걸 뒤늦게 알았다. 컨트롤러 패키지 분리 표준 원칙 5는 “아키텍처별로 instructions 파일을 두 벌 만든다”를 “한쪽만 고치는 순간 어긋난다” 로 버렸다. 배포 스크립트가 .claude와 .agents를 대조하는 것도 그 둘이 어긋난 채 배포된 적이 있어서다. 원칙 2의 validate도 같은 문장이다 — Entity와 스키마가 두 벌이 될 수밖에 없으니, 어긋나면 그 자리에서 멈추게 한다. 계속 쓰고 있던 기준에 이름을 붙인 셈이다.
마지막으로 인덱스에서 막혔다. 선언 자리는 원칙 1을 따라 바로 정해졌는데, 복합 인덱스의 컬럼 순서는 정할 수가 없었다. 선택적 조건이 N개면 WHERE 조합이 2^N인데 인덱스를 그만큼 만들 수 없고, 복합 인덱스는 선행 컬럼이 빠지면 못 탄다. 조건 5개의 32가지 중 인덱스 하나가 덮는 건 5가지뿐이다.
막힌 진짜 이유는 인덱스 바깥에 있었다. ORDER BY도 인덱스를 타는데, 정렬 키를 무엇까지 허용하느냐가 아직 안 정해졌다. 열어주는 키마다 인덱스가 하나씩 필요하니, 조건 쪽을 아무리 줄여도 정렬 쪽에서 다시 폭발한다. 페이징 방식도 걸린다 — offset은 깊은 페이지에서 인덱스가 있어도 앞 행을 전부 읽고 버리지만, 커서 페이징이면 (정렬키, id) 인덱스가 그대로 커서가 된다. 셋이 한 문제였다. 그래서 인덱스 순서만 먼저 정하면 나머지 둘을 정할 때 뒤집힌다. 여기서 멈추고 장부에 남겼다.
정리
- 스키마는 Flyway 마이그레이션 파일이 소유한다.
ddl-auto로 만들지 않는다 ddl-auto: validate로 어긋남을 부팅에서 막는다. 단 JPA 경로에만 걸린다- 파일 이름은
V{타임스탬프}__{설명}.sql. 병렬로 만들어도 번호가 안 부딪힌다 - 되돌리기는 없다. 앞으로 가는 마이그레이션을 하나 더 쓴다
- 테스트도 같은 마이그레이션을 실행한다. 검증한 스키마가 곧 배포되는 스키마여야 한다
- 인덱스는 마이그레이션이 선언한다.
@Index는 아무것도 만들지 않는다 - 같은 것을 두 벌 적으면 언젠가 어긋난다. 한 벌로 만들거나, 어긋나면 그 자리에서 멈추게 한다
AI 코드 어시스턴트에 바로 적용하기
coding-standards/.claude/skills/schema-migration-standard/SKILL.md
---
name: schema-migration-standard
description: 스키마 DDL을 Flyway 마이그레이션으로 소유하고 Hibernate는 검증만 하게 하는 규칙
---
- 스키마 DDL은 Flyway 마이그레이션 파일이 소유한다. `ddl-auto`로 생성하거나 변경하지 않는다.
- `spring.jpa.hibernate.ddl-auto`는 `validate`로 둔다. `update`·`create`·`create-drop`을 쓰지 않는다.
- 마이그레이션 파일은 `src/main/resources/db/migration/`에 둔다.
- 파일 이름은 `V{yyyyMMddHHmm}__{설명}.sql`로 짓는다. 일련번호를 쓰지 않는다.
- 적용된 마이그레이션 파일을 수정하지 않는다. 고칠 것이 있으면 새 마이그레이션을 추가한다.
- 되돌리는 마이그레이션은 없다. 앞으로 가는 마이그레이션으로 고친다.
- Entity에 필드를 추가·변경·삭제하면 같은 커밋에 마이그레이션 파일을 추가한다.
- `createdAt`·`updatedAt` 컬럼은 마이그레이션에 `DEFAULT CURRENT_TIMESTAMP(6)`와 `ON UPDATE CURRENT_TIMESTAMP(6)`로 적는다. database-instant-storage-standard를 따른다.
- 테스트는 운영과 같은 마이그레이션을 실행한다. 테스트 전용 `schema.sql`을 두지 않는다.
- 테스트에서 `ddl-auto: create-drop`을 쓰지 않는다.
- 인덱스는 마이그레이션에 `CREATE INDEX`로 선언한다. `@Table(indexes = ...)`와 `@Index`를 쓰지 않는다.
- 인덱스 이름은 `idx_{테이블}_{컬럼들}`로 짓는다.
- 조회 조건으로 쓸 것이 이미 정해진 컬럼은 테이블을 만들 때 인덱스를 함께 넣는다.
- 복합 인덱스는 등호 조건 컬럼을 앞에, 범위 조건 컬럼을 뒤에 둔다.
- MyBatis만 쓰는 테이블은 `validate`가 검사하지 않는다. 마이그레이션 누락을 사람이 확인한다.
coding-standards/.github/instructions/schema-migration-standard.instructions.md
---
description: 스키마 DDL을 Flyway 마이그레이션으로 소유하고 Hibernate는 검증만 하게 하는 규칙
applyTo: "**/db/migration/*.sql, **/*.yml, **/*.yaml, **/infra/**/*.java, **/repository/**/*.java"
---
- 스키마 DDL은 Flyway 마이그레이션 파일이 소유한다. `ddl-auto`로 생성하거나 변경하지 않는다.
- `spring.jpa.hibernate.ddl-auto`는 `validate`로 둔다.
- 마이그레이션 파일은 `src/main/resources/db/migration/`에 두고 `V{yyyyMMddHHmm}__{설명}.sql`로 짓는다.
- 적용된 마이그레이션 파일을 수정하지 않는다. 고칠 것이 있으면 새 마이그레이션을 추가한다.
- Entity에 필드를 추가·변경·삭제하면 같은 커밋에 마이그레이션 파일을 추가한다.
- `createdAt`·`updatedAt`은 `DEFAULT CURRENT_TIMESTAMP(6)`와 `ON UPDATE CURRENT_TIMESTAMP(6)`로 적는다.
- 테스트는 운영과 같은 마이그레이션을 실행한다. 테스트 전용 `schema.sql`이나 `create-drop`을 쓰지 않는다.
- 인덱스는 `CREATE INDEX`로 선언하고 이름은 `idx_{테이블}_{컬럼들}`로 짓는다. `@Index`를 쓰지 않는다.
- 복합 인덱스는 등호 조건 컬럼을 앞에, 범위 조건 컬럼을 뒤에 둔다.
자신만의 철학을 만들어가는 중입니다.
댓글남기기