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는 @ColumnDefaultDEFAULT까지는 표현한다. 그런데 ON UPDATE CURRENT_TIMESTAMP(6)는 MariaDB 고유 컬럼 속성이라 columnDefinitionSQL 문자열을 직접 적어야 나온다. 그 순간 DDL은 Entity 어노테이션 안으로 들어가고, 위 안 A의 모양이 된다.

셋 — MyBatis 경로는 Entity에 보이지 않는다. Repository 설계 표준이 동적 검색을 MyBatis로 갈랐고, 대량 연산도 MyBatis다. ddl-auto는 JPA Entity만 보므로 MyBatis만 건드리는 테이블과 컬럼은 스키마에 아예 안 생긴다.

넷 — 이게 제일 크다. 테스트가 실제 MariaDB를 쓰기로 이미 정했다. DB 시간 저장 표준 원칙 6이 Testcontainers로 “운영과 같은 DB·드라이버 변환을 검증한다”고 못 박았다. 그런데 ddl-auto로 테스트 스키마를 만들면 검증하는 스키마와 운영 스키마가 서로 다른 물건이 된다. 표준의 증거로 삼겠다던 테스트가 증거가 못 된다.

넷이 같은 방향을 가리켰다. ddl-auto는 이 저장소에서 이미 죽어 있었고, 장부가 그걸 몰랐을 뿐이다.

이 표준이 서는 자리 — 넷이 못 좁힌 한 자리는 다른 조건이 정했다. 마이그레이션을 만드는 주체가 사람만이 아니다. 원칙 3의 파일명이 거기서 갈렸다. 사람 하나만 만드는 프로젝트면 일련번호로 돌아간다 — 번호가 부딪힐 일이 없으므로 읽기 편한 쪽이 이긴다. 나머지 원칙은 이 조건과 무관하게 성립한다.


원칙 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-autovalidate로 두고 어긋나면 부팅을 막는다

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-autovalidate면 Hibernate는 아무것도 만들지 않는다. 그러면 @Index적어두면 인덱스가 생기는 것처럼 보이는데 실제로는 아무 일도 안 하는 선언이 된다. 게다가 Hibernate 검증기는 테이블과 컬럼을 대조하지 인덱스를 대조하지 않는다. 없는데 있다고 믿게 만드는 자리라, 원칙 2가 막으려던 어긋남이 여기서 조용히 생긴다.

언제 넣는가는 두 갈래로 간다.

  • 사전 — 테이블을 만들 때 같이 넣는다. 조회 조건으로 쓸 것이 이미 보이는 컬럼은 처음부터 인덱스를 건다
  • 사후 — 느려진 쿼리에 대응해 넣는다

사후 규칙은 지금 발동 조건이 없다. “느리다”의 기준, 즉 슬로우 쿼리 임계값을 정한 적이 없어서다. 그건 장부에 열린 항목으로 남아 있고, 그것을 정할 때 이 규칙이 완성된다.

그리고 복합 인덱스의 컬럼 순서는 이 글에서 정하지 않는다. 정하려다 막혔고, 왜 막혔는지는 아래에 적는다. 막았던 것이 풀린 뒤 원칙 8에서 정했다.


원칙 6. 커넥션 풀과 슬로우 쿼리 기준은 실측 전 기본값으로 정하고 다시 볼 조건을 남긴다

원칙 5가 미뤄둔 “사후” 인덱싱 규칙은 “느리다”의 기준이 없어서 발동할 수 없었다. 커넥션 풀 크기와 쿼리를 언제 끊을지도 마찬가지로 정한 적이 없다 — 지금까지 표준의 타임아웃은 전부 외부 API 쪽(예외 종류별 처리 표준 원칙 3)이었다.

정해야 했던 건 이거였다 — 실측할 프로젝트가 없는데 숫자를 정할 수 있는가.

안 A — 실측 후에 정한다. 얻는 것은 근거 없는 숫자를 표준에 안 박는다는 점이다. 버린 이유는 열린 항목 장부에 이런 식으로 미뤄둔 자리가 여럿인데, 미루면 이 세 가지도 “정한 적 없음”으로 무기한 남아 매 프로젝트가 같은 논의를 반복한다는 것이다. 원칙 5가 이미 한 번 이렇게 미뤘고, 그 미룸이 이 표준을 계속 미완성으로 만들었다.

안 B — 실측 전 기본값을 못 박고, 다시 볼 조건을 함께 적는다. 원칙 3(타임스탬프 파일명)과 원칙 4(테스트 마이그레이션)에서 이미 쓴 방식이다 — 확신이 없을 때는 조건을 걸어 나중에 뒤집을 자리를 남긴다.

