본문으로 건너뛰기

"bloat" 태그로 연결된 2개 게시물개의 게시물이 있습니다.

모든 태그 보기

PG19 REPACK 온라인 재구성

· 약 15분

PostgreSQL 19에 REPACK 명령이 추가됐고, CONCURRENTLY 옵션을 사용하면 읽기와 쓰기를 막지 않고 테이블을 다시 쓸 수 있어요. 지금까지 운영 중 bloat 정리는 확장 도구인 pg_repack에 맡기는 경우가 많았는데, 이제 코어가 그 역할을 맡게 됐어요. 이 글에서는 공식 개발 문서와 commit으로 동작을 확인하고, 19beta4 컨테이너에서 VACUUM FULL, REPACK (CONCURRENTLY), pg_repack을 같은 부하 조건에서 비교해 봤어요.

VACUUM FULL과 CLUSTER, 그리고 pg_repack​

PostgreSQL 18까지 테이블 파일 전체를 다시 쓰는 명령은 두 가지였습니다. VACUUM FULL은 dead tuple을 제외한 행만 새 파일로 옮기고, CLUSTER는 같은 작업을 하면서 지정한 인덱스 순서로 행을 정렬합니다. 두 명령 모두 작업하는 동안 ACCESS EXCLUSIVE 잠금을 유지하므로 해당 테이블에는 SELECT도 들어가지 못합니다.

이름도 혼란스러웠습니다. VACUUM FULL은 이름에 vacuum이 들어가지만 일반 vacuum과 전혀 다른 작업을 합니다. CLUSTER는 IT 전반과 PostgreSQL 안에서 이미 "database cluster"라는 뜻으로 쓰이는 단어입니다. REPACK을 도입한 commit ac58465e0의 메시지도 이 두 가지를 이유로 제시합니다.

REPACK absorbs the functionality of VACUUM FULL and CLUSTER in a single command. Because this functionality is completely different from regular VACUUM, having it separate from VACUUM makes it easier for users to understand; as for CLUSTER, the term is heavily overloaded in the IT world and even in Postgres itself, so it's good that we can avoid it.

운영 중에 잠금 없이 테이블을 재구성하려면 지금까지는 코어 외부의 도구를 써야 했습니다. 대표적인 도구가 pg_repack입니다. 트리거와 로그 테이블로 변경 사항을 추적하면서 새 테이블을 만든 뒤 마지막에 교체합니다. dbalog의 VACUUM과 autovacuum 장과 인덱스 BLOAT와 REINDEX CONCURRENTLY 장도 운영 중 VACUUM FULL 대신 pg_repack을 쓰라고 안내합니다. PostgreSQL 19의 REPACK (CONCURRENTLY)는 이 역할을 코어 기능으로 제공하려는 시도입니다. 설계와 코드 대부분은 Antonin Houska(안토닌 하우스카)가 만든 서드파티 확장 pg_squeeze에 기반한다고 commit 28d534e2a에 적혀 있습니다.

REPACK 문법​

공식 개발 문서의 synopsis는 다음과 같습니다.

REPACK [ ( option [, ...] ) ] [ table_and_columns [ USING INDEX [ index_name ] ] ]
REPACK [ ( option [, ...] ) ] USING INDEX

-- option:
-- VERBOSE [ boolean ]
-- ANALYZE [ boolean ]
-- CONCURRENTLY [ boolean ]

기존 명령과의 대응은 단순합니다. USING INDEX가 없으면 VACUUM FULL, 있으면 CLUSTER입니다.

REPACK t; -- VACUUM FULL t 와 같은 재작성
REPACK t USING INDEX t_pkey; -- CLUSTER t USING t_pkey 와 같은 재작성
REPACK (CONCURRENTLY) t; -- 교체 순간에만 ACCESS EXCLUSIVE
REPACK (CONCURRENTLY, ANALYZE) t; -- 재작성 후 ANALYZE까지

VACUUM FULL과 CLUSTER는 호환성을 위해 남아 있고, 문서에서는 비중이 줄었습니다. 19beta4에서 CLUSTER t USING t_pkey와 VACUUM FULL은 그대로 동작했습니다. 진행 상황을 보여 주는 뷰로 pg_stat_progress_repack이 새로 생겼습니다. VACUUM FULL로 실행해도 이 뷰의 command 컬럼에는 VACUUM FULL로 표시됐습니다. 기존 pg_stat_progress_cluster는 하위 호환용으로만 남아 있다고 문서에 적혀 있습니다.

테이블 이름 없이 REPACK만 실행하면 현재 사용자가 MAINTAIN 권한을 가진 모든 테이블과 materialized view를 처리합니다. REPACK USING INDEX만 실행하면 clustering 인덱스가 지정된 테이블이 대상입니다.

CONCURRENTLY 동작 구조​

CONCURRENTLY 없이 실행하면 VACUUM FULL과 같은 방식으로 동작해 처음부터 끝까지 ACCESS EXCLUSIVE를 유지합니다. CONCURRENTLY를 사용하면 흐름이 달라집니다. commit 28d534e2a의 설명을 정리하면 다음과 같습니다.

  1. 원본 테이블에 SHARE UPDATE EXCLUSIVE만 획득합니다. 일반 vacuum과 같은 잠금이므로 DML과 충돌하지 않습니다.
  2. 임시 logical replication slot을 만들고, 그 시점의 MVCC snapshot을 기준으로 기존 행을 새 파일에 복사합니다.
  3. 별도의 background worker(REPACK decoding worker)가 slot에서 logical decoding을 진행하며 복사 중에 발생한 변경을 수집합니다.
  4. 새 파일에 인덱스를 만든 뒤, 수집한 변경을 새 파일에 다시 적용합니다(catch-up).
  5. 마지막으로 ACCESS EXCLUSIVE를 획득해 남은 변경을 적용하고 relfilenode를 교체합니다.

