Skip to content

필사 모드: Expand-Contract 패턴 — 무중단 스키마 변경의 정석

한국어
0%
정확도 0%
💡 왼쪽 원문을 읽으면서 오른쪽에 따라 써보세요. Tab 키로 힌트를 받을 수 있습니다.
원문 렌더가 준비되기 전까지 텍스트 가이드로 표시합니다.

들어가며

운영 중인 서비스의 데이터베이스 스키마를 바꾸는 일은 늘 긴장됩니다. 트래픽이 흐르는 동안, 수십 개의 애플리케이션 인스턴스가 같은 테이블을 읽고 쓰는 와중에, 컬럼 하나를 지우거나 이름을 바꾸는 단순해 보이는 변경이 전체 서비스를 멈추게 만들 수 있기 때문입니다.

많은 팀이 한 번쯤은 이런 경험을 합니다. 배포 직전에 `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: 컬럼 삭제

컬럼 삭제도 즉시 하면 위험합니다. 아직 그 컬럼을 참조하는 구 버전 코드가 떠 있을 수 있기 때문입니다. 삭제는 항상 **코드에서 참조를 먼저 제거**한 뒤에 합니다.

순서는 다음과 같습니다.

1. 해당 컬럼을 읽거나 쓰지 않는 코드를 먼저 배포합니다.

2. 모든 인스턴스가 새 코드로 교체될 때까지 기다립니다 (관찰 기간).

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는 신중하게." 확장은 되돌리기 쉽지만 수축은 되돌리기 어렵습니다.

흔한 함정들

실무에서 자주 마주치는 함정을 정리합니다.

1. **한 릴리스에 Expand와 Contract를 같이 넣기.** 가장 흔한 실수입니다. 둘 사이에 코드 배포와 관찰 기간이 들어가야 하는데, 한 번에 처리하면 무중단 보장이 깨집니다.

2. **백필을 한 방에 UPDATE.** 수백만 행을 한 트랜잭션으로 갱신하면 락, WAL 폭증, 복제 지연이 따라옵니다. 반드시 배치로 나눕니다.

3. **더블 라이트를 빼먹기.** 백필 중 들어온 신규 데이터가 새 컬럼에 누락됩니다. 백필이 끝났는데도 새 컬럼에 NULL이 남는 미스터리의 주범입니다.

4. **함수 기본값으로 컬럼 추가.** `DEFAULT now()` 같은 휘발성 기본값은 전체 테이블 재작성을 유발할 수 있습니다. 상수 기본값을 쓰거나, 추가 후 백필로 채웁니다.

5. **즉시 검증되는 제약 추가.** `NOT NULL`이나 `CHECK`를 바로 걸면 전체 스캔 + 강한 락입니다. `NOT VALID` 후 `VALIDATE`로 나눕니다.

6. **CONCURRENTLY를 트랜잭션 안에서 실행.** `CREATE INDEX CONCURRENTLY`는 트랜잭션 블록 밖에서만 동작합니다. 마이그레이션 도구가 자동으로 트랜잭션을 감싸지 않도록 설정해야 합니다.

7. **롤링 배포 완료를 안 기다림.** 모든 인스턴스가 새 코드로 교체되기 전에 다음 단계로 넘어가면 가정이 깨집니다.

무중단 스키마 변경 체크리스트

배포 전에 다음을 점검하면 좋습니다.

- [ ] 이 변경을 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

현재 단락 (1/190)

운영 중인 서비스의 데이터베이스 스키마를 바꾸는 일은 늘 긴장됩니다. 트래픽이 흐르는 동안, 수십 개의 애플리케이션 인스턴스가 같은 테이블을 읽고 쓰는 와중에, 컬럼 하나를 지우거...

작성 글자: 0원문 글자: 8,392작성 단락: 0/190