- Authors

- Name
- Youngju Kim
- @fjvbn20031
- 들어가며
- 파괴적 변경은 왜 위험한가
- Expand-Contract 패턴의 3단계
- 시나리오별 실전 가이드
- 더블 라이트와 점진적 백필 깊이 보기
- 애플리케이션 코드와의 협업
- 롤백을 항상 가능하게 유지하기
- 흔한 함정들
- 무중단 스키마 변경 체크리스트
- 맺으며
- 참고 자료
들어가며
운영 중인 서비스의 데이터베이스 스키마를 바꾸는 일은 늘 긴장됩니다. 트래픽이 흐르는 동안, 수십 개의 애플리케이션 인스턴스가 같은 테이블을 읽고 쓰는 와중에, 컬럼 하나를 지우거나 이름을 바꾸는 단순해 보이는 변경이 전체 서비스를 멈추게 만들 수 있기 때문입니다.
많은 팀이 한 번쯤은 이런 경험을 합니다. 배포 직전에 ALTER TABLE로 컬럼 이름을 바꾸고, 새 코드를 배포했는데, 아직 롤링 배포가 끝나지 않아 구 버전 인스턴스가 사라진 컬럼을 참조하면서 에러가 폭발하는 상황입니다. 혹은 큰 테이블에 NOT NULL 제약을 한 번에 걸었다가 테이블 전체가 잠기고, 그 사이 모든 쓰기 쿼리가 타임아웃 나는 상황도 흔합니다.
이 글에서는 이런 사고를 구조적으로 막는 Expand-Contract 패턴(확장-수축 패턴, Parallel Change라고도 부릅니다)을 정리합니다. 이 패턴은 "스키마와 코드를 동시에 바꾸지 않는다"는 단 하나의 원칙에서 출발합니다. 그 원칙을 지키기 위한 3단계 흐름과, 시나리오별 실제 SQL, 그리고 애플리케이션 코드와의 협업 방법을 구체적으로 다루겠습니다.
파괴적 변경은 왜 위험한가
먼저 "파괴적 변경(destructive change)"이 무엇인지 정의해 보겠습니다. 파괴적 변경이란 기존 코드가 의존하던 스키마 계약을 한순간에 깨뜨리는 변경을 말합니다. 대표적으로 다음과 같습니다.
- 컬럼 이름 변경 (
RENAME COLUMN) - 컬럼 삭제 (
DROP COLUMN) - 컬럼 타입 변경 (
ALTER COLUMN ... TYPE) NOT NULL제약을 즉시 적용- 테이블 이름 변경
이런 변경들이 위험한 이유는 크게 두 가지입니다.
1. 배포는 원자적이지 않다
현대적인 서비스는 롤링 배포 또는 블루-그린 배포를 사용합니다. 즉, 새 버전 코드와 구 버전 코드가 동시에 떠 있는 시간 구간이 반드시 존재합니다. 짧게는 수십 초, 길게는 수 분입니다.
스키마 변경은 이 시간 구간을 고려하지 않으면 반드시 깨집니다. 스키마를 먼저 바꾸면 구 버전 코드가 깨지고, 코드를 먼저 바꾸면 새 코드가 아직 없는 스키마를 참조하다 깨집니다. 어느 쪽을 먼저 해도 한쪽이 깨지는 것입니다. 이 모순을 해결하는 것이 바로 Expand-Contract 패턴입니다.
2. DDL은 락을 잡는다
ALTER TABLE은 종류에 따라 강한 락(ACCESS EXCLUSIVE LOCK)을 잡습니다. PostgreSQL을 예로 들면, 큰 테이블에 기본값이 있는 컬럼을 추가하거나, 즉시 검증되는 제약을 추가하면 테이블 전체를 스캔하며 락을 유지합니다. 이 동안 들어오는 모든 쿼리는 대기열에 쌓이고, 결국 커넥션 풀이 고갈되어 서비스 전체가 멈춥니다.
시간 ──────────────────────────────────────▶
ALTER TABLE (전체 스캔 + ACCESS EXCLUSIVE LOCK)
┌──────────────────────────────┐
│ 테이블 잠김 │
└──────────────────────────────┘
읽기/쓰기 쿼리 ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ 대기 ▶ 타임아웃
따라서 무중단 스키마 변경은 "코드와의 호환성"과 "락 회피"라는 두 축을 동시에 만족해야 합니다.
Expand-Contract 패턴의 3단계
Expand-Contract 패턴은 하나의 변경을 세 단계로 쪼갭니다. 핵심은 각 단계 사이에 항상 안전하게 멈출 수 있고, 롤백할 수 있다는 점입니다.
단계 개요
| 단계 | 영문 | 스키마 상태 | 코드 상태 | 목적 |
|---|---|---|---|---|
| 1. 확장 | Expand | 구/신 구조 모두 존재 | 변경 없음 또는 양쪽 호환 | 새 구조를 추가하되 기존 것을 깨지 않음 |
| 2. 이행 | Migrate | 양쪽 구조 공존 | 신 구조로 점진 전환 | 데이터를 옮기고 코드가 새 구조를 쓰게 함 |
| 3. 수축 | Contract | 구 구조 제거 | 신 구조만 사용 | 더 이상 필요 없는 구 구조를 정리 |
ASCII 타임라인
Expand Migrate Contract
┌─────────┐ ┌──────────────┐ ┌──────────┐
│ 새 컬럼 │ │ 백필 + │ │ 구 컬럼 │
│ 추가 │ ───▶ │ 더블 라이트 │ ───▶ │ 삭제 │
│ (호환) │ │ + 코드 전환 │ │ (정리) │
└─────────┘ └──────────────┘ └──────────┘
│ │ │
▼ ▼ ▼
롤백 가능 롤백 가능 되돌리려면
(그냥 무시) (구 컬럼 살아있음) 재확장 필요
◀── 구 버전/신 버전 코드 동시 동작 안전 구간 ──▶
이 타임라인에서 가장 중요한 점은, Expand와 Migrate 단계 동안에는 구 버전 코드와 신 버전 코드가 동시에 떠 있어도 모두 정상 동작한다는 것입니다. 이것이 무중단의 핵심입니다.
시나리오별 실전 가이드
이제 각 변경 시나리오를 Expand-Contract 관점에서 구체적인 SQL과 함께 살펴보겠습니다. 예시는 PostgreSQL 기준이며, MySQL 차이는 별도로 언급하겠습니다.
시나리오 1: 컬럼 추가
가장 단순하지만, 여기에도 함정이 있습니다. 새 컬럼을 추가할 때 기본값과 NOT NULL을 한 번에 거는 것입니다.
PostgreSQL 11 이상에서는 상수 기본값을 가진 컬럼 추가가 메타데이터만 바꾸는 빠른 연산으로 최적화되었습니다. 하지만 휘발성 함수를 기본값으로 쓰면 전체 테이블을 다시 써야 하므로 주의해야 합니다.
Expand 단계 — 새 컬럼을 nullable로 추가합니다.
-- 안전: nullable 컬럼 추가 (메타데이터 변경만)
ALTER TABLE orders
ADD COLUMN discount_code text;
만약 기본값이 필요하다면, 상수 기본값은 안전하지만 함수 기반 기본값은 피합니다.
-- 안전 (PostgreSQL 11+): 상수 기본값
ALTER TABLE orders
ADD COLUMN status text NOT NULL DEFAULT 'pending';
-- 위험: 휘발성 함수 기본값은 전체 테이블 재작성 유발 가능
-- ALTER TABLE orders ADD COLUMN created_at timestamptz DEFAULT now();
코드 측면에서는, 새 컬럼을 읽고 쓰는 코드는 컬럼이 실제로 생긴 뒤에 배포합니다. 컬럼이 nullable이므로 구 버전 코드는 이 컬럼을 무시하면 되고, 깨지지 않습니다.
시나리오 2: 컬럼 이름 변경
이름 변경은 가장 흔하게 사고가 나는 작업입니다. RENAME COLUMN 한 줄이면 끝날 것 같지만, 그 한 줄이 구 버전 코드를 즉시 깨뜨립니다. Expand-Contract 패턴에서는 이름 변경을 추가 + 백필 + 전환 + 삭제의 4단계로 분해합니다.
예를 들어 users.username을 users.handle로 바꾼다고 합시다.
Expand: 새 컬럼 추가
ALTER TABLE users
ADD COLUMN handle text;
Migrate: 백필과 더블 라이트
기존 데이터를 새 컬럼으로 옮깁니다. 큰 테이블은 한 번에 UPDATE하면 락과 WAL 폭증을 유발하므로, 반드시 배치로 나눕니다.
-- 배치 백필: 한 번에 일정 건수만 처리
UPDATE users
SET handle = username
WHERE handle IS NULL
AND id IN (
SELECT id FROM users
WHERE handle IS NULL
ORDER BY id
LIMIT 5000
);
-- 위 쿼리를 영향 행이 0이 될 때까지 반복 실행
백필과 동시에, 애플리케이션은 두 컬럼에 모두 쓰는 더블 라이트를 수행합니다. 이렇게 하면 백필 도중 새로 들어온 데이터도 양쪽에 반영됩니다.
# 애플리케이션 더블 라이트 (의사 코드)
def update_user_handle(user_id, new_value):
db.execute(
"UPDATE users SET username = :v, handle = :v WHERE id = :id",
v=new_value, id=user_id,
)
def read_user_handle(row):
# 읽기는 새 컬럼 우선, 없으면 구 컬럼 폴백
return row["handle"] if row["handle"] is not None else row["username"]
Contract: 구 컬럼 제거
백필이 끝나고, 모든 인스턴스가 새 컬럼만 읽고 쓰는 코드로 배포된 뒤에야 구 컬럼을 지웁니다.
ALTER TABLE users
DROP COLUMN username;
이 순서를 지키면, 어느 시점에 배포가 멈추거나 롤백되어도 서비스는 깨지지 않습니다.
시나리오 3: 컬럼 삭제
컬럼 삭제도 즉시 하면 위험합니다. 아직 그 컬럼을 참조하는 구 버전 코드가 떠 있을 수 있기 때문입니다. 삭제는 항상 코드에서 참조를 먼저 제거한 뒤에 합니다.
순서는 다음과 같습니다.
- 해당 컬럼을 읽거나 쓰지 않는 코드를 먼저 배포합니다.
- 모든 인스턴스가 새 코드로 교체될 때까지 기다립니다 (관찰 기간).
- 컬럼을 삭제합니다.
-- 모든 코드가 더 이상 이 컬럼을 참조하지 않음을 확인한 뒤
ALTER TABLE users
DROP COLUMN legacy_score;
삭제 전에 충분한 관찰 기간을 두는 것이 좋습니다. 컬럼을 즉시 DROP하는 대신, 먼저 애플리케이션 레벨에서 무시하도록 만들고 며칠 운영하면서 정말 아무도 안 쓰는지 모니터링한 뒤에 지우는 것이 안전합니다.
시나리오 4: NOT NULL 제약 추가
큰 테이블에 SET NOT NULL을 직접 걸면 전체 스캔과 함께 ACCESS EXCLUSIVE LOCK을 잡습니다. PostgreSQL에서는 CHECK 제약을 NOT VALID로 먼저 추가하고, 나중에 검증하는 우회로가 있습니다.
Expand: 검증을 미루는 CHECK 제약 추가
-- NOT VALID: 기존 행은 검사하지 않고, 신규/변경 행만 검사
ALTER TABLE orders
ADD CONSTRAINT orders_amount_not_null
CHECK (amount IS NOT NULL) NOT VALID;
NOT VALID는 짧은 락만 잡고 즉시 끝납니다. 이 시점부터 새로 들어오는 데이터는 제약을 만족해야 합니다.
Migrate: 기존 데이터 백필 후 검증
-- 1) NULL인 기존 행을 채움 (배치로)
UPDATE orders SET amount = 0 WHERE amount IS NULL AND id IN (
SELECT id FROM orders WHERE amount IS NULL ORDER BY id LIMIT 5000
);
-- 2) 제약 검증: ACCESS EXCLUSIVE가 아닌 약한 락으로 전체 검사
ALTER TABLE orders
VALIDATE CONSTRAINT orders_amount_not_null;
VALIDATE CONSTRAINT는 전체 테이블을 읽지만 SHARE UPDATE EXCLUSIVE 수준의 약한 락만 잡아, 동시 읽기/쓰기를 막지 않습니다. 이것이 핵심 차이입니다.
시나리오 5: 컬럼 타입 변경
타입 변경(ALTER COLUMN ... TYPE)은 거의 항상 테이블을 다시 써야 하므로 가장 무거운 작업입니다. 안전한 접근은 이름 변경과 동일하게 새 컬럼을 만들어 옮기는 방식입니다.
price를 integer에서 bigint로 바꾸는 예시입니다.
-- Expand: 새 타입의 컬럼 추가
ALTER TABLE products
ADD COLUMN price_v2 bigint;
-- Migrate: 배치 백필 + 애플리케이션 더블 라이트
UPDATE products
SET price_v2 = price
WHERE price_v2 IS NULL AND id IN (
SELECT id FROM products WHERE price_v2 IS NULL ORDER BY id LIMIT 5000
);
-- (코드 전환 완료 후) Contract: 구 컬럼 삭제, 필요시 새 컬럼 이름 정리
ALTER TABLE products DROP COLUMN price;
ALTER TABLE products RENAME COLUMN price_v2 TO price;
마지막 RENAME은 메타데이터만 바꾸므로 빠르지만, 이 순간 다시 컬럼 이름이 바뀌므로 이 이름을 읽는 코드와의 동기화에 주의해야 합니다. 보수적으로 가려면 마지막 RENAME을 생략하고 price_v2라는 이름을 그대로 유지하는 것도 방법입니다.
인덱스 추가는 CONCURRENTLY로
스키마 변경에는 인덱스 추가도 포함됩니다. 일반 CREATE INDEX는 테이블에 쓰기 락을 잡지만, CONCURRENTLY 옵션은 락 없이 인덱스를 빌드합니다.
-- 쓰기를 막지 않고 인덱스 생성
CREATE INDEX CONCURRENTLY idx_orders_discount_code
ON orders (discount_code);
단, CONCURRENTLY는 트랜잭션 블록 안에서 실행할 수 없고, 실패하면 무효(invalid) 인덱스를 남길 수 있으므로 실패 시 정리 로직이 필요합니다.
더블 라이트와 점진적 백필 깊이 보기
Migrate 단계의 두 기둥은 더블 라이트와 점진적 백필입니다. 이 둘이 함께 동작해야 데이터 정합성이 보장됩니다.
왜 둘 다 필요한가
백필만 하고 더블 라이트를 안 하면, 백필이 진행되는 동안 새로 들어온 쓰기는 구 컬럼에만 반영되어 새 컬럼이 비어버립니다. 더블 라이트만 하고 백필을 안 하면, 더블 라이트 시작 이전의 과거 데이터는 새 컬럼이 영원히 비어 있습니다. 그래서 둘을 함께 써서 "과거(백필)와 미래(더블 라이트)"를 모두 덮어야 합니다.
더블 라이트 시작 시점
│
과거 데이터 │ 미래 데이터
◀─────────┼──────────▶
백필이 │ 더블 라이트가
채움 │ 자동으로 채움
배치 백필 패턴
백필은 반드시 작은 배치로 나누고, 배치 사이에 잠깐 쉬어 복제 지연과 락 경합을 완화합니다.
# 점진적 백필 루프 (의사 코드)
BATCH = 5000
while True:
rows = db.execute("""
UPDATE users SET handle = username
WHERE handle IS NULL
AND id IN (
SELECT id FROM users WHERE handle IS NULL
ORDER BY id LIMIT :n
)
RETURNING id
""", n=BATCH)
if len(rows) == 0:
break
sleep(0.2) # 복제 지연/락 경합 완화
이렇게 하면 거대한 테이블도 서비스에 영향을 주지 않고 천천히 채울 수 있습니다.
애플리케이션 코드와의 협업
Expand-Contract 패턴은 데이터베이스만의 일이 아니라 애플리케이션 배포와 박자를 맞춰야 합니다. 단계별로 코드가 어떤 상태여야 하는지 정리하면 다음과 같습니다.
| 시점 | 스키마 | 읽기 코드 | 쓰기 코드 |
|---|---|---|---|
| Expand 직후 | 새 컬럼 추가됨 | 구 컬럼 읽음 | 구 컬럼만 씀 |
| 더블 라이트 배포 | 양쪽 존재 | 신 우선, 구 폴백 | 양쪽에 씀 |
| 신 컬럼 전환 배포 | 양쪽 존재 | 신 컬럼만 읽음 | 신 컬럼만 씀 |
| Contract | 구 컬럼 삭제 | 신 컬럼만 읽음 | 신 컬럼만 씀 |
핵심 규칙은 각 배포 사이에 반드시 "모든 인스턴스가 교체될 때까지" 기다린다는 것입니다. 롤링 배포가 완전히 끝났는지 확인하지 않고 다음 단계로 넘어가면, 여전히 구 버전 코드가 남아 있어 가정이 깨질 수 있습니다.
마이그레이션 도구(Flyway, Liquibase, golang-migrate 등)를 쓴다면, 한 릴리스에 하나의 안전한 단계만 담는 것이 좋습니다. Expand 마이그레이션과 Contract 마이그레이션을 별도 릴리스로 분리하면, 그 사이에 코드 배포와 관찰 기간을 끼워 넣을 수 있습니다.
롤백을 항상 가능하게 유지하기
Expand-Contract의 가장 큰 장점은 각 단계가 독립적으로 롤백 가능하다는 점입니다.
- Expand 단계 롤백: 새로 추가한 nullable 컬럼은 아무도 안 쓰므로, 그냥 두거나 나중에 지우면 됩니다. 코드 롤백도 안전합니다.
- Migrate 단계 롤백: 구 컬럼이 아직 살아 있고 더블 라이트로 최신 상태를 유지하므로, 코드를 구 버전으로 되돌려도 데이터가 멀쩡합니다.
- Contract 단계 롤백: 이 단계만큼은 주의가 필요합니다. 구 컬럼을 이미 지웠다면 단순 코드 롤백으로는 못 돌아갑니다. 그래서 Contract는 가장 마지막에, 신 구조가 충분히 안정화된 뒤에만 수행합니다.
이 비대칭성 때문에 실무 격언이 하나 있습니다. "Expand는 자유롭게, Contract는 신중하게." 확장은 되돌리기 쉽지만 수축은 되돌리기 어렵습니다.
흔한 함정들
실무에서 자주 마주치는 함정을 정리합니다.
-
한 릴리스에 Expand와 Contract를 같이 넣기. 가장 흔한 실수입니다. 둘 사이에 코드 배포와 관찰 기간이 들어가야 하는데, 한 번에 처리하면 무중단 보장이 깨집니다.
-
백필을 한 방에 UPDATE. 수백만 행을 한 트랜잭션으로 갱신하면 락, WAL 폭증, 복제 지연이 따라옵니다. 반드시 배치로 나눕니다.
-
더블 라이트를 빼먹기. 백필 중 들어온 신규 데이터가 새 컬럼에 누락됩니다. 백필이 끝났는데도 새 컬럼에 NULL이 남는 미스터리의 주범입니다.
-
함수 기본값으로 컬럼 추가.
DEFAULT now()같은 휘발성 기본값은 전체 테이블 재작성을 유발할 수 있습니다. 상수 기본값을 쓰거나, 추가 후 백필로 채웁니다. -
즉시 검증되는 제약 추가.
NOT NULL이나CHECK를 바로 걸면 전체 스캔 + 강한 락입니다.NOT VALID후VALIDATE로 나눕니다. -
CONCURRENTLY를 트랜잭션 안에서 실행.
CREATE INDEX CONCURRENTLY는 트랜잭션 블록 밖에서만 동작합니다. 마이그레이션 도구가 자동으로 트랜잭션을 감싸지 않도록 설정해야 합니다. -
롤링 배포 완료를 안 기다림. 모든 인스턴스가 새 코드로 교체되기 전에 다음 단계로 넘어가면 가정이 깨집니다.
무중단 스키마 변경 체크리스트
배포 전에 다음을 점검하면 좋습니다.
- 이 변경을 Expand / Migrate / Contract 세 단계로 쪼갰는가?
- Expand와 Contract를 별도 릴리스로 분리했는가?
- 새 컬럼은 nullable로 추가하는가? (또는 상수 기본값만 사용)
- 백필은 배치로 나누고 배치 사이에 쉬는가?
- 더블 라이트로 과거와 미래 데이터를 모두 덮는가?
- 제약은
NOT VALID후VALIDATE로 나눴는가? - 인덱스는
CONCURRENTLY로 만드는가? - 각 단계 사이에 롤링 배포 완료를 기다리는가?
- Contract 직전까지 구 구조를 유지해 롤백 경로를 확보했는가?
- 컬럼 삭제 전 충분한 관찰 기간을 두었는가?
맺으며
Expand-Contract 패턴의 본질은 "한 번에 바꾸지 않는다"입니다. 파괴적인 변경 하나를, 각각 안전하고 되돌릴 수 있는 작은 단계들로 분해하여, 구 버전과 신 버전 코드가 공존하는 시간 구간을 항상 무사히 지나가도록 만드는 것입니다.
처음에는 단계가 많아 번거롭게 느껴질 수 있습니다. 하지만 한밤중 배포에서 컬럼 하나 때문에 서비스가 멈추는 경험을 한 번이라도 해 보면, 이 번거로움이 얼마나 값진 보험인지 알게 됩니다. 무중단은 운이 아니라 절차로 만들어집니다.
참고 자료
- PostgreSQL ALTER TABLE 문서: https://www.postgresql.org/docs/current/sql-altertable.html
- PostgreSQL CREATE INDEX (CONCURRENTLY) 문서: https://www.postgresql.org/docs/current/sql-createindex.html
- MySQL Online DDL 문서: https://dev.mysql.com/doc/refman/8.4/en/innodb-online-ddl.html
- AWS Database Migration Service 문서: https://docs.aws.amazon.com/dms/
- Flyway 문서: https://flywaydb.org/documentation/
- Liquibase 문서: https://docs.liquibase.com/
- golang-migrate: https://github.com/golang-migrate/migrate
- Martin Fowler, Evolutionary Database Design: https://martinfowler.com/articles/evodb.html