19beta4에서 0.25초 간격으로 pg_stat_activity, pg_locks, pg_replication_slots를 샘플링하면 이 흐름을 그대로 확인할 수 있습니다. 실행 중에는 pg_repack_735처럼 pg_repack_ 접두사가 붙은 temporary logical slot이 생겼고, 명령이 끝나면 사라졌습니다. 확장 도구 pg_repack과 이름이 겹칩니다. 모니터링 쿼리에서 slot 이름으로 구분할 때 혼동하지 않도록 주의해야 합니다. 백엔드 목록에서는 backend_type이 REPACK decoding worker인 프로세스가 별도로 실행되고 있었습니다.

wal_level도 달라집니다. 설정값이 기본값 replica인 상태에서 REPACK (CONCURRENTLY)를 실행하자 effective_wal_level이 실행 중에는 logical로 올라갔다가, 끝난 뒤에는 replica로 돌아왔습니다. REPACK도 PostgreSQL 19의 동적 wal_level 동작(PG19 동적 wal_level 참고)을 그대로 따릅니다. 이는 명령 실행 중에 클러스터 전체의 DML이 logical decoding용 정보를 WAL에 추가로 기록한다는 뜻이기도 합니다. wal_level이 replica보다 낮으면 실행할 수 없습니다.

측정 환경​

측정은 macOS 호스트의 Docker VM(2 CPU, 약 12GB 메모리, Linux 6.8 커널)에서 postgres:19beta4 이미지로 진행했습니다. 컨테이너는 --cpus 2 --memory 2g로 제한했고, shared_buffers=256MB, max_wal_size=4GB, checkpoint_timeout=30min만 변경했습니다. 이 VM은 다른 작업과 공유되고 있어 TPS 절대값은 회차마다 크게 달라졌습니다. 아래 수치는 방식 간 경향을 확인하는 용도로만 읽어 주세요.

테스트 테이블은 다음과 같이 만들었습니다. 400만 행을 넣고 75%를 지운 뒤 일반 VACUUM만 실행했습니다. 파일 크기는 그대로이고 살아 있는 행은 100만 개인 상태입니다.

CREATE TABLE t (id int PRIMARY KEY, v int NOT NULL DEFAULT 0, pad text NOT NULL)
WITH (autovacuum_enabled = off);
INSERT INTO t SELECT g, 0, repeat(md5(g::text), 3) FROM generate_series(1, 4000000) g;
DELETE FROM t WHERE id % 4 <> 0;
VACUUM (ANALYZE) t;

재작성 전 heap은 539MB, 인덱스는 86MB였고, 세 방식 모두 재작성 후 heap 137~140MB, 인덱스 21MB로 줄었습니다. 부하는 pgbench 4개 클라이언트가 남은 행을 무작위로 UPDATE하고 SELECT하는 스크립트를 45초 동안 실행하는 방식으로 생성했습니다. 시작 10초 뒤에 각 명령을 실행했습니다. 방식마다 테이블을 새로 만들어 3회씩 반복했습니다. pg_repack은 PGDG apt 저장소의 postgresql-19-repack 1.5.3 패키지를 컨테이너에 설치해 측정했습니다. pg_repack 공식 문서에는 지원 범위가 PostgreSQL 18까지로 적혀 있지만, 이 패키지는 19beta4에서 설치하고 실행할 수 있었습니다.

잠금 범위​

pgbench 부하 중 세 방식의 결과입니다. 값은 3회 측정값을 순서대로 적었습니다.

방식소요 시간(초)pgbench 최대 지연(ms)명령 구간 WAL(MB)
VACUUM FULL1.24 / 1.24 / 1.521,208 / 1,216 / 1,481154 / 154 / 154
REPACK (CONCURRENTLY)5.30 / 1.47 / 1.43132 / 63 / 122181 / 178 / 176
pg_repack 1.5.32.54 / 2.35 / 1.53167 / 102 / 46181 / 180 / 179

VACUUM FULL은 소요 시간이 가장 짧았지만, pgbench의 최대 지연은 명령 소요 시간과 거의 같았습니다. 샘플링 결과에서도 VACUUM FULL이 ACCESS EXCLUSIVE를 보유하는 동안 pgbench 세션 4개가 모두 Lock 대기 상태였습니다. 3회차에는 1초 동안 처리한 트랜잭션이 32건까지 줄어든 구간이 있었습니다. 또한 VACUUM FULL이 ACCESS EXCLUSIVE를 기다리는 동안 pgbench 세션 2개가 그 뒤에서 대기하는 샘플도 관찰됐습니다.

REPACK (CONCURRENTLY)는 전체 샘플 구간에서 SHARE UPDATE EXCLUSIVE만 보였고, Lock 대기 중인 pgbench 세션은 0개였습니다. 교체 순간의 ACCESS EXCLUSIVE는 0.25초 간격의 샘플에서 한 번도 관찰되지 않을 만큼 짧았습니다. 최대 지연 63~132ms는 잠금 대기보다 2 CPU VM에서 복사 작업과 CPU를 나눠 쓴 영향으로 보입니다. 다만 이번 측정으로 원인을 분리하지는 못했습니다. 1회차만 5.30초가 걸렸는데, 샘플상 seq scanning heap 단계가 다른 회차보다 길었습니다. 공유 VM이라 이 차이의 원인도 확인하지 못했습니다.

pg_repack도 pgbench를 멈추지 않았고, 최대 지연은 REPACK (CONCURRENTLY)와 비슷한 범위였습니다. pg_repack 문서에 따르면 초기 설정과 마지막 교체 단계에서 짧게 ACCESS EXCLUSIVE를 획득하고, 나머지 구간에서는 SHARE UPDATE EXCLUSIVE를 유지합니다. 이번 샘플링에서는 복사 세션의 AccessShareLock만 관찰됐습니다.

WAL과 디스크 공간​

부하 중 WAL 양에는 pgbench가 생성한 WAL이 섞여 있습니다. 명령 없이 pgbench만 실행한 구간은 3초에 약 23MB였습니다. 명령 자체의 WAL을 확인하려고 부하 없이 한 번씩 더 실행하고 pg_waldump --stats로 구성을 확인했습니다. 크기는 pg_size_pretty()와 같은 1024 단위로 적었습니다.

