Skip to content

Split View: 무중단 스키마 변경 완전 가이드: DDL이 잡는 잠금 등급과 안전 실행 절차

|

무중단 스키마 변경 완전 가이드: DDL이 잡는 잠금 등급과 안전 실행 절차

들어가며

이 블로그에는 무중단 스키마 변경에 관한 글이 이미 여럿 있습니다. Expand-Contract 패턴은 변경을 되돌릴 수 있는 작은 단계로 쪼개는 설계 패턴을 다루고, 대용량 테이블 온라인 스키마 변경은 gh-ost와 pt-online-schema-change 같은 도구를 다룹니다.

이 글은 그 아래층입니다. PostgreSQL이 각 DDL에서 실제로 어떤 잠금을 잡는가, 그리고 그 잠금이 서비스 트래픽과 어떻게 부딪히는가. 패턴과 도구를 알아도 이 층을 모르면 사고가 납니다. "컬럼 하나 추가하는 건데요"라고 말한 배포가 서비스를 5분간 세우는 일이 실제로 벌어지는 이유가 여기 있습니다.

기준 엔진은 PostgreSQL 18 이며, 잠금 등급과 재작성 여부는 모두 PostgreSQL 18 문서에서 확인했습니다. MySQL의 온라인 DDL은 알고리즘 선택(INPLACE, COPY, INSTANT)이라는 완전히 다른 모델을 쓰므로 이 글에서 섞지 않았습니다.

1. 무중단이 깨지는 진짜 이유는 잠금 대기열이다

가장 흔한 오해가 있습니다. "DDL이 10초 걸리니까 서비스가 10초 느려지겠지." 실제로는 그렇지 않습니다. DDL이 잠금을 얻으려고 기다리는 동안, 그 뒤에 들어온 평범한 쿼리들도 함께 멈춥니다.

PostgreSQL의 잠금 요청은 대기열에 들어갑니다. 어떤 세션이 ACCESS EXCLUSIVE 잠금을 요청했는데 앞서 실행 중인 긴 SELECT 때문에 얻지 못하고 대기하면, 그 뒤에 도착한 새 SELECT들은 DDL과 충돌하는 잠금을 요청하게 되므로 대기열 뒤에 줄을 섭니다. 결과적으로 DDL 자체는 1초짜리인데 앞선 30초 쿼리 하나 때문에 30초 동안 모든 신규 요청이 막히는 상황이 만들어집니다.

이 구조를 이해하면 무중단 스키마 변경의 원칙 세 가지가 자연히 따라옵니다.

  1. 강한 잠금을 요구하는 DDL은 가능한 한 피하고, 피할 수 없으면 최대한 짧게 만든다.
  2. DDL 실행 전에 장수 트랜잭션이 없는지 확인한다.
  3. DDL에는 반드시 lock_timeout을 걸어 대기열을 오래 막지 않게 한다.

2. 여덟 가지 테이블 잠금 모드

PostgreSQL의 테이블 수준 잠금은 여덟 개입니다. 약한 것부터 강한 것 순서이며, 충돌 관계는 문서의 표 그대로입니다.

잠금 모드충돌하는 모드
ACCESS SHAREACCESS EXCLUSIVE
ROW SHAREEXCLUSIVE, ACCESS EXCLUSIVE
ROW EXCLUSIVESHARE, SHARE ROW EXCLUSIVE, EXCLUSIVE, ACCESS EXCLUSIVE
SHARE UPDATE EXCLUSIVESHARE UPDATE EXCLUSIVE, SHARE, SHARE ROW EXCLUSIVE, EXCLUSIVE, ACCESS EXCLUSIVE
SHAREROW EXCLUSIVE, SHARE UPDATE EXCLUSIVE, SHARE ROW EXCLUSIVE, EXCLUSIVE, ACCESS EXCLUSIVE
SHARE ROW EXCLUSIVEROW EXCLUSIVE, SHARE UPDATE EXCLUSIVE, SHARE, SHARE ROW EXCLUSIVE, EXCLUSIVE, ACCESS EXCLUSIVE
EXCLUSIVEROW SHARE 이하를 제외한 거의 전부
ACCESS EXCLUSIVE모든 모드

실무에서 외워야 할 것은 두 줄입니다.

  • ACCESS SHARE는 평범한 SELECT가 잡는 잠금이고, 오직 ACCESS EXCLUSIVE하고만 충돌합니다.ACCESS EXCLUSIVE를 잡지 않는 DDL은 읽기를 막지 않습니다.
  • ROW EXCLUSIVEINSERT, UPDATE, DELETE, MERGE가 잡는 잠금입니다. 이것과 충돌하는 등급(SHARE 이상)을 잡는 DDL은 쓰기를 막습니다.

정리하면 이렇게 됩니다. SHARE UPDATE EXCLUSIVE까지는 읽기와 쓰기를 모두 통과시킵니다. SHARE ROW EXCLUSIVE부터 쓰기가 막히고, ACCESS EXCLUSIVE에서 읽기까지 막힙니다.

문서가 명시한 명령별 잠금은 다음과 같습니다.

  • ACCESS EXCLUSIVEDROP TABLE, TRUNCATE, REINDEX, CLUSTER, VACUUM FULL, CONCURRENTLY 없는 REFRESH MATERIALIZED VIEW, 그리고 상당수의 ALTER TABLEALTER INDEX 형태
  • SHARE UPDATE EXCLUSIVEFULL 없는 VACUUM, ANALYZE, CREATE INDEX CONCURRENTLY, REINDEX CONCURRENTLY, CREATE STATISTICS, COMMENT ON, 일부 ALTER TABLE 형태
  • SHARE ROW EXCLUSIVECREATE TRIGGER, 일부 ALTER TABLE 형태
  • ROW EXCLUSIVEUPDATE, DELETE, INSERT, MERGE

3. ALTER TABLE 형태별 잠금 등급

