Skip to content
Published on

PostgreSQL VACUUM과 MVCC 내부 구조 완벽 가이드: 테이블 Bloat 방지·Autovacuum 튜닝·XID Wraparound 대응

Authors
  • Name
    Twitter
PostgreSQL VACUUM과 MVCC

들어가며

PostgreSQL은 MVCC(Multi-Version Concurrency Control)를 통해 읽기와 쓰기가 서로를 차단하지 않는 높은 동시성을 제공한다. 하지만 이 아키텍처에는 반드시 이해해야 할 대가가 따른다. UPDATE나 DELETE를 실행해도 이전 버전의 튜플이 즉시 제거되지 않으며, 이 Dead Tuple이 누적되면 테이블 크기가 비정상적으로 커지는 Bloat 현상이 발생한다.

2017년 GitLab의 유명한 장애 사례에서는 테이블 Bloat과 autovacuum 설정 미비로 인해 디스크 사용량이 급증하고 서비스가 수시간 동안 중단되었다. 이런 사고는 VACUUM 메커니즘과 Autovacuum 튜닝에 대한 이해 부족에서 비롯된다.

이 글에서는 PostgreSQL의 MVCC 내부 구조부터 VACUUM 동작 원리, Autovacuum 튜닝, XID Wraparound 예방, pg_repack을 활용한 무중단 Bloat 제거까지 프로덕션 DBA가 반드시 알아야 할 모든 것을 다룬다.

MVCC 내부 구조

튜플 헤더: xmin, xmax, ctid

PostgreSQL의 모든 행(튜플)은 숨겨진 시스템 컬럼을 가지고 있다. MVCC의 핵심은 각 튜플 헤더에 저장되는 트랜잭션 메타데이터이다.

시스템 컬럼설명역할
xmin이 튜플을 INSERT한 트랜잭션 ID튜플의 "생성 시점" 기록
xmax이 튜플을 DELETE/UPDATE한 트랜잭션 ID튜플의 "소멸 시점" 기록 (0이면 아직 유효)
ctid현재 페이지 내 물리적 위치 (page, offset)UPDATE 시 새 버전의 위치를 가리킴
t_infomask트랜잭션 상태 힌트 비트COMMITTED, ABORTED 등 빠른 판단용

실제 행의 xmin/xmax 값을 확인해 보자.

-- 튜플의 숨겨진 시스템 컬럼 조회
SELECT ctid, xmin, xmax, id, name
FROM users
WHERE id = 1;

-- 결과 예시:
--  ctid  | xmin  | xmax | id |  name
-- -------+-------+------+----+--------
--  (0,1) | 12345 |    0 |  1 | Alice

UPDATE를 실행하면 xmax가 설정되고, 새 튜플이 생성되는 것을 확인할 수 있다.

-- UPDATE 전후 비교
BEGIN;
UPDATE users SET name = 'Bob' WHERE id = 1;

-- 같은 트랜잭션에서 확인 (pageinspect 확장 필요)
SELECT t_xmin, t_xmax, t_ctid, t_data
FROM heap_page_items(get_raw_page('users', 0))
WHERE t_xmin IS NOT NULL;

-- 결과: 원본 튜플의 xmax가 현재 트랜잭션 ID로 설정됨
-- 새로운 튜플이 별도 위치에 생성됨
COMMIT;

트랜잭션 스냅샷과 가시성 판단

각 트랜잭션은 시작 시점에 스냅샷을 획득한다. 스냅샷에는 현재 활성 중인 모든 트랜잭션 목록이 포함되며, PostgreSQL은 이 정보를 바탕으로 각 튜플의 가시성을 판단한다.

가시성 판단 규칙을 간략히 정리하면 다음과 같다.

  • xmin이 커밋되었고 xmax가 0이면: 유효한 튜플 (보임)
  • xmin이 커밋되었고 xmax도 커밋되었으면: 삭제된 튜플 (안 보임, Dead Tuple)
  • xmin이 아직 진행 중이면: 다른 트랜잭션에서는 안 보임
  • xmin이 취소(ABORT)되었으면: 모든 트랜잭션에서 안 보임

