무중단 PostgreSQL 스키마 마이그레이션 실전 가이드 — Expand-Contract 패턴으로 컬럼 리네임부터 타입 변경까지 롤백 없이 배포하기
END;
$$;
CALL backfill_preferences();-- 3. NOT VALID로 제약을 빠르게 추가 (신규 행만 즉시 검증)
SET lock_timeout = '2s';
ALTER TABLE users
ADD CONSTRAINT users_preferences_not_null
CHECK (preferences IS NOT NULL) NOT VALID;
-- 4. 기존 행 검증 (SHARE UPDATE EXCLUSIVE 락 — SELECT·대부분의 DML 차단 없음)
SET lock_timeout = '2s';
ALTER TABLE users VALIDATE CONSTRAINT users_preferences_not_null;
-- 5. PG 12+: 검증된 CHECK를 재활용해 실제 NOT NULL로 전환 (풀스캔 없음)
SET lock_timeout = '2s';
ALTER TABLE users ALTER COLUMN preferences SET NOT NULL;
-- 6. NOT NULL이 보장되므로 CHECK 제약 제거 (선택)
SET lock_timeout = '2s';
ALTER TABLE users DROP CONSTRAINT users_preferences_not_null;NOT VALID + VALIDATE CONSTRAINT 조합은 PostgreSQL 12+에서 특히 강력합니다. VALIDATE CONSTRAINT는 SHARE UPDATE EXCLUSIVE 락만 잡기 때문에 SELECT는 물론이고 대부분의 DML도 막지 않습니다. 그리고 PostgreSQL 12+에서는 검증된 CHECK 제약이 존재하면 5단계의 SET NOT NULL이 풀스캔 없이 처리됩니다.
시나리오 4: 인덱스 추가
CREATE INDEX는 테이블 락을 잡지만, CREATE INDEX CONCURRENTLY는 백그라운드에서 실행됩니다.
-- 잘못된 방법: 테이블 락 발생
CREATE INDEX idx_users_email ON users(email);
-- 올바른 방법: 트랜잭션 블록 밖에서 실행
CREATE INDEX CONCURRENTLY idx_users_email ON users(email);주의할 점이 두 가지 있습니다. 첫째, CONCURRENTLY는 트랜잭션 블록 안에서 실행할 수 없습니다. 둘째, 실패하면 INVALID 상태의 인덱스가 남으니 확인 후 정리가 필요합니다.
-- INVALID 인덱스 확인
SELECT indexrelid::regclass, indisvalid
FROM pg_index
WHERE indexrelid = 'idx_users_email'::regclass;
-- INVALID면 먼저 드롭 후 재생성
DROP INDEX CONCURRENTLY IF EXISTS idx_users_email;
CREATE INDEX CONCURRENTLY idx_users_email ON users(email);어떤 전략을 써야 할까? 의사결정 흐름
"대형 테이블"의 기준은 절대적이지 않습니다. 수십만 건까지는 lock_timeout을 걸고 직접 ALTER COLUMN TYPE을 시도해볼 수 있지만, 수백만 건이 넘는다면 Expand-Contract 전체 절차를 밟는 것이 좋습니다. 테이블 크기 외에도 쓰기 빈도, 트랜잭션 길이, 동시성이 판단 기준에 들어갑니다.
장단점 분석
장점
| 항목 | 설명 |
|---|---|
| 무중단 배포 | 각 단계가 독립적으로 안전해서 서비스 가용성이 유지됩니다 |
| 자연스러운 복귀 | Contract 이전에는 구 구조가 살아있어, 이전 코드로 되돌아가는 것만으로 복구됩니다 |
| 점진적 검증 | 단계별로 데이터 정합성을 확인할 수 있어 신뢰도가 높아집니다 |
| 피처 플래그 통합 | 코드 배포와 데이터 컷오버를 완전히 분리할 수 있습니다 |
단점 및 주의사항
| 항목 | 설명 |
|---|---|
| 운영 복잡도 증가 | 단순 DDL 1회 대신 3~4회의 배포가 필요합니다 |
| 듀얼 라이트 오버헤드 | 두 컬럼에 동시에 쓰는 기간 동안 스토리지와 I/O가 증가합니다 |
| 트리거 성능 영향 | 동기화 트리거가 쓰기 성능에 영향을 줍니다. 고부하 테이블에서는 사전 벤치마킹이 필요합니다 |
| 배치 백필 소요 시간 | 수억 건 테이블이라면 백필만 며칠이 걸릴 수 있어 Contract 일정에 충분한 여유를 반영하는 것이 좋습니다 |
실무에서 자주 하는 실수
고아 트리거: Contract 단계에서 트리거 제거를 빠뜨리고 컬럼을 먼저 드롭하면, 삭제된 컬럼을 참조하는 트리거가 살아있어 모든 INSERT/UPDATE에서 에러가 납니다. 체크리스트나 마이그레이션 스크립트에서 반드시 트리거와 함수를 컬럼보다 먼저 삭제하는 순서를 지키는 것이 좋습니다.
트리거 오버라이트: 듀얼 라이트 없이 신 컬럼에만 쓰면서 트리거를 남겨두면, 트리거가 구 컬럼의 이전 값으로 신 컬럼을 덮어씁니다. 트리거는 Contract 직전까지 살아있어야 하고, 그 동안은 반드시 두 컬럼 모두에 쓰는 방식을 유지해야 합니다.
스테이징 환경 데이터 규모: 개발 DB에서 2초 걸리는 마이그레이션이 프로덕션에서 2시간 걸릴 수 있습니다. 프로덕션과 유사한 규모의 스테이징 환경에서 반드시 검증하는 것이 좋습니다.
배치 크기 결정: 너무 크면 각 배치가 락을 오래 잡고, 너무 작으면 전체 시간이 길어집니다. 일반적으로 1만~10만 건 범위에서 시작해서 조정하는 방식이 무난합니다.
ORM과의 통합: ORM을 쓴다면 Expand 단계에서 모델이 두 컬럼을 모두 인식해야 할 수 있습니다. 프레임워크마다 처리 방법이 다르니 사전에 확인하는 것이 좋습니다.
도구 비교
| 도구 | 역할 | 특징 |
|---|---|---|
| pgroll (Xata) | Expand-Contract 자동화 | 버전드 뷰로 구·신 스키마 동시 서비스, PostgreSQL 14+, RDS/Aurora 지원 |
| squawk | 마이그레이션 린터 | lock_timeout 누락·비 CONCURRENTLY 인덱스 생성 등 안전하지 않은 DDL 패턴을 PR 단계에서 자동 차단 |
| pg_repack | 온라인 테이블 재팩킹 | ACCESS EXCLUSIVE 없이 물리 구조 재구성 (DDL 도구가 아님) |
| Bytebase | 스키마 변경 관리 플랫폼 | GUI 기반 리뷰·승인 워크플로우 |
pgroll은 Expand-Contract 패턴을 자동화하면서 마이그레이션 윈도우 동안 구·신 스키마를 버전드 뷰로 동시에 서비스합니다. 복잡한 마이그레이션이 많은 팀이라면 검토해볼 만합니다.
마치며
스키마 변경은 작은 실수 하나가 큰 장애로 이어질 수 있는 영역입니다. 그렇다고 매번 유지보수 윈도우를 잡아야 할 만큼 어렵지도 않습니다. Expand-Contract 패턴을 팀에 정착시키면 스키마 변경을 다른 기능 배포와 같은 방식으로 처리할 수 있게 됩니다.
핵심을 세 가지로 정리하면:
첫째, 모든 DDL 앞에는 SET lock_timeout을 설정하는 것이 좋습니다. 이것만으로도 락 큐 연쇄 장애의 상당 부분을 방지할 수 있습니다.
둘째, 타입 변경은 Full Table Rewrite를 피하는 것이 핵심입니다. 새 컬럼 추가 → 트리거 동기화 → 배치 백필 → 코드 전환(듀얼 라이트 유지) → 트리거 삭제 → 구 컬럼 삭제 순서를 따르면 됩니다.
셋째, 배치 백필은 항상 범위 기반으로, PROCEDURE를 활용해 독립 트랜잭션으로 처리하는 것이 좋습니다. 단일 UPDATE로 수백만 건을 한 번에 바꾸는 것은 DDL 락 못지않은 장애를 만들 수 있습니다.
지금 바로 시작하는 세 단계를 제안한다면:
squawk를 CI에 통합해서 안전하지 않은 DDL 패턴을 PR 단계에서 차단하는 것이 가장 빠른 첫 걸음입니다.- 기존 마이그레이션 스크립트를 검토해서
lock_timeout없이 실행되는 DDL을 찾아 수정해보시면 좋습니다. - 다음 스키마 변경에 Expand-Contract를 적용해서 단계별 배포를 직접 경험해보시면, 이후엔 자연스럽게 이 방식으로 접근하게 됩니다.
처음엔 배포 횟수가 늘어나서 번거롭게 느껴질 수 있습니다. 하지만 배포 복잡도가 올라간 만큼 서비스 가용성과 팀의 자신감이 함께 올라갑니다.
참고 자료
- Zero-Downtime PostgreSQL Schema Migrations: Expand/Contract vs Blue-Green Deployment — DEV Community
- Database Migrations in Production: Zero-Downtime Schema Changes (2026 Guide) — DEV Community
- Database Migrations Without Downtime — Expand-Contract, Shadow Tables, and Feature Flags | datasops Blog
- Zero-Downtime PostgreSQL Migrations: Expand/Contract, Backfill and Rollback Strategies | Michal Drozd
- Using the expand and contract pattern | Prisma's Data Guide
- Zero-downtime Postgres schema migrations need this: lock_timeout and retries | PostgresAI
- Schema changes and the Postgres lock queue — Xata Blog
- pgroll — Zero-downtime, reversible, schema changes for PostgreSQL
- Schema changes and the power of expand-contract with pgroll — Xata Blog
- How to perform Postgres schema changes in production with zero downtime — Xata Blog
- Applying migrations safely | Squawk — a linter for Postgres migrations
- Top Open Source Postgres Migration Tools in 2026 | Bytebase
- Database Migrations Without Drama: Expand/Contract in Practice
- Database Migration Strategies for Zero-Downtime Deployments | DeployHQ
- Change management tools and techniques — PostgreSQL Wiki