문서의 원문은 명확합니다. "각 하위 형태마다 필요한 잠금 등급이 다를 수 있다. 명시적으로 언급되지 않은 경우 ACCESS EXCLUSIVE 잠금을 획득한다."

기본값이 최악입니다. 예외만 외우면 됩니다. 문서가 더 낮은 등급을 명시한 형태들입니다.

ALTER TABLE 형태잠금 등급
SET STATISTICSSHARE UPDATE EXCLUSIVE
SET (...) / RESET (...) 컬럼별 옵션 변경SHARE UPDATE EXCLUSIVE
CLUSTER ON / SET WITHOUT CLUSTERSHARE UPDATE EXCLUSIVE
VALIDATE CONSTRAINTSHARE UPDATE EXCLUSIVE
ATTACH PARTITION (부모 테이블에 대해)SHARE UPDATE EXCLUSIVE
ADD FOREIGN KEYSHARE ROW EXCLUSIVE
ENABLE / DISABLE TRIGGERSHARE ROW EXCLUSIVE
그 밖의 모든 형태ACCESS EXCLUSIVE

몇 가지 중요한 세부가 있습니다.

ADD FOREIGN KEY는 문서가 특별히 짚어 둔 예외입니다. "대부분의 ADD table_constraint 형태는 ACCESS EXCLUSIVE 잠금을 요구하지만, ADD FOREIGN KEYSHARE ROW EXCLUSIVE 잠금만 요구한다." 그래도 쓰기는 막힙니다.

ATTACH PARTITION은 부모에 SHARE UPDATE EXCLUSIVE만 걸지만, 문서에 따르면 붙이는 테이블 자체와 DEFAULT 파티션(있다면)에는 ACCESS EXCLUSIVE를 겁니다. DEFAULT 파티션이 있으면 그 파티션이 완전히 잠기므로, 큰 DEFAULT 파티션을 두고 운영하면 파티션 추가가 그때마다 사고가 됩니다.

4. 재작성을 유발하는 변경과 그렇지 않은 변경

잠금 등급만큼 중요한 것이 테이블 재작성 여부입니다. ACCESS EXCLUSIVE를 1밀리초 잡는 것과 1시간 잡는 것은 완전히 다른 이야기이기 때문입니다.

문서가 명시하는 가장 유용한 사실 하나. 비휘발성 기본값을 가진 컬럼 추가는 테이블을 재작성하지 않습니다. 문서 원문은 이렇습니다. "ADD COLUMN으로 컬럼을 추가하고 비휘발성 DEFAULT를 지정하면, 기본값은 문장 시점에 평가되어 테이블 메타데이터에 저장되며 기존 행에 접근할 때 반환된다. 이 값은 테이블이 재작성될 때만 실제로 적용되므로, 큰 테이블에서도 ALTER TABLE이 매우 빠르다."

-- 재작성 없음: 메타데이터만 갱신된다 (PostgreSQL 11 이후)
ALTER TABLE orders ADD COLUMN channel text DEFAULT 'WEB' NOT NULL;

이 동작은 PostgreSQL 11에서 도입되었습니다. 10 이하를 운영 중이라면 같은 문장이 전체 테이블을 재작성하므로 절대 그대로 실행하면 안 됩니다.

반면 문서는 재작성을 유발하는 경우도 열거합니다. 휘발성 DEFAULT(예: clock_timestamp()), 저장 생성 컬럼, 아이덴티티 컬럼, 제약이 있는 도메인 타입 컬럼을 추가하면 테이블과 모든 인덱스가 재작성됩니다.

타입 변경도 마찬가지입니다. "기존 컬럼의 타입을 변경하면 보통 테이블과 인덱스 전체가 재작성된다. 예외적으로 USING 절이 컬럼 내용을 바꾸지 않고 기존 타입이 새 타입으로 이진 호환(binary coercible)이거나 새 타입에 대한 제약 없는 도메인이면 재작성이 필요 없다."

경고: ALTER TABLE ... ALTER COLUMN ... TYPE은 대부분의 경우 ACCESS EXCLUSIVE 잠금을 잡은 채 테이블 전체를 재작성합니다. 1억 행 테이블이면 수십 분 동안 읽기까지 완전히 차단됩니다. 안전한 대안은 새 컬럼을 추가하고, 백필하고, 애플리케이션을 전환한 뒤, 옛 컬럼을 지우는 expand-contract 절차입니다. varchar(50)varchar(100)으로 늘리는 것처럼 길이 제약만 완화하는 경우는 재작성 없이 처리되지만, 줄이는 것은 재작성입니다.

5. NOT VALID와 VALIDATE — 제약을 두 단계로

제약 추가는 무중단 스키마 변경에서 가장 잘 정리된 영역입니다. 문서가 방법과 이유를 모두 적어 두었습니다.

"새 외래 키, 검사, NOT NULL 제약을 검증하기 위해 큰 테이블을 스캔하는 데는 오랜 시간이 걸릴 수 있고, ALTER TABLE ADD CONSTRAINT 명령이 커밋될 때까지 그 테이블에 대한 다른 갱신이 차단된다. NOT VALID 제약 옵션의 주된 목적은 제약 추가가 동시 갱신에 미치는 영향을 줄이는 것이다. NOT VALID를 쓰면 ADD CONSTRAINT 명령이 테이블을 스캔하지 않고 즉시 커밋할 수 있다. 그 후 VALIDATE CONSTRAINT 명령으로 기존 행이 제약을 만족하는지 검증할 수 있다. 검증 단계는 동시 갱신을 차단할 필요가 없는데, 다른 트랜잭션이 삽입하거나 갱신하는 행에 대해서는 이미 제약이 강제되고 있음을 알기 때문이다. 기존 행만 검사하면 되므로 검증은 대상 테이블에 SHARE UPDATE EXCLUSIVE 잠금만 획득한다."

