2026년 6월 4일 목요일
글 목록
Lv.3 중급PostgreSQL
20분 읽기Lv.3 중급
시리즈PostgreSQL Vacuum 완전 정복 · 파트 1시리즈 허브 보기

PostgreSQL Vacuum Part 1 — VACUUM의 존재 이유: MVCC와 Dead Tuple

PostgreSQL Vacuum Part 1 — VACUUM의 존재 이유: MVCC와 Dead Tuple

PostgreSQL에서 DELETE 이후 테이블 크기가 줄지 않는 것은 버그가 아니라 MVCC 설계의 결과다. xmin·xmax·Snapshot 기반 가시성 규칙이 Dead Tuple을 만들고, 이것이 Table Bloat와 쿼리 성능 저하로 이어지므로 VACUUM은 선택이 아닌 필수 운영 작업이다. 표준 VACUUM과 VACUUM FULL의 차이, Visibility Map의 최적화 역할, XID Wraparound를 막는 Freeze 메커니즘까지 VACUUM의 존재 이유를 구조적으로 정리한다.

시리즈 구성

  • Part 1 — VACUUM의 존재 이유: MVCC와 Dead Tuple (현재 편)
  • Part 2 — Autovacuum 튜닝과 Transaction ID Wraparound
  • Part 3 — 실전 모니터링, Bloat 제거, 운영 전략

목차

  1. 왜 VACUUM이 필요한가?
  2. MVCC(Multi-Version Concurrency Control) 작동 원리
  3. Dead Tuple의 탄생과 구조
  4. PostgreSQL vs MySQL — MVCC 구현 방식 비교
  5. VACUUM이 하는 일 — 표준 VACUUM vs VACUUM FULL
  6. Visibility Map과 Freeze
  7. Part 1 핵심 정리 및 다음 편 예고

1. 왜 VACUUM이 필요한가?

PostgreSQL을 처음 운영하는 엔지니어들이 자주 겪는 상황이 있다.

-- 100만 건을 DELETE 했는데...
DELETE FROM events WHERE created_at < now() - interval '1 year';

-- 테이블 크기가 전혀 줄지 않는다!
SELECT pg_size_pretty(pg_total_relation_size('events'));

분명히 수백만 건을 지웠는데 디스크 공간은 요지부동이다. 쿼리 성능도 오히려 나빠졌다는 느낌이 든다. 이 현상의 원인은 PostgreSQL의 동시성 모델인 MVCC에 있으며, 그 해결책이 바로 VACUUM이다.

PostgreSQL 공식 문서는 VACUUM이 필요한 이유를 세 가지로 요약한다.

  • 업데이트/삭제된 행이 차지한 디스크 공간 회수
  • PostgreSQL 쿼리 플래너가 사용하는 통계 정보 갱신
  • Transaction ID Wraparound 방지 (Part 2에서 심층 다룸)

2. MVCC(Multi-Version Concurrency Control) 작동 원리

MVCC는 "읽기가 쓰기를 막지 않고, 쓰기가 읽기를 막지 않는다"는 철학을 구현한 동시성 제어 메커니즘이다.

트랜잭션 스냅샷과 가시성(Visibility)

트랜잭션이 시작되면 PostgreSQL은 **스냅샷(Snapshot)**을 생성한다. 이 스냅샷은 "지금 이 순간 어떤 트랜잭션들이 커밋되어 있는가"를 기록한 일종의 사진이다. 쿼리는 이 스냅샷 기준으로만 데이터를 읽으므로, 다른 트랜잭션이 한창 데이터를 바꾸고 있어도 블로킹 없이 일관된 결과를 돌려받는다.

핵심 시스템 컬럼: xmin과 xmax

PostgreSQL의 모든 행(tuple)에는 사용자 눈에 보이지 않는 숨겨진 시스템 컬럼이 존재한다.

컬럼의미
xmin이 행 버전을 생성한 트랜잭션 ID
xmax이 행 버전을 삭제하거나 업데이트한 트랜잭션 ID
-- 숨겨진 시스템 컬럼 확인
SELECT xmin, xmax, id, name FROM my_table LIMIT 5;
  • INSERT 시 → 새 행 생성, xmin = 현재 트랜잭션 ID, xmax = 0
  • UPDATE 시 → 기존 행의 xmax에 현재 트랜잭션 ID 기록 후, 새 행을 xmin = 현재 트랜잭션 ID로 생성
  • DELETE 시 → 기존 행의 xmax에 현재 트랜잭션 ID 기록, 행은 물리적으로 제거되지 않음

아래 예시로 흐름을 살펴보자.

