PostgreSQL VACUUM·MVCC

왜 PostgreSQL에는 VACUUM이 필요한가: MVCC의 구조적 배경

PostgreSQL은 동시성 제어를 위해 MVCC(Multi-Version Concurrency Control) 모델을 사용합니다. 전통적인 RDBMS가 행 수준 잠금(row-level lock)으로 읽기와 쓰기를 직렬화하는 것과 달리, PostgreSQL에서는 읽기가 쓰기를 차단하지 않고, 쓰기가 읽기를 차단하지 않습니다. 이 원칙은 Serializable 격리 수준에서도 SSI(Serializable Snapshot Isolation)를 통해 유지됩니다.

MVCC의 핵심 메커니즘은 다음과 같습니다:

  • 각 행(tuple)에는 xmin(생성한 트랜잭션 ID)과 xmax(삭제/갱신한 트랜잭션 ID) 시스템 컬럼이 존재합니다.
  • UPDATE는 기존 행을 삭제 표시하고 새 버전의 행을 INSERT하는 방식으로 동작합니다.
  • DELETE는 기존 행의 xmax를 현재 트랜잭션 ID로 설정하여 “삭제 예정” 상태로 만듭니다.
  • 다른 트랜잭션은 자신의 스냅샷 시점에 따라 어떤 버전의 행이 보이는지 결정합니다.

문제는 여기서 발생합니다. 삭제되거나 갱신된 이전 버전의 행(dead tuple)은 어떤 트랜잭션도 더 이상 참조하지 않는 시점이 오더라도 자동으로 제거되지 않습니다. 이 dead tuple이 누적되면 테이블이 점점 부풀어 오르고(table bloat), 인덱스 스캔 성능이 저하되며, 디스크 공간이 낭비됩니다. 이를 정리하는 것이 바로 VACUUM입니다.

Dead Tuple의 생명주기: xmin·xmax·Visibility Map

dead tuple이 실제로 정리 가능해지는 과정을 단계별로 살펴보겠습니다.

1단계: 행 갱신 시 dead tuple 생성

-- 트랜잭션 100이 실행
UPDATE orders SET status = 'shipped' WHERE id = 42;
-- 기존 행: xmin=80, xmax=100 (dead tuple 후보)
-- 새 행:   xmin=100, xmax=0   (live tuple)

2단계: 가시성 판단

다른 트랜잭션이 해당 행을 조회할 때, PostgreSQL 엔진은 xmin/xmax 값과 현재 스냅샷의 활성 트랜잭션 목록을 비교하여 어떤 버전이 보이는지 결정합니다. 트랜잭션 100이 커밋되고, 그보다 오래된 활성 트랜잭션이 모두 종료되면 이전 버전(xmax=100인 행)은 어떤 스냅샷에서도 더 이상 보이지 않게 됩니다.

3단계: VACUUM이 정리

VACUUM은 테이블의 각 페이지를 순회하면서 어떤 활성 트랜잭션도 참조하지 않는 dead tuple의 공간을 재사용 가능하도록 표시합니다. 이때 FSM(Free Space Map)을 업데이트하여 이후 INSERT가 해당 공간을 재활용할 수 있게 합니다.

Visibility Map의 역할

Visibility Map(VM)은 각 페이지가 “모든 활성 트랜잭션에게 완전히 보이는 상태”인지를 비트 하나로 추적합니다. VM에 표시된 페이지는:

  • VACUUM이 건너뛸 수 있어 대규모 테이블의 vacuum 시간이 크게 단축됩니다.
  • Index-Only Scan이 heap 접근 없이 결과를 반환할 수 있어 쿼리 성능이 향상됩니다.

VACUUM vs VACUUM FULL: 동작 원리와 선택 기준

구분 VACUUM (표준) VACUUM FULL
잠금 ShareUpdateExclusiveLock (DML 병행 가능) AccessExclusiveLock (전체 테이블 잠금)
디스크 반환 페이지 내부 재사용 (OS 반환은 끝 페이지만) 테이블 전체를 재작성하여 OS에 반환
추가 디스크 불필요 테이블 크기만큼 임시 공간 필요
운영 영향 I/O 부하 있으나 서비스 유지 가능 해당 테이블 전체 접근 불가
사용 시점 일상적 유지보수 극심한 bloat 이후 비상 조치

운영 원칙: 표준 VACUUM을 충분히 자주 실행하여 VACUUM FULL이 필요한 상황 자체를 예방하는 것이 정석입니다. autovacuum 데몬은 VACUUM FULL을 절대 실행하지 않으며, 이는 의도된 설계입니다.

Autovacuum 핵심 파라미터와 튜닝 공식

autovacuum은 각 테이블의 dead tuple 비율이 임계값을 넘으면 자동으로 VACUUM을 트리거합니다. 트리거 공식은 다음과 같습니다:

vacuum 트리거 조건:
  dead_tuples > autovacuum_vacuum_threshold
                + autovacuum_vacuum_scale_factor × reltuples