방식명령 구간 WAL주요 레코드
VACUUM FULL154MBXLOG(full page image) 628건, 약 153MB
REPACK (CONCURRENTLY)173MBHeap 1,000,067건, 약 153MB / XLOG FPI 약 19MB
pg_repack 1.5.3173MBHeap 1,000,169건, 약 153MB / XLOG FPI 약 19MB

VACUUM FULL은 새 heap을 페이지 이미지 단위로 WAL에 기록했습니다. 반면 REPACK (CONCURRENTLY)는 이번 측정에서 행 100만 개를 Heap 레코드 100만 건으로 하나씩 기록했습니다. 레코드 구성은 INSERT ... SELECT로 복사하는 pg_repack과 거의 같았습니다. 그 결과 무부하 기준 WAL은 VACUUM FULL보다 약 12% 많았습니다. 행 폭이 다르면 이 비율도 달라질 것으로 보입니다. 다만 이번에는 행 하나당 약 100바이트인 테이블 하나만 측정했습니다. standby나 WAL archive가 연결된 환경이라면 이 차이가 전송량과 보관량으로 이어집니다.

디스크 공간은 원본과 새 파일이 잠시 함께 존재하므로 기존 명령과 마찬가지로 넉넉해야 합니다. 문서는 인덱스 스캔이나 정렬이 없는 순차 스캔이면 테이블 크기와 인덱스 크기의 합만큼 여유 공간이 필요하다고 설명합니다. 순차 스캔 후 정렬이면 테이블 크기의 최대 두 배와 인덱스 크기만큼 필요합니다. CONCURRENTLY는 복사 중 들어온 변경을 새 파일에 반영할 수 있을 때까지 임시 파일에 따로 보관하므로 임시 공간을 조금 더 사용합니다.

logical slot이 WAL을 보존하는 문제도 있습니다. 개발 초기에는 slot의 restart_lsn이 가장 오래된 트랜잭션, 대개 REPACK 자신의 트랜잭션에 묶여 WAL이 필요 이상으로 남았습니다. commit 45b02984e는 WAL segment 경계를 넘을 때마다 slot을 전진시켜 실행 중에도 오래된 WAL을 재활용하도록 고쳤습니다. 이번 측정 규모에서는 이 동작을 따로 확인하지 않았습니다.

장점​

가장 큰 변화는 온라인 재구성에 확장 설치가 필요 없어졌다는 점입니다. pg_repack은 대상 데이터베이스에서 CREATE EXTENSION pg_repack을 실행해야 하고, 서버 버전에 맞는 클라이언트 바이너리도 따로 관리해야 합니다. 관리형 서비스라면 허용된 확장 목록에도 포함돼야 합니다. REPACK (CONCURRENTLY)는 SQL 한 줄로 실행할 수 있고, 필요한 권한도 테이블의 MAINTAIN 권한입니다. commit 5dbb63fc8에서 REPLICATION이나 LOGIN 속성 요구도 제거했습니다.

변경 추적 방식도 다릅니다. pg_repack은 원본 테이블에 트리거를 추가해 모든 DML을 로그 테이블에 한 번 더 기록하지만, REPACK은 이미 기록되는 WAL을 logical decoding으로 읽습니다. 진행 상황은 pg_stat_progress_repack의 단계와 카운터로 확인합니다. 전용 slot 풀(max_repack_replication_slots, 기본값 5)이 따로 있어 logical replication용 slot을 소진하지 않는다는 점도 운영에 편리합니다(max_repack_replication_slots 참고).

단점과 제약​

사용할 수 없는 테이블​

문서에 적힌 제약을 19beta4에서 하나씩 실행해 봤습니다. 모두 명시적인 오류와 함께 거부됐습니다.

대상19beta4 오류 메시지
identity index 없는 테이블Relation "nopk" has no identity index.
REPLICA IDENTITY FULLThis operation does not support tables with REPLICA IDENTITY FULL.
트랜잭션 블록 안REPACK (CONCURRENTLY) cannot run inside a transaction block
테이블 이름 생략REPACK (CONCURRENTLY) requires an explicit table name
파티션 테이블 부모REPACK (CONCURRENTLY) is not supported for partitioned tables
UNLOGGED 테이블This operation is only supported for permanent relations.
materialized viewThis operation is not supported for materialized views.
max_repack_replication_slots = 0REPACK can only be used if "max_repack_replication_slots" > 0

문서에는 조건이 "primary key와 index 기반 replica identity가 모두 없는 경우"로 적혀 있습니다. 실제로 primary key 없이 NOT NULL unique 인덱스를 REPLICA IDENTITY USING INDEX로 지정한 테이블은 통과했습니다. 파티션 테이블은 부모에서 거부되지만 파티션 하나를 지정하면 실행됐고, 오류 HINT도 파티션별로 실행하라고 안내합니다. 이 밖에도 시스템 카탈로그와 TOAST 테이블, heap이 아닌 access method, user_catalog_table 테이블은 문서상 제외 대상입니다. max_repack_replication_slots는 서버를 재시작해야 적용되는 파라미터입니다.

시작 단계의 대기​

initializing 단계에서는 logical decoding을 준비하면서 XID를 할당받은 트랜잭션이 모두 끝나기를 기다립니다. 다른 테이블을 UPDATE한 뒤 5초 동안 커밋하지 않는 세션을 열어 두고, 0.5초 뒤 작은 테이블에 REPACK (CONCURRENTLY)를 실행했습니다. 단독 실행 때 0.13초 걸리던 명령이 4.70초 걸렸습니다. 기다리는 동안 백엔드에는 initializing 단계에서 IPC / RepackWorkerExport 대기 이벤트가 나타났습니다. 대상 테이블과 관계없는 트랜잭션이라도 XID를 가진 채 오래 열려 있으면 REPACK 시작이 그만큼 늦어집니다. 문서도 이 단계가 명령 시작 시점에 실행 중이던 가장 긴 트랜잭션보다 오래 걸릴 수 있다고 설명합니다.

한 번에 하나만 진행​

