pgroll로 PostgreSQL 스키마 변경을 무중단으로 배포하기
프로덕션 배포 전날 밤, 팀 채널에 이런 메시지가 올라온 경험은 다들 한 번쯤 있을 겁니다. "오늘 새벽 2시에 잠깐 점검 공지 띄워야 할 것 같아요 — 컬럼 타입 바꿔야 해서요." ALTER TABLE 한 줄이 수십억 행 테이블에서 몇 분짜리 AccessExclusiveLock을 걸고, 그 사이 모든 쿼리가 밀려 타임아웃 알람이 쏟아지는 문제. 이 글은 그 문제를 pgroll로 어떻게 해체하는지에 대한 이야기입니다.
pgroll은 Xata가 만든 오픈소스 CLI로, 마이그레이션이 진행되는 동안 구 버전과 신 버전 스키마를 동시에 노출합니다. 낡은 앱 인스턴스는 예전 컬럼 이름을 계속 읽고, 새 앱 인스턴스는 바뀐 컬럼 이름을 읽되 — 데이터 불일치 없이. 뒤에서 어떻게 이게 가능한지, 그리고 프로덕션에서 실제로 어떻게 배포하는지 다룹니다.
이 글에서는 Expand/Contract 패턴의 동작 원리, RENAME·타입 변경·NOT NULL 추가 세 시나리오를 JSON 파일과 함께 배포하는 실제 절차, search_path 전환을 애플리케이션 레이어에서 구현하는 방법, 그리고 프로덕션 도입 시 실제로 걸려 넘어지는 지점들을 다룹니다.
핵심 개념
Expand/Contract 패턴이란
전통적인 마이그레이션 도구는 DDL을 순서대로 실행합니다. ALTER TABLE이 끝날 때까지 다른 트랜잭션은 기다려야 하고, 구 버전 앱과 신 버전 앱이 잠깐이라도 동시에 뜨는 순간이 오면 스키마 불일치로 에러가 납니다.
Expand/Contract(Parallel Change라고도 부릅니다) 패턴은 파괴적 변경을 세 단계로 쪼갭니다.
- Expand: 기존 컬럼을 건드리지 않은 채 신 버전 스키마를 새로 노출합니다. 타입 변경처럼 데이터 변환이 필요한 경우에는 새 물리 컬럼과 양방향 트리거가 추가되고 배치 백필이 실행됩니다. RENAME처럼 데이터 변환이 없는 경우에는 뷰 레이어만 추가됩니다.
- Coexist: 구 앱과 신 앱이 각각 다른
search_path로 접속해 서로 다른 버전 뷰를 바라봅니다. - Contract: 모든 트래픽이 신 버전으로 넘어간 뒤
pgroll complete를 실행하면 구 뷰와 잔여 트리거·컬럼이 정리됩니다.
pgroll이 내부에서 하는 일
물리 테이블은 하나입니다. pgroll은 그 위에 PostgreSQL 뷰를 버전별로 씌우고, 각 버전은 별도 스키마로 격리됩니다. 스키마 이름은 마이그레이션 이름 기반으로 자동 생성되어(예: pgroll_rename_name_to_full_name), pgroll_v2 같은 고정 이름이 아닙니다.
아래 다이어그램은 타입 변경 또는 add-column처럼 새 물리 컬럼이 필요한 경우의 구조입니다.
RENAME-only의 경우에는 물리 컬럼도, 트리거도 추가되지 않습니다. 두 버전 뷰가 같은 물리 컬럼을 서로 다른 이름으로 노출할 뿐입니다. 이 차이가 백필 시간과 트리거 부하에 큰 영향을 주기 때문에 뒤 시나리오에서 다시 짚습니다.
세 가지 핵심 명령
| 명령 | 시점 | 하는 일 |
|---|---|---|
pgroll start <파일> |
배포 직전 | 신 버전 뷰 생성, 필요 시 컬럼·트리거 추가와 백필 수행 (백필이 끝날 때까지 프로세스가 블로킹됨) |
pgroll complete |
신 버전 배포 완료 후 | 구 버전 뷰·트리거·잔여 컬럼 정리, 물리 스키마 확정 |
pgroll rollback |
문제 발생 시 | 신 버전 오브젝트 제거, 구 스키마 즉시 복원 |
pgroll start가 블로킹이라는 점은 CI/CD 파이프라인 설계에 직접적인 영향을 줍니다. 수억 건 테이블에서는 이 단계가 수 시간 걸릴 수 있어서, 파이프라인 타임아웃과 배포 게이트를 그에 맞게 설계해야 합니다.
실전 적용
설치와 초기화
brew install xataio/tap/pgroll
pgroll init --postgres-url "$DATABASE_URL"초기화하면 pgroll 스키마가 생기고 마이그레이션 이력 테이블이 만들어집니다. 이후 모든 마이그레이션 상태는 여기서 관리됩니다.
애플리케이션에서 search_path 전환하기
pgroll 도입에서 실제로 손이 가장 많이 가는 부분은 이 지점입니다. "앱 코드에서 스키마 분기 로직을 짜지 않아도 된다"는 말은 맞지만, 각 배포 버전이 자기 버전의 스키마를 바라보도록 search_path는 반드시 설정해야 합니다. 세 가지 방식이 있습니다.
1) 연결 문자열에 옵션으로 넣기
가장 간단합니다. PostgreSQL 드라이버 대부분이 지원하는 options 파라미터를 씁니다.
postgres://user:pass@host:5432/mydb?options=-csearch_path%3Dpgroll_rename_name_to_full_name%2Cpublic-c 뒤에 search_path=<version_schema>,public을 URL-encode해서 붙입니다. public을 뒤에 두면 pgroll이 관리하지 않는 테이블(예: 세션 테이블)도 그대로 접근 가능합니다.
2) 커넥션 초기화 훅에서 SET search_path 실행
HikariCP, pgx, node-postgres 등 대부분의 드라이버가 커넥션 확립 직후 실행할 쿼리를 등록하는 훅을 제공합니다.
// node-postgres 예시
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
pool.on('connect', (client) => {
client.query(`SET search_path TO ${process.env.PGROLL_SCHEMA}, public`);
});3) ORM 설정에서 지정
Prisma는 schemas, TypeORM은 schema, SQLAlchemy는 connect_args={"options": "-csearch_path=..."} 형태로 넘길 수 있습니다.
세 방식 모두 배포 환경 변수에 현재 활성화된 pgroll 버전 스키마 이름을 넣어 관리하는 게 일반적입니다. pgroll start 명령이 출력하는 스키마 이름을 파이프라인 변수로 캡처해서 앱 배포 시 주입하면 자동화가 깔끔해집니다.
PgBouncer 트랜잭션 모드 주의:
search_path는 세션 레벨 설정이라 PgBouncer 트랜잭션 풀링에서는 유지되지 않습니다. 이 경우 세션 모드로 바꾸거나, 방식 1처럼 연결 문자열에 옵션으로 박아 두어야 합니다.
시나리오 1 — 컬럼 RENAME (users.name → users.full_name)
가장 흔한 케이스입니다. pgroll에서 RENAME-only 마이그레이션은 물리 컬럼을 새로 만들지 않고, 두 버전 스키마의 뷰가 같은 물리 컬럼을 서로 다른 이름으로 노출하는 방식으로 처리됩니다. 백필도 트리거도 없어서 부하가 거의 없습니다.
{
"name": "rename_name_to_full_name",
"operations": [
{
"alter_column": {
"table": "users",
"column": "name",
"name": "full_name",
"up": "name",
"down": "full_name"
}
}
]
}여기서 up과 down은 버전 간 값 변환을 정의하는 SQL 표현식입니다. 방향성이 헷갈리기 쉬운데, pgroll 문서 기준으로는 "구 버전 → 신 버전 방향 변환이 up, 신 버전 → 구 버전 방향 변환이 down"입니다. 이름과 방향이 직관적으로 맞지 않는 이유는 마이그레이션 자체를 "위로 올린다"는 관용어에서 왔기 때문입니다. RENAME은 값 변환이 없으니 컬럼 레퍼런스를 그대로 씁니다.
pgroll start rename_name_to_full_name.json --postgres-url "$DATABASE_URL"이 명령이 반환되면 신 버전 스키마(pgroll_rename_name_to_full_name)가 생성되어 구 앱은 public.users.name을, 신 앱은 pgroll_rename_name_to_full_name.users.full_name을 읽습니다. 배포가 완료되면 아래를 실행합니다.
pgroll complete --postgres-url "$DATABASE_URL"이 시점에 물리 컬럼 이름이 최종적으로 full_name으로 확정되고 구 버전 뷰가 제거됩니다.
시나리오 2 — 컬럼 타입 변경 (status VARCHAR(20) → TEXT)
RENAME과 달리 타입 변경은 새 물리 컬럼을 만들고 백필이 필요합니다. 기존 방식으로는 테이블 전체를 잠궈야 했지만, pgroll은 새 타입 컬럼을 별도로 만들고 지정한 SQL 표현식으로 변환하며 배치 백필을 수행합니다.
{
"name": "widen_status_column",
"operations": [
{
"alter_column": {
"table": "orders",
"column": "status",
"type": "text",
"up": "status",
"down": "LEFT(status, 20)"
}
}
]
}up이 VARCHAR(20) 값을 TEXT로 넘길 때 쓰는 표현식, down이 롤백 시 TEXT를 VARCHAR(20)로 좁힐 때 쓰는 표현식입니다. down에서 데이터 손실이 발생할 수 있는 경우(TEXT의 길이가 20자를 넘는 값)에는 위 예시처럼 자르기 로직을 명시해야 합니다.
수억 건짜리 테이블이면 백필이 오래 걸립니다. I/O 부하가 걱정되면 배치 크기와 지연을 조절할 수 있습니다.
pgroll start widen_status_column.json \
--postgres-url "$DATABASE_URL" \
--backfill-batch-size 5000 \
--backfill-batch-delay 100ms플래그 이름과 duration 포맷(Go 스타일 100ms, 2s 등)은 실행 전 pgroll start --help로 현재 버전의 정확한 시그니처를 재확인하는 걸 권장합니다.
Primary Key 컬럼 타입 변경은 별개 문제:
INT → BIGINT같은 PK 타입 변경은 참조 외래 키, 관련 시퀀스, 인덱스, 파티션 정의까지 함께 다뤄야 해서 pgroll의alter_column하나로 자동 해결되지 않습니다. PK 확장이 필요하면 별도의 마이그레이션 설계(신 컬럼 추가 → 참조 테이블 업데이트 → PK 스왑)가 선행되어야 합니다.
시나리오 3 — NOT NULL 제약 추가
ALTER TABLE users ALTER COLUMN email SET NOT NULL은 전체 테이블을 스캔하면서 AccessExclusiveLock을 겁니다. 행이 많을수록 잠금 시간이 길어집니다. pgroll은 내부적으로 NOT VALID 제약을 먼저 추가하고 이후 VALIDATE CONSTRAINT로 검증을 분리해 잠금 시간을 최소화합니다.
{
"name": "add_not_null_to_email",
"operations": [
{
"alter_column": {
"table": "users",
"column": "email",
"nullable": false,
"up": "COALESCE(email, 'unknown@example.com')",
"down": "email"
}
}
]
}up 표현식은 기존 null 값을 어떻게 채울지 결정합니다. 위 예시는 기본 이메일로 채워 넣었는데, 실제 비즈니스 로직(예: 사용자 ID 기반 placeholder 생성)에 맞게 바꿔야 합니다.
마이그레이션 파일 사전 검증
pgroll은 마이그레이션 파일을 실행 전에 검증하는 validate 서브커맨드를 제공합니다.
pgroll validate migrations/add_not_null_to_email.json --postgres-url "$DATABASE_URL"JSON 스키마 검증뿐 아니라 참조 테이블·컬럼의 존재 여부까지 확인해서, pgroll start로 실제 실행하기 전에 배포 파이프라인 앞단에서 잡을 수 있는 오류를 걸러줍니다. CI에서 마이그레이션 파일이 변경된 PR에 대해 자동으로 돌리는 걸 권장합니다.
CI/CD 파이프라인 연동
pgroll start가 백필 완료까지 블로킹된다는 점을 반영한 배포 흐름은 아래와 같습니다.
GitHub Actions 예시입니다. 실제 배포 스텝에서 search_path 값을 어떻게 앱에 전달하는지가 핵심이므로 그 부분을 구체적으로 넣었습니다.
jobs:
deploy:
steps:
- name: Validate migration
run: pgroll validate migrations/widen_status_column.json --postgres-url $DATABASE_URL
env:
DATABASE_URL: ${{ secrets.DATABASE_URL }}
- name: Start migration (blocking; waits for backfill)
id: pgroll_start
timeout-minutes: 120
run: |
pgroll start migrations/widen_status_column.json --postgres-url $DATABASE_URL
echo "schema=pgroll_widen_status_column" >> "$GITHUB_OUTPUT"
env:
DATABASE_URL: ${{ secrets.DATABASE_URL }}
- name: Deploy new version
run: ./scripts/deploy.sh
env:
PGROLL_SCHEMA: ${{ steps.pgroll_start.outputs.schema }}
DATABASE_URL: ${{ secrets.DATABASE_URL }}?options=-csearch_path%3D${{ steps.pgroll_start.outputs.schema }}%2Cpublic
- name: Drain old instances
run: ./scripts/drain-old.sh
- name: Complete migration
run: pgroll complete --postgres-url $DATABASE_URL
env:
DATABASE_URL: ${{ secrets.DATABASE_URL }}Drain old instances 스텝에서 반드시 구 버전 인스턴스가 완전히 내려갔는지 확인해야 합니다. 구 앱이 아직 떠 있는데 complete를 실행하면 구 앱이 바라보던 뷰가 사라지면서 쿼리가 실패합니다.
장단점 분석
장점
| 항목 | 내용 |
|---|---|
| 무중단 배포 | 마이그레이션 중 구·신 앱이 동시 운영, 서비스 중단 없음 |
| 즉각적 롤백 | pgroll rollback 한 명령으로 신 버전 오브젝트 제거, 구 스키마 복원 |
| 잠금 최소화 | 변경을 단계별로 분리해 긴 테이블 락 회피 |
| 앱 스키마 분기 불필요 | 버전별 뷰가 스키마 차이를 흡수, 앱 코드는 search_path만 신경 씀 |
| 폭넓은 호환성 | PostgreSQL 14 이상, RDS·Aurora·Cloud SQL 등 매니지드 서비스에서 동작 |
| 권한 요구 낮음 | 슈퍼유저 불필요, 스키마 소유자 권한으로 운영 가능 |
단점 및 주의사항
| 항목 | 내용 |
|---|---|
| 쓰기 증폭 | 타입 변경 등에서 트리거가 두 컬럼에 모두 쓰기 때문에 마이그레이션 활성화 중 쓰기 부하 증가 |
| 백필 시간 | 수억 건 이상 테이블은 백필 완료까지 수 시간 소요 가능, pgroll start가 그동안 블로킹됨 |
| 뷰 기반 성능 영향 | 뷰 레이어가 추가되어 일부 쿼리 플래너 결정이 달라질 수 있음 |
| PostgreSQL 14 미만 미지원 | 레거시 버전 환경에서는 사용 불가 |
search_path 설정 필수 |
애플리케이션·커넥션 풀 전체가 올바른 버전 스키마를 바라보도록 배포 파이프라인에서 관리해야 함 |
실무에서 흔한 실수
1. PgBouncer 트랜잭션 모드에서 search_path 유실
트랜잭션 풀링은 트랜잭션 단위로 백엔드 커넥션을 재사용하므로 세션 레벨 SET search_path가 유지되지 않습니다. 세션 모드로 전환하거나 연결 문자열의 options 파라미터로 search_path를 고정해야 합니다.
2. complete를 너무 일찍 실행
구 버전 인스턴스가 완전히 내려가기 전에 pgroll complete를 호출하면 구 버전 뷰가 제거되며 쿼리가 실패합니다. 배포 파이프라인에 구 인스턴스 종료 확인 게이트를 명시적으로 두어야 합니다.
3. 공존 기간을 며칠씩 방치
마이그레이션을 시작해놓고 complete나 rollback 없이 방치하면 트리거 부하와 모니터링 복잡도가 계속 누적됩니다. "배포 완료 후 N시간 이내에 complete 실행" 같은 SLO를 파이프라인에 넣어 강제하는 편이 안전합니다.
4. up/down 표현식 누락 및 하방 손실 방치
alter_column으로 타입을 좁힐 때 down 표현식을 원본 컬럼 참조로만 두면 데이터 손실 시나리오(예: TEXT → VARCHAR(50) 롤백)에서 트리거가 실패합니다. 손실이 발생할 수 있는 방향에는 LEFT(), SUBSTRING() 같은 잘라내기 로직을 명시적으로 넣어 두어야 합니다.
마치며
pgroll이 해결하는 건 단순히 "무중단"만이 아닙니다. 스키마 변경을 롤백 가능한 단위로 만드는 것, 그리고 구·신 버전 앱이 같은 DB를 공유하면서도 서로 다른 스키마를 볼 수 있게 하는 것 — 이 두 가지가 본질입니다.
Expand 단계에서 신 버전 뷰를 세우고, Coexist 단계에서 두 버전이 공존하며, Contract 단계에서 구 뷰를 정리하는 흐름은 처음엔 절차가 복잡해 보이지만, 한 번 CI 파이프라인에 자리 잡으면 배포 리스크가 확연히 줄어듭니다. pgroll rollback 한 명령으로 되돌아올 수 있다는 것 하나만으로도 스키마 배포 창구를 새벽 점검 시간에 몰아넣지 않을 수 있습니다.
도입을 고려할 만한 기준을 몇 가지 제시하면 이런 상황들입니다.
- 단일 테이블 행 수가 1억 건을 넘겨
ALTER TABLE의 락 시간이 서비스 SLA를 위협하기 시작한 경우 - 롤링 배포 중 구·신 인스턴스가 수 분 이상 공존하며 스키마 차이로 인해 5xx가 증가한 경험이 있는 경우
- 스키마 변경 롤백을 수동 SQL로 준비하다가 실수로 데이터를 훼손한 사고가 있었던 팀
- 매니지드 PostgreSQL(RDS·Aurora·Cloud SQL 등) 위에서 슈퍼유저 없이 마이그레이션 워크플로우를 개선해야 하는 조직
시작은 스테이징에 pgroll init을 걸고 RENAME 마이그레이션 하나를 Expand → Complete까지 끝까지 돌려 보는 것으로 충분합니다. psql로 붙어서 버전 스키마가 어떻게 생기고 뷰가 어떻게 정의되어 있는지 직접 확인하면, 프로덕션 적용 전에 팀 내에서 필요한 논의(파이프라인 순서, search_path 주입 방식, complete 게이트 정책)가 자연스럽게 정리됩니다.
참고 자료
- pgroll 공식 사이트
- GitHub - xataio/pgroll
- Introducing pgroll: zero-downtime, reversible, schema migrations for Postgres (Xata 블로그)
- Schema changes and the power of expand-contract with pgroll (Xata 블로그)
- How pgroll works under the hood (Xata 블로그)
- pgroll 0.14 - new commands and control over version schemas
- Zero downtime schema migrations with pgroll - Neon Guides
- pgroll in Action: Client-Side Evaluation (OpenSourceDB)
- Zero-Downtime Schema Migrations for Self-Hosted Postgres with pgroll (SyneHQ)
- Zero-Downtime PostgreSQL Schema Migrations: Expand/Contract vs Blue-Green Deployment (DEV Community)
- Top Open Source Postgres Migration Tools in 2026 (Bytebase)
- Introducing multi-version schema migrations (pgroll.com 블로그)