기본값: 50 + 0.2 × 전체행수

즉, 1,000만 행 테이블에서는 dead tuple이 약 200만 개가 쌓여야 autovacuum이 동작합니다. 대형 테이블에서는 이 기본값이 너무 느슨합니다.

대형 테이블 튜닝 예시

-- 테이블 단위로 autovacuum 파라미터 오버라이드
ALTER TABLE events SET (
  autovacuum_vacuum_scale_factor = 0.01,   -- 1%로 낮춤
  autovacuum_vacuum_threshold = 1000,
  autovacuum_analyze_scale_factor = 0.005,
  autovacuum_analyze_threshold = 500
);

이렇게 하면 1,000만 행 기준 약 10만 개의 dead tuple에서 vacuum이 시작되어, bloat가 누적되기 전에 정리할 수 있습니다.

autovacuum_vacuum_cost_delay와 I/O 조절

autovacuum은 cost-based throttling으로 I/O를 조절합니다:

파라미터 기본값 설명
autovacuum_vacuum_cost_delay 2ms cost limit 도달 시 대기 시간
autovacuum_vacuum_cost_limit -1 (vacuum_cost_limit=200 사용) 한 사이클에서 소비 가능한 I/O 비용
vacuum_cost_page_hit 1 shared buffer에서 읽은 페이지 비용
vacuum_cost_page_miss 2 디스크에서 읽은 페이지 비용
vacuum_cost_page_dirty 20 vacuum이 dirty한 페이지 비용

SSD 환경에서는 autovacuum_vacuum_cost_delay0~1ms로, autovacuum_vacuum_cost_limit800~2000으로 높여 vacuum 속도를 크게 개선할 수 있습니다. PostgreSQL 12부터 기본 delay가 20ms → 2ms로 변경되었으므로, 12 이상이면 cost_limit 조정이 더 효과적입니다.

Transaction ID Wraparound: 무시하면 서비스가 멈춘다

PostgreSQL의 트랜잭션 ID(XID)는 32비트 정수(약 42억)입니다. MVCC 가시성 판단에서 XID의 대소 비교는 modular arithmetic으로 수행되며, 전체 범위의 절반인 약 21억이 “과거”로, 나머지 절반이 “미래”로 해석됩니다.

만약 특정 테이블이 VACUUM 없이 20억 트랜잭션 이상 방치되면, 해당 테이블의 오래된 행들이 갑자기 “미래의 트랜잭션이 만든 것”으로 해석되어 데이터가 사라지는 것처럼 보입니다. 이를 방지하기 위해:

  1. Freeze: VACUUM은 충분히 오래된 행의 xmin을 특수한 FrozenTransactionId로 교체하여, XID 순환과 무관하게 항상 “과거”로 인식되도록 합니다.
  2. autovacuum_freeze_max_age (기본값: 2억): 테이블의 가장 오래된 unfrozen XID가 이 값을 넘으면 autovacuum이 강제 트리거됩니다. 이 강제 vacuum은 autovacuum_vacuum_cost_delay 설정을 무시하고 최대 속도로 실행됩니다.
  3. 최후의 안전장치: 남은 XID가 약 100만 개까지 줄어들면 PostgreSQL은 모든 쓰기 트랜잭션을 거부하고 단일 사용자 모드에서의 수동 VACUUM을 요구합니다.
-- 각 테이블의 XID age 확인 (wraparound 위험도 모니터링)
SELECT
  schemaname,
  relname,
  age(relfrozenxid) AS xid_age,
  pg_size_pretty(pg_total_relation_size(oid)) AS total_size
FROM pg_stat_user_tables
JOIN pg_class USING (relname)
WHERE schemaname = 'public'
ORDER BY age(relfrozenxid) DESC
LIMIT 10;

운영 규칙: xid_ageautovacuum_freeze_max_age(기본 2억)의 70%를 넘으면 즉시 원인을 조사해야 합니다. 가장 흔한 원인은 장시간 유지되는 IDLE 트랜잭션으로, 이것이 vacuum의 dead tuple 정리를 차단합니다.

Bloat 진단: pg_stat_user_tables 실전 쿼리

테이블 bloat를 사전에 감지하려면 다음 쿼리들을 주기적으로 실행합니다:

Dead Tuple 비율 확인

SELECT
  schemaname || '.' || relname AS table_name,
  n_live_tup,
  n_dead_tup,
  CASE WHEN n_live_tup > 0
    THEN round(100.0 * n_dead_tup / n_live_tup, 2)
    ELSE 0
  END AS dead_ratio_pct,
  last_vacuum,
  last_autovacuum,
  last_analyze,
  last_autoanalyze
FROM pg_stat_user_tables
WHERE n_dead_tup > 10000
ORDER BY n_dead_tup DESC;

Autovacuum이 지연되는 테이블 찾기