PostgreSQL 19에서는 REPACK (CONCURRENTLY)가 클러스터 전체에서 한 번에 하나만 진행됩니다. 데이터베이스별 snapshot으로 이 제약을 풀려던 commit 0d3dba38c는 근본적인 결함이 발견돼 01a80f062로 되돌려졌고, 제약 해소는 다음 개발 주기로 넘어갔습니다. max_repack_replication_slots 기본값이 5여도 동시에 5개가 실행되지는 않습니다.

측정에서도 같은 결과가 나왔습니다. 큰 테이블 t에 REPACK (CONCURRENTLY)를 시작하고 0.4초 뒤 작은 테이블 t2에 같은 명령을 실행했습니다. t2는 오류 없이 initializing 단계에서 RepackWorkerExport를 기다리다가 t가 끝난 뒤 완료됐습니다. t2를 단독으로 실행했을 때는 0.13초였지만, 이때는 1.07초가 걸렸습니다. 여러 테이블을 정리하는 스크립트를 병렬로 실행해도 실제로는 직렬로 처리된다는 점을 고려해 일정을 세워야 합니다.

교체 단계의 잠금 대기​

마지막 교체에는 여전히 ACCESS EXCLUSIVE가 필요합니다. 대상 테이블을 읽은 뒤 4초 동안 트랜잭션을 열어 둔 세션을 만들고 REPACK (CONCURRENTLY)를 실행했습니다. REPACK은 catch-up 단계에서 ACCESS EXCLUSIVE를 기다렸습니다. 그 뒤에 들어온 단순 SELECT도 REPACK의 잠금 요청 뒤에서 2.04초를 기다렸습니다. 당시 pg_locks에는 부여되지 않은 AccessExclusiveLock 1건(REPACK)과 AccessShareLock 1건(새 SELECT)이 함께 나타났습니다. ALTER TABLE 같은 DDL에서 흔히 나타나는 잠금 대기열과 같은 형태입니다. 따라서 긴 트랜잭션이 있는지 먼저 확인하고 실행하는 습관은 여전히 필요합니다.

MVCC 안전성​

문서는 REPACK (CONCURRENTLY)를 TRUNCATE, 테이블을 재작성하는 ALTER TABLE과 함께 MVCC-safe하지 않은 명령으로 분류합니다(13.6절). 명령이 커밋되기 전에 snapshot을 얻었으며 그때까지 이 테이블에 접근하지 않은 트랜잭션은, 커밋 후 테이블이 비어 있는 것으로 볼 수 있습니다. 테이블에 한 번이라도 접근한 트랜잭션은 ACCESS SHARE를 보유합니다. REPACK의 교체는 이 트랜잭션이 끝날 때까지 기다리므로, 이런 트랜잭션은 해당하지 않습니다. REPEATABLE READ나 SERIALIZABLE로 긴 보고서 쿼리를 실행하는 시스템이라면 알아 둘 부분입니다. 이번 측정에서는 이 현상을 재현하지 않았습니다.

그 밖에 문서는 REPACK이 시작된 뒤 들어온 행은 USING INDEX 순서로 정렬되지 않으며, 실행 중 다른 트랜잭션이 DDL을 실행하면 REPACK이 실패할 수 있다고 설명합니다. 실행 도중 취소하면 어떻게 되는지도 확인했습니다. 실행 중 temporary logical slot이 생성된 것을 확인한 뒤 pg_cancel_backend()로 취소하자 slot은 남지 않았고 effective_wal_level도 replica로 돌아왔습니다.

pg_repack과 비교​

문서와 측정에서 확인한 차이를 정리하면 다음과 같습니다.

항목REPACK (CONCURRENTLY)pg_repack 1.5.3
설치코어 명령확장과 클라이언트 바이너리
변경 추적logical decoding (temporary slot)트리거와 로그 테이블
필요 키identity index (PK 또는 replica identity 인덱스)PK 또는 NOT NULL 컬럼의 unique 인덱스
파티션 테이블파티션 단위로만--parent-table 옵션
정렬 재작성USING INDEX--order-by, --no-order 등
wal_level 영향실행 중 effective_wal_level = logical없음
무부하 WAL (이번 측정)173MB173MB

어느 쪽이든 교체 순간의 ACCESS EXCLUSIVE와 원본 크기만큼의 추가 디스크는 피할 수 없습니다. PostgreSQL 19로 올린 뒤 바로 pg_repack을 제거하기보다는 파티션 테이블 일괄 처리(--parent-table)나 인덱스 병렬 재생성(--jobs)처럼 pg_repack에만 있는 기능을 사용하고 있는지 먼저 확인하는 것이 안전합니다.

진행 상황 확인 쿼리​

측정 중에는 아래 쿼리에서 command, phase 컬럼과 slot 목록, 잠금 집계를 사용했습니다. 나머지 컬럼과 조건은 문서를 기준으로 덧붙였습니다. 진행 단계와 처리량은 pg_stat_progress_repack에서 확인합니다.

SELECT pid, relid::regclass, command, phase,
heap_blks_scanned, heap_blks_total,
heap_tuples_inserted, heap_tuples_updated, heap_tuples_deleted
FROM pg_stat_progress_repack;

initializing에 오래 머물면 XID를 가진 긴 트랜잭션이나 먼저 실행 중인 다른 REPACK (CONCURRENTLY)가 있는지 확인합니다. catch-up에서 멈춰 있으면 교체용 잠금을 기다리는 중인지 확인합니다. 문서도 catch-up 단계가 ACCESS EXCLUSIVE 대기 시간을 포함한다고 설명합니다.

-- REPACK이 만든 temporary slot과 wal_level 상태
SELECT slot_name, slot_type, temporary, restart_lsn
FROM pg_replication_slots
WHERE slot_name LIKE 'pg\_repack\_%';

SHOW effective_wal_level;

-- 대상 테이블의 잠금 대기열
SELECT mode, granted, count(*)
FROM pg_locks
WHERE relation = 't'::regclass
GROUP BY 1, 2
ORDER BY 1;

버전별 차이​