Dead Tuple 발생 메커니즘

PostgreSQL에서는 UPDATE가 내부적으로 DELETE + INSERT로 동작한다. 이는 Oracle이나 MySQL(InnoDB)과 근본적으로 다른 접근 방식이다.

  1. DELETE: 기존 튜플의 xmax에 현재 트랜잭션 ID를 기록한다. 물리적 삭제는 하지 않는다.
  2. UPDATE: 기존 튜플의 xmax를 설정하고(논리적 삭제), 새 버전의 튜플을 별도 위치에 INSERT한다.
  3. Dead Tuple 축적: 커밋 후 더 이상 어떤 스냅샷에서도 보이지 않는 이전 버전 튜플은 Dead Tuple이 된다.

이 Dead Tuple은 VACUUM이 정리하기 전까지 디스크 공간을 계속 점유한다.

HOT(Heap-Only Tuple) 업데이트

PostgreSQL 8.3부터 도입된 HOT 업데이트는 Dead Tuple 문제를 완화하는 핵심 최적화이다. 인덱스 컬럼이 변경되지 않고, 새 튜플이 같은 페이지에 들어갈 수 있으면 인덱스 업데이트를 건너뛰고 힙 내에서만 연결 리스트를 형성한다.

HOT 업데이트의 조건은 다음과 같다.

  • 변경되는 컬럼에 인덱스가 없어야 한다
  • 새 튜플이 같은 힙 페이지에 배치되어야 한다
  • 테이블에 충분한 fillfactor 여유 공간이 있어야 한다

HOT 업데이트 비율은 성능의 핵심 지표이다. 다음 쿼리로 확인할 수 있다.

-- Dead Tuple 현황 및 HOT 업데이트 비율 조회
SELECT
    schemaname,
    relname AS table_name,
    n_live_tup,
    n_dead_tup,
    ROUND(n_dead_tup::numeric / NULLIF(n_live_tup + n_dead_tup, 0) * 100, 2) AS dead_ratio_pct,
    n_tup_hot_upd,
    n_tup_upd,
    ROUND(n_tup_hot_upd::numeric / NULLIF(n_tup_upd, 0) * 100, 2) AS hot_update_ratio_pct,
    last_vacuum,
    last_autovacuum
FROM pg_stat_user_tables
WHERE n_dead_tup > 1000
ORDER BY n_dead_tup DESC;

HOT 업데이트 비율이 낮다면 fillfactor를 조정하는 것이 효과적이다.

-- fillfactor를 80%로 설정하여 HOT 업데이트 공간 확보
ALTER TABLE orders SET (fillfactor = 80);
-- 기존 데이터에 적용하려면 VACUUM FULL 또는 pg_repack 필요

VACUUM 메커니즘 심화

Standard VACUUM vs VACUUM FULL 비교

특성Standard VACUUMVACUUM FULL
잠금 수준ShareUpdateExclusiveLock (읽기/쓰기 가능)AccessExclusiveLock (완전 차단)
공간 반환OS에 반환하지 않음 (재사용 가능 표시)OS에 완전 반환
테이블 크기줄어들지 않음최소 크기로 축소
소요 시간상대적으로 빠름테이블 크기에 비례 (매우 느림)
I/O 영향낮음~중간매우 높음 (전체 재작성)
프로덕션 사용일상적 사용 가능다운타임 필수, 비권장
인덱스 처리인덱스 정리인덱스 완전 재구축

핵심 포인트: Standard VACUUM은 Dead Tuple이 점유하던 공간을 "재사용 가능"으로 표시할 뿐, 파일 크기를 줄이지는 않는다. 한번 커진 테이블은 VACUUM FULL이나 pg_repack 없이는 물리적으로 줄어들지 않는다.

Visibility Map과 Free Space Map

