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

- Name
- Youngju Kim
- @fjvbn20031
- 들어가며
- 1. 무중단이 깨지는 진짜 이유는 잠금 대기열이다
- 2. 여덟 가지 테이블 잠금 모드
- 3. ALTER TABLE 형태별 잠금 등급
- 4. 재작성을 유발하는 변경과 그렇지 않은 변경
- 5. NOT VALID와 VALIDATE — 제약을 두 단계로
- 6. 안전 실행 절차 — lock_timeout과 재시도
- 7. 인덱스와 파티션의 무중단 작업
- 8. 마이그레이션을 게이트로 막기
- 퀴즈: 실력을 확인해 보세요
- 마치며
- 참고 자료
- 이어서 읽기
들어가며
이 블로그에는 무중단 스키마 변경에 관한 글이 이미 여럿 있습니다. 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초 동안 모든 신규 요청이 막히는 상황이 만들어집니다.
이 구조를 이해하면 무중단 스키마 변경의 원칙 세 가지가 자연히 따라옵니다.
- 강한 잠금을 요구하는 DDL은 가능한 한 피하고, 피할 수 없으면 최대한 짧게 만든다.
- DDL 실행 전에 장수 트랜잭션이 없는지 확인한다.
- DDL에는 반드시
lock_timeout을 걸어 대기열을 오래 막지 않게 한다.
2. 여덟 가지 테이블 잠금 모드
PostgreSQL의 테이블 수준 잠금은 여덟 개입니다. 약한 것부터 강한 것 순서이며, 충돌 관계는 문서의 표 그대로입니다.
| 잠금 모드 | 충돌하는 모드 |
|---|---|
| ACCESS SHARE | ACCESS EXCLUSIVE |
| ROW SHARE | EXCLUSIVE, ACCESS EXCLUSIVE |
| ROW EXCLUSIVE | SHARE, SHARE ROW EXCLUSIVE, EXCLUSIVE, ACCESS EXCLUSIVE |
| SHARE UPDATE EXCLUSIVE | SHARE UPDATE EXCLUSIVE, SHARE, SHARE ROW EXCLUSIVE, EXCLUSIVE, ACCESS EXCLUSIVE |
| SHARE | ROW EXCLUSIVE, SHARE UPDATE EXCLUSIVE, SHARE ROW EXCLUSIVE, EXCLUSIVE, ACCESS EXCLUSIVE |
| SHARE ROW EXCLUSIVE | ROW EXCLUSIVE, SHARE UPDATE EXCLUSIVE, SHARE, SHARE ROW EXCLUSIVE, EXCLUSIVE, ACCESS EXCLUSIVE |
| EXCLUSIVE | ROW SHARE 이하를 제외한 거의 전부 |
| ACCESS EXCLUSIVE | 모든 모드 |
실무에서 외워야 할 것은 두 줄입니다.
ACCESS SHARE는 평범한SELECT가 잡는 잠금이고, 오직ACCESS EXCLUSIVE하고만 충돌합니다. 즉ACCESS EXCLUSIVE를 잡지 않는 DDL은 읽기를 막지 않습니다.ROW EXCLUSIVE는INSERT,UPDATE,DELETE,MERGE가 잡는 잠금입니다. 이것과 충돌하는 등급(SHARE이상)을 잡는 DDL은 쓰기를 막습니다.
정리하면 이렇게 됩니다. SHARE UPDATE EXCLUSIVE까지는 읽기와 쓰기를 모두 통과시킵니다. SHARE ROW EXCLUSIVE부터 쓰기가 막히고, ACCESS EXCLUSIVE에서 읽기까지 막힙니다.
문서가 명시한 명령별 잠금은 다음과 같습니다.
- ACCESS EXCLUSIVE —
DROP TABLE,TRUNCATE,REINDEX,CLUSTER,VACUUM FULL,CONCURRENTLY없는REFRESH MATERIALIZED VIEW, 그리고 상당수의ALTER TABLE과ALTER INDEX형태 - SHARE UPDATE EXCLUSIVE —
FULL없는VACUUM,ANALYZE,CREATE INDEX CONCURRENTLY,REINDEX CONCURRENTLY,CREATE STATISTICS,COMMENT ON, 일부ALTER TABLE형태 - SHARE ROW EXCLUSIVE —
CREATE TRIGGER, 일부ALTER TABLE형태 - ROW EXCLUSIVE —
UPDATE,DELETE,INSERT,MERGE
3. ALTER TABLE 형태별 잠금 등급
문서의 원문은 명확합니다. "각 하위 형태마다 필요한 잠금 등급이 다를 수 있다. 명시적으로 언급되지 않은 경우 ACCESS EXCLUSIVE 잠금을 획득한다."
즉 기본값이 최악입니다. 예외만 외우면 됩니다. 문서가 더 낮은 등급을 명시한 형태들입니다.
| ALTER TABLE 형태 | 잠금 등급 |
|---|---|
SET STATISTICS | SHARE UPDATE EXCLUSIVE |
SET (...) / RESET (...) 컬럼별 옵션 변경 | SHARE UPDATE EXCLUSIVE |
CLUSTER ON / SET WITHOUT CLUSTER | SHARE UPDATE EXCLUSIVE |
VALIDATE CONSTRAINT | SHARE UPDATE EXCLUSIVE |
ATTACH PARTITION (부모 테이블에 대해) | SHARE UPDATE EXCLUSIVE |
ADD FOREIGN KEY | SHARE ROW EXCLUSIVE |
ENABLE / DISABLE TRIGGER | SHARE ROW EXCLUSIVE |
| 그 밖의 모든 형태 | ACCESS EXCLUSIVE |
몇 가지 중요한 세부가 있습니다.
ADD FOREIGN KEY는 문서가 특별히 짚어 둔 예외입니다. "대부분의 ADD table_constraint 형태는 ACCESS EXCLUSIVE 잠금을 요구하지만, ADD FOREIGN KEY는 SHARE 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 CONCURRENTLY와REINDEX 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 INDEX에CONCURRENTLY가 붙어 있는가 - 모든 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 COLUMN은 ACCESS 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 VALID와 VALIDATE CONSTRAINT, CONCURRENTLY, SET LOCAL lock_timeout. 이 세 가지가 도구 대부분을 대체합니다.
팀에 남길 규칙은 한 문장으로 요약됩니다. 모든 DDL은 자신이 어떤 잠금을 몇 초 동안 잡는지 아는 상태로 실행한다. 모르면 스테이징에서 log_lock_waits를 켜고 측정한 뒤에 실행합니다.
마이그레이션 순서를 시각적으로 점검하고 싶다면 DB 마이그레이션 탐색기를, DDL을 직접 실행해 보려면 Postgres 놀이터를 활용하세요.
참고 자료
- PostgreSQL 18, ALTER TABLE: https://www.postgresql.org/docs/18/sql-altertable.html (2026-08-15 확인)
- PostgreSQL 18, Explicit Locking: https://www.postgresql.org/docs/18/explicit-locking.html (2026-08-15 확인)
- PostgreSQL 18, CREATE INDEX: https://www.postgresql.org/docs/18/sql-createindex.html (2026-08-15 확인)
- PostgreSQL 18, Client Connection Defaults: https://www.postgresql.org/docs/18/runtime-config-client.html (2026-08-15 확인)
- PostgreSQL 18, Lock Management: https://www.postgresql.org/docs/18/runtime-config-locks.html (2026-08-15 확인)
- PostgreSQL 18, Table Partitioning: https://www.postgresql.org/docs/18/ddl-partitioning.html (2026-08-15 확인)
- PostgreSQL 18, REFRESH MATERIALIZED VIEW: https://www.postgresql.org/docs/18/sql-refreshmaterializedview.html (2026-08-15 확인)
이어서 읽기
- 이전 편: 트랜잭션 격리 수준 완전 가이드 — 격리 수준의 운영 계약
- 다음 편: 파티셔닝과 샤딩 완전 가이드 — 한 노드의 한계를 넘는 순서
- Expand-Contract 패턴 — 변경을 단계로 쪼개는 설계
- 대용량 테이블 온라인 스키마 변경 — gh-ost와 pt-online-schema-change
- 마이그레이션 롤백과 검증 — 안전망 설계
- DB 마이그레이션 탐색기 — 마이그레이션 순서 점검
- Postgres 놀이터 — DDL 잠금 실험