골랐다 — 안 B.

  • 커넥션 풀 크기는 HikariCP 위키가 권장하는 공식 ((core_count * 2) + effective_spindle_count)을 기본값으로 둔다. 코어 수와 디스크 구성이 프로젝트마다 달라 고정 숫자가 아니라 공식을 못 박는다.
  • 슬로우 쿼리 기준은 300ms를 기본값으로 둔다. 목록 조회 요청 계약 표준이 목록 조회 상한을 100행으로 이미 정해뒀으니, 그 상한 안에서 조회 하나가 써도 되는 예산으로 잡은 값이다. 실측치가 아니다.
  • MariaDB에는 다음을 설정한다.
long_query_time = 0.3
log_queries_not_using_indexes = ON

대가는 숫자가 틀릴 수 있다는 것이다. 실측 없이 정했으므로 실제 트래픽의 병목이 다른 곳에 있을 수 있다. 그래도 정하지 않는 것보다는 낫다 — 정하지 않으면 원칙 5의 사후 인덱싱 규칙 자체가 영영 발동하지 못한다. 다시 볼 조건은 첫 부하 테스트 결과다. 그 값이 나오면 이 기본값을 교체한다.

DB 쿼리가 이 기준을 넘겨 실제로 끊길 때 무엇으로 응답하는지는 이 표준의 영역이 아니다. 예외 종류별 처리 표준 원칙 2-1이 받는다.


원칙 7. 인덱스는 슬로우 쿼리로 관측된 조합에만 반응해서 추가한다

원칙 5를 막았던 두 축(정렬 키·페이징 방식)은 이후 다른 두 표준이 정하면서 사라졌다 — 이유는 「이 표준을 정하기까지」에 있다. 남은 건 조합 폭발(2^N) 자체다. 이건 정렬이 아니라 검색 조건 개수의 문제라 두 축이 사라져도 그대로 남는다.

정해야 했던 건 이거였다 — 동적 검색의 선택적 조건 조합에 인덱스를 어디까지 미리 깔아둘 것인가.

안 A — 선택적 조건의 모든 조합을 사전에 인덱싱한다.

-- 조건 5개(등호 3 + 범위 2)면 최대 31개 조합, 최악의 경우 인덱스도 그만큼
CREATE INDEX idx_orders_status ON orders (status);
CREATE INDEX idx_orders_status_user ON orders (status, user_id);
-- ... 조합의 수만큼 반복

얻는 것은 어떤 조합이 와도 인덱스를 탄다는 점이다. 버린 이유는 목록 조회 요청 계약 표준이 이미 같은 근거로 다중 정렬을 닫은 적이 있다는 것이다 — 정렬 키 3개의 조합만으로 인덱스가 39개가 나온다고 적혀 있다. 같은 조합 폭발이 검색 조건에도 그대로 적용되고, 여기에 더해 INSERT·UPDATE마다 그 인덱스 전부를 갱신하는 쓰기 비용까지 진다.

안 B — 아무것도 미리 안 깔고, 슬로우 쿼리로 잡히면 그 조합에 인덱스를 추가한다. 얻는 것은 실제로 쓰이는 조합에만 비용을 쓴다는 점이다. 목록 조회 요청 계약 표준의 “정렬 키를 하나 추가할 때 그 키의 인덱스를 함께 넣는다”와 같은 결이다 — 필요해질 때 넣는다.

골랐다 — 안 B.

  • 원칙 6의 슬로우 쿼리 기준(300ms)을 넘긴 조합이 실측으로 관측되면, 그 조합 전용 복합 인덱스를 새 마이그레이션으로 추가한다.
  • 컬럼 순서는 원칙 8을 따른다.
  • 예외 — 검색 조건 중 “항상 같이 오는 조합”이 기획 단계에서 이미 확정돼 있으면, 관측을 기다리지 않고 원칙 5의 “사전” 규칙으로 처음부터 넣는다. 반응형은 모를 때의 기본값이지, 이미 아는 것도 모르는 척하라는 뜻이 아니다.

대가는 첫 번째 요청이 항상 손해를 본다는 것이다. 새로 나온 검색 조합의 첫 실행은 인덱스 없이 풀스캔을 감당하고, 그 한 번이 슬로우 쿼리 로그에 잡혀야 비로소 인덱스가 붙는다. 인덱스가 생기는 시점은 항상 문제가 발생한 다음이다.


원칙 8. 복합 인덱스의 컬럼 순서는 범위 조건이 정렬 키의 첫 컬럼인가로 가른다

