Skip to content
Published on

Expand-Contract 패턴 — 무중단 스키마 변경의 정석

Authors

들어가며

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

많은 팀이 한 번쯤은 이런 경험을 합니다. 배포 직전에 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.usernameusers.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)은 거의 항상 테이블을 다시 써야 하므로 가장 무거운 작업입니다. 안전한 접근은 이름 변경과 동일하게 새 컬럼을 만들어 옮기는 방식입니다.

priceinteger에서 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 VALIDVALIDATE로 나눕니다.

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

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

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

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

  • 이 변경을 Expand / Migrate / Contract 세 단계로 쪼갰는가?
  • Expand와 Contract를 별도 릴리스로 분리했는가?
  • 새 컬럼은 nullable로 추가하는가? (또는 상수 기본값만 사용)
  • 백필은 배치로 나누고 배치 사이에 쉬는가?
  • 더블 라이트로 과거와 미래 데이터를 모두 덮는가?
  • 제약은 NOT VALIDVALIDATE로 나눴는가?
  • 인덱스는 CONCURRENTLY로 만드는가?
  • 각 단계 사이에 롤링 배포 완료를 기다리는가?
  • Contract 직전까지 구 구조를 유지해 롤백 경로를 확보했는가?
  • 컬럼 삭제 전 충분한 관찰 기간을 두었는가?

맺으며

Expand-Contract 패턴의 본질은 "한 번에 바꾸지 않는다"입니다. 파괴적인 변경 하나를, 각각 안전하고 되돌릴 수 있는 작은 단계들로 분해하여, 구 버전과 신 버전 코드가 공존하는 시간 구간을 항상 무사히 지나가도록 만드는 것입니다.

처음에는 단계가 많아 번거롭게 느껴질 수 있습니다. 하지만 한밤중 배포에서 컬럼 하나 때문에 서비스가 멈추는 경험을 한 번이라도 해 보면, 이 번거로움이 얼마나 값진 보험인지 알게 됩니다. 무중단은 운이 아니라 절차로 만들어집니다.

참고 자료