왜 마이그레이션이 배포를 멈추는가

PostgreSQL이나 MySQL에서 ALTER TABLE 같은 DDL은 대상 테이블에 ACCESS EXCLUSIVE(PG) 혹은 메타데이터 락(MySQL)을 요구한다. 문제는 이 락을 잡기 전에, 이미 그 테이블을 읽고 있는 짧은 트랜잭션 하나만 있어도 DDL이 대기 큐에 들어간다는 점이다. 그리고 DDL이 대기하는 동안, 그 뒤에 도착한 모든 SELECT까지 함께 막힌다. 즉 마이그레이션 한 줄이 트래픽 전체를 정지시키는 락 큐 폭주가 발생한다.

가장 흔한 실패 시나리오

배포 파이프라인에서 마이그레이션을 무한 대기로 실행하면, 락을 잡지 못한 채 몇 분씩 매달리고 그 사이 서비스가 500을 뿜는다. 반대로 타임아웃이 너무 짧으면 마이그레이션이 실패해 배포가 롤백된다. 핵심 원칙은 "마이그레이션은 락을 짧게, 그리고 실패하면 빠르게 실패한다"이다.

lock_timeout으로 빠르게 실패시키기

가장 먼저 적용할 것은 lock_timeout이다. 락을 정해진 시간 안에 못 잡으면 DDL만 취소되고, 대기하던 일반 쿼리는 풀려난다. 배포 스크립트는 이 실패를 감지해 재시도하면 된다.

-- PostgreSQL: 마이그레이션 세션에만 적용
SET lock_timeout = '3s';
SET statement_timeout = '30s';

-- 여러 번 재시도해도 안전하도록 IF NOT EXISTS 사용
ALTER TABLE orders ADD COLUMN IF NOT EXISTS status text;

3초 안에 락을 못 잡으면 예외가 나고, 트래픽을 붙잡지 않는다. 파이프라인에서 짧은 백오프를 두고 재시도하면 한산한 순간에 락을 확보한다.

재시도 래퍼 예시

import time, psycopg2

def run_ddl(conn, sql, retries=10, backoff=5):
    for i in range(retries):
        try:
            with conn.cursor() as cur:
                cur.execute("SET lock_timeout = '3s'")
                cur.execute(sql)
            conn.commit()
            return
        except psycopg2.errors.LockNotAvailable:
            conn.rollback()
            print(f"lock busy, retry {i+1}/{retries}")
            time.sleep(backoff)
    raise RuntimeError("migration failed: lock never acquired")

락을 아예 짧게 만드는 설계

타임아웃은 방어일 뿐, 근본 해법은 락 보유 시간 자체를 줄이는 것이다. PostgreSQL 11+ 기준으로 위험한 작업을 안전한 형태로 바꾼다.

위험한 작업안전한 대안
NOT NULL 컬럼 즉시 추가 + 기본값 채우기nullable로 추가 → 백필 → 제약 검증
ADD CONSTRAINT (전체 스캔)ADD ... NOT VALID → 이후 VALIDATE CONSTRAINT
CREATE INDEXCREATE INDEX CONCURRENTLY
-- 전체 테이블 락 없이 인덱스 생성 (트랜잭션 밖에서 실행해야 함)
CREATE INDEX CONCURRENTLY idx_orders_status ON orders (status);

-- 제약은 짧은 락으로 등록만, 검증은 별도 단계에서
ALTER TABLE orders ADD CONSTRAINT chk_status
  CHECK (status IN ('new','paid','shipped')) NOT VALID;
ALTER TABLE orders VALIDATE CONSTRAINT chk_status;  -- 이 단계는 락이 약함

배포 순서를 코드와 분리하기

컬럼 삭제나 이름 변경은 코드와 스키마를 동시에 바꾸면 배포 도중 반드시 깨진다. 확장 → 마이그레이션 → 정리(expand-contract) 패턴으로 나눈다. 먼저 새 컬럼을 추가하고, 애플리케이션이 신·구 양쪽을 모두 처리하도록 배포한 뒤, 데이터 백필과 트래픽 전환이 끝난 다음 릴리스에서 옛 컬럼을 제거한다. 이렇게 하면 각 단계의 DDL이 작고, 롤백도 독립적으로 가능하다.

주의점

CREATE INDEX CONCURRENTLY는 트랜잭션 블록 안에서 실행할 수 없으므로, 마이그레이션 도구의 자동 트랜잭션 래핑을 꺼야 한다(예: Django의 atomic = False). 또한 concurrently는 실패 시 무효 인덱스를 남기므로 재실행 전 정리가 필요하다. 백필은 한 번에 전체를 UPDATE하지 말고 수천 건 단위 배치로 나눠 롱 트랜잭션과 WAL 폭증을 피한다. 마지막으로 statement_timeout과 lock_timeout은 세션 단위로만 걸어 애플리케이션 커넥션 풀의 기본값을 오염시키지 않도록 한다.