-- 1단계: 즉시 커밋. 이후 삽입/갱신부터 제약이 강제된다
ALTER TABLE order_items
  ADD CONSTRAINT fk_order_items_order
  FOREIGN KEY (order_id) REFERENCES orders (id) NOT VALID;

-- 2단계: 기존 행 검증. SHARE UPDATE EXCLUSIVE만 잡으므로 읽기/쓰기를 막지 않는다
ALTER TABLE order_items VALIDATE CONSTRAINT fk_order_items_order;

NOT NULL 추가에도 같은 발상을 쓸 수 있습니다. 문서에 따르면 "SET NOT NULL은 보통 ALTER TABLE 중에 테이블 전체를 스캔하여 검사한다. 다만 NULL이 존재할 수 없음을 증명하는 유효한 CHECK 제약이 이미 있으면 테이블 스캔을 생략한다."

-- 1단계: NOT VALID CHECK로 즉시 등록
ALTER TABLE users
  ADD CONSTRAINT chk_users_email_nn CHECK (email IS NOT NULL) NOT VALID;

-- 2단계: 약한 잠금으로 검증
ALTER TABLE users VALIDATE CONSTRAINT chk_users_email_nn;

-- 3단계: 이제 SET NOT NULL이 전체 스캔을 건너뛴다
ALTER TABLE users ALTER COLUMN email SET NOT NULL;

-- 4단계: 중복이 된 CHECK 제약 정리
ALTER TABLE users DROP CONSTRAINT chk_users_email_nn;

3단계와 4단계는 여전히 ACCESS EXCLUSIVE를 잡지만, 스캔이 없으므로 순식간에 끝납니다. 잠금 등급을 낮추는 것이 아니라 잠금 보유 시간을 줄이는 전략입니다.

6. 안전 실행 절차 — lock_timeout과 재시도

이제 1절의 대기열 문제로 돌아옵니다. ACCESS EXCLUSIVE를 짧게 잡는 DDL이라도, 잠금을 얻기까지 오래 기다리면 그동안 뒤의 요청이 전부 막힙니다.

해법은 "짧게 시도하고, 못 얻으면 포기하고, 잠시 뒤 다시 시도하는" 것입니다. 여기에 필요한 도구가 lock_timeout입니다. 문서 정의는 "테이블, 인덱스, 행, 기타 데이터베이스 객체에 대한 잠금을 얻으려 시도하며 지정된 시간보다 오래 기다리는 문장을 중단한다"이고, 기본값은 0으로 타임아웃이 비활성 입니다. 기본 설정에서는 DDL이 무한정 기다린다는 뜻입니다.

-- 안전한 DDL 실행 템플릿
BEGIN;
SET LOCAL lock_timeout = '3s';
SET LOCAL statement_timeout = '30s';

ALTER TABLE orders ADD COLUMN channel text DEFAULT 'WEB' NOT NULL;

COMMIT;

SET LOCAL을 쓰면 이 트랜잭션에서만 적용되고 커밋 또는 롤백과 함께 되돌아갑니다. 3초 안에 잠금을 얻지 못하면 오류로 끝나므로 대기열을 오래 막지 않습니다. 실패하면 몇 초 뒤 다시 시도합니다. 트래픽이 잠깐 뜸해지는 순간에 성공합니다.

DDL을 실행하기 전에 장수 트랜잭션을 확인하는 것도 절차에 넣으세요.

-- DDL 실행 직전 점검: 5분 이상 열려 있는 트랜잭션
SELECT pid, state, now() - xact_start AS xact_age,
       wait_event_type, left(query, 80) AS query
FROM pg_stat_activity
WHERE xact_start IS NOT NULL
  AND now() - xact_start > interval '5 minutes'
ORDER BY xact_start;

log_lock_waits를 켜 두면 잠금 대기가 deadlock_timeout(기본값 1초)을 넘길 때 로그가 남습니다. 배포 후 이 로그를 확인하면 "괜찮아 보였지만 사실 3초 막혔던" 배포를 사후에 잡아낼 수 있습니다.

7. 인덱스와 파티션의 무중단 작업

경고: CONCURRENTLY 없는 CREATE INDEX는 완료될 때까지 대상 테이블의 쓰기를 막습니다. 문서 표현으로 "삽입, 갱신, 삭제를 잠그며 읽기는 허용"합니다. REINDEX는 더 나쁩니다. ACCESS EXCLUSIVE를 잡으므로 읽기까지 막힙니다. 운영 테이블에서는 항상 CREATE INDEX CONCURRENTLYREINDEX INDEX CONCURRENTLY를 쓰세요. 두 명령 모두 SHARE UPDATE EXCLUSIVE만 잡습니다.

CONCURRENTLY에는 세 가지 제약이 따라옵니다.

첫째, 트랜잭션 블록 안에서 실행할 수 없습니다. 마이그레이션 도구가 모든 마이그레이션을 하나의 트랜잭션으로 감싸는 기본 설정이라면 실패합니다. 도구별로 트랜잭션을 끄는 설정을 찾아 두세요.

둘째, 실패하면 무효 인덱스가 남습니다. 문서 표현으로 이 인덱스는 "불완전할 수 있으므로 질의에서는 무시되지만 갱신 오버헤드는 계속 발생시킵니다." 마이그레이션 후 점검 쿼리를 자동화하세요.

-- 배포 후 자동 점검: 무효 인덱스
SELECT c.relname AS index_name, t.relname AS table_name
FROM pg_index i
JOIN pg_class c ON c.oid = i.indexrelid
JOIN pg_class t ON t.oid = i.indrelid
WHERE NOT i.indisvalid;

셋째, 파티션 테이블에는 직접 쓸 수 없습니다. 문서는 "파티션된 테이블의 인덱스 동시 생성은 현재 지원되지 않는다"라고 명시하고, 우회 방법을 제시합니다. 각 파티션에 개별적으로 CONCURRENTLY로 인덱스를 만든 다음, 마지막에 부모에 비동시적으로 인덱스를 만들면 쓰기가 잠기는 시간을 줄일 수 있습니다.