REPACK은 PostgreSQL 19에 처음 들어온 명령이라 18 이하에는 없습니다. 18 이하에서는 VACUUM FULL, CLUSTER, pg_repack이 그대로 선택지입니다. 19 개발 주기에 확인한 주요 commit은 다음과 같습니다.

날짜(UTC)commit내용
2026-03-10ac58465e0REPACK 명령 추가
2026-04-0628d534e2aCONCURRENTLY 옵션 추가
2026-04-07e76d8c749max_repack_replication_slots 추가
2026-04-205dbb63fc8REPLICATION, LOGIN 요구 제거
2026-05-2401a80f062데이터베이스별 snapshot revert, 동시 1개 제약 유지
2026-05-272af1dc892실행 후 effective_wal_level이 logical에 머무는 문제 수정
2026-05-2945b02984e실행 중 오래된 WAL 재활용 허용

19beta4 발표(2026-09-24)에도 REPACK의 crash, invalid 인덱스와 materialized view 처리, 권한과 오류 메시지 수정이 포함돼 있습니다. 발표 이후에도 ADD COLUMN으로 테이블을 재작성하지 않고 추가된 컬럼을 처리하는 수정(45da2c1d7)과 REPACK 스트레스 테스트에서 발견된 초기 decoding snapshot 문제 수정(46024c573)이 이어졌습니다. 후자는 모든 브랜치에 있던 버그라고 commit 메시지에 적혀 있습니다. RC는 10월 초로 예고돼 있으니, 운영 적용 여부는 GA 버전의 릴리스 노트를 다시 확인한 뒤 판단하는 편이 좋습니다.

측정해 보니 부하가 있을 때의 잠금 영향은 pg_repack과 비슷했고, 설치 없이 SQL 한 줄로 끝난다는 점이 가장 크게 느껴졌어요. 다만 동시에 하나만 실행된다는 제약과 시작, 교체 단계의 대기는 GA 이후에도 계속 확인해 볼 생각이에요.

참고​

MVCC 엔진별 비용 비교

· 약 13분

PostgreSQL을 오래 운영하면 vacuum이 미워지는 날이 와요. dead tuple이 안 줄고, autovacuum은 계속 도는데 테이블은 커지고, VACUUM FULL은 락 때문에 걸 수 없어요. 그럴 때 검색하면 나오는 글이 CMU Andy Pavlo(앤디 파블로)의 The Part of PostgreSQL We Hate the Most와 Uber의 2016년 MySQL 전환기예요. 둘 다 틀린 말을 하지는 않아요.

그런데 이 비판들에는 공통으로 빠진 게 있습니다. 바로 비교 대상입니다. PostgreSQL의 MVCC가 나쁘다면, 나쁘지 않은 MVCC는 어디에 있는지 궁금해집니다.

이 질문을 직접 다룬 글이 최근 나왔습니다. boringSQL의 Radim Marek(라딤 마렉)이 쓴 PostgreSQL's MVCC is bad. So is everyone else's.입니다. 이 글은 그 논의를 출발점으로 삼되, 원문에 없는 두 가지를 채웁니다. 하나는 Docker에 PostgreSQL 18.4를 띄워 직접 재본 수치이고, 다른 하나는 엔진별로 같은 문제를 진단하는 쿼리입니다.

모든 MVCC 구현이 답해야 하는 네 가지 질문​

원문이 제시한 프레임이 좋아서 그대로 빌려 옵니다. 어떤 엔진이든 다중 버전을 구현하려면 네 가지를 결정해야 합니다.

  1. 옛 버전을 어디에 두는가
  2. 버전 체인은 어느 방향을 가리키는가
  3. 인덱스는 무엇을 가리키는가
  4. 누가, 언제 치우는가

PostgreSQL의 답은 이렇습니다. 옛 버전은 테이블 안에 두고, 체인은 과거에서 미래로 향하고, 인덱스는 물리 위치(ctid)를 가리키며, 청소는 백그라운드 프로세스가 나중에 합니다.

네 번째 질문이 특히 중요합니다. 청소를 나중에 하면 쓰레기가 눈에 보이고, 청소를 트랜잭션이 직접 하면 그 트랜잭션이 느려집니다. 어느 쪽도 공짜가 아닙니다.

PostgreSQL이 치르는 값을 직접 재봤다​

논의를 수치 없이 하면 감상이 됩니다. Docker에 PostgreSQL 18.4를 띄우고 직접 측정했습니다. 프로덕션 서버가 아닌 노트북 위 컨테이너라서, 절대값보다 비율을 보면 됩니다.

docker run -d --name mvcclab -e POSTGRES_PASSWORD=lab postgres:18

테이블은 100만 행이고, 컬럼은 id(PK)와 텍스트 4개, last_seen 타임스탬프로 구성했습니다. 이 중 10만 행을 UPDATE하면서 생성되는 WAL 양과 HOT update 비율을 측정했습니다. 측정 전마다 CHECKPOINT를 실행해 full page write 조건을 맞췄습니다.

write amplification은 인덱스 개수가 아니라 페이지 여유가 결정한다​

구성UPDATE 대상 컬럼생성 WALHOT 비율heap 변화
보조 인덱스 없음, fillfactor 100last_seen116 MB0%73 MB → 80 MB
보조 인덱스 4개, fillfactor 100last_seen218 MB0%73 MB → 80 MB
보조 인덱스 4개, fillfactor 70last_seen81 MB100%104 MB → 104 MB
보조 인덱스 4개, fillfactor 100c1 (인덱스 컬럼)228 MB0%73 MB → 81 MB

같은 10만 행 UPDATE에서 구성별 WAL 생성량과 HOT 비율 비교

두 번째 줄이 흔히 인용되는 그 비용입니다. 인덱스 컬럼을 건드리지도 않았는데 보조 인덱스 4개를 붙였다는 이유만으로 WAL이 116MB에서 218MB로 뜁니다. 새 tuple이 다른 페이지에 만들어지면 ctid가 바뀌고, 모든 인덱스가 그 새 위치를 따라가야 하기 때문입니다.