-- autovacuum이 24시간 넘게 실행되지 않은 큰 테이블
SELECT
  relname,
  n_dead_tup,
  last_autovacuum,
  now() - last_autovacuum AS since_last_vacuum
FROM pg_stat_user_tables
WHERE n_dead_tup > 50000
  AND (last_autovacuum IS NULL
       OR last_autovacuum < now() - interval '24 hours')
ORDER BY n_dead_tup DESC;

현재 실행 중인 VACUUM 모니터링

SELECT
  pid,
  datname,
  relid::regclass AS table_name,
  phase,
  heap_blks_total,
  heap_blks_scanned,
  CASE WHEN heap_blks_total > 0
    THEN round(100.0 * heap_blks_scanned / heap_blks_total, 1)
    ELSE 0
  END AS progress_pct
FROM pg_stat_progress_vacuum;

Long-Running Transaction이 VACUUM을 무력화하는 메커니즘

VACUUM의 가장 흔한 실패 원인은 장시간 열린 트랜잭션입니다. VACUUM은 “현재 활성 트랜잭션 중 가장 오래된 스냅샷”보다 이전에 삭제된 tuple만 정리할 수 있습니다. 따라서:

-- 이 트랜잭션이 열려 있는 동안, 이후 발생한 모든 dead tuple은 정리 불가
BEGIN;  -- xid=1000에서 시작
SELECT * FROM some_table;  -- 이 시점의 스냅샷 유지
-- ... 개발자가 커밋을 잊고 점심 식사 ...
-- 그 사이 수백만 건의 UPDATE/DELETE가 발생해도 dead tuple 정리 불가

이를 방지하는 운영 설정:

-- idle 상태로 트랜잭션을 유지하는 세션을 자동 종료
idle_in_transaction_session_timeout = '10min'

-- 장시간 쿼리 자동 취소
statement_timeout = '30s'  -- 애플리케이션 수준에서 설정 권장

현재 vacuum을 차단하는 세션 찾기

SELECT
  pid,
  usename,
  state,
  backend_xmin,
  age(backend_xmin) AS xmin_age,
  now() - xact_start AS tx_duration,
  left(query, 80) AS query_snippet
FROM pg_stat_activity
WHERE backend_xmin IS NOT NULL
  AND state != 'active'
ORDER BY age(backend_xmin) DESC
LIMIT 5;

실무 Autovacuum 튜닝 체크리스트

아래는 프로덕션 환경에서 autovacuum을 안정적으로 운영하기 위한 체크리스트입니다:

항목 기본값 권장 조치
autovacuum_max_workers 3 코어 수에 따라 5~8로 증가 (테이블 수 많을 때)
autovacuum_naptime 1min high-write 환경에서 15~30s로 단축
autovacuum_vacuum_scale_factor 0.2 (20%) 대형 테이블은 ALTER TABLE로 0.01~0.05 적용
autovacuum_vacuum_cost_limit -1 (200) SSD 환경이면 800~2000으로 상향
idle_in_transaction_session_timeout 0 (없음) 반드시 설정 (5~10분 권장)
XID age 모니터링 1.4억 이상이면 알림 발송

pg_repack: VACUUM FULL의 무중단 대안

이미 bloat가 심각하여 VACUUM으로 해결할 수 없는 상황이라면, VACUUM FULL 대신 pg_repack 확장을 사용하는 것이 운영 환경에서의 표준 접근입니다. pg_repack은 테이블의 전체 재작성을 수행하되, ACCESS EXCLUSIVE 잠금을 최종 스왑 순간에만 아주 짧게 획득합니다.

# pg_repack 설치 (확장 등록)
CREATE EXTENSION pg_repack;

# CLI로 특정 테이블 repack (서비스 중 실행 가능)
pg_repack -d mydb -t public.events --no-superuser-check

# 인덱스만 재구성
pg_repack -d mydb -t public.events --only-indexes

주의사항: pg_repack 실행 중에는 원본 테이블 크기만큼의 추가 디스크 공간이 필요합니다. 또한 트리거 기반으로 변경사항을 추적하므로, 대량 쓰기가 동시에 발생하면 repack 시간이 길어질 수 있습니다.

핵심 정리

  • MVCC 구조상 dead tuple은 필연적으로 발생하며, VACUUM이 유일한 정리 수단입니다.
  • autovacuum의 기본 scale_factor(0.2)는 대형 테이블에 너무 느슨합니다. 수백만 행 이상 테이블은 반드시 테이블 단위로 낮춰야 합니다.
  • XID wraparound는 이론적 위험이 아닌 실제 장애 원인입니다. age() 모니터링을 시스템에 내장하세요.
  • Long-running transaction은 VACUUM의 최대 적입니다. idle_in_transaction_session_timeout을 반드시 설정하세요.
  • VACUUM FULL 대신 pg_repack을 사용하면 서비스 중단 없이 bloat를 해결할 수 있습니다.

참고 자료

위로 스크롤
WordPress Appliance - Powered by TurnKey Linux