반증: MariaDB 11.8 · 50만 행 · 등호 2 + 범위 + ORDER BY created_at, id LIMIT 20 에 Using filesort 가 나는가(파라미터 3벌) — Q1 범위=created_at: (가)(status,user_id,created_at,id) 인덱스 사용 3/3 · filesort 0/3 · 인덱스 없음 filesort 3/3 · Q2 범위=amount: (가)(status,user_id,amount,created_at,id) 인덱스 사용 3/3 · filesort 3/3 · (나)(status,user_id,created_at,id,amount) 인덱스 사용 3/3 · filesort 0/3 · 인덱스 없음 filesort 3/3 — 증명

원칙 7이 인덱스를 언제 넣을지 정했다면, 여기는 넣을 때 어떤 순서로 넣을지다.

처음 적힌 규칙은 「등호 → 범위 → 정렬 키」였다. 그런데 그 규칙은 글에서 안을 비교해 고른 적이 없고 규칙 파일에만 들어가 있었다. 원칙 7은 없는 「원칙 5의 규칙」을 인용하고 있었다. 그래서 MariaDB 11.8에 50만 행을 넣고 ORDER BY created_at, id LIMIT 20 쿼리로 쟀다.

범위 = created_at (정렬 키의 첫 컬럼)
  (status, user_id, created_at, id)          filesort 없음 · 20행만 읽음

범위 = amount (정렬 키가 아닌 컬럼)
  (status, user_id, amount, created_at, id)  filesort 3/3 · 범위 구간 124~137행을 읽고 정렬
  (status, user_id, created_at, id, amount)  filesort 없음 · 정렬 순서로 읽다 20건 채우면 멈춤(63~81행)

범위 컬럼 뒤에 둔 정렬 키는 범위 구간 전체에서 정렬돼 있지 않다. B-tree는 amount 값마다 따로 created_at 순서를 가지므로, 범위가 여러 amount에 걸치면 다시 정렬해야 한다. 옛 규칙은 범위와 정렬 키가 같은 컬럼일 때만 맞았다.

정해야 했던 건 이거였다 — 범위 조건이 정렬 키와 다를 때 순서를 무엇으로 정하는가.

안 A — 옛 규칙대로 「등호 → 범위 → 정렬」로 고정한다. 얻는 것은 범위가 좁으면 읽는 행이 적다는 점이다. 버린 이유는 실측에서 filesort가 매번 났다는 것이다. 범위가 넓어질수록 읽고 정렬하는 행이 같이 는다.

안 B — 「등호 → 정렬 → 범위」로 고정한다. 얻는 것은 filesort가 없고 LIMIT에서 일찍 멈춘다는 점이다. 버린 이유는 범위 조건이 걸러내는 비율에 따라 뒤집힌다는 것이다. 조건에 맞는 행이 드물면 정렬 순서로 한참 읽어야 20건이 찬다. 이번에 잰 것은 약 25%가 걸리는 한 조건뿐이라 고정할 근거가 없다.

골랐다 — 안 C. 규칙은 경우를 가르고, 갈리는 경우는 관측된 쿼리로 고른다.

  • 등호 조건 컬럼을 앞에 둔다
  • 범위 조건이 정렬 키의 첫 컬럼이면 그 뒤에 정렬 키를 이어 둔다 — 한 인덱스가 범위와 정렬을 같이 받는다
  • 범위 조건이 다른 컬럼이면 한 인덱스로 둘 다 받을 수 없다. 관측된 슬로우 쿼리에 두 순서를 모두 EXPLAIN해 읽는 행 수와 filesort를 보고 고른다

안 A와 B가 한 순서를 모든 쿼리에 고정해 한쪽 경우를 놓치는 것과 달리, 안 C는 답이 갈리는 경우를 원칙 7이 이미 붙잡은 관측된 쿼리에 맡긴다. 원칙 7이 반응형이라 그 쿼리는 늘 손에 있다.

대가는 규칙만 보고 순서를 정할 수 없는 경우가 남는다는 것이다. 범위와 정렬 키가 다르면 매번 EXPLAIN 두 번이 든다. 실행 시간, 커서 조건 (created_at, id) > (?, ?), 범위 조건이 둘 이상인 경우는 재지 않았다.


판단 기준 정리