파티션 분리에는 동시 모드가 있습니다. ALTER TABLE ... DETACH PARTITION ... CONCURRENTLY는 문서 표현으로 "파티션된 테이블에 접근하는 다른 세션을 막지 않도록 낮춰진 잠금 등급으로 실행"됩니다. 오래된 파티션을 정기적으로 떼어 내는 보존 정책에서 이 옵션이 핵심입니다.

8. 마이그레이션을 게이트로 막기

지금까지의 규칙을 사람의 기억에 맡기면 언젠가 뚫립니다. 코드 리뷰와 CI에 게이트를 두는 편이 낫습니다.

체크리스트로 정리하면 이렇습니다.

  • 운영 테이블에 대한 CREATE INDEX / REINDEX / DROP INDEXCONCURRENTLY가 붙어 있는가
  • 모든 DDL 앞에 SET LOCAL lock_timeout이 있는가
  • 제약 추가가 NOT VALID + VALIDATE CONSTRAINT 두 단계로 나뉘어 있는가
  • 컬럼 타입 변경이 포함되어 있지 않은가 (있다면 expand-contract로 대체)
  • 컬럼 삭제가 배포와 같은 릴리스에 있지 않은가 (구버전 애플리케이션이 아직 그 컬럼을 참조할 수 있음)
  • ADD COLUMN의 기본값이 비휘발성인가
  • 마이그레이션이 하나의 거대 트랜잭션으로 묶여 있지 않은가

컬럼 삭제에 관해 한 가지 덧붙입니다. ALTER TABLE ... DROP COLUMN은 실제로 데이터를 지우지 않고 컬럼을 숨김 처리하므로 빠릅니다. 하지만 구버전 애플리케이션 인스턴스가 아직 살아 있는 롤링 배포 중에는 그 인스턴스의 SELECT *가 깨집니다. 컬럼 삭제는 항상 애플리케이션 배포가 완전히 끝난 다음 릴리스로 미루세요.

되돌리기 계획도 함께 준비해야 합니다. 스키마 변경의 롤백은 "역방향 DDL"이 아니라 "역방향 DDL + 그 사이에 쌓인 데이터 처리"입니다. 컬럼을 지웠다가 되돌리면 그 사이 데이터는 없습니다. 그래서 파괴적 변경은 항상 마지막 단계로 미루는 것이 원칙입니다.

퀴즈: 실력을 확인해 보세요

퀴즈 1: 아래 DDL은 1초 만에 끝났는데 서비스가 40초 동안 멈췄습니다. 왜일까요?
ALTER TABLE orders ADD COLUMN memo text;

정답: DDL 자체는 빨랐지만, ACCESS EXCLUSIVE 잠금을 얻기까지 기다리는 동안 뒤따라온 모든 쿼리가 대기열에 쌓였습니다.

설명: ALTER TABLE ADD COLUMNACCESS EXCLUSIVE 잠금을 요구하고, 이 잠금은 평범한 SELECT가 잡는 ACCESS SHARE와도 충돌합니다. 실행 시점에 40초짜리 리포트 쿼리가 돌고 있었다면 DDL은 그것이 끝날 때까지 기다립니다. 그리고 DDL이 대기열에 있는 동안 새로 들어오는 요청들도 그 뒤에 줄을 섭니다. 대응은 SET LOCAL lock_timeout = '3s'로 짧게 시도하고 실패하면 재시도하는 것, 그리고 DDL 실행 전에 pg_stat_activity로 장수 트랜잭션을 확인하는 것입니다.

퀴즈 2: 1억 행 테이블에 NOT NULL 제약을 추가해야 합니다. 가장 짧게 잠그는 순서는?

정답: NOT VALID CHECK 제약을 먼저 만들고 검증한 뒤 SET NOT NULL을 겁니다.

설명: ALTER TABLE ... SET NOT NULL을 바로 실행하면 ACCESS EXCLUSIVE 잠금을 잡은 채 테이블 전체를 스캔합니다. 문서에 따르면 NULL이 존재할 수 없음을 증명하는 유효한 CHECK 제약이 이미 있으면 이 스캔을 생략합니다.

ALTER TABLE users ADD CONSTRAINT chk_email_nn
  CHECK (email IS NOT NULL) NOT VALID;
ALTER TABLE users VALIDATE CONSTRAINT chk_email_nn;
ALTER TABLE users ALTER COLUMN email SET NOT NULL;
ALTER TABLE users DROP CONSTRAINT chk_email_nn;

1단계는 스캔 없이 즉시 커밋되고, 2단계는 SHARE UPDATE EXCLUSIVE만 잡으므로 읽기와 쓰기를 막지 않으며, 3단계는 ACCESS EXCLUSIVE를 잡지만 스캔이 없어 순식간에 끝납니다. 잠금 등급을 낮춘 것이 아니라 강한 잠금의 보유 시간을 밀리초 단위로 줄인 것입니다.

퀴즈 3: 아래 두 문장 중 어느 쪽이 테이블을 재작성하나요?
-- A
ALTER TABLE events ADD COLUMN created_by text DEFAULT 'system' NOT NULL;
-- B
ALTER TABLE events ADD COLUMN created_at timestamptz DEFAULT clock_timestamp() NOT NULL;

정답: B가 재작성합니다.

설명: 문서는 비휘발성 DEFAULT를 지정한 ADD COLUMN이 기본값을 테이블 메타데이터에 저장하고 재작성하지 않는다고 명시합니다. A의 'system'은 상수이므로 비휘발성입니다. 반면 B의 clock_timestamp()는 휘발성 함수이므로 행마다 값이 달라야 하고, 따라서 테이블과 모든 인덱스가 재작성됩니다. 같은 목적이라면 now()(트랜잭션 시작 시각으로 고정, 안정적 함수)를 쓰거나, 컬럼을 기본값 없이 추가한 뒤 청크 단위로 백필하고 마지막에 기본값과 NOT NULL을 거는 편이 안전합니다.