Visibility Map(VM) 은 각 힙 페이지에 대해 2비트를 사용한다.

  • all-visible 비트: 페이지의 모든 튜플이 모든 활성 트랜잭션에 보이는 경우 설정. Index-Only Scan과 VACUUM 스킵에 활용
  • all-frozen 비트: 페이지의 모든 튜플이 freeze 되어 XID에 무관하게 영구히 보이는 경우 설정. Anti-wraparound VACUUM 스킵에 활용

Free Space Map(FSM) 은 각 페이지의 사용 가능한 빈 공간 크기를 추적하여, INSERT나 UPDATE 시 새 튜플을 배치할 적절한 페이지를 빠르게 찾는다.

VACUUM의 3단계 동작

VACUUM은 내부적으로 3단계로 동작한다.

  1. 스캔 단계(Scan Phase): 테이블 전체를 순차적으로 스캔하면서 Dead Tuple의 TID(페이지 번호, 오프셋)를 maintenance_work_mem 크기의 배열에 수집한다. Visibility Map에서 all-visible로 표시된 페이지는 건너뛴다.

  2. 인덱스 정리 단계(Index Vacuum Phase): 수집된 Dead Tuple TID 목록을 기준으로, 테이블의 모든 인덱스를 순회하며 해당 TID를 가리키는 인덱스 항목을 제거한다. 인덱스가 많을수록 이 단계가 오래 걸린다.

  3. 힙 정리 단계(Heap Vacuum Phase): 테이블 힙에서 Dead Tuple을 실제로 정리하고, 해당 공간을 Free Space Map에 재사용 가능으로 등록한다. 테이블 끝 부분에 연속된 빈 페이지가 있으면 파일을 truncate하여 OS에 반환한다.

Freeze 처리와 XID 관리

VACUUM은 Dead Tuple 정리 외에도 트랜잭션 ID Freeze라는 중요한 역할을 수행한다. PostgreSQL의 트랜잭션 ID(XID)는 32비트 부호 없는 정수로, 약 21억 개까지만 사용할 수 있다. XID가 순환하면 과거 데이터가 "미래의 트랜잭션이 삽입한 것"으로 보여 데이터가 사라지는 참사가 발생한다.

이를 방지하기 위해 VACUUM은 충분히 오래된 튜플의 xmin을 특수한 FrozenTransactionId(값 2)로 교체한다. Freeze된 튜플은 XID 비교에서 항상 "과거"로 취급되어 Wraparound 위험에서 벗어난다.

VACUUM VERBOSE로 실제 동작을 확인해 보자.

-- VACUUM VERBOSE로 상세 동작 확인
VACUUM (VERBOSE, ANALYZE) orders;

-- 출력 예시:
-- INFO:  vacuuming "public.orders"
-- INFO:  scanned index "orders_pkey" to remove 15234 row versions
-- INFO:  scanned index "idx_orders_user_id" to remove 15234 row versions
-- INFO:  table "orders": removed 15234 dead item identifiers in 892 pages
-- INFO:  table "orders": found 15234 removable, 2847561 nonremovable row versions
--        in 45213 out of 62874 pages
-- INFO:  table "orders": truncated 62874 to 51230 pages
-- DETAIL:  CPU: user: 2.15 s, system: 0.89 s, elapsed: 5.43 s

pgstattuple 확장을 사용하면 테이블의 실제 물리적 상태를 정밀하게 측정할 수 있다.

-- pgstattuple로 정밀 bloat 측정
CREATE EXTENSION IF NOT EXISTS pgstattuple;

SELECT
    table_len,
    tuple_count,
    tuple_len,
    tuple_percent,
    dead_tuple_count,
    dead_tuple_len,
    dead_tuple_percent,
    free_space,
    free_percent
FROM pgstattuple('orders');

