본문으로 건너뛰기
wooncloud

무중단 데이터베이스 스키마 마이그레이션

운영 중 스키마 변경은 한 번에 바꾸지 않고 expand→migrate→contract 로 나눠야 무중단이 되며, CREATE INDEX CONCURRENTLY 와 NOT VALID→VALIDATE 로 강한 락을 피합니다.

·28 min read·— views··

운영 중 스키마 변경이 왜 위험한가

운영 중인 서비스의 데이터베이스 스키마를 바꾸는 작업은 로컬에서 ALTER TABLE 한 줄 실행하는 것과는 전혀 다른 문제입니다. 가장 큰 위험은 락(lock) 입니다. PostgreSQL의 많은 DDL은 ACCESS EXCLUSIVE 락을 잡는데, 이 락은 해당 테이블에 대한 모든 읽기와 쓰기를 차단합니다. 락을 잡는 시간이 길어지면 그 사이 들어온 모든 쿼리가 락 대기 큐에 줄을 서고, 커넥션 풀이 순식간에 고갈되며, 결국 서비스 전체가 응답을 멈춥니다. 테이블 하나를 잠갔을 뿐인데 장애로 번지는 이유입니다.

게다가 더 교묘한 위험이 하나 더 있습니다. 무중단 배포 환경에서는 구버전 코드와 신버전 코드가 잠깐 동시에 떠 있습니다. 롤링 배포 중에는 항상 그렇습니다. 스키마를 신버전에 맞춰 바꿔버리면, 아직 살아 있는 구버전 인스턴스가 사라진 컬럼이나 바뀐 타입을 참조하다가 에러를 뱉습니다. 반대로 코드를 먼저 바꾸면 아직 안 바뀐 스키마 때문에 신버전이 깨집니다.

그래서 무중단 마이그레이션의 핵심 원칙은 두 가지로 압축됩니다.

  1. 강한 락을 짧게 잡거나 아예 피합니다. 긴 락이 필요한 작업은 잘게 쪼개거나 CONCURRENTLY 같은 비차단 방식으로 우회합니다.
  2. 모든 변경을 하위호환으로 만듭니다. 구·신 코드가 공존하는 그 짧은 시간 동안 양쪽 다 정상 동작해야 합니다. 그러려면 "한 번에 바꾸는" 변경을 "여러 단계로 나누는" 변경으로 재설계해야 합니다.

이 글은 어떤 변경이 왜 위험한지 먼저 짚고, 그 위험을 안전한 여러 단계로 분해하는 표준 패턴(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. 먼저 1단계의 스키마 변경(컬럼 추가)을 적용합니다. 이건 구버전 코드에 영향이 없습니다.
  2. 그다음 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
  # 출력과 종료코드를 따로 받는다 — 파이프 안에서 || echo 0 을 쓰면 psql 실패가
  # "갱신 0행 = 완료" 로 둔갑해 루프가 조용히 끝난다 (그 뒤 NOT NULL 을 걸면 터진다)
  out=$(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;
  " 2>&1)
  rc=$?
  updated=$(printf '%s' "$out" | grep -oE '[0-9]+$')
  if [ "$rc" -ne 0 ] || ! [ "$updated" -eq "$updated" ] 2>/dev/null; then
    echo "backfill 실패 (rc=$rc): $out" >&2
    exit 1
  fi
  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로 전환됐으며, 데이터가 완전히 채워진 게 확인됐습니다. 보통 다음 순서로 끝냅니다.

  1. 읽기 전환 배포: 애플리케이션 읽기 경로를 amount에서 amount_v2로 옮깁니다. 쓰기는 아직 dual write를 유지합니다(롤백 안전망).
  2. dual write 중단 배포: 안정성이 확인되면 옛 컬럼 amount에 더 이상 쓰지 않도록 코드에서 제거합니다.
  3. 컬럼 드롭: 어떤 코드도 amount를 참조하지 않게 된 다음에야 스키마에서 제거합니다.
-- 마지막 배포 이후. 어떤 실행 코드도 amount 를 참조하지 않을 때만.
ALTER TABLE orders DROP COLUMN amount;

DROP COLUMN은 메타데이터만 바꿔(데이터를 즉시 지우지 않고 숨김 처리) 락이 짧습니다. 위험은 락이 아니라 타이밍입니다. 아직 구버전 인스턴스가 한 대라도 남아 있는데 컬럼을 지우면 그 인스턴스가 깨집니다. 그래서 드롭은 항상 가장 마지막, 별도 배포로 분리합니다. 이것이 expand가 아니라 contract인 이유입니다. 더하는 변경은 즉시 안전하지만, 빼는 변경은 모두가 그것을 안 쓰는 게 확인된 뒤에야 안전합니다.

컬럼 이름 변경: RENAME 대신 새 컬럼 + 동기화 + 전환 + 제거

이름 변경은 위 패턴의 가장 흔한 적용 사례입니다. 단독 ALTER ... RENAME COLUMN은 락은 짧아도 구버전 코드를 즉시 깨뜨리므로, 같은 expand/contract 흐름으로 풉니다.

  1. Expand: 새 이름의 컬럼을 nullable로 추가합니다. (amount → total_amount라면 total_amount 추가)
  2. 동기화(dual write): 신버전 코드가 두 컬럼에 모두 씁니다. 양방향이 필요하면 트리거로 동기화할 수도 있지만, 가능하면 애플리케이션 레벨 dual write가 단순하고 디버깅이 쉽습니다.
  3. Backfill: 위와 같은 배치 방식으로 과거 행의 새 컬럼을 채웁니다.
  4. 읽기 전환: 읽기를 새 컬럼으로 옮겨 배포합니다.
  5. 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으로 락 대기 마비와 폭주를 막고, 트랜잭션은 짧게, 스키마는 하위호환을 코드보다 먼저 배포합니다.

번거로워 보여도, 각 단계가 모두 하위호환이라는 점이 핵심입니다. 그래서 어느 중간 상태에서 멈추거나 롤백해도 서비스가 살아 있습니다. 그것이 "무중단"의 실체입니다.