퀴즈 4: CREATE INDEX CONCURRENTLY가 마이그레이션 도구에서 계속 실패합니다. 오류는 "cannot run inside a transaction block"입니다.

정답: 마이그레이션 도구가 각 마이그레이션을 트랜잭션으로 감싸고 있기 때문입니다.

설명: 문서는 "일반 CREATE INDEX 명령은 트랜잭션 블록 안에서 수행될 수 있지만 CREATE INDEX CONCURRENTLY는 그럴 수 없다"라고 명시합니다. CONCURRENTLY는 내부적으로 여러 트랜잭션을 사용하기 때문입니다. 도구별로 해당 마이그레이션만 트랜잭션 밖에서 실행하는 설정이 있습니다. 그리고 트랜잭션 밖에서 실행된다는 것은 실패 시 자동 롤백이 없다는 뜻이기도 합니다. 실패하면 무효 인덱스가 남으므로, 마이그레이션 이후 무효 인덱스 점검 쿼리를 반드시 자동화하세요.

퀴즈 5: DEFAULT 파티션이 있는 파티션 테이블에 새 월 파티션을 붙이는데 매번 서비스가 멈춥니다. 왜일까요?

정답: ATTACH PARTITION이 DEFAULT 파티션에 ACCESS EXCLUSIVE 잠금을 걸기 때문입니다.

설명: 문서에 따르면 ATTACH PARTITION은 부모 테이블에 SHARE UPDATE EXCLUSIVE 잠금을 걸지만, 붙이는 테이블 자체와 DEFAULT 파티션(있는 경우)에는 ACCESS EXCLUSIVE 잠금을 겁니다. 게다가 DEFAULT 파티션에 새 파티션 범위와 겹치는 행이 없는지 확인하기 위해 스캔이 필요합니다. DEFAULT 파티션이 크면 이 스캔이 길어지고, 그동안 DEFAULT 파티션에 대한 모든 접근이 차단됩니다. 대응은 두 가지입니다. DEFAULT 파티션을 아예 두지 않고 파티션을 미리 넉넉히 만들어 두거나, DEFAULT 파티션을 항상 비어 있게 유지하는 것입니다. 문서도 DEFAULT 파티션에 대해 "붙일 파티션의 제약을 배제하는 CHECK 제약을 만들어 두어 불필요한 스캔을 피하라"고 권합니다.

마치며

무중단 스키마 변경은 도구의 문제가 아니라 잠금 등급을 아는 문제입니다. gh-ost 같은 도구가 필요한 경우는 생각보다 적고, PostgreSQL에서는 대부분의 변경을 문서에 적힌 방법만으로 안전하게 처리할 수 있습니다. NOT VALIDVALIDATE CONSTRAINT, CONCURRENTLY, SET LOCAL lock_timeout. 이 세 가지가 도구 대부분을 대체합니다.

팀에 남길 규칙은 한 문장으로 요약됩니다. 모든 DDL은 자신이 어떤 잠금을 몇 초 동안 잡는지 아는 상태로 실행한다. 모르면 스테이징에서 log_lock_waits를 켜고 측정한 뒤에 실행합니다.

마이그레이션 순서를 시각적으로 점검하고 싶다면 DB 마이그레이션 탐색기를, DDL을 직접 실행해 보려면 Postgres 놀이터를 활용하세요.

참고 자료

이어서 읽기

The Complete Guide to Zero-Downtime Schema Changes: The Lock Level Each DDL Takes and How to Run It Safely

Introduction

This blog already has several posts about zero-downtime schema changes. The Expand-Contract Pattern covers the design pattern of breaking a change into small, reversible steps, and Online Schema Changes on Large Tables covers tools like gh-ost and pt-online-schema-change.

This post is the layer underneath those. What lock does PostgreSQL actually take for each DDL statement, and how does that lock collide with production traffic? Knowing the patterns and the tools is not enough — miss this layer and you get an incident. This is exactly why a deploy someone described as "it's just adding one column" can actually take a service down for five minutes.

The reference engine is PostgreSQL 18, and every lock level and rewrite behavior stated here has been confirmed against the PostgreSQL 18 documentation. MySQL's online DDL uses a completely different model built around algorithm choice (INPLACE, COPY, INSTANT), so it is not mixed into this post.

1. What Actually Breaks Zero Downtime Is the Lock Queue

There is a very common misunderstanding: "the DDL takes 10 seconds, so the service will just be 10 seconds slower." In reality, that is not what happens. While the DDL waits to acquire its lock, the ordinary queries that arrive behind it stop too.

A lock request in PostgreSQL joins a queue. If a session requests an ACCESS EXCLUSIVE lock and cannot get it because of a long-running SELECT already in progress, it waits — and every new SELECT that arrives afterward also ends up requesting a lock that conflicts with the DDL, so it lines up behind the DDL in the same queue. The result: the DDL itself takes one second, but a single 30-second query ahead of it blocks every new request for the full 30 seconds.

Once you understand this mechanism, three principles for zero-downtime schema changes follow naturally.

  1. Avoid DDL that demands a strong lock whenever you can, and when you cannot avoid it, make it as short as possible.
  2. Confirm there is no long-lived transaction before running the DDL.
  3. Always attach a lock_timeout to the DDL so it cannot block the queue for long.

2. The Eight Table Lock Modes

PostgreSQL has eight table-level lock modes. Listed from weakest to strongest, with conflicts exactly as the documentation's table states them.