-- Transaction 100: 행 삽입
BEGIN; -- XID = 100
INSERT INTO products (id, name) VALUES (1, 'Widget');
COMMIT;
-- 결과: xmin=100, xmax=0

-- Transaction 200: 행 업데이트
BEGIN; -- XID = 200
UPDATE products SET name = 'Super Widget' WHERE id = 1;
COMMIT;
-- 결과 (이전 버전): xmin=100, xmax=200  <- Dead Tuple 탄생!
-- 결과 (새 버전):   xmin=200, xmax=0    <- 살아있는 Tuple

3. Dead Tuple의 탄생과 구조

위 예시에서 UPDATE가 실행된 후 두 개의 행 버전이 테이블에 공존한다. 트랜잭션 200이 커밋되면, xmax=200인 이전 버전은 더 이상 어떤 트랜잭션에도 보이지 않는다. 이것이 Dead Tuple이다.

[Heap Page 구조]

+------------------+------------------+
| Tuple 1          | Tuple 2          |
| xmin=100         | xmin=200         |
| xmax=200 (DEAD)  | xmax=0   (LIVE)  |
| name='Widget'    | name='Super ...' |
+------------------+------------------+

Dead Tuple은 "공간을 차지하면서 아무 역할도 하지 않는" 데이터다. 이 상태가 쌓이면 두 가지 문제가 생긴다.

① Table Bloat (테이블 비대화)

삭제/업데이트가 반복될수록 페이지 내 유효 데이터 비율이 떨어진다. 1,000만 행 테이블에서 100만 건을 삭제하면 n_dead_tup = 1,000,000이 되고, Seq Scan 시 쓸모없는 페이지를 모두 읽어야 한다.

② 쿼리 성능 저하

인덱스 스캔을 해도 Dead Tuple을 가리키는 인덱스 엔트리가 남아 있어 힙 페이지를 불필요하게 참조하는 비용이 발생한다.

-- Dead Tuple 현황 모니터링
SELECT
    relname,
    n_live_tup,
    n_dead_tup,
    round(100.0 * n_dead_tup / nullif(n_live_tup + n_dead_tup, 0), 2) AS dead_pct,
    last_autovacuum
FROM pg_stat_user_tables
WHERE n_dead_tup > 1000
ORDER BY n_dead_tup DESC;

실무 팁: dead_pct가 20%를 넘기 시작하면 Autovacuum 튜닝을 검토해야 한다.


4. PostgreSQL vs MySQL — MVCC 구현 방식 비교

같은 MVCC라도 PostgreSQL과 MySQL(InnoDB)의 구현 철학은 뚜렷하게 다르다.

항목PostgreSQLMySQL (InnoDB)
이전 버전 저장 위치테이블 힙 내부 (Heap-based)별도 Undo Log
읽기 성능빠름 (재구성 불필요)Undo Log 체인 순회 비용 존재
공간 관리Dead Tuple 누적 → VACUUM 필수Undo Log 자체 관리 필요
Bloat 위험높음 (관리 소홀 시)낮음 (테이블은 항상 최신 버전만)
특징 요약읽기 최적화공간 효율 최적화

PostgreSQL 방식의 핵심 장점은 읽기 시 행을 재구성하는 오버헤드가 전혀 없다는 것이다. 대신, Dead Tuple이 테이블 안에 누적되므로 반드시 VACUUM으로 청소해야 한다.


5. VACUUM이 하는 일 — 표준 VACUUM vs VACUUM FULL

표준 VACUUM

VACUUM my_table;
VACUUM VERBOSE ANALYZE my_table; -- 상세 로그 + 통계 갱신
  • 잠금 수준: ShareUpdateExclusive Lock — 일반 읽기/쓰기와 동시에 실행 가능
  • Dead Tuple을 제거하고 해당 공간을 "재사용 가능" 상태로 표시
  • 운영체제에 디스크 공간을 반환하지는 않는다 (테이블 끝 페이지가 완전히 비는 경우 제외)
  • 테이블 통계 정보 갱신 (ANALYZE와 병행 가능)

VACUUM FULL

VACUUM FULL my_table; -- 프로덕션에서 신중하게 사용
  • 잠금 수준: AccessExclusive Lock — 테이블을 완전히 잠금
  • 테이블과 인덱스를 처음부터 재작성 → 물리적 디스크 공간 반환
  • 처리 중 원본 + 사본 파일 동시 생성 → 추가 디스크 공간 필요
  • 가능하면 pg_repack 또는 pg_squeeze 사용을 권장
[표준 VACUUM 동작]

Before:  [LIVE][DEAD][DEAD][LIVE][DEAD][LIVE]
After:   [LIVE][    ][    ][LIVE][    ][LIVE]
         ^ 공간은 재사용 가능하지만 OS에 반환되진 않음

[VACUUM FULL 동작]

Before:  [LIVE][DEAD][DEAD][LIVE][DEAD][LIVE]
After:   [LIVE][LIVE][LIVE]
         ^ 완전 재작성 -> 실제 파일 크기 축소

6. Visibility Map과 Freeze

Visibility Map (VM)

VACUUM의 성능을 획기적으로 높여주는 구조가 Visibility Map이다. 각 테이블 힙 파일에 대해 1비트짜리 별도 파일(_vm 접미어)이 존재한다.

  • 비트가 1 → "이 페이지의 모든 Tuple이 모든 활성 트랜잭션에게 가시적" → VACUUM이 이 페이지를 스킵할 수 있음
  • Index-Only Scan 최적화 → VM 비트가 1인 경우에만 힙 페이지 조회를 완전히 생략하고 인덱스만으로 결과를 반환한다. VM 비트가 0이면 여전히 힙 페이지를 조회해야 하므로 이 최적화의 효과를 누리려면 VACUUM이 정기적으로 실행되어야 한다.

이 최적화 덕분에 대용량 테이블에서 VACUUM은 변경된 페이지만 선택적으로 처리한다.

Tuple Freezing (XID Wraparound 방지)

PostgreSQL의 트랜잭션 ID(XID)는 32비트 정수다. 약 42억(2³²) 개의 XID를 다 쓰면 처음으로 되돌아가는 Wraparound 문제가 발생한다. 이때 오래된 행이 "미래 트랜잭션이 만든 행"으로 잘못 인식될 수 있으며, 최악의 경우 데이터 손실로 이어진다.

VACUUM은 이를 방지하기 위해 충분히 오래된 Tuple의 xminFrozenXID(영구 가시적)로 교체하는 Freeze 작업을 수행한다.

-- XID 고갈 위험도 모니터링
SELECT
    datname,
    age(datfrozenxid) AS xid_age,
    2000000000 - age(datfrozenxid) AS xid_remaining
FROM pg_database
ORDER BY xid_age DESC;

모니터링 기준: autovacuum_freeze_max_age 기본값은 2억이다. xid_age가 2억을 넘으면 적극적으로 모니터링하고, 8억을 넘으면 즉각 조치가 필요하다. PostgreSQL 14+에서는 vacuum_failsafe_age(기본 16억)에 도달하면 강제 VACUUM이 발동하여 모든 쿼리를 차단할 수 있다.


7. Part 1 핵심 정리 및 다음 편 예고

Part 1 핵심 요약

개념핵심 내용
MVCC행을 수정/삭제해도 즉시 제거하지 않고 버전을 남겨 동시성 보장
Dead TupleUPDATE/DELETE로 생긴 불필요한 구버전 행 → 공간 낭비 유발
xmin / xmax각 행의 생성/소멸 트랜잭션 ID → 가시성 결정의 핵심
표준 VACUUM비블로킹 방식으로 Dead Tuple 제거, 공간 재사용 표시
VACUUM FULL테이블 재작성으로 실제 공간 반환, 단 AccessExclusive Lock
Visibility MapVACUUM 최적화 + Index-Only Scan 가속화
FreezeXID Wraparound 방지를 위한 오래된 Tuple 영구 가시화

Part 2 예고: Autovacuum 튜닝과 XID Wraparound

다음 편에서는 실전에서 가장 중요한 주제를 다룬다.

  • Autovacuum 내부 구조: launcher, worker, cost-based throttling
  • Autovacuum 트리거 조건: autovacuum_vacuum_threshold, scale_factor 계산법
  • 대형 테이블을 위한 테이블별 파라미터 오버라이드
  • PostgreSQL 13-17 버전별 Vacuum 개선 사항
  • XID Wraparound 재난 시나리오와 긴급 대응 절차

참고 자료

  • PostgreSQL 17 공식 문서 — Routine Vacuuming
  • Google Cloud Blog — Deep dive into PostgreSQL VACUUM
  • Postgres Professional — MVCC in PostgreSQL: Vacuum

이 글 공유하기

시리즈 내비게이션

PostgreSQL Vacuum 완전 정복

현재 글 1 · 3 편 공개

같은 주제 더 보기·대표 시리즈로 시작

English

최신 글을 RSS로 받아보세요

RSS로 새 글과 시리즈 업데이트를 바로 받아볼 수 있습니다.

RSS 구독 안내 보기