무중단 데이터베이스 스키마 마이그레이션
운영 중 스키마 변경의 위험(테이블 잠금·rewrite)을 피하는 expand→migrate→contract 패턴을, CREATE INDEX CONCURRENTLY·NOT VALID/VALIDATE·배치 backfill 등 단계별 SQL로 정리한다.

운영 중 스키마 변경이 왜 위험한가
운영 중인 서비스의 데이터베이스 스키마를 바꾸는 작업은 로컬에서 ALTER TABLE 한 줄 실행하는 것과는 전혀 다른 문제다. 가장 큰 위험은 락(lock) 이다. PostgreSQL의 많은 DDL은 ACCESS EXCLUSIVE 락을 잡는데, 이 락은 해당 테이블에 대한 모든 읽기와 쓰기를 차단한다. 락을 잡는 시간이 길어지면 그 사이 들어온 모든 쿼리가 락 대기 큐에 줄을 서고, 커넥션 풀이 순식간에 고갈되며, 결국 서비스 전체가 응답을 멈춘다. 테이블 하나를 잠갔을 뿐인데 장애로 번지는 이유다.
게다가 더 교묘한 위험이 하나 더 있다. 무중단 배포 환경에서는 구버전 코드와 신버전 코드가 잠깐 동시에 떠 있다. 롤링 배포 중에는 항상 그렇다. 스키마를 신버전에 맞춰 바꿔버리면, 아직 살아 있는 구버전 인스턴스가 사라진 컬럼이나 바뀐 타입을 참조하다가 에러를 뱉는다. 반대로 코드를 먼저 바꾸면 아직 안 바뀐 스키마 때문에 신버전이 깨진다.
그래서 무중단 마이그레이션의 핵심 원칙은 두 가지로 압축된다.
- 강한 락을 짧게 잡거나 아예 피한다. 긴 락이 필요한 작업은 잘게 쪼개거나
CONCURRENTLY같은 비차단 방식으로 우회한다. - 모든 변경을 하위호환으로 만든다. 구·신 코드가 공존하는 그 짧은 시간 동안 양쪽 다 정상 동작해야 한다. 그러려면 "한 번에 바꾸는" 변경을 "여러 단계로 나누는" 변경으로 재설계해야 한다.
이 글은 어떤 변경이 왜 위험한지 먼저 짚고, 그 위험을 안전한 여러 단계로 분해하는 표준 패턴(expand → migrate → contract)을 SQL과 함께 정리한다.
위험한 변경과 그 이유
먼저 "그냥 실행하면 안 되는" 대표적인 DDL들을 이유와 함께 본다. 핵심은 두 가지 비용이다. (a) 테이블 전체를 다시 쓰는 rewrite, (b) 그동안 잡고 있는 ACCESS EXCLUSIVE 락. 둘 다 테이블이 클수록 치명적이다.
ADD COLUMN with NOT NULL DEFAULT
PostgreSQL 11부터는 ADD COLUMN ... DEFAULT <상수> 가 메타데이터만 갱신하는 빠른 연산으로 최적화됐다. 기존 행을 건드리지 않고 카탈로그에 기본값을 기록해 두고, 읽을 때 채워주는 방식이다. 여기까진 안전하다.
문제는 기본값이 volatile, 즉 행마다 다르게 평가되는 함수일 때다.
-- 위험: gen_random_uuid() 는 행마다 다른 값을 내야 하므로
-- 기존 모든 행을 다시 써야 한다 → full table rewrite + ACCESS EXCLUSIVE
ALTER TABLE orders
ADD COLUMN public_id uuid NOT NULL DEFAULT gen_random_uuid();now(), gen_random_uuid(), 시퀀스 호출 등은 모든 기존 행에 서로 다른 값을 채워야 하므로 PostgreSQL이 최적화를 포기하고 테이블 전체를 rewrite한다. 그 사이 테이블은 ACCESS EXCLUSIVE로 잠긴다. 또 한 가지 함정: PostgreSQL 10 이하라면 상수 기본값이라도 rewrite가 발생한다. 운영 DB의 메이저 버전을 반드시 확인하라.
안전한 대안은 뒤에서 보겠지만, "nullable 컬럼으로 먼저 추가 → 배치로 backfill → 나중에 NOT NULL 부여"로 쪼개는 것이다.
타입 변경 / 이름 변경
-- 위험: 거의 항상 rewrite, ACCESS EXCLUSIVE
ALTER TABLE orders ALTER COLUMN amount TYPE bigint;컬럼 타입 변경은 저장 표현이 바뀌면 테이블을 rewrite하고, 그동안 ACCESS EXCLUSIVE를 잡는다. int → bigint, varchar(n) 길이 축소, text → jsonb 등이 모두 여기 해당한다. (예외적으로 varchar(n)을 더 긴 길이나 text로 넓히는 것처럼 바이너리 호환이 보장되는 일부 변경은 rewrite 없이 끝나지만, 의존하지 말고 매번 확인하는 게 안전하다.)
이름 변경은 더 미묘하다.
-- 락은 짧지만 구버전 코드를 즉시 깨뜨린다
ALTER TABLE orders RENAME COLUMN amount TO total_amount;RENAME은 메타데이터만 바꾸므로 락 자체는 짧다. 하지만 이름이 바뀌는 순간, 아직 살아 있는 구버전 인스턴스가 amount를 조회하다 즉시 깨진다. 락 비용이 아니라 하위호환성 때문에 단독 RENAME은 무중단 환경에서 금지다. 해법은 "새 컬럼 추가 + 동기화 + 전환 + 제거"로 풀어내는 것인데, 뒤에서 다룬다.
일반 CREATE INDEX
-- 위험: 인덱스를 다 만들 때까지 테이블 쓰기를 차단
CREATE INDEX idx_orders_user_id ON orders (user_id);일반 CREATE INDEX는 SHARE 락을 잡는데, 이 락은 읽기는 허용하지만 쓰기(INSERT/UPDATE/DELETE)를 인덱스 구축이 끝날 때까지 막는다. 큰 테이블이면 수 분에서 수십 분간 쓰기가 멈출 수 있다. 운영에서는 반드시 비차단 버전을 쓴다.
-- 안전: 쓰기를 막지 않고 인덱스 생성
CREATE INDEX CONCURRENTLY idx_orders_user_id ON orders (user_id);CONCURRENTLY는 쓰기를 막지 않는 대신 몇 가지 제약이 있다. 트랜잭션 블록 안에서 실행할 수 없다. 따라서 모든 마이그레이션을 하나의 트랜잭션으로 감싸는 도구(예: 일부 ORM 마이그레이션 기본 설정)에서는 이 단계를 트랜잭션 밖으로 빼야 한다. 또 실패하면 INVALID 상태의 쓸모없는 인덱스가 남으므로, 실패 시 DROP INDEX로 정리하고 재시도해야 한다.
-- 실패한 인덱스 정리
DROP INDEX CONCURRENTLY IF EXISTS idx_orders_user_id;NOT NULL 제약 추가
-- 위험: 제약을 거는 순간 테이블 전체를 스캔해 검증
ALTER TABLE orders ALTER COLUMN public_id SET NOT NULL;SET NOT NULL은 "기존 행 전부가 정말 NULL이 아닌지"를 확인하기 위해 테이블 전체를 스캔한다. 그동안 ACCESS EXCLUSIVE를 잡으므로 큰 테이블에서는 위험하다. 이 역시 뒤에서 "NOT VALID CHECK 제약 → VALIDATE → SET NOT NULL" 순서로 락 시간을 최소화하는 법을 본다.
정리하면, 위험한 작업의 공통 원인은 (1) 테이블 rewrite와 (2) 긴 ACCESS EXCLUSIVE 락이다. 우리의 목표는 이 두 가지를 피하면서 같은 결과에 도달하는 것이다.
핵심 패턴: expand → migrate → contract
무중단 스키마 변경의 표준 레시피는 확장(expand) → 이행(migrate) → 수축(contract) 세 국면으로 나누는 것이다. 한 번의 배포에서 끝내려 하지 말고, 여러 번의 배포에 걸쳐 점진적으로 옮긴다. 각 국면 사이에서 구·신 코드와 구·신 스키마가 항상 하위호환을 유지하는 게 핵심이다.
- Expand: 새 구조를 추가하기만 한다. 기존 것은 그대로 두므로 구버전 코드가 깨지지 않는다.
- Migrate: 데이터를 새 구조로 옮기고(backfill), 앱이 양쪽을 동시에 다루게 한다(dual write).
- Contract: 모든 트래픽이 새 구조로 넘어간 게 확인되면, 그때 비로소 옛 구조를 제거한다.
int 키를 쓰던 orders.amount(원래 int)를 bigint로 넓히는 시나리오를 예로 전 과정을 따라가 보자. 타입 변경은 단독으로는 rewrite를 유발하므로, 새 컬럼으로 우회한다.
1단계 — nullable 새 컬럼 추가 (Expand)
-- 빠르다: 상수 기본값 없이 nullable 로 추가하면 메타데이터만 갱신
ALTER TABLE orders ADD COLUMN amount_v2 bigint;기본값 없는 nullable 컬럼 추가는 기존 행을 건드리지 않으므로 락이 매우 짧다. 이 시점에 구버전 코드는 amount_v2의 존재를 모른 채 정상 동작하고, 신버전 코드도 아직 배포 전이다. 안전하다.
2단계 — 앱이 구·신 둘 다 쓰기 (Dual write)
스키마를 바꾼 다음, 하위호환 코드를 먼저 배포한다. 신버전 애플리케이션은 쓰기 시 옛 컬럼과 새 컬럼에 동시에 기록한다.
-- 애플리케이션이 INSERT/UPDATE 때 둘 다 채운다
UPDATE orders SET amount = $1, amount_v2 = $1 WHERE id = $2;여기서 배포 순서가 중요하다.
- 먼저 1단계의 스키마 변경(컬럼 추가)을 적용한다. 이건 구버전 코드에 영향이 없다.
- 그다음 dual write를 하는 애플리케이션 코드를 배포한다.
스키마가 코드보다 항상 한 발 앞서고, 모든 스키마 변경이 하위호환이므로 롤링 배포 중 어느 조합에서도 깨지지 않는다. 읽기는 아직 옛 컬럼(amount)을 기준으로 한다. 신·구 컬럼이 같이 채워지는 시점부터 새로 들어오는 데이터는 일관성을 갖는다. 남은 건 dual write 이전에 쌓여 있던 과거 데이터다.
3단계 — 배치로 backfill (Migrate)
이제 과거 행의 amount_v2를 채운다. 핵심 함정: 절대 한 트랜잭션에서 전체를 UPDATE하지 마라.
-- 절대 금지: 수백만 행을 한 트랜잭션에 — 락·WAL 폭증·롱 트랜잭션
UPDATE orders SET amount_v2 = amount WHERE amount_v2 IS NULL;거대한 단일 UPDATE는 수많은 행에 락을 잡고, 막대한 WAL을 생성하며, 오래 열린 트랜잭션이 VACUUM을 방해해 테이블 비대화(bloat)를 부른다. 도중에 실패하면 전부 롤백되어 처음부터 다시 해야 한다. 대신 PK 범위나 LIMIT으로 작은 배치를 나눠 각 배치를 독립 트랜잭션으로 커밋한다.
-- 배치 backfill: 한 번에 일정 개수씩, 각 배치는 독립 커밋
WITH batch AS (
SELECT id FROM orders
WHERE amount_v2 IS NULL
ORDER BY id
LIMIT 5000
)
UPDATE orders o
SET amount_v2 = o.amount
FROM batch
WHERE o.id = batch.id;이 구문을 amount_v2 IS NULL 인 행이 없어질 때까지 반복한다. 셸 루프로 묶는다면 다음과 같다.
# 갱신 행이 0 이 될 때까지 배치 반복, 사이마다 잠깐 쉬어 부하 분산
while true; do
updated=$(psql "$DATABASE_URL" -tA -c "
WITH batch AS (
SELECT id FROM orders
WHERE amount_v2 IS NULL
ORDER BY id
LIMIT 5000
)
UPDATE orders o SET amount_v2 = o.amount
FROM batch WHERE o.id = batch.id;
" | grep -oE '[0-9]+$' || echo 0)
echo "updated=$updated"
[ "$updated" = "0" ] && break
sleep 0.5 # autovacuum·복제 지연에 숨 쉴 틈을 준다
done배치 크기와 sleep은 테이블 크기, 복제 지연, autovacuum 상태를 보며 조절한다. 배치 사이에 쉬는 이유는 backfill이 정상 트래픽과 autovacuum, 그리고 읽기 복제본(replica)으로의 복제를 압도하지 않게 하기 위해서다. dual write가 이미 켜져 있으므로, backfill이 도는 동안 새로 들어오는 행은 알아서 채워진다. 둘이 만나면 빈 곳이 사라진다.
4단계 — 제약을 락 짧게 추가 (NOT VALID → VALIDATE)
backfill이 끝나 모든 행이 채워졌다면, 이제 무결성 제약을 걸 차례다. 외래 키나 CHECK 제약을 평범하게 추가하면 기존 행 전체를 검증하느라 긴 락을 잡는다. PostgreSQL은 이를 두 단계로 쪼개는 길을 제공한다.
-- (a) NOT VALID: 기존 행은 검증하지 않고 제약만 등록 → 락이 짧다
-- 이후 들어오는 INSERT/UPDATE 부터는 제약이 적용된다
ALTER TABLE orders
ADD CONSTRAINT orders_amount_v2_fk
FOREIGN KEY (amount_v2_ref) REFERENCES other_table (id)
NOT VALID;NOT VALID는 기존 데이터를 스캔하지 않고 제약을 등록만 한다. 그래서 락을 짧게 잡는다. 단, 이 시점부터 새로 들어오거나 수정되는 행에는 제약이 강제된다. 그다음 별도 명령으로 기존 행을 검증한다.
-- (b) VALIDATE: 기존 행을 스캔해 검증.
-- 이 작업은 ACCESS EXCLUSIVE 가 아니라 약한 SHARE UPDATE EXCLUSIVE 락을 잡아
-- 읽기/쓰기를 막지 않는다
ALTER TABLE orders VALIDATE CONSTRAINT orders_amount_v2_fk;핵심은 비싼 검증 스캔(VALIDATE)이 강한 락을 잡지 않는다는 점이다. 락을 짧게 잡는 NOT VALID와, 락은 약하지만 시간이 걸리는 VALIDATE를 분리함으로써, 어느 단계도 테이블을 멈추지 않는다.
NOT NULL은 CHECK를 우회로로 쓴다
SET NOT NULL은 앞서 봤듯 전체 스캔 + ACCESS EXCLUSIVE다. 이를 같은 NOT VALID/VALIDATE 기법으로 우회한다.
-- (a) NOT NULL 과 동치인 CHECK 를 NOT VALID 로 건다 (락 짧음)
ALTER TABLE orders
ADD CONSTRAINT orders_amount_v2_not_null
CHECK (amount_v2 IS NOT NULL) NOT VALID;
-- (b) 기존 행 검증 (약한 락)
ALTER TABLE orders VALIDATE CONSTRAINT orders_amount_v2_not_null;이렇게 검증까지 끝난 상태라면, 마지막으로 진짜 NOT NULL 속성을 부여할 수 있다. PostgreSQL 12 이상에서는 동일 컬럼에 이미 검증된(valid) CHECK 제약이 있으면 SET NOT NULL이 전체 스캔을 건너뛰므로 락이 짧게 끝난다.
-- 검증된 CHECK 가 있으므로 PG12+ 에서 전체 스캔 생략 → 빠름
ALTER TABLE orders ALTER COLUMN amount_v2 SET NOT NULL;
-- 더 이상 필요 없는 보조 CHECK 는 제거
ALTER TABLE orders DROP CONSTRAINT orders_amount_v2_not_null;PostgreSQL 11 이하라면 이 최적화가 없으므로, NOT NULL이 정말 필요한지 재검토하거나 유지보수 시간대에 잡는 편이 낫다.
5단계 — 옛 컬럼·코드 제거 (Contract)
이제 모든 인스턴스가 신버전 코드이고, 읽기까지 amount_v2로 전환됐으며, 데이터가 완전히 채워진 게 확인됐다. 보통 다음 순서로 끝낸다.
- 읽기 전환 배포: 애플리케이션 읽기 경로를
amount에서amount_v2로 옮긴다. 쓰기는 아직 dual write를 유지(롤백 안전망). - dual write 중단 배포: 안정성이 확인되면 옛 컬럼
amount에 더 이상 쓰지 않도록 코드에서 제거한다. - 컬럼 드롭: 어떤 코드도
amount를 참조하지 않게 된 다음에야 스키마에서 제거한다.
-- 마지막 배포 이후. 어떤 실행 코드도 amount 를 참조하지 않을 때만.
ALTER TABLE orders DROP COLUMN amount;DROP COLUMN은 메타데이터만 바꿔(데이터를 즉시 지우지 않고 숨김 처리) 락이 짧다. 위험은 락이 아니라 타이밍이다. 아직 구버전 인스턴스가 한 대라도 남아 있는데 컬럼을 지우면 그 인스턴스가 깨진다. 그래서 드롭은 항상 가장 마지막, 별도 배포로 분리한다. 이것이 expand가 아니라 contract인 이유다. 더하는 변경은 즉시 안전하지만, 빼는 변경은 모두가 그것을 안 쓰는 게 확인된 뒤에야 안전하다.
컬럼 이름 변경: RENAME 대신 새 컬럼 + 동기화 + 전환 + 제거
이름 변경은 위 패턴의 가장 흔한 적용 사례다. 단독 ALTER ... RENAME COLUMN은 락은 짧아도 구버전 코드를 즉시 깨뜨리므로, 같은 expand/contract 흐름으로 푼다.
- Expand: 새 이름의 컬럼을 nullable로 추가한다. (
amount→total_amount라면total_amount추가) - 동기화(dual write): 신버전 코드가 두 컬럼에 모두 쓴다. 양방향이 필요하면 트리거로 동기화할 수도 있지만, 가능하면 애플리케이션 레벨 dual write가 단순하고 디버깅이 쉽다.
- Backfill: 위와 같은 배치 방식으로 과거 행의 새 컬럼을 채운다.
- 읽기 전환: 읽기를 새 컬럼으로 옮겨 배포한다.
- Contract: dual write를 끊고, 마지막 배포에서 옛 컬럼을 드롭한다.
결과적으로 "이름을 바꾼다"는 단일 작업이, 하위호환을 유지하는 여러 단계의 배포로 분해된다. 번거롭지만 이것이 무중단의 대가다.
안전장치: 타임아웃과 배포 순서
패턴과 별개로, 모든 운영 DDL에 깔아두면 좋은 안전장치가 있다.
lock_timeout — 락 대기로 인한 마비 방지
가장 위험한 시나리오 중 하나: 짧게 끝날 줄 알았던 ALTER TABLE이 마침 도는 긴 트랜잭션 때문에 락을 못 잡고 대기에 들어간다. 그런데 DDL이 락 큐에서 대기하는 동안, 그 뒤에 줄 선 일반 쿼리들까지 전부 막힌다. ACCESS EXCLUSIVE를 기다리는 DDL은 후속 요청 앞을 가로막기 때문이다. 한 줄짜리 마이그레이션이 전면 장애로 번지는 전형적 경로다.
이를 막으려면 마이그레이션 세션에 lock_timeout을 건다. 정해진 시간 안에 락을 못 잡으면 대기를 무한정 끌지 않고 즉시 실패시켜, 줄 선 트래픽을 풀어준다.
-- 락을 3초 안에 못 잡으면 이 DDL 은 실패 (대기로 트래픽을 막지 않는다)
SET lock_timeout = '3s';
-- 개별 문이 30초 넘게 돌면 중단 (폭주하는 backfill·rewrite 방어)
SET statement_timeout = '30s';
ALTER TABLE orders ADD COLUMN amount_v2 bigint;lock_timeout은 "락을 기다리는 시간"의 상한이고, statement_timeout은 "문 하나가 실행되는 시간"의 상한이다. 둘은 다르다. 전자는 마비를, 후자는 폭주를 막는다. 실패하면 짧은 백오프 후 재시도하는 래퍼로 감싸면, 운 나쁜 타이밍을 자동으로 비껴갈 수 있다. 단, CREATE INDEX CONCURRENTLY처럼 본질적으로 오래 걸리는 작업에는 statement_timeout을 비활성화(0)하거나 충분히 키워야 하니 작업별로 구분하라.
짧은 트랜잭션, 그리고 스키마 먼저
두 가지 운영 규칙을 반복 강조한다.
- 트랜잭션을 짧게 유지하라. 오래 열린 트랜잭션은 그 자체로 락을 오래 잡고, autovacuum이 죽은 튜플을 회수하지 못하게 막아 bloat를 키운다. backfill을 작은 배치로 쪼개는 것도, 마이그레이션을 단계로 나누는 것도 결국 트랜잭션을 짧게 만들기 위해서다.
- 항상 하위호환 스키마를 코드보다 먼저 배포하라. "컬럼 추가 → 코드 배포", "읽기 전환 코드 배포 → (확인) → 컬럼 드롭"처럼, 스키마는 더하는 방향(expand)을 앞세우고 빼는 방향(contract)을 가장 뒤로 미룬다. 그러면 롤링 배포 중 어떤 구·신 조합에서도 양쪽 코드가 다 동작한다.
정리
운영 중 스키마 변경에서 기억할 한 문장: 한 번에 바꾸지 말고 여러 배포로 나눠라.
- 위험의 정체는 두 가지다 — 테이블 rewrite(volatile default, 타입 변경)와 긴 ACCESS EXCLUSIVE 락(일반 인덱스 생성, NOT NULL 검증). 둘을 피하는 게 무중단의 전부다.
- 인덱스는
CONCURRENTLY, 제약은NOT VALID후VALIDATE, NOT NULL은 검증된 CHECK를 거쳐 마지막에SET NOT NULL. 모두 "강한 락을 짧게, 비싼 스캔은 약한 락으로" 라는 같은 원리다. - 큰 변경은 expand → migrate → contract로 분해한다. nullable 컬럼 추가 → dual write(하위호환 코드 먼저) → 배치 backfill(한 트랜잭션에 몰지 말 것) → 제약을 락 짧게 → 옛 컬럼/코드 제거(다음 배포).
- 이름 변경은 RENAME 한 줄이 아니라 새 컬럼 + 동기화 + 전환 + 제거로 푼다.
lock_timeout/statement_timeout으로 락 대기 마비와 폭주를 막고, 트랜잭션은 짧게, 스키마는 하위호환을 코드보다 먼저 배포한다.
번거로워 보여도, 각 단계가 모두 하위호환이라는 점이 핵심이다. 그래서 어느 중간 상태에서 멈추거나 롤백해도 서비스가 살아 있다. 그것이 "무중단"의 실체다.