Lock modeConflicts with
ACCESS SHAREACCESS EXCLUSIVE
ROW SHAREEXCLUSIVE, ACCESS EXCLUSIVE
ROW EXCLUSIVESHARE, SHARE ROW EXCLUSIVE, EXCLUSIVE, ACCESS EXCLUSIVE
SHARE UPDATE EXCLUSIVESHARE UPDATE EXCLUSIVE, SHARE, SHARE ROW EXCLUSIVE, EXCLUSIVE, ACCESS EXCLUSIVE
SHAREROW EXCLUSIVE, SHARE UPDATE EXCLUSIVE, SHARE ROW EXCLUSIVE, EXCLUSIVE, ACCESS EXCLUSIVE
SHARE ROW EXCLUSIVEROW EXCLUSIVE, SHARE UPDATE EXCLUSIVE, SHARE, SHARE ROW EXCLUSIVE, EXCLUSIVE, ACCESS EXCLUSIVE
EXCLUSIVEAlmost everything except ROW SHARE and weaker
ACCESS EXCLUSIVEEvery mode

There are two lines worth memorizing for daily work.

  • ACCESS SHARE is the lock an ordinary SELECT takes, and it conflicts only with ACCESS EXCLUSIVE. In other words, a DDL statement that does not take ACCESS EXCLUSIVE does not block reads.
  • ROW EXCLUSIVE is the lock INSERT, UPDATE, DELETE, and MERGE take. A DDL statement that takes a level conflicting with this (SHARE or stronger) blocks writes.

Put together: everything up through SHARE UPDATE EXCLUSIVE lets both reads and writes pass. Starting at SHARE ROW EXCLUSIVE, writes get blocked, and at ACCESS EXCLUSIVE, reads are blocked too.

The documentation states which lock each command takes:

  • ACCESS EXCLUSIVEDROP TABLE, TRUNCATE, REINDEX, CLUSTER, VACUUM FULL, REFRESH MATERIALIZED VIEW without CONCURRENTLY, and a good many ALTER TABLE and ALTER INDEX variants
  • SHARE UPDATE EXCLUSIVEVACUUM without FULL, ANALYZE, CREATE INDEX CONCURRENTLY, REINDEX CONCURRENTLY, CREATE STATISTICS, COMMENT ON, and some ALTER TABLE variants
  • SHARE ROW EXCLUSIVECREATE TRIGGER, and some ALTER TABLE variants
  • ROW EXCLUSIVEUPDATE, DELETE, INSERT, MERGE

3. Lock Level by ALTER TABLE Variant

The documentation's own wording is explicit: "The lock level required may differ for each variant. Where a lock level is not explicitly mentioned, ACCESS EXCLUSIVE lock is acquired."

In other words, the default is the worst case. All you need to memorize are the exceptions — the variants for which the documentation specifies a lower level.

ALTER TABLE variantLock level
SET STATISTICSSHARE UPDATE EXCLUSIVE
SET (...) / RESET (...) per-column option changeSHARE UPDATE EXCLUSIVE
CLUSTER ON / SET WITHOUT CLUSTERSHARE UPDATE EXCLUSIVE
VALIDATE CONSTRAINTSHARE UPDATE EXCLUSIVE
ATTACH PARTITION (on the parent table)SHARE UPDATE EXCLUSIVE
ADD FOREIGN KEYSHARE ROW EXCLUSIVE
ENABLE / DISABLE TRIGGERSHARE ROW EXCLUSIVE
Every other variantACCESS EXCLUSIVE

A few details matter here.

ADD FOREIGN KEY is an exception the documentation specifically calls out: "most forms of ADD table_constraint require ACCESS EXCLUSIVE lock, but ADD FOREIGN KEY requires only SHARE ROW EXCLUSIVE lock." Writes are still blocked, though.

ATTACH PARTITION takes only SHARE UPDATE EXCLUSIVE on the parent, but according to the documentation, it takes ACCESS EXCLUSIVE on the table being attached itself, and on the DEFAULT partition (if one exists). If a DEFAULT partition exists, that partition is locked completely, so if you run a large DEFAULT partition in production, adding a partition becomes an incident every single time.

4. Changes That Trigger a Rewrite, and Changes That Do Not

Just as important as the lock level is whether the table gets rewritten. Holding ACCESS EXCLUSIVE for one millisecond and holding it for one hour are completely different stories.

Here is the single most useful fact the documentation states. Adding a column with a non-volatile default does not rewrite the table. In the documentation's own words: adding a column with ADD COLUMN and specifying a non-volatile DEFAULT means the default value is evaluated at the time of the statement and stored in the table's metadata, to be returned whenever an existing row is accessed. Because this value is only actually applied once the table is rewritten, ALTER TABLE runs very fast even on a large table.

-- No rewrite: only metadata is updated (since PostgreSQL 11)
ALTER TABLE orders ADD COLUMN channel text DEFAULT 'WEB' NOT NULL;

This behavior was introduced in PostgreSQL 11. If you are running version 10 or earlier, the same statement rewrites the entire table, so never run it as-is there.

The documentation also lists the cases that do trigger a rewrite. Adding a volatile DEFAULT (for example, clock_timestamp()), a stored generated column, an identity column, or a column of a domain type with a constraint rewrites the table and every index on it.

Type changes work the same way. Changing the type of an existing column normally rewrites the entire table and its indexes. As an exception, no rewrite is needed when the USING clause does not change the column's contents and the old type is either binary-coercible to the new type, or an unconstrained domain over the new type.

Warning: ALTER TABLE ... ALTER COLUMN ... TYPE rewrites the entire table, in most cases while holding an ACCESS EXCLUSIVE lock. On a 100-million-row table, that blocks even reads completely for tens of minutes. The safe alternative is an expand-contract procedure: add a new column, backfill it, cut the application over, and only then drop the old column. Relaxing only a length constraint — for instance widening varchar(50) to varchar(100) — is handled without a rewrite, but narrowing it is a rewrite.

5. NOT VALID and VALIDATE — Splitting a Constraint into Two Phases

Adding a constraint is the best-organized area of zero-downtime schema change. The documentation spells out both the method and the reasoning.