-- 결과 예시:
--  table_len   | 514850816
--  tuple_count | 2847561
--  tuple_len   | 398258540
--  tuple_percent | 77.35
--  dead_tuple_count | 0        -- VACUUM 직후라면 0
--  dead_tuple_len   | 0
--  dead_tuple_percent | 0
--  free_space   | 89456280
--  free_percent | 17.37       -- 이 비율이 높으면 bloat 상태

Autovacuum 튜닝 가이드

핵심 파라미터 이해

Autovacuum은 백그라운드에서 자동으로 VACUUM을 실행하는 프로세스이다. 튜닝의 핵심은 "언제 시작할 것인가"와 "얼마나 빠르게/느리게 실행할 것인가"를 조절하는 것이다.

실행 트리거 조건: Dead Tuple 수가 다음 임계값을 초과하면 autovacuum이 시작된다.

임계값 = autovacuum_vacuum_threshold + autovacuum_vacuum_scale_factor x 테이블 행 수

기본값은 threshold=50, scale_factor=0.2이다. 즉, 1000만 행 테이블은 Dead Tuple이 200만 개가 되어야 autovacuum이 시작된다. 대용량 테이블에서는 이 기본값이 너무 느슨하다.

비용 기반 지연(Cost-based Delay) 최적화

Autovacuum은 I/O 부하를 제어하기 위해 비용 기반 지연을 사용한다.

파라미터기본값설명
autovacuum_vacuum_cost_delay2ms비용 한도 도달 시 대기 시간
autovacuum_vacuum_cost_limit-1 (vacuum_cost_limit=200 사용)한번에 처리할 최대 비용
vacuum_cost_page_hit1shared_buffers에서 읽은 페이지 비용
vacuum_cost_page_miss2디스크에서 읽은 페이지 비용
vacuum_cost_page_dirty20수정된 페이지 비용

PostgreSQL 17에서는 기본 cost_delay가 2ms로 낮아졌지만, 대용량 OLTP 시스템에서는 더 공격적인 설정이 필요할 수 있다.

워커 수와 테이블별 개별 설정

전역 설정과 대용량 테이블에 대한 개별 설정을 분리하는 것이 핵심이다.

-- postgresql.conf 권장 설정 (전역)
-- autovacuum_max_workers = 5             -- 기본 3에서 증가
-- autovacuum_vacuum_cost_delay = 2ms     -- PostgreSQL 17 기본값
-- autovacuum_vacuum_cost_limit = 400     -- 기본 200에서 증가
-- maintenance_work_mem = 1GB             -- VACUUM용 메모리 충분히 할당
-- autovacuum_naptime = 30s               -- 기본 1분에서 단축

-- 대용량 테이블에 대한 개별 autovacuum 설정
ALTER TABLE orders SET (
    autovacuum_vacuum_scale_factor = 0.01,     -- 1% (기본 20% 대비 공격적)
    autovacuum_vacuum_threshold = 1000,         -- 최소 1000개
    autovacuum_analyze_scale_factor = 0.005,    -- 분석도 더 자주
    autovacuum_vacuum_cost_delay = 1,           -- 더 빠르게 처리 (ms)
    autovacuum_vacuum_cost_limit = 600          -- 더 많은 작업 허용
);

-- 로그성 테이블 (INSERT만 발생, UPDATE/DELETE 없음)
ALTER TABLE audit_logs SET (
    autovacuum_vacuum_scale_factor = 0.05,
    autovacuum_freeze_max_age = 500000000,     -- freeze만 관리
    autovacuum_enabled = true
);

대용량 테이블 전용 튜닝 전략

1억 행 이상의 대용량 테이블에서는 다음 전략을 적용한다.

  • scale_factor를 0.01 이하로: 기본 0.2에서는 Dead Tuple이 2000만 개까지 쌓여야 vacuum이 시작된다
  • maintenance_work_mem을 1GB 이상으로: Dead Tuple TID를 저장하는 배열 크기를 늘려 다중 패스를 방지한다
  • 인덱스 수 최소화: 인덱스가 많을수록 VACUUM의 인덱스 정리 단계가 길어진다
  • fillfactor 80 설정: HOT 업데이트 비율을 높여 Dead Tuple 생성을 억제한다