흥미로운 건 세 번째 줄입니다. 인덱스 4개를 그대로 둔 채 fillfactor만 70으로 낮추면 WAL이 81MB로 떨어집니다. 인덱스가 아예 없는 첫 번째 줄(116MB)보다도 적습니다. 페이지에 30% 여유가 생기니 새 버전이 같은 페이지 안에 들어가고, HOT update 조건이 성립해 인덱스를 아예 건드리지 않습니다. 원문 실험에서는 HOT 비율이 66%였는데, 이 실험은 행이 작아 100%가 나왔습니다.

정리하면 write amplification을 좌우하는 진짜 변수는 같은 페이지에 새 버전이 들어갈 자리가 남아 있느냐입니다. 인덱스를 줄이는 것보다 fillfactor를 조정하는 편이 대개 현실적입니다. 대신 heap이 73MB에서 104MB로 커집니다. 디스크를 미리 내주고 WAL과 인덱스 갱신을 아끼는 거래입니다.

네 번째 줄은 이 거래가 통하지 않는 경우입니다. 인덱스가 걸린 컬럼 자체를 바꾸면 HOT 조건이 깨지므로 fillfactor를 아무리 낮춰도 소용없습니다. 인덱스 크기도 107MB에서 129MB로 함께 부풉니다.

같은 UPDATE를 fillfactor 70 테이블에 네 번 반복해도 HOT 비율은 100%를 유지했고 WAL은 90MB 근처에서 평평했습니다. heap도 104MB에서 늘지 않았습니다. 여유 공간이 소진되어 HOT이 깨지는 지점은 이 워크로드에서는 오지 않았습니다.

bloat는 커밋했을 때와 롤백했을 때가 다르다​

원문에도 있는 실험인데, 실제로 돌려 보니 원문이 다루지 않은 갈래가 나왔습니다.

100만 행을 UPDATE한 뒤 ROLLBACK합니다.

BEGIN;
UPDATE t SET last_seen = now(); -- 2847 ms
ROLLBACK; -- 1.0 ms

롤백은 1밀리초에 끝납니다. PostgreSQL은 롤백할 때 되돌릴 게 없습니다. 새로 쓴 tuple을 "이 트랜잭션은 실패했다"고 표시하면 그만입니다. 상수 시간입니다. 그런데 테이블은 89MB에서 178MB로 두 배가 됐고 dead tuple이 100만 개 생겼습니다. 커밋하지 않은 작업이 디스크를 두 배로 쓴 것입니다.

여기서 갈립니다.

ROLLBACK 케이스: 89MB → 178MB → VACUUM → 89MB
COMMIT 케이스: 89MB → 178MB → VACUUM → 178MB

롤백한 경우에는 일반 VACUUM만으로 파일이 89MB로 돌아갑니다. 롤백으로 죽은 tuple은 전부 테이블 뒤쪽에 새로 붙은 것들이라 연속 구간을 이루고, vacuum이 꼬리를 잘라 OS에 반납하기 때문입니다.

커밋한 경우는 다릅니다. 죽는 건 테이블 앞쪽 여기저기에 흩어진 옛 버전입니다. vacuum은 그 자리를 재사용 가능하게 표시할 뿐 파일을 줄이지 못합니다. 178MB를 회수하려면 VACUUM FULL이 필요하고, 그건 ACCESS EXCLUSIVE 락을 잡습니다.

다만 이 178MB가 무한히 자라지는 않습니다. 같은 UPDATE를 두 번, 세 번 반복하고 매번 vacuum을 돌려도 파일은 178MB에서 멈췄습니다. vacuum이 표시해 둔 자리를 다음 UPDATE가 재사용하기 때문입니다. 전면 갱신 워크로드에서 bloat는 대략 두 배 지점으로 수렴합니다. 흔히 걱정하는 "방치하면 무한정 커진다"는 vacuum이 제때 못 도는 경우의 이야기이지, MVCC 구조 자체의 결론은 아닙니다.

열어 둔 트랜잭션 하나가 정리를 통째로 막는다​

PostgreSQL의 실패 모드 중 운영에서 가장 자주 만나는 것입니다. 다른 세션에서 트랜잭션을 열어 놓고 방치한 상태로 dead tuple을 만든 뒤 VACUUM VERBOSE를 실행했습니다.

tuples: 0 removed, 600000 remain, 400000 are dead but not yet removable
removable cutoff: 861, which was 2 XIDs old when operation ended

40만 개가 죽었는데 하나도 회수되지 않았습니다. dead but not yet removable, 이 문구가 나오면 원인은 거의 항상 누군가 오래 붙들고 있는 스냅샷입니다. 그 트랜잭션을 종료하고 다시 실행하면 이렇게 바뀝니다.

tuples: 400000 removed, 200000 remain, 0 are dead but not yet removable
index scan needed: 6897 pages from table (66.67% of total) had 400000 dead item identifiers removed

주의할 건 이 트랜잭션이 문제의 테이블을 건드리지 않아도 상관없다는 점입니다. backend_xmin을 잡고 있는 한 데이터베이스 전체의 정리가 그 지점에서 멈춥니다. 점심 먹으러 가면서 커밋하지 않고 자리를 뜬 세션 하나가 무관한 테이블의 vacuum을 막습니다.

다른 엔진은 이 비용을 어디로 보냈나​

여기까지가 PostgreSQL이 내는 청구서입니다. 다른 엔진이라고 비용을 피하지는 못합니다. 비용이 없는 게 아니라 다른 항목으로 냅니다.

Oracle과 InnoDB는 옛 버전을 테이블 밖 undo 영역에 둡니다. 테이블은 행마다 한 버전만 유지하니 깔끔하고, 보조 인덱스는 물리 위치 대신 논리 키를 가리키므로 위치 변경에 따라올 필요가 없습니다. vacuum도 없고 freeze 의식도 없습니다. 대신 세 가지가 따라옵니다. 우선 롤백이 정직하게 비쌉니다. PostgreSQL이 1밀리초에 끝낸 100만 행 롤백을 undo 방식은 한 건씩 되돌려야 합니다. 읽기도 비싸집니다. 옛 스냅샷을 보려면 undo 체인을 거슬러 올라가 행을 재구성해야 합니다. 그리고 오래 도는 읽기 쿼리가 undo를 소진하면 그 쿼리를 죽입니다. Oracle에서 20년 넘게 DBA를 괴롭혀 온 ORA-01555: snapshot too old가 바로 그것입니다.