Scanning a large table to verify a new foreign key, check, or NOT NULL constraint can take a long time, and other updates to that table are blocked until the ALTER TABLE ADD CONSTRAINT command commits. The main purpose of the NOT VALID constraint option is to reduce the impact adding a constraint has on concurrent updates. With NOT VALID, the ADD CONSTRAINT command does not scan the table and can commit immediately. After that, a VALIDATE CONSTRAINT command can verify that existing rows satisfy the constraint. This validation step does not need to block concurrent updates, because it already knows the constraint is being enforced on any row another transaction inserts or updates. Since only existing rows need to be checked, validation acquires only a SHARE UPDATE EXCLUSIVE lock on the target table.

-- Phase 1: commits immediately. The constraint is enforced starting with the next insert/update
ALTER TABLE order_items
  ADD CONSTRAINT fk_order_items_order
  FOREIGN KEY (order_id) REFERENCES orders (id) NOT VALID;

-- Phase 2: validate existing rows. Takes only SHARE UPDATE EXCLUSIVE, so it does not block reads/writes
ALTER TABLE order_items VALIDATE CONSTRAINT fk_order_items_order;

The same idea applies to adding NOT NULL. According to the documentation, SET NOT NULL normally scans the entire table during ALTER TABLE to check it. However, if a valid CHECK constraint already exists that proves no NULL can be present, the table scan is skipped.

-- Phase 1: register immediately with a NOT VALID CHECK
ALTER TABLE users
  ADD CONSTRAINT chk_users_email_nn CHECK (email IS NOT NULL) NOT VALID;

-- Phase 2: validate under a weak lock
ALTER TABLE users VALIDATE CONSTRAINT chk_users_email_nn;

-- Phase 3: SET NOT NULL now skips the full scan
ALTER TABLE users ALTER COLUMN email SET NOT NULL;

-- Phase 4: clean up the now-redundant CHECK constraint
ALTER TABLE users DROP CONSTRAINT chk_users_email_nn;

Phases 3 and 4 still take ACCESS EXCLUSIVE, but because there is no scan, they finish instantly. This is a strategy that shortens how long the strong lock is held, not one that lowers the lock level.

6. A Safe Execution Procedure — lock_timeout and Retries

Now we return to the queueing problem from section 1. Even a DDL statement that holds ACCESS EXCLUSIVE only briefly will, if it waits a long time just to acquire that lock, block every request behind it for that entire wait.

The fix is: "try briefly, give up if you cannot get it, and try again a little later." The tool for this is lock_timeout. The documentation defines it as aborting any statement that waits longer than the specified duration while attempting to acquire a lock on a table, index, row, or other database object, and its default is 0, meaning the timeout is disabled. On default settings, that means a DDL statement waits indefinitely.

-- A template for running DDL safely
BEGIN;
SET LOCAL lock_timeout = '3s';
SET LOCAL statement_timeout = '30s';

ALTER TABLE orders ADD COLUMN channel text DEFAULT 'WEB' NOT NULL;

COMMIT;

Using SET LOCAL applies the setting only to this transaction, and it reverts automatically on commit or rollback. If the lock cannot be acquired within 3 seconds, the statement ends in an error, so it never blocks the queue for long. On failure, retry a few seconds later — it succeeds the moment traffic happens to thin out for an instant.

Build a check for long-lived transactions into the procedure too, before you run the DDL.

-- A check to run right before DDL: transactions open 5 minutes or longer
SELECT pid, state, now() - xact_start AS xact_age,
       wait_event_type, left(query, 80) AS query
FROM pg_stat_activity
WHERE xact_start IS NOT NULL
  AND now() - xact_start > interval '5 minutes'
ORDER BY xact_start;

Turning on log_lock_waits logs an entry whenever a lock wait exceeds deadlock_timeout (default 1 second). Checking this log after a deploy lets you catch, after the fact, the deploy that "looked fine but actually blocked things for 3 seconds."

7. Zero-Downtime Work on Indexes and Partitions

Warning: CREATE INDEX without CONCURRENTLY blocks writes to the target table until it finishes. In the documentation's words, it "locks out writes but not reads." REINDEX is worse — it takes ACCESS EXCLUSIVE, so it blocks reads too. On a production table, always use CREATE INDEX CONCURRENTLY and REINDEX INDEX CONCURRENTLY. Both commands take only SHARE UPDATE EXCLUSIVE.

CONCURRENTLY comes with three constraints.

First, it cannot run inside a transaction block. If your migration tool wraps every migration in a single transaction by default, it fails. Find the setting your tool provides to turn transactions off for this case.

Second, a failure leaves behind an invalid index. In the documentation's words, such an index "is ignored for querying purposes because it might be incomplete," but "it will still consume update overhead." Automate a check for this after every migration.

-- Automated post-deploy check: invalid indexes
SELECT c.relname AS index_name, t.relname AS table_name
FROM pg_index i
JOIN pg_class c ON c.oid = i.indexrelid
JOIN pg_class t ON t.oid = i.indrelid
WHERE NOT i.indisvalid;

Third, it cannot be used directly on a partitioned table. The documentation states that concurrent index creation on a partitioned table is currently not supported, and offers a workaround: build the index on each partition individually with CONCURRENTLY, then build it non-concurrently on the parent last — this reduces how long writes stay locked.

Detaching a partition has a concurrent mode too. ALTER TABLE ... DETACH PARTITION ... CONCURRENTLY runs, in the documentation's words, with a reduced lock level so as not to block other sessions accessing the partitioned table. This option is central to any retention policy that periodically detaches old partitions.

8. Gating Migrations

Leaving the rules covered so far to human memory means they eventually get bypassed. It is better to put a gate in code review and CI.