Autovacuum 모니터링

-- 현재 실행 중인 autovacuum 프로세스 확인
SELECT
    pid,
    datname,
    relid::regclass AS table_name,
    phase,
    heap_blks_total,
    heap_blks_scanned,
    heap_blks_vacuumed,
    ROUND(100.0 * heap_blks_vacuumed / NULLIF(heap_blks_total, 0), 1) AS progress_pct,
    index_vacuum_count,
    num_dead_tuples
FROM pg_stat_progress_vacuum;

-- Autovacuum이 필요하지만 아직 실행되지 않은 테이블 목록
SELECT
    schemaname,
    relname,
    n_live_tup,
    n_dead_tup,
    ROUND(n_dead_tup::numeric / NULLIF(n_live_tup, 0) * 100, 2) AS dead_pct,
    last_autovacuum,
    last_autoanalyze,
    CURRENT_TIMESTAMP - COALESCE(last_autovacuum, '2000-01-01'::timestamp) AS since_last_vacuum
FROM pg_stat_user_tables
WHERE n_dead_tup > 10000
ORDER BY n_dead_tup DESC
LIMIT 20;

테이블 Bloat 탐지와 방지

Bloat 측정 방법 비교

방법정확도성능 영향접근 방식
pgstattuple매우 높음 (정밀 측정)높음 (전체 테이블 스캔)물리적 페이지를 직접 읽어 실제 상태 계산
통계 기반 추정중간 (오차 10~20%)없음 (카탈로그만 조회)pg_class.relpages와 통계를 기반으로 예상 크기 계산
pg_stat_user_tables낮음 (참고 수준)없음 (누적 통계)n_dead_tup 기반 단순 비율만 확인

프로덕션에서는 평상시 통계 기반 추정으로 대시보드를 구성하고, 이상이 감지되면 pgstattuple로 정밀 진단하는 2단계 접근을 권장한다.

Bloat 추정 쿼리

다음은 pg_class 통계를 활용한 Bloat 추정 쿼리이다. pgstattuple 없이도 실행 가능하다.

-- 테이블 Bloat 추정 쿼리 (통계 기반, 무잠금)
WITH constants AS (
    SELECT
        current_setting('block_size')::numeric AS bs,
        23 AS hdr,   -- 힙 튜플 헤더 크기
        8 AS ma      -- MAXALIGN
),
bloat_info AS (
    SELECT
        schemaname,
        tablename,
        cc.reltuples::bigint AS est_rows,
        cc.relpages::bigint AS real_pages,
        bs,
        CEIL((cc.reltuples * (datahdr + nullhdr + 4 + ma -
            CASE WHEN datahdr % ma = 0 THEN ma ELSE datahdr % ma END
        )) / (bs - 20)) AS est_pages
    FROM (
        SELECT
            schemaname,
            tablename,
            hdr + COALESCE(SUM(
                CASE WHEN staattnum IS NOT NULL
                    THEN (1 + stawidth) ELSE 0 END
            ), 0) AS datahdr,
            COALESCE(SUM(
                CASE WHEN staattnum IS NOT NULL AND stanullfrac > 0
                    THEN 1 ELSE 0 END
            ) / 8, 0) AS nullhdr,
            ma, bs, hdr
        FROM pg_stats
        CROSS JOIN constants
        LEFT JOIN pg_statistic ON schemaname = schemaname AND tablename = tablename
        GROUP BY schemaname, tablename, hdr, ma, bs
    ) AS sub
    JOIN pg_class cc ON cc.relname = sub.tablename
    JOIN pg_namespace nn ON cc.relnamespace = nn.oid AND nn.nspname = sub.schemaname
)
SELECT
    schemaname,
    tablename,
    real_pages,
    est_pages,
    CASE WHEN real_pages > 0
        THEN ROUND(100.0 * (real_pages - est_pages) / real_pages, 1)
        ELSE 0
    END AS bloat_pct,
    pg_size_pretty((real_pages - est_pages)::bigint * bs::bigint) AS wasted_size