질문 결론
스키마를 누가 소유하나 Flyway 마이그레이션 파일 ddl-auto로 만들지 않는다
Hibernate는 스키마에 무엇을 하나 대조만 한다 ddl-auto: validate
마이그레이션 파일 이름은 V{타임스탬프}__{설명}.sql 병렬로 만들어도 안 부딪힌다
되돌리려면 앞으로 가는 마이그레이션을 하나 더 쓴다 Community 판에 undo가 없다
테스트 스키마는 어디서 오나 운영과 같은 마이그레이션 검증한 것이 곧 배포되는 것
인덱스는 어디에 선언하나 마이그레이션 @Index는 아무것도 안 만든다
인덱스를 언제 넣나 만들 때 미리 + 슬로우 쿼리로 관측되면 사후 조합 폭발을 다 감당하지 않는다
복합 인덱스 순서는 등호 → (범위가 정렬 키 첫 컬럼이면) 정렬 키. 아니면 두 순서를 EXPLAIN해 고른다 범위 뒤의 정렬 키는 filesort를 못 피한다
슬로우 쿼리 기준은 300ms (실측 전 기본값) 부하 테스트 결과가 나오면 교체한다
커넥션 풀 크기는 HikariCP 공식 기반 기본값 코어 수·디스크 구성마다 다르다

이 표준을 정하기까지

장부에 “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) 인덱스가 그대로 커서가 된다. 셋이 한 문제였다. 그래서 인덱스 순서만 먼저 정하면 나머지 둘을 정할 때 뒤집힌다. 여기서 멈추고 장부에 남겼다.

막고 나서 아무도 다시 안 열었다. 그 사이 목록 조회 요청 계약 표준이 정렬 키를, 페이징 방식 표준이 커서를 정했다. 막았던 두 축이 사라졌는데도 이 글은 계속 “정하지 않음”으로 남아 있었다. 장부를 다시 훑다가 이 자리를 발견했다 — 새로 생긴 빈틈이 아니라, 이미 풀린 걸 아무도 확인하지 않은 빈틈이었다.

다시 열고 보니 순서 규칙은 근거 없이 규칙 파일에만 들어가 있었다(2026-09-16). 원칙 7이 「원칙 5의 규칙을 그대로 쓴다」고 적었지만 원칙 5에는 그 규칙이 없었다. 글로 정식화하기 전에 쟀더니 범위 조건이 정렬 키와 다른 컬럼이면 규칙대로 만든 인덱스에서 filesort가 매번 났다. 정식화했으면 틀린 규칙에 근거를 붙일 뻔했다. 한 순서로 고정하는 두 안을 버리고, 갈리는 경우는 관측된 쿼리를 재서 고르게 했다(원칙 8).

커넥션 풀과 슬로우 쿼리 기준에서는 다른 종류의 막힘을 만났다. 이번엔 다른 표준이 막은 게 아니라 실측할 프로젝트가 없다는 사실 자체였다. 처음엔 안 A(실측 후 결정)로 가려 했는데, 그러면 원칙 5의 사후 인덱싱 규칙이 영원히 발동할 조건을 못 얻는다는 걸 알아차렸다. 정하지 않는 것의 대가가 “틀린 숫자를 정하는 것”보다 크다고 판단했다. 그래서 실측 전 기본값을 못 박고 다시 볼 조건(첫 부하 테스트)을 명시하는 쪽으로 갔다. 이미 원칙 3·4에서 쓰던 패턴이라 낯선 결정은 아니었다.


정리

  • 스키마는 Flyway 마이그레이션 파일이 소유한다. ddl-auto로 만들지 않는다
  • ddl-auto: validate로 어긋남을 부팅에서 막는다. 단 JPA 경로에만 걸린다
  • 파일 이름은 V{타임스탬프}__{설명}.sql. 병렬로 만들어도 번호가 안 부딪힌다
  • 되돌리기는 없다. 앞으로 가는 마이그레이션을 하나 더 쓴다
  • 테스트도 같은 마이그레이션을 실행한다. 검증한 스키마가 곧 배포되는 스키마여야 한다
  • 인덱스는 마이그레이션이 선언한다. @Index는 아무것도 만들지 않는다
  • 복합 인덱스 순서는 범위 조건이 정렬 키의 첫 컬럼인가로 가른다. 아니면 한 인덱스로 범위와 정렬을 같이 받을 수 없어 두 순서를 EXPLAIN해 고른다
  • 동적 검색의 인덱스는 반응형이다. 슬로우 쿼리로 관측된 조합에만 추가한다. 이미 확정된 조합은 예외로 사전에 넣는다
  • 커넥션 풀·슬로우 쿼리 기준은 실측 전 기본값이다. 풀은 HikariCP 공식, 슬로우 쿼리는 300ms. 부하 테스트가 나오면 교체한다
  • 같은 것을 두 벌 적으면 언젠가 어긋난다. 한 벌로 만들거나, 어긋나면 그 자리에서 멈추게 한다

자신만의 철학을 만들어가는 중입니다.
최상단으로 이동했습니다!
확대 이미지

댓글남기기