As a checklist:

  • Does every CREATE INDEX / REINDEX / DROP INDEX on a production table have CONCURRENTLY attached?
  • Does every DDL statement have SET LOCAL lock_timeout in front of it?
  • Is adding a constraint split into the two NOT VALID + VALIDATE CONSTRAINT phases?
  • Does it avoid a column type change? (If one is present, replace it with expand-contract.)
  • Is a column drop kept out of the same release as the deploy? (An older application instance may still reference that column.)
  • Is the default on every ADD COLUMN non-volatile?
  • Is the migration free of being wrapped as one giant transaction?

One more note on dropping columns. ALTER TABLE ... DROP COLUMN does not actually erase the data — it just hides the column — so it is fast. But during a rolling deploy where an older application instance is still alive, that instance's SELECT * breaks. Always push a column drop to a release after the application deploy has fully finished.

You need a rollback plan ready too. Rolling back a schema change is not "the reverse DDL" — it is "the reverse DDL plus handling whatever data accumulated in between." If you drop a column and then bring it back, the data from in between is gone. That is why the rule is to always push destructive changes to the very last step.

Quiz: Check Your Understanding

Quiz 1: The DDL below finished in one second, but the service froze for 40 seconds. Why?
ALTER TABLE orders ADD COLUMN memo text;

Answer: The DDL itself was fast, but while it waited to acquire the ACCESS EXCLUSIVE lock, every query that arrived behind it piled up in the queue.

Explanation: ALTER TABLE ADD COLUMN requires an ACCESS EXCLUSIVE lock, and this lock also conflicts with ACCESS SHARE, the lock an ordinary SELECT takes. If a 40-second report query happened to be running at the moment the DDL ran, the DDL waits for it to finish. And while the DDL sits in the queue, every newly arriving request lines up behind it too. The fix is to try briefly with SET LOCAL lock_timeout = '3s' and retry on failure, and to check for long-lived transactions in pg_stat_activity before running the DDL.

Quiz 2: You need to add a NOT NULL constraint to a 100-million-row table. What is the sequence that locks it for the shortest time?

Answer: Create a NOT VALID CHECK constraint first, validate it, and only then apply SET NOT NULL.

Explanation: Running ALTER TABLE ... SET NOT NULL directly scans the entire table while holding an ACCESS EXCLUSIVE lock. According to the documentation, this scan is skipped if a valid CHECK constraint already exists that proves no NULL can be present.

ALTER TABLE users ADD CONSTRAINT chk_email_nn
  CHECK (email IS NOT NULL) NOT VALID;
ALTER TABLE users VALIDATE CONSTRAINT chk_email_nn;
ALTER TABLE users ALTER COLUMN email SET NOT NULL;
ALTER TABLE users DROP CONSTRAINT chk_email_nn;

Phase 1 commits immediately with no scan; phase 2 takes only SHARE UPDATE EXCLUSIVE, so it blocks neither reads nor writes; phase 3 takes ACCESS EXCLUSIVE, but with no scan it finishes instantly. This does not lower the lock level — it shrinks how long the strong lock is held down to milliseconds.

Quiz 3: Which of the two statements below rewrites the table?
-- A
ALTER TABLE events ADD COLUMN created_by text DEFAULT 'system' NOT NULL;
-- B
ALTER TABLE events ADD COLUMN created_at timestamptz DEFAULT clock_timestamp() NOT NULL;

Answer: B rewrites the table.

Explanation: The documentation states that ADD COLUMN with a non-volatile DEFAULT stores the default value in the table metadata and does not rewrite the table. A's 'system' is a constant, so it is non-volatile. B's clock_timestamp(), on the other hand, is a volatile function, so its value must differ per row, and that rewrites the table and every index on it. For the same purpose, it is safer to use now() (fixed to the transaction's start time, a stable function), or to add the column with no default, backfill it in chunks, and only apply the default and NOT NULL at the end.

Quiz 4: CREATE INDEX CONCURRENTLY keeps failing in your migration tool, with the error "cannot run inside a transaction block."

Answer: Because the migration tool wraps each migration in a transaction.

Explanation: The documentation states that a plain CREATE INDEX command can be performed within a transaction block, but CREATE INDEX CONCURRENTLY cannot. That is because CONCURRENTLY uses several transactions internally. Most tools provide a setting to run just that one migration outside a transaction. And running outside a transaction also means there is no automatic rollback on failure. A failure leaves an invalid index behind, so make sure you automate a check for invalid indexes after every migration.

Quiz 5: You attach a new monthly partition to a partitioned table that has a DEFAULT partition, and the service freezes every single time. Why?

Answer: Because ATTACH PARTITION takes an ACCESS EXCLUSIVE lock on the DEFAULT partition.

Explanation: According to the documentation, ATTACH PARTITION takes a SHARE UPDATE EXCLUSIVE lock on the parent table, but it takes an ACCESS EXCLUSIVE lock on the table being attached and on the DEFAULT partition (if one exists). On top of that, a scan is required to confirm the DEFAULT partition has no rows overlapping the new partition's range. If the DEFAULT partition is large, this scan takes a long time, and every access to the DEFAULT partition is blocked for the duration. There are two responses. Either do not keep a DEFAULT partition at all — create partitions generously ahead of time instead — or keep the DEFAULT partition permanently empty. The documentation itself recommends, for a DEFAULT partition, creating a CHECK constraint that excludes the constraint of the partition being attached, to avoid the unnecessary scan.

Conclusion

Zero-downtime schema change is not a tooling problem — it is a problem of knowing the lock level. Cases that actually need a tool like gh-ost are fewer than people think, and in PostgreSQL, most changes can be handled safely using nothing but the methods written in the documentation. NOT VALID and VALIDATE CONSTRAINT, CONCURRENTLY, and SET LOCAL lock_timeout — these three replace most of what a tool would otherwise do for you.

The rule worth leaving with your team fits in one sentence: run every DDL statement knowing which lock it takes and for how many seconds. If you do not know, turn on log_lock_waits in staging, measure it, and only then run it.

If you want to check a migration order visually, use the DB Migration Explorer; if you want to run DDL yourself and watch what happens, use the PostgreSQL Playground.

References

Further reading