이 대비가 중요합니다. PostgreSQL은 오래된 reader를 위해 쓰레기를 쌓아 두고 견딥니다. Oracle은 쓰레기를 정리하고 reader를 죽입니다. 어느 쪽이 나은지는 워크로드가 정합니다.

SQL Server는 기본값이 아예 MVCC가 아닙니다. 잠금으로 격리를 만들고, RCSI를 켜야 행 버전 관리가 시작됩니다. 그때 옛 버전은 tempdb의 version store로 갑니다. 문제는 tempdb가 인스턴스 전체 공용이라는 점입니다. 데이터베이스 하나에서 오래 열린 스냅샷이 tempdb를 부풀리면 같은 인스턴스의 다른 데이터베이스까지 함께 멈춥니다. 폭발 반경이 데이터베이스 경계를 넘습니다. Microsoft가 2019년 ADR을 내놓으며 버전을 사용자 데이터베이스로 되돌린 이유가 이것이고, 그 과정에서 얻으려 한 상수 시간 abort는 PostgreSQL이 처음부터 갖고 있던 성질입니다.

MongoDB의 WiredTiger는 버전을 메모리 캐시에 델타로 들고 있다가 캐시를 넘치면 WiredTigerHS.wt 히스토리 저장소로 흘려보냅니다. 디스크의 dead tuple은 없지만 캐시 압력이 대신 옵니다. eviction이 따라가지 못하면 애플리케이션 스레드가 직접 eviction 작업을 떠맡아 전체 노드가 느려집니다.

CockroachDB나 YugabyteDB 같은 LSM 계열은 버전을 타임스탬프가 붙은 키로 저장하고 compaction으로 정리합니다. "vacuum이 없다"고 홍보하지만 compaction이 곧 vacuum입니다. 대신 GC 윈도를 넘긴 읽기는 실패합니다. CockroachDB의 gc.ttlseconds 기본값은 오랫동안 25시간이었는데 스케줄 백업이 들어온 뒤 4시간으로 낮아졌으니, 버전에 따라 창이 얼마나 좁은지 확인하고 써야 합니다. 삭제가 많은 테이블에서 tombstone이 쌓여 빈 테이블 스캔이 점점 느려지는 것도 이 계열의 특징입니다.

Kubernetes를 쓰면 매일 만지는 etcd도 사실상 같은 구조입니다. (key, revision)으로 버전을 쌓고 수동 compaction으로 회수합니다. 회수된 revision을 다시 읽으려 하면 etcdserver: mvcc: required revision has been compacted가 나오고, 백엔드 쿼터 2GB를 넘기면 제어 평면 쓰기가 거부됩니다. defrag는 VACUUM FULL과 같은 자리에 있는 의식입니다.

엔진옛 버전 위치인덱스가 가리키는 것abort 비용오래 열린 reader의 결과
PostgreSQL heap테이블 안물리 위치 (ctid)상수 시간bloat 누적, vacuum 정지
Oracle / InnoDBundo 영역논리 키작업량 비례ORA-01555, undo 폭증
SQL Server RCSItempdb클러스터링 키상수 시간 (ADR 이후)tempdb 증가, 인스턴스 전체 위험
WiredTiger캐시, 히스토리 저장소RecordId상수 시간캐시 압력, 노드 지연
LSM 계열타임스탬프 키논리 키상수 시간GC 윈도 초과 오류

같은 사고, 엔진별로 다른 진단 쿼리​

위 표의 마지막 열은 결국 하나의 사건입니다. 누군가 트랜잭션을 오래 열어 둔 것입니다. 엔진마다 증상과 확인 방법이 다를 뿐입니다.

PostgreSQL에서는 backend_xmin을 봅니다. 아래 쿼리는 위 실험에서 실제로 범인을 찾는 데 사용한 것입니다.

SELECT pid, state,
backend_xmin,
age(backend_xmin) AS xid_age,
now() - xact_start AS tx_age,
left(query, 60) AS query
FROM pg_stat_activity
WHERE backend_xmin IS NOT NULL
ORDER BY age(backend_xmin) DESC;

주의할 게 있습니다. 범인이 pg_stat_activity에만 있는 게 아닙니다. 비활성 replication slot과 준비된 트랜잭션도 똑같이 xmin을 붙듭니다. 셋을 같이 확인해야 합니다.

-- 비활성 replication slot
SELECT slot_name, slot_type, active, xmin, catalog_xmin
FROM pg_replication_slots
WHERE NOT active OR xmin IS NOT NULL;

-- 잊혀진 2PC 트랜잭션
SELECT gid, prepared, owner FROM pg_prepared_xacts;

MySQL InnoDB에서 같은 증상은 history list length로 나타납니다.

SHOW ENGINE INNODB STATUS\G
-- TRANSACTIONS 절의 "History list length" 값을 확인한다

SELECT trx_id, trx_state,
TIMESTAMPDIFF(SECOND, trx_started, NOW()) AS age_sec,
trx_mysql_thread_id, LEFT(trx_query, 60) AS query
FROM information_schema.innodb_trx
ORDER BY trx_started;

history list length가 계속 늘면 purge가 밀리고 있다는 뜻이고, 원인은 PostgreSQL과 똑같이 오래 열린 트랜잭션입니다. 격리 수준을 READ COMMITTED로 낮추면 InnoDB가 유지해야 할 히스토리가 줄어 완화되는 경우가 있습니다.

Oracle에서는 undo 사용량과 retention을 봅니다.

SELECT begin_time, undoblks, maxquerylen, ssolderrcnt
FROM v$undostat
ORDER BY begin_time DESC
FETCH FIRST 12 ROWS ONLY;

ssolderrcnt가 ORA-01555 발생 횟수이고 maxquerylen이 가장 오래 돈 쿼리의 길이입니다. 이 둘이 함께 오르면 undo 보존 기간과 실제 쿼리 시간이 어긋나고 있다는 신호입니다.

