Expand-Contract 패턴으로 무중단 PostgreSQL 스키마 마이그레이션 구현하기
컬럼 이름 변경·테이블 분리·외래키 추가를 롤백 가능한 3단계로 배포하는 실전 가이드
배포 전날 팀 채널에 이런 메시지가 올라온 경험, 많을 겁니다. "내일 DB 마이그레이션 있으니 새벽 트래픽 낮을 때 진행합시다." RENAME COLUMN 하나 때문에 서비스 점검 공지를 올리고 온 팀이 새벽 2시에 슬랙을 보고 있는 상황.
Expand-Contract 패턴을 적용하면 컬럼 이름 변경, 테이블 분리, 외래키 추가 같은 파괴적(breaking) 스키마 변경을 서비스 다운타임 없이, 각 단계에서 완전히 롤백 가능한 형태로 배포할 수 있습니다. 이 글은 직접 ALTER TABLE이 왜 위험한지부터 시작해, 3단계 구조가 그 위험을 어떻게 제거하는지, 그리고 실제 시나리오와 함께 CI/CD 파이프라인에 통합하는 방법을 다룹니다.
핵심 개념
왜 직접 ALTER TABLE이 위험한가
users 테이블에서 name 컬럼을 full_name으로 바꾸고 싶다고 해봅시다. 직관적으로는 이렇게 하고 싶을 겁니다.
-- 아래에서 이 방식의 문제를 설명합니다
ALTER TABLE users RENAME COLUMN name TO full_name;DDL 자체는 원자적이고 빠릅니다. 문제는 **배포 간격(deploy gap)**에 있습니다. 쿠버네티스 롤링 업데이트 환경에서 구 버전 앱 인스턴스가 아직 name을 참조하는 동안 이 명령이 실행되면, 구 코드는 즉각 column "name" does not exist 에러를 뱉습니다.
대용량 테이블에서는 락 문제도 있습니다. PostgreSQL 11 이전에서 ADD COLUMN NOT NULL DEFAULT '값'을 수억 건 테이블에 실행하면 ACCESS EXCLUSIVE 락이 테이블 전체 리라이트 동안 유지됩니다. PostgreSQL 11+에서는 상수 기본값에 한해 메타데이터 변경만으로 처리되지만, 잘못된 패턴으로 대규모 테이블을 건드리면 여전히 위험합니다.
Expand-Contract 패턴의 3단계 구조
Expand-Contract(Parallel Change 패턴이라고도 합니다)의 핵심 아이디어는 파괴적 변경을 절대로 한 번에 하지 않는 것입니다. 구 스키마와 신 스키마가 잠시 공존하는 "중간 상태"를 허용하고, 그 사이에 데이터와 코드를 조용히 전환합니다.
| 단계 | 명칭 | 동작 원칙 |
|---|---|---|
| 1단계 | Expand 확장 | 기존 구조를 유지하면서 새로운 컬럼·테이블·제약조건을 추가만 함. 구 코드는 깨지지 않음 |
| 2단계 | Migrate 전환 | 기존 데이터를 배치로 백필하고, 듀얼 라이트 후 점진적으로 신 스키마로 전환 |
| 3단계 | Contract 수축 | 모든 앱 인스턴스가 신 스키마를 사용함이 확인된 뒤, 구 컬럼·테이블을 DROP |
이 구조에서 각 단계가 끝난 시점에 시스템은 항상 정상 동작 상태입니다. Expand 단계는 추가만 하기 때문에 문제가 생겨도 DROP COLUMN으로 완전히 되돌릴 수 있습니다.
Expand 단계에서 반드시 NULL 허용으로 추가해야 하는 이유
구 코드는 새로 추가된 컬럼을 모릅니다. 만약 NOT NULL로 컬럼을 추가하면, 구 코드가 해당 컬럼을 포함하지 않은 INSERT를 날리는 순간 바로 오류가 납니다. Expand 단계의 신규 컬럼은 반드시 NULL 허용으로 추가해야 구 코드와 신 코드가 같은 DB에서 안전하게 공존할 수 있습니다.
실전 적용
시나리오 1 — 컬럼 이름 변경: name → full_name
Phase 1: Expand — 신 컬럼 추가
-- 마이그레이션 파일: V001__expand_add_full_name.sql
SET lock_timeout = '5s';
SET statement_timeout = '30s';
ALTER TABLE users ADD COLUMN full_name TEXT; -- 반드시 nullable이 마이그레이션을 적용한 뒤, 앱 코드를 듀얼 라이트로 변경해 배포합니다.
# 앱 코드: 구 컬럼과 신 컬럼 모두에 쓰기
def create_user(name: str):
db.execute(
"INSERT INTO users (name, full_name) VALUES (%s, %s)",
[name, name]
)
def update_user_name(user_id: int, name: str):
db.execute(
"UPDATE users SET name = %s, full_name = %s WHERE id = %s",
[name, name, user_id]
)Phase 2: Migrate — 배치 백필 + 읽기 전환
기존 행들에 full_name 값이 없으니 채워줘야 합니다. 고정 범위 슬라이딩 방식(id > last_id AND id <= last_id + BATCH_SIZE)은 ID 갭이 있는 테이블에서 첫 빈 범위를 만나는 즉시 루프를 종료하는 버그가 있습니다. 커서 기반 페이지네이션을 사용해야 안전합니다.
# scripts/backfill_full_name.py
import time
import psycopg2
BATCH_SIZE = 10_000
SLEEP_SECONDS = 0.1
conn = psycopg2.connect(DATABASE_URL)
last_id = 0
while True:
with conn.cursor() as cur:
cur.execute("""
UPDATE users
SET full_name = name
WHERE id IN (
SELECT id FROM users
WHERE full_name IS NULL AND id > %s
ORDER BY id
LIMIT %s
)
RETURNING id
""", [last_id, BATCH_SIZE])
rows = cur.fetchall()
conn.commit()
if not rows:
break
last_id = max(row[0] for row in rows)
time.sleep(SLEEP_SECONDS) # 복제 지연 방지
print("백필 완료")백필이 끝나면 앱 코드에서 읽기를 full_name으로 전환하고, 배포 후 안정을 확인합니다. 이어서 name으로의 듀얼 라이트를 중단하고 full_name에만 쓰는 코드를 배포한 뒤 Phase 3로 넘어갑니다.
Phase 3: Contract — 구 컬럼 제거
모든 앱 인스턴스가 full_name만 사용하고 있음이 확인된 뒤 진행합니다.
-- 마이그레이션 파일: V003__contract_drop_name.sql
SET lock_timeout = '5s';
ALTER TABLE users DROP COLUMN name;시나리오 2 — 테이블 분리: orders → order_headers + order_items
하나의 orders 테이블을 order_headers와 order_items로 나누는 작업은 좀 더 복잡합니다. 트리거를 활용한 듀얼 라이트가 핵심인데, 순서와 중복 처리 방식이 중요합니다.
Phase 1: Expand — 신규 테이블 생성 + 트리거
CREATE TABLE order_headers (
id BIGINT PRIMARY KEY,
user_id BIGINT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE order_items (
id BIGSERIAL PRIMARY KEY,
order_id BIGINT NOT NULL REFERENCES order_headers(id),
product_id BIGINT NOT NULL,
quantity INT NOT NULL,
price NUMERIC(10,2) NOT NULL,
source_order_id BIGINT UNIQUE -- 백필과 트리거 간 멱등성 보장
);
CREATE OR REPLACE FUNCTION sync_orders_to_new_tables()
RETURNS TRIGGER AS $$
BEGIN
IF TG_OP = 'INSERT' THEN
INSERT INTO order_headers (id, user_id, created_at)
VALUES (NEW.id, NEW.user_id, NEW.created_at)
ON CONFLICT (id) DO NOTHING;
INSERT INTO order_items (order_id, product_id, quantity, price, source_order_id)
VALUES (NEW.id, NEW.product_id, NEW.quantity, NEW.price, NEW.id)
ON CONFLICT (source_order_id) DO NOTHING;
ELSIF TG_OP = 'UPDATE' THEN
INSERT INTO order_headers (id, user_id, created_at)
VALUES (NEW.id, NEW.user_id, NEW.created_at)
ON CONFLICT (id) DO UPDATE
SET user_id = EXCLUDED.user_id,
created_at = EXCLUDED.created_at;
UPDATE order_items
SET product_id = NEW.product_id,
quantity = NEW.quantity,
price = NEW.price
WHERE source_order_id = NEW.id;
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER trg_sync_orders
AFTER INSERT OR UPDATE ON orders
FOR EACH ROW EXECUTE FUNCTION sync_orders_to_new_tables();TG_OP 분기가 핵심입니다. UPDATE 시에도 INSERT INTO order_items를 실행하면 호출될 때마다 행이 추가되어 중복 데이터가 생깁니다.
Phase 2: Migrate — 기존 데이터 배치 복사 + 뷰 노출
트리거는 신규 데이터만 동기화하므로, 기존 데이터를 별도로 백필합니다. source_order_id의 UNIQUE 제약조건 덕분에 트리거가 이미 처리한 행을 백필이 다시 삽입해도 중복 없이 멱등하게 처리됩니다.
INSERT INTO order_headers (id, user_id, created_at)
SELECT id, user_id, created_at FROM orders
ON CONFLICT (id) DO NOTHING;
INSERT INTO order_items (order_id, product_id, quantity, price, source_order_id)
SELECT id, product_id, quantity, price, id FROM orders
ON CONFLICT (source_order_id) DO NOTHING;
-- 앱 코드 전환이 완료될 때까지 하위 호환용 뷰 노출
CREATE VIEW orders_view AS
SELECT
h.id,
h.user_id,
h.created_at,
i.product_id,
i.quantity,
i.price
FROM order_headers h
JOIN order_items i ON i.order_id = h.id;이 단계에서 앱 코드를 order_headers와 order_items를 직접 조회하는 방식으로 전환해 배포합니다. orders_view는 전환이 완전히 완료될 때까지 하위 호환 경로로 유지됩니다.
Phase 3: Contract — 구 테이블 정리
앱 코드가 order_headers와 order_items를 직접 조회하도록 전환이 완료된 것을 확인한 뒤 진행합니다.
DROP TRIGGER trg_sync_orders ON orders;
DROP FUNCTION sync_orders_to_new_tables();
DROP VIEW orders_view;
DROP TABLE orders;시나리오 3 — 외래키 추가: NOT VALID 패턴
외래키를 단번에 추가하면 기존 행 전체 검증 스캔 동안 SHARE ROW EXCLUSIVE 락이 유지됩니다. 이 락은 읽기(SELECT)와는 충돌하지 않지만, INSERT·UPDATE·DELETE 같은 쓰기 연산을 차단합니다. 대용량 테이블에서 스캔이 수 분 이상 걸린다면 그 시간 동안 쓰기가 모두 대기합니다.
2단계로 나누면 쓰기 차단 시간을 최소화할 수 있습니다.
-- Phase 1: 기존 행 검증 없이 제약조건 추가
-- SHARE ROW EXCLUSIVE 락이지만 스캔 없이 순간적으로 완료
SET lock_timeout = '5s';
ALTER TABLE orders
ADD CONSTRAINT fk_user
FOREIGN KEY (user_id) REFERENCES users(id) NOT VALID;
-- Phase 2: 별도 트랜잭션에서 기존 행 검증
-- SHARE UPDATE EXCLUSIVE 락 — 읽기와 쓰기 모두 차단하지 않음
ALTER TABLE orders VALIDATE CONSTRAINT fk_user;ADD CONSTRAINT NOT VALID와 단순 ADD CONSTRAINT의 차이는 락의 강도가 아닌 유지 시간에 있습니다. 두 경우 모두 같은 SHARE ROW EXCLUSIVE 락을 획득하지만, NOT VALID는 테이블 스캔을 건너뛰기 때문에 락 유지 시간이 극히 짧습니다. 이후 VALIDATE CONSTRAINT는 더 약한 SHARE UPDATE EXCLUSIVE 락으로 장시간 스캔을 수행하며, 이 락은 일반 읽기·쓰기와 충돌하지 않습니다.
NOT VALID 상태의 제약조건도 신규 INSERT와 UPDATE에는 정상적으로 적용됩니다. 기존 데이터의 정합성만 나중에 검증하는 방식이라 실제로 위험하지 않습니다.
CI/CD 파이프라인에 통합하기
3단계를 각각 별도 PR과 배포로 관리하는 게 핵심입니다. 한 가지 중요한 점은 DB 마이그레이션과 앱 코드 배포가 반드시 순서에 맞게 이루어져야 한다는 것입니다. CI/CD YAML 파일만으로는 앱 배포 단계를 완전히 표현하기 어렵지만, 흐름은 이렇게 구성할 수 있습니다.
# .github/workflows/db-migration.yml
jobs:
phase1-expand:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Phase 1 - Expand 마이그레이션 적용
run: flyway migrate -locations=filesystem:migrations/phase1
# 이 단계 완료 후: 듀얼 라이트 앱 코드를 별도 배포 파이프라인으로 배포
phase2-backfill:
needs: phase1-expand
runs-on: ubuntu-latest
steps:
- name: Phase 2 - 배치 백필 실행
run: python scripts/backfill_full_name.py
# 전제: 듀얼 라이트 앱 배포가 이미 완료된 상태
# 이 단계 완료 후: 신 컬럼만 쓰는 앱 코드를 별도 배포 파이프라인으로 배포
phase3-contract:
needs: phase2-backfill
environment: production # 수동 승인 게이트
runs-on: ubuntu-latest
steps:
- name: Phase 3 - Contract 마이그레이션 적용
run: flyway migrate -locations=filesystem:migrations/phase3
# 전제: 모든 앱 인스턴스가 신 컬럼만 쓰는 코드로 전환 완료실제 배포 순서를 정리하면 이렇습니다. Phase 1 DB 마이그레이션 적용 → 듀얼 라이트 앱 배포 → 백필 실행 → 읽기 전환 및 듀얼 라이트 종료 앱 배포 → Phase 3 DB 마이그레이션 적용. Phase 3는 environment: production으로 수동 승인 게이트를 두어, 데이터 검증과 앱 전환 완료 여부를 사람이 한 번 더 확인하는 것을 권장합니다.
pgroll로 이 모든 걸 자동화하기
Xata가 개발한 오픈소스 CLI 도구 pgroll(현재 v0.11+)은 Expand-Contract 전체 라이프사이클을 자동화합니다. PostgreSQL 뷰로 구 스키마와 신 스키마를 동시에 노출하는 멀티 버전 스키마 방식이 인상적입니다. 구 버전 앱과 신 버전 앱이 같은 PostgreSQL 인스턴스에서 서로 다른 스키마 뷰를 바라보도록 해줘서, 블루-그린 배포와 롤링 업데이트 모두 자연스럽게 지원됩니다.
# pgroll 마이그레이션 파일 예시
version: "1"
operations:
- rename_column:
table: users
from: name
to: full_name이 파일 하나로 pgroll이 내부적으로 Expand-Contract 전 과정을 처리합니다. 수동으로 트리거를 짜고, 배치 스크립트를 만들고, 뷰를 생성하는 작업을 자동화해줍니다.
장단점 분석
장점
| 장점 | 설명 |
|---|---|
| 무중단 배포 | 각 단계가 독립적으로 배포 가능하고, 구·신 코드가 동일 DB 상태에서 공존 |
| 롤백 안전성 | Expand 단계는 추가만 하므로 DROP COLUMN으로 완전 롤백 가능 |
| 점진적 검증 | 데이터 이중화 기간 동안 신 스키마의 정합성을 실제 트래픽으로 검증 |
| 롤링 업데이트 호환 | 쿠버네티스 롤링 업데이트 환경과 자연스럽게 통합 |
단점 및 실무에서 흔한 실수
| 고려사항 | 설명 |
|---|---|
| 배포 복잡성 | 단일 마이그레이션이 2~3번의 별도 PR/배포로 분리됨. 팀 규율과 추적 체계 필요 |
| 듀얼 라이트 오버헤드 | 전환 기간 동안 구·신 컬럼 모두에 쓰므로 I/O·스토리지 증가 |
| Schema Debt | Contract 단계를 잊고 구 컬럼을 방치할 위험 |
| 트리거 복잡성 | TG_OP 분기 누락, 멱등성 미확보 같은 엣지 케이스 버그 유발 가능 |
| 배치 타이밍 | 배치 크기와 슬립 간격을 잘못 설정하면 복제 지연 악화 |
실무에서 가장 흔히 저지르는 실수는 lock_timeout을 설정하지 않는 것입니다. lock_timeout이 없으면 DDL이 장기 실행 쿼리 뒤에서 무한정 대기하면서 새로운 커넥션까지 막아 커넥션 풀을 고갈시킵니다. GoCardless가 대규모 결제 레코드 마이그레이션 경험을 다룬 글(Zero-downtime Postgres migrations: the hard parts)에서도 이 문제를 상세히 다루고 있습니다.
-- 모든 마이그레이션 스크립트 시작부에 추가
SET lock_timeout = '5s';
SET statement_timeout = '30s';한 가지 주의할 점이 있습니다. SET은 세션 레벨로 적용되어 커넥션 풀로 반환된 뒤에도 설정이 유지될 수 있습니다. 트랜잭션 내에서 실행하는 경우라면 SET LOCAL이 더 안전합니다. Flyway나 Liquibase는 보통 각 마이그레이션을 별도 트랜잭션으로 실행하므로 SET LOCAL이 적합합니다.
lock_timeout은 너무 짧으면 DDL이 반복 실패하고, 너무 길면 커넥션 풀이 고갈될 수 있습니다. 팀의 p99 쿼리 시간을 참고해 적절한 값을 설정하는 게 좋습니다.
두 번째로 흔한 실수는 Contract 단계를 미루다 잊어버리는 것입니다. Phase 2 배포까지 잘 해놓고 몇 달 뒤에 보면 구 컬럼이 그대로 남아 있는 경우가 정말 많습니다. PR이나 티켓에 "이 PR 머지 후 N일 안에 Phase 3 진행" 같은 명시적 추적 체계를 만들어두면 이를 방지하는 데 도움이 됩니다.
마치며
핵심을 정리하면 이렇습니다.
- 파괴적 스키마 변경을 한 번에 하지 말고, Expand → Migrate → Contract 세 단계로 나눠 배포합니다.
- 신규 컬럼은 반드시 nullable로 추가해야 구 코드와 공존할 수 있습니다.
- 모든 DDL 스크립트 시작부에
lock_timeout을 설정하는 습관이 커넥션 풀 고갈을 막는 가장 빠른 개선입니다. - 외래키 추가는
NOT VALID+VALIDATE CONSTRAINT두 단계로 나눠 쓰기 차단 시간을 최소화합니다. - 트리거로 듀얼 라이트를 구현할 때는
TG_OP분기로 INSERT와 UPDATE를 반드시 구분하고, 멱등성을 보장하는 자연키를 두어야 합니다.
바로 시작할 수 있는 세 가지를 제안합니다. 다음 컬럼 추가 작업에 Expand-Contract를 적용해보는 것, 모든 마이그레이션 스크립트에 lock_timeout을 추가하는 것, pgroll을 로컬 환경에서 시험해보는 것입니다. 도구가 패턴을 어떻게 자동화하는지 직접 보면, 수동 구현을 이해하는 데도 큰 도움이 됩니다.
패턴을 처음 팀에 도입할 때는 "배포를 세 번 나눠서 한다고?"라는 반응이 나옵니다. 그 복잡성이 팀 전체에 분산되고 위험이 줄어드는 경험을 한 번 해보면, 그 질문이 다른 방향으로 바뀝니다.
참고 자료
- pgroll 공식 문서 — Zero-downtime reversible schema migrations for Postgres
- Xata 블로그 — Schema changes and the power of expand-contract with pgroll
- GitHub — xataio/pgroll
- GoCardless — Zero-downtime Postgres migrations: the hard parts
- Fabian Lindfors — Zero-downtime schema migrations in Postgres using views
- postgres.ai — lock_timeout and retries for zero-downtime migrations
- Michal Drozd — Zero-Downtime PostgreSQL Migrations: Expand/Contract, Backfill and Rollback Strategies
- Stormatics — Zero-Pain PostgreSQL DDL Migrations: Avoiding Locks & Long-running Queries
- DEV Community — Zero-Downtime PostgreSQL Schema Migrations: Expand/Contract vs Blue-Green Deployment
- Nexis Ltd — Zero-Downtime Database Migrations: Expand-and-Contract Pattern Explained
- datasops Blog — Database Migrations Without Downtime: Expand-Contract, Shadow Tables, and Feature Flags
- Thomas Skowron — Migrating Foreign Keys in PostgreSQL
- Bytebase — Flyway vs. Liquibase: The Definitive Comparison in 2026
- Reliable Penguin — Database Migrations Without Drama: Expand/Contract in Practice
- Hacker News 토론 — pgroll: zero-downtime, reversible schema migrations for Postgres