FROM bloat_info
WHERE real_pages > est_pages + 10
ORDER BY (real_pages - est_pages) * bs DESC
LIMIT 20;

Bloat 비율이 50%를 넘으면 주의 알림, 70%를 넘으면 즉각적인 pg_repack 실행을 권장한다.

XID Wraparound 예방

32비트 트랜잭션 카운터의 한계

PostgreSQL의 트랜잭션 ID(XID)는 32비트 부호 없는 정수로, 최대 약 42억(2의 32승)개의 값을 가진다. 이 중 3개는 예약되어 있고, 실질적으로 약 21억 개의 트랜잭션마다 순환이 발생한다.

XID 순환이 발생하면 과거에 커밋된 트랜잭션이 "미래"로 인식되어 해당 데이터가 보이지 않게 된다. 이는 사실상 데이터 손실과 동일한 결과를 초래하며, PostgreSQL은 이를 방지하기 위해 Wraparound가 임박하면 강제로 모든 쓰기 작업을 중단시킨다(Single-User Mode로만 복구 가능).

Emergency Autovacuum 동작 원리

autovacuum_freeze_max_age(기본값 2억)에 도달하면 PostgreSQL은 다른 autovacuum 작업보다 우선적으로 Anti-Wraparound VACUUM을 실행한다. 이 특수 VACUUM은 비용 기반 지연을 무시하고 가능한 한 빠르게 오래된 XID를 freeze 한다.

더 위험한 상황인 vacuum_failsafe_age(기본값 16억, PostgreSQL 14 이상)에 도달하면 인덱스 정리를 건너뛰고 오직 freeze만 수행하는 페일세이프 모드로 전환된다.

XID Wraparound 모니터링

-- 데이터베이스별 XID 사용량 모니터링
SELECT
    datname,
    age(datfrozenxid) AS xid_age,
    ROUND(100.0 * age(datfrozenxid) / 2147483647, 2) AS pct_towards_wraparound,
    current_setting('autovacuum_freeze_max_age')::bigint AS freeze_max_age,
    CASE
        WHEN age(datfrozenxid) > 1500000000 THEN 'CRITICAL'
        WHEN age(datfrozenxid) > 1000000000 THEN 'WARNING'
        WHEN age(datfrozenxid) > 500000000  THEN 'CAUTION'
        ELSE 'OK'
    END AS status
FROM pg_database
WHERE datallowconn
ORDER BY age(datfrozenxid) DESC;

-- 테이블별 XID age 확인 (가장 오래된 것부터)
SELECT
    c.oid::regclass AS table_name,
    age(c.relfrozenxid) AS xid_age,
    pg_size_pretty(pg_table_size(c.oid)) AS table_size,
    ROUND(100.0 * age(c.relfrozenxid) /
        current_setting('autovacuum_freeze_max_age')::bigint, 1) AS pct_of_freeze_max
FROM pg_class c
JOIN pg_namespace n ON c.relnamespace = n.oid
WHERE c.relkind = 'r'
    AND n.nspname NOT IN ('pg_catalog', 'information_schema')
ORDER BY age(c.relfrozenxid) DESC
LIMIT 20;

운영 환경에서는 xid_age가 5억을 넘으면 알림을 보내고, 10억을 넘으면 즉각적인 조치를 취해야 한다.

pg_repack: 무중단 Bloat 제거

VACUUM FULL 대신 pg_repack을 써야 하는 이유

VACUUM FULL은 AccessExclusiveLock을 획득하여 테이블에 대한 모든 읽기/쓰기를 차단한다. 프로덕션 환경에서 대용량 테이블에 VACUUM FULL을 실행하면 수분에서 수시간 동안 서비스가 중단될 수 있다.

pg_repack은 백그라운드에서 테이블을 재구성하고, 마지막 순간에만 짧은 잠금(수 밀리초)을 획득하여 교체한다. 서비스 무중단으로 Bloat을 제거할 수 있는 사실상 유일한 방법이다.

pg_repack 동작 원리와 제약사항

pg_repack의 내부 동작은 다음과 같다.

  1. 대상 테이블과 동일한 구조의 임시 테이블을 생성한다
  2. 트리거를 설치하여 원본 테이블의 INSERT/UPDATE/DELETE를 임시 테이블에도 반영한다
  3. 원본 테이블의 데이터를 임시 테이블로 복사한다
  4. 복사 중 발생한 변경 사항을 동기화한다
  5. 짧은 AccessExclusiveLock을 획득하고, 파일 이름을 교체(swap)한다
  6. 이전 파일을 삭제한다

제약사항에 주의해야 한다.

  • PRIMARY KEY 또는 UNIQUE NOT NULL 인덱스가 반드시 필요하다
  • 작업 중 원본 테이블 크기만큼의 추가 디스크 공간이 필요하다
  • DDL 작업(ALTER TABLE 등)과 동시에 실행할 수 없다
  • 대상 테이블에 publication이나 trigger가 있으면 주의가 필요하다

프로덕션 실행 절차

# 1. pg_repack 설치 (서버에서)
# Debian/Ubuntu
sudo apt-get install postgresql-17-repack

# RHEL/CentOS
sudo yum install pg_repack_17

# 2. 확장 설치 (데이터베이스에서)
psql -d mydb -c "CREATE EXTENSION IF NOT EXISTS pg_repack;"

# 3. 디스크 공간 확인 (대상 테이블 크기의 2배 이상 필요)
psql -d mydb -c "SELECT pg_size_pretty(pg_total_relation_size('orders'));"

# 4. 단일 테이블 repack (인덱스 포함)
pg_repack -d mydb -t orders --no-superuser-check --wait-timeout=60

# 5. 특정 인덱스만 repack
pg_repack -d mydb -i idx_orders_created_at --no-superuser-check

# 6. 스키마 전체 repack (주의: 시간 오래 걸림)
pg_repack -d mydb -s public --no-superuser-check --wait-timeout=120

# 7. 실행 후 확인
psql -d mydb -c "
SELECT
    relname,
    pg_size_pretty(pg_table_size(oid)) AS table_size,
    pg_size_pretty(pg_indexes_size(oid)) AS indexes_size,
    pg_size_pretty(pg_total_relation_size(oid)) AS total_size
FROM pg_class
WHERE relname = 'orders';
"

실패 사례와 복구 절차

사례 1: 오래된 Replication Slot이 Autovacuum 차단

논리 복제(Logical Replication)에서 사용하는 Replication Slot이 비활성 상태로 오랫동안 방치되면, 해당 Slot이 참조하는 시점 이후의 Dead Tuple을 VACUUM이 정리할 수 없다. Slot의 restart_lsn이 과거에 머물러 있으면 Dead Tuple이 무한히 누적된다.

증상은 다음과 같다.

  • autovacuum이 실행되지만 n_dead_tup이 줄어들지 않음
  • pg_stat_replication에서 비활성 Slot 확인
  • WAL 파일도 제거되지 않아 디스크 사용량 급증

대응 절차는 다음과 같다.

-- 비활성 Replication Slot 확인
SELECT
    slot_name,
    slot_type,
    active,
    age(xmin) AS slot_xid_age,
    age(catalog_xmin) AS catalog_xid_age,
    pg_size_pretty(pg_wal_lsn_diff(pg_current_wal_lsn(), restart_lsn)) AS wal_lag
FROM pg_replication_slots
WHERE NOT active;

-- 불필요한 Slot 제거 (확인 후 실행)
SELECT pg_drop_replication_slot('inactive_slot_name');

사례 2: 대량 UPDATE로 Bloat 폭증

배치 작업으로 수천만 행을 한번에 UPDATE하면 순간적으로 대량의 Dead Tuple이 발생한다. Autovacuum이 처리하기 전에 다음 배치가 실행되면 Bloat이 기하급수적으로 증가한다.

예방 전략은 다음과 같다.

  • 대량 UPDATE는 1만~10만 행 단위로 분할 실행하고, 각 배치 사이에 짧은 대기를 둔다
  • 배치 작업 전후로 수동 VACUUM을 실행한다
  • 배치 전용 테이블에는 aggressive autovacuum 설정을 적용한다

복구 절차 체크리스트

  1. 장기 실행 트랜잭션 확인 및 종료
  2. 비활성 Replication Slot 확인 및 정리
  3. 현재 Bloat 상태 정밀 진단 (pgstattuple)
  4. 디스크 여유 공간 확인 (pg_repack 시 테이블 크기의 2배 필요)
  5. pg_repack 실행 (트래픽 낮은 시간대 권장)
  6. Autovacuum 파라미터 재검토 및 조정
  7. 모니터링 알림 설정 확인

운영 시 주의사항

장기 실행 트랜잭션 관리

장기 실행 트랜잭션은 VACUUM의 최대 적이다. 열려 있는 트랜잭션의 스냅샷보다 이후에 생성된 Dead Tuple은 VACUUM이 정리할 수 없다.

-- 5분 이상 실행 중인 트랜잭션 확인
SELECT
    pid,
    usename,
    state,
    age(backend_xid) AS xid_age,
    now() - xact_start AS xact_duration,
    now() - query_start AS query_duration,
    LEFT(query, 100) AS query_preview
FROM pg_stat_activity
WHERE state != 'idle'
    AND xact_start IS NOT NULL
    AND now() - xact_start > interval '5 minutes'
ORDER BY xact_start;

-- idle in transaction 상태 확인 (가장 위험)
SELECT
    pid,
    usename,
    state,
    now() - state_change AS idle_duration,
    LEFT(query, 100) AS last_query
FROM pg_stat_activity
WHERE state = 'idle in transaction'
    AND now() - state_change > interval '10 minutes';

idle_in_transaction_session_timeout 설정을 통해 일정 시간 이상 유휴 상태인 트랜잭션을 자동으로 종료할 수 있다.

Prepared Transaction 정리

2단계 커밋(Two-Phase Commit)에서 사용하는 Prepared Transaction도 장기 트랜잭션과 동일한 문제를 일으킨다. 오래된 Prepared Transaction이 남아 있으면 VACUUM이 Dead Tuple을 정리하지 못한다.

-- 오래된 Prepared Transaction 확인
SELECT
    gid,
    prepared,
    owner,
    database,
    now() - prepared AS age
FROM pg_prepared_xacts
ORDER BY prepared;

모니터링 대시보드 필수 메트릭

프로덕션 환경에서 반드시 모니터링해야 할 VACUUM 관련 메트릭은 다음과 같다.

메트릭임계값데이터 소스
Dead Tuple 비율10% 초과 시 주의pg_stat_user_tables
테이블 Bloat 비율50% 초과 시 경고pgstattuple 또는 추정 쿼리
XID Age (데이터베이스)5억 초과 시 주의pg_database.datfrozenxid
XID Age (테이블)freeze_max_age의 70% 시 경고pg_class.relfrozenxid
Autovacuum 실행 빈도24시간 이상 미실행 시 경고pg_stat_user_tables.last_autovacuum
장기 트랜잭션1시간 초과 시 경고pg_stat_activity
비활성 Replication Slot존재 시 즉시 확인pg_replication_slots
Autovacuum 워커 포화도max_workers 도달 시 경고pg_stat_progress_vacuum

Prometheus + Grafana 조합에서는 postgres_exporter를 사용하여 이 메트릭들을 수집하고, 임계값 기반 알림을 설정하는 것이 표준 패턴이다.

참고자료