무중단 스키마 마이그레이션과 expand-contract

천만 행짜리 테이블에도 컬럼 하나를 눈 깜빡할 사이에 추가할 수 있는 시대입니다. MySQL 8.0의
ADD COLUMN은 조건만 맞으면 기존 데이터를 다시 쓰지 않고 메타데이터 변경만으로 끝납니다. 그런데 그렇게 방금 만든 컬럼을 조회했더니, 오래전부터 있던 행들까지 전부 값을 갖고 있었습니다.그 값은 대체 언제부터 있었던 걸까요? DDL이 즉시 끝나면 마이그레이션도 끝난 걸까요?
이 글에서 다루는 내용
이 글은 살아 있는 서비스에서 스키마를 바꿀 때 생기는 전환기를 다룹니다. 먼저 ALGORITHM=INSTANT가 왜 빠른지 살펴보고, 구조 변경(DDL)과 값 채우기(DML)의 시차가 어떤 문제를 만드는지 봅니다. 이어서 NOT NULL DEFAULT가 과거 데이터의 의미를 어떻게 바꿀 수 있는지, 마지막으로 expand-contract를 이용해 그 전환기를 어떻게 안전하게 지나갈지 정리합니다.
1. DDL은 순간이고, 마이그레이션은 시간입니다
이 절의 row version 설명은 MySQL 8.0.29 이상 기준입니다. instant 컬럼 추가는 8.0.12에서 도입됐고, 임의 위치의 컬럼 추가·삭제와 현재의 row version 체계는 8.0.29에서 확장됐습니다.
예전의 ALTER TABLE이 무서웠던 이유는 테이블 전체를 다시 쓰는 비용 때문이었습니다. 지금 MySQL은 작업에 따라 INSTANT, INPLACE, COPY 같은 알고리즘을 선택할 수 있습니다.
| 알고리즘 | 핵심 의미 | 일반적인 비용 특성 |
|---|---|---|
INSTANT | 데이터 페이지를 건드리지 않고 메타데이터 중심으로 바꾼다 | 매우 빠르다 |
INPLACE | 테이블 전체를 새로 복사하지 않고 처리한다 | 작업에 따라 재구축·동시 DML 여부가 다르다 |
COPY | 새 테이블을 만들어 데이터를 복사한다 | 비용이 크고 대개 DML이 막힌다 |
여기서 INPLACE를 곧바로 "online"이나 "non-blocking"으로 이해하면 안 됩니다. 데이터를 복사하는지, 테이블을 재구축하는지, 동시 DML을 허용하는지는 서로 다른 문제입니다.
INSTANT 역시 잠금을 완전히 없애는 것은 아닙니다. 데이터를 다시 쓰지 않을 뿐 metadata lock은 필요합니다. 오래 열린 트랜잭션 때문에 이 락을 얻지 못하면 ALTER TABLE이 기다릴 수 있습니다. 즉 "instant"는 작업량이 거의 없다는 뜻이지, 언제나 즉시 배포된다는 보장은 아닙니다.
INSTANT는 값을 저장하지 않습니다
MySQL이 컬럼을 instant 방식으로 추가하면 새 컬럼의 정의와 기본값을 데이터 딕셔너리에 기록하고 기존 행은 건드리지 않습니다. 이후 옛 행을 읽을 때 필요한 값을 만들어 돌려주고, 어느 행이 옛 포맷인지는 row version으로 구분합니다.
PostgreSQL 11 이상도 비휘발성(non-volatile) 기본값에 대해 비슷한 최적화를 합니다. 기본값을 pg_attribute에 보관해 두었다가 기존 행을 읽을 때 공급합니다. 반면 clock_timestamp()처럼 행마다 달라져야 하는 값은 이런 방식으로 처리할 수 없습니다.
즉시 끝나는 이유는 일을 빨리 해서가 아니라, 기존 행을 다시 쓰는 일을 하지 않아서입니다. 이름은 instant지만 실제 전략은 lazy에 가깝습니다.
물론 모든 변경이 이 좁은 길을 통과하는 것은 아닙니다. 자료형 변경이나 기존 컬럼의 NOT NULL 전환은 instant가 아니고, row version에도 64개의 제한이 있습니다. 더 무거운 DDL에는 gh-ost, pt-online-schema-change 같은 도구를 쓸 수 있지만, 이 도구들이 해결하는 것도 결국 구조를 바꾸는 문제입니다. 새 구조에 어떤 값을 넣어야 하는지는 여전히 애플리케이션의 몫입니다.
그 사이에 전환기가 있습니다
여기서 시간 차이가 생깁니다.
- DDL: 구조 변경은 거의 즉시 끝날 수 있습니다.
- DML: 기존 데이터에 새 값을 채우는 백필은 몇 시간 이상 걸릴 수 있습니다.
천만 행을 한 트랜잭션으로 한꺼번에 갱신할 수는 없습니다. 트랜잭션이 길어지고, 언두·리두 로그와 락 유지 시간이 늘어나며, 복제 지연까지 쌓일 수 있습니다. 그래서 보통 청크로 나누고, 중간에 실패해도 이어서 실행할 수 있게 만듭니다.
-- 마지막으로 처리한 id 이후를 조금씩 갱신한다.
UPDATE employee
SET pay_cycle_v2 = 'MONTHLY'
WHERE id > :last_id
AND pay_cycle_v2 IS NULL
ORDER BY id
LIMIT 1000;
DDL이 끝난 순간부터 백필이 끝날 때까지가 전환기(transitional state)입니다.
문제는 이 시기에 새 컬럼이 있다고 해서 모든 행이 이미 새 의미를 갖는 것은 아니라는 데 있습니다. 이를 따로 표현하지 않으면 "아직 옮기지 않은 행"과 "옮긴 행"이 조회 결과에서 똑같아질 수 있습니다. 락은 풀렸는데 사고는 여전히 나는 이유입니다.
Recap
INSTANT는 기존 행을 다시 쓰지 않기 때문에 빠릅니다. 하지만 구조를 추가하는 일과 기존 데이터를 새 구조에 맞게 옮기는 일은 별개입니다. DDL은 순간이어도 마이그레이션에는 시간이 걸리고, 그 사이에 전환기가 생깁니다.
2. DEFAULT는 과거를 조작합니다
전환기가 번거롭다면 아주 매력적인 한 줄이 보입니다.
-- 즉시 끝나고, 백필도 널 체크도 필요 없어 보인다.
ALTER TABLE employee
ADD COLUMN consent_marketing BOOLEAN NOT NULL DEFAULT TRUE;
NOT NULL이라 널 분기가 필요 없고, DEFAULT가 있으니 별도 백필도 필요 없어 보입니다. ADD COLUMN까지 instant로 끝난다면 거의 공짜처럼 느껴집니다.
없던 과거가 생깁니다
먼저 DB가 잘못한 것은 아닙니다. ADD COLUMN ... DEFAULT가 기존 행에도 기본값이 있는 것처럼 보이게 만드는 것은 정의된 동작입니다.
문제는 그 기본값을 과거에도 참이었다고 말할 수 있는가입니다.
예를 들어 schema_version = 1처럼 옛 행이 실제로 버전 1이었다면 아무 문제가 없습니다. 하지만 consent_marketing = TRUE는 다릅니다. 이 값은 단순한 상태가 아니라 사용자가 동의했다는 사건을 뜻합니다.
2023년에 입사한 사람의 레코드에 2026년 새 컬럼을 추가하면서 TRUE를 부여한다면, 그 사람은 한 적 없는 대답을 한 사람이 됩니다. 이 글에서는 이런 상황을 소급 조작(retroactive fabrication)이라고 부르겠습니다. 업계 표준 용어가 아니라 이 문제를 설명하기 위해 붙인 이름입니다.
진짜 비용은 값 하나가 틀렸다는 데 있지 않습니다. 나중에는 무엇이 실제 값이고 무엇이 DEFAULT가 만든 값인지 구별할 수 없다는 데 있습니다. 마케팅 수신 동의처럼 근거가 필요한 데이터라면 특히 치명적입니다.
그래서 전환기에서는 NULL이 오히려 유용할 수 있습니다. NULL은 "아직 값이 없다" 또는 "모른다"를 표현하는 가장 단순한 방법입니다. nullable로 두면 TRUE, FALSE, UNKNOWN을 구별할 수 있습니다.
값을 읽는 시점에 결정하면
nullable 컬럼을 읽는 코드는 세 가지 상태(tri-state)를 만납니다.
val consented = employee.consentMarketing ?: false
COALESCE(consent_marketing, FALSE)
이 코드도 결국 없는 값을 FALSE로 만들어냅니다. 하지만 DEFAULT와는 결정적인 차이가 있습니다.
원본 데이터에는 여전히 NULL이 남아 있습니다.
즉 FALSE로 보는 것은 저장된 사실이 아니라 읽기 정책입니다. 정책이 잘못됐다고 판단되면 그 정책만 바꾸면 됩니다. 쓰는 순간 값을 확정하는 write-time fabrication보다 읽는 순간 대체하는 read-time fallback이 전환기에 더 다루기 쉬운 이유입니다.
다만 ?: false를 호출부마다 흩뿌리면 이 장점도 사라집니다. 대체 정책은 매퍼나 리드 모델 같은 경계 한 곳에 모으고, 도메인 안쪽에서는 가능하면 "모름" 자체를 타입으로 유지하는 편이 좋습니다.
sealed interface Consent {
data object Granted : Consent
data object Denied : Consent
data object NeverAsked : Consent
}
PATCH 요청에서 null이 "비우기"인지 "건드리지 않기"인지 모호한 문제와도 같은 맥락입니다. 의도를 값에서 추측하지 말고 계약에 드러내야 합니다.
백필을 아예 하지 않는 선택
백필을 미루는 것을 끝까지 밀면 lazy migration이 됩니다. 행을 다음에 읽거나 수정할 때 실제 새 형태로 바꿔 저장하는 방식입니다. 대규모 일괄 UPDATE가 필요 없고, 다시는 읽지 않을 콜드 데이터에는 비용도 들지 않습니다.
INSTANT DDL 역시 "지금 모든 행을 고치지 않는다"는 점에서는 비슷하지만 같은 것은 아닙니다. INSTANT는 옛 행을 읽을 때 값을 합성해서 보여줄 뿐, 그 순간 행을 새 포맷으로 다시 쓰지는 않습니다. 반면 lazy migration은 실제로 데이터를 변환해 커밋합니다.
대가는 분명합니다. 전환기가 길어지고, 읽기 경로는 한동안 두 형태를 모두 이해해야 합니다. 그래서 lazy migration은 일회성 운영 비용을 장기적인 코드 복잡도로 바꾸는 선택입니다.
어떤 방식을 택하든 중요한 것은 옮겨진 상태를 구별할 수 있게 만드는 것입니다.
ALTER TABLE employee
ADD COLUMN schema_version SMALLINT NOT NULL DEFAULT 1;
여기서 DEFAULT 1은 문제가 아닙니다. 기존 행이 실제로 버전 1이기 때문입니다. 규칙은 NOT NULL DEFAULT를 쓰지 말라는 것이 아니라, 그 기본값이 과거에 대해서도 참이어야 한다는 것입니다.
| 어디서 값을 결정하는가 | 시점 | 되돌릴 수 있나 | 대가 |
|---|---|---|---|
NOT NULL DEFAULT | 스키마 변경 시점 | ✗ 만들어진 값을 구별하기 어렵다 | 과거 의미를 잘못 만들 수 있다 |
| 읽기 시점 대체 | 읽을 때마다 | ○ 정책을 바꿀 수 있다 | 경계 관리가 필요하다 |
| 백필 | 명시적 일괄 실행 | △ 추적 방법에 따라 다르다 | 시간·부하·재시작 설계 |
| lazy migration | 행을 다시 만날 때 | ○ | 전환기가 길어진다 |
| 세 가지 상태 유지 | 값을 만들지 않는다 | — | 복잡도를 코드가 부담한다 |
Recap
DEFAULT 자체가 문제는 아닙니다. 핵심은 그 값을 과거에도 참이었다고 말할 수 있느냐입니다. 그렇지 않다면 nullable 상태를 남겨 두고 읽는 시점에 정책으로 해석하는 편이 가역적입니다. 결국 전환기에서 중요한 것은 값을 빨리 채우는 것이 아니라 모르는 것과 아는 것을 구별할 수 있게 남겨 두는 것입니다.
3. 배포는 원자적이지 않습니다
이제 남은 문제는 순서입니다. 배포는 스위치처럼 한순간에 바뀌지 않습니다.
롤링 배포 중에는 옛 버전과 새 버전의 파드가 같은 시간에 같은 DB를 봅니다. 카나리 배포라면 이 공존이 더 오래 지속될 수도 있습니다. 따라서 스키마 마이그레이션의 진짜 제약은 "새 코드가 새 스키마에서 동작하는가"가 아닙니다.
전환기의 모든 단계에서 옛 코드와 새 코드가 같은 데이터를 안전하게 읽고 쓸 수 있어야 합니다.
예를 들어 새 코드가 pay_cycle_v2에만 쓰기 시작했는데 옛 파드는 여전히 pay_cycle을 읽는다고 해봅시다. 사용자가 값을 바꾸고 다음 요청이 옛 파드로 가면, 변경 전 값이 다시 보일 수 있습니다. 예외도 없고 로그도 깨끗합니다.
그래서 전환기에는 한 방향의 backward compatibility보다 양방향 호환성이 필요합니다.
expand-contract, 화살표마다 하나의 단계
이를 위해 흔히 expand-contract 또는 parallel change 패턴을 씁니다.
핵심은 단계 수가 아닙니다. 각 화살표를 독립적으로 관찰하고, 문제가 생기면 이전 단계로 돌아갈 수 있어야 합니다.
①에서는 새 nullable 컬럼만 추가하므로 옛 코드는 영향을 받지 않습니다. ②에서는 새 코드가 두 컬럼에 함께 쓰되 판단은 아직 옛 컬럼으로 합니다. ③에서 기존 행을 백필하고, 충분히 검증한 뒤 ④에서 읽기를 새 컬럼으로 옮깁니다. 그래도 한동안 옛 컬럼에도 계속 쓰는 이유는 롤백 경로를 남기기 위해서입니다. 마지막으로 옛 컬럼 쓰기를 멈추고, 충분히 안전하다고 확인한 뒤 제거합니다.
여섯 단계가 곧 여섯 번의 CI/CD 배포를 뜻하는 것은 아닙니다. 어떤 단계는 DB 마이그레이션이고, 어떤 단계는 백필 잡입니다. 중요한 것은 호환성 경계를 건너뛰지 않는 것입니다.
프론트엔드에서 컴포넌트를 점진적으로 교체할 때와 비슷합니다. 새 컴포넌트를 옆에 만들고, 플래그로 트래픽을 옮기고, 충분히 확인한 뒤 옛 것을 지웁니다. 데이터 마이그레이션에는 여기에 백필이라는 단계가 하나 더 들어갈 뿐입니다.
dual write에서 봐야 할 경계
②의 dual write도 공짜는 아닙니다. 여기서 기준은 테이블이 같은지가 아니라 하나의 트랜잭션으로 묶을 수 있는가입니다.
같은 DB의 InnoDB 테이블이라면 서로 다른 테이블이어도 하나의 로컬 트랜잭션에서 함께 커밋할 수 있습니다.
START TRANSACTION;
UPDATE employee
SET pay_cycle = 'MONTHLY'
WHERE id = :id;
INSERT INTO employee_pay_cycle (employee_id, cycle)
VALUES (:id, 'MONTHLY');
COMMIT;
이때 보장되는 것은 둘 다 반영되거나 둘 다 반영되지 않는 원자성입니다. 두 변환 로직이 항상 같은 값을 만든다는 것까지 보장하지는 않습니다.
문제가 더 어려워지는 것은 하나의 로컬 트랜잭션으로 묶을 수 없는 저장소를 함께 쓸 때입니다. 다른 DB 서버나 검색 엔진까지 동시에 갱신하면 한쪽만 성공하는 구간이 생길 수 있습니다. 이런 경우에는 XA 같은 분산 트랜잭션이나 outbox/CDC, 멱등 재처리와 같은 별도의 정합성 전략이 필요합니다.
중요한 것은 어떤 방식을 택하든 옛 값과 새 값이 다른 행을 먼저 발견하고 셀 수 있어야 한다는 것입니다. 조용히 한쪽을 정답으로 간주하면 §2에서 본 문제를 다시 만들게 됩니다.
contract는 정리가 아니라 마지막 검증입니다
expand-contract에서 가장 쉽게 빠지는 단계는 마지막 contract입니다. 읽기가 새 컬럼으로 넘어가고 장애도 없으면, 옛 컬럼 삭제는 늘 다음으로 밀립니다.
하지만 옛 컬럼을 실제로 지울 수 있을 때 비로소 마이그레이션이 끝났다고 말할 수 있습니다. 지우지 않으면 임시 fallback, 이중 쓰기, 사용하지 않는 컬럼이 계속 남고 몇 달 뒤에는 그것이 전환기 코드인지 원래 설계인지 구별하기 어려워집니다.
그래서 종료 조건은 마지막에 정하는 것이 아니라 시작 전에 정해 두는 편이 좋습니다.
- 옛 컬럼에만 값이 있는 행이 0건인가
- 옛 컬럼을 읽는 코드 경로가 N일간 0회인가
그리고 읽기 전환과 옛 컬럼 제거를 같은 단계에 넣지 않습니다. 읽기를 옮긴 뒤 문제가 생겼을 때 돌아갈 곳이 남아 있어야 하기 때문입니다.
결국 이 글의 세 절은 같은 질문을 다룹니다.
없는 값을 언제, 어디서, 얼마나 되돌릴 수 있는 방식으로 만들어낼 것인가?
DDL에 기본값을 박으면 단순하지만 과거의 의미까지 확정할 수 있습니다. 읽는 시점에 해석하면 가역적이지만 코드가 복잡해집니다. 값을 끝까지 세 가지 상태로 들고 가면 가장 정직하지만 그 복잡도를 도메인이 감당해야 합니다.
어느 쪽을 택해도 괜찮습니다. 중요한 건 그 선택을 의식적으로 했고, 전환기를 끝낼 방법까지 함께 설계했느냐입니다.
Recap
롤링 배포 중에는 옛 코드와 새 코드가 함께 존재하므로 전환기에는 양방향 호환성이 필요합니다. expand-contract의 핵심은 단계 수가 아니라 각 단계가 관찰 가능하고 롤백 가능해야 한다는 것입니다. 그리고 마지막 contract까지 끝내야 임시 상태가 설계로 굳어지지 않습니다.
References
MySQL 온라인 DDL
- MySQL 8.0 Reference Manual — Online DDL Operations
- MySQL 8.0 Reference Manual — Metadata Locking
- MySQL Server Blog — MySQL 8.0 INSTANT ADD and DROP Column(s)
- MySQL WL#11250 — Support Instant Add Column
- MySQL 8.0 Reference Manual — XA Transactions
PostgreSQL
- PostgreSQL commit 16828d5 — Fast ALTER TABLE ADD COLUMN with a non-NULL default
- PostgreSQL Documentation —
pg_attribute(atthasmissing,attmissingval) - PostgreSQL Documentation — ALTER TABLE
패턴과 도구
- Martin Fowler — ParallelChange
- Martin Fowler, Pramod Sadalage — Evolutionary Database Design
- gh-ost — GitHub's online schema migration for MySQL
- Percona Toolkit — pt-online-schema-change