SQL Server에서는 tempdb version store를 봅니다.

SELECT DB_NAME(database_id) AS db,
reserved_page_count,
reserved_space_kb / 1024 AS reserved_mb
FROM sys.dm_tran_version_store_space_usage
ORDER BY reserved_space_kb DESC;

이 값이 계속 오르면서 줄지 않으면 어딘가 스냅샷이 붙들려 있습니다. 인스턴스 공용 자원이므로 다른 데이터베이스 담당자도 같이 곤란해진다는 점에서 PostgreSQL보다 대응이 급합니다.

PostgreSQL 쿼리 세 개는 위 실험 환경에서 직접 실행해 확인했고, 나머지 엔진은 벤더 문서를 근거로 정리했습니다. 엔진을 옮겨도 물어야 할 질문은 하나로 같습니다. 지금 가장 오래 열려 있는 트랜잭션은 무엇이고, 그것이 무엇을 붙들고 있는지 물어야 합니다.

PostgreSQL만 하지 않는 선택 하나​

목록을 늘어놓다 보면 PostgreSQL이 특히 나빠 보이지만, 하나는 짚어 둘 만합니다. 위에 나열한 엔진 대부분은 어느 시점에 reader를 죽입니다. Oracle은 ORA-01555, WiredTiger는 타임스탬프 기반 거부, LSM 계열은 GC 윈도 초과, etcd는 compacted revision. FoundationDB는 아예 트랜잭션 수명을 5초로 강제합니다.

PostgreSQL은 기본 설정의 primary에서는 읽기 쿼리를 취소하지 않습니다(hot standby에서는 max_standby_streaming_delay, 기본 30s를 넘기면 recovery conflict로 취소합니다). 대신 그 쿼리가 볼지도 모르는 쓰레기를 계속 들고 있습니다. 결함으로 볼 일은 아닙니다. 선택입니다.

실제로 PostgreSQL도 한 번은 반대편을 시도했습니다. 9.6에 들어온 old_snapshot_threshold가 그것으로, 설정한 시간이 지나면 옛 스냅샷을 무효화하고 snapshot too old 오류를 던졌습니다. 그런데 이 기능은 vacuum이 아직 보이는 행을 지워 버릴 수 있는 정합성 문제를 안고 있었고, 고칠 계획이 서지 않은 채 시간이 흐르다 PostgreSQL 17에서 제거됐습니다. 더 나은 구현이 나오면 다시 들어올 여지는 남겨 뒀습니다.

즉 PostgreSQL은 다른 엔진의 실패 모드를 흉내 냈다가, 제대로 못 하겠으면 안 하는 쪽을 택했습니다.

남은 문제와 지금 진행 중인 것​

32비트 XID는 PostgreSQL만의 짐이 맞습니다. InnoDB의 DB_TRX_ID는 6바이트, 즉 48비트입니다. Oracle SCN도 원래 48비트였는데 12.2.0.1부터 compatible을 12.2로 올리면 상한이 2^63으로 넓어집니다. PostgreSQL은 여전히 32비트라 주기적으로 freeze를 돌려야 하고, 몇 달 동안 아무도 건드리지 않은 페이지까지 다시 써야 합니다. 2015년 Sentry의 wraparound 장애처럼 크게 터진 사례도 있습니다.

64비트 XID 패치는 Postgres Professional을 중심으로 수년째 논의 중이고 대부분의 상용 fork에는 이미 들어가 있지만, 본류에는 아직 없습니다. table access method 계층에서 처리해야 한다는 방향에는 합의가 있는 상태입니다.

저장 엔진 자체를 바꾸려는 시도도 이어집니다. zheap은 사실상 멈췄고, 지금 가장 활발한 것은 Supabase가 인수한 OrioleDB입니다. undo 기반으로 heap을 대체하는 extension이고 2026년 현재 퍼블릭 베타입니다. 벤치마크에서 최대 5.5배를 주장하지만 프로덕션 사용은 권장하지 않습니다.

당장 손에 잡히는 개선은 vacuum 쪽에서 옵니다. PostgreSQL 18은 autovacuum_worker_slots와 autovacuum_max_workers를 분리해 재시작 없이 worker 수를 조정하게 만들었고, 19에서는 autovacuum이 카탈로그 순서 대신 테이블별 우선순위 점수로 대상을 고르도록 바뀝니다. pg_stat_autovacuum_scores 뷰와 가중치 파라미터가 함께 들어옵니다. 청소 자체를 없애지는 못해도, 언제 무엇부터 치울지는 계속 정교해지는 중입니다.

정리​

MVCC 비용은 보존됩니다. 없앤 엔진은 없고, 어디로 보낼지만 다릅니다. PostgreSQL은 테이블 안에 쌓아 두고 나중에 치우는 쪽을 골랐습니다. 그 대가가 bloat와 vacuum 튜닝이고, 그 대신 얻은 것이 상수 시간 롤백과 취소당하지 않는 읽기 쿼리입니다.

그래서 "PostgreSQL의 MVCC는 나쁘다"는 문장은 절반만 맞습니다. 정확히 말하면 PostgreSQL은 시끄럽게 실패하고 수동으로 튜닝해야 하는 조합을 골랐습니다. undo 계열은 조용히 실패하고 자동으로 정리하지만, 실패할 때는 사용자 쿼리를 죽입니다.

다음에 누가 어떤 데이터베이스가 다중 버전 문제를 해결했다고 하면 네 가지를 물어보면 됩니다. 옛 버전은 어디에 사는지, 체인은 어느 방향인지, 인덱스는 무엇을 가리키는지, 누가 언제 치우는지 물으면 됩니다. 그리고 마지막으로 하나 더 묻습니다. 누군가 트랜잭션을 열어 놓고 점심을 먹으러 가면 무슨 일이 생기는지 묻습니다.

참고​

이 블로그의 관련 글로는 PostgreSQL 18의 autovacuum_worker_slots와 PostgreSQL은 왜 이건 아직 못 할까가 있어요.