Published on

PostgreSQL autovacuum 지연으로 테이블 bloat 해결

Authors

서버 디스크가 꾸준히 늘고, 동일 쿼리가 점점 느려지며, VACUUM을 돌려도 체감이 없을 때 가장 흔한 원인 중 하나가 autovacuum 지연입니다. autovacuum이 제때 dead tuple을 정리하지 못하면 테이블과 인덱스에 bloat가 쌓이고, 결국 I/O 증가, 캐시 효율 저하, 쿼리 플랜 악화로 이어집니다.

이 글에서는 autovacuum 지연을 “관측 가능한 지표”로 확인하고, 지연의 대표 원인(긴 트랜잭션, vacuum 비용 제한, I/O 병목, 테이블별 임계값 부적절)을 분해해 해결하는 실전 접근을 다룹니다. bloat 자체 진단은 아래 글과 함께 보면 더 빠르게 전체 그림을 잡을 수 있습니다.

autovacuum 지연이 bloat로 이어지는 메커니즘

PostgreSQL은 MVCC 특성상 UPDATE/DELETE가 즉시 공간을 회수하지 않습니다. 이전 버전의 튜플이 남아 있고, 이를 “dead tuple”이라고 부릅니다. vacuum이 dead tuple을 정리해 “재사용 가능” 상태로 만들며, autovacuum은 이를 자동으로 수행합니다.

문제는 다음 두 가지입니다.

  1. 정리(가시성 맵, dead tuple cleanup)가 늦어지면 테이블 페이지에 쓸데없는 튜플이 늘고, 스캔해야 하는 페이지 수가 증가합니다.
  2. 인덱스는 더 민감합니다. dead tuple이 많아지면 인덱스 엔트리도 누적되어 랜덤 I/O가 증가하고, 인덱스 스캔이 비효율적으로 변합니다.

즉 autovacuum 지연은 “디스크가 커진다” 수준을 넘어, “쿼리 플랜은 같아도 실제 읽는 페이지가 늘어 느려진다”로 나타납니다.

1단계: autovacuum이 정말 지연 중인지 확인하기

먼저 감으로 접근하지 말고, 아래 뷰들로 사실을 확인합니다.

테이블별 vacuum 상태와 dead tuple

SELECT
  schemaname,
  relname,
  n_live_tup,
  n_dead_tup,
  ROUND(100.0 * n_dead_tup / NULLIF(n_live_tup + n_dead_tup, 0), 2) AS dead_ratio_pct,
  last_autovacuum,
  last_vacuum,
  vacuum_count,
  autovacuum_count
FROM pg_stat_user_tables
ORDER BY n_dead_tup DESC
LIMIT 30;
  • n_dead_tup가 크고 last_autovacuum이 오래되었으면 지연 가능성이 큽니다.
  • 단, n_dead_tup는 추정치라서 100퍼센트 정확하진 않습니다. 그래도 “상대 비교”에는 충분히 유용합니다.

현재 실행 중인 autovacuum 확인

SELECT
  pid,
  datname,
  relid::regclass AS relation,
  phase,
  heap_blks_scanned,
  heap_blks_vacuumed,
  index_vacuum_count,
  max_dead_tuples,
  num_dead_tuples
FROM pg_stat_progress_vacuum
ORDER BY pid;

여기서 중요한 포인트는 다음입니다.

  • autovacuum이 아예 안 돈다면 “트리거 조건 미충족” 또는 “워커 부족”을 의심합니다.
  • autovacuum이 돌고는 있는데 진척이 느리다면 “I/O 병목, 비용 제한(cost limit), 락/경합, 긴 트랜잭션으로 인한 제거 불가”를 의심합니다.

autovacuum 워커 포화 여부

SHOW autovacuum_max_workers;

SELECT
  COUNT(*) FILTER (WHERE query LIKE 'autovacuum:%') AS running_autovacuum,
  COUNT(*) AS total_backends
FROM pg_stat_activity;
  • 실행 중인 autovacuum이 항상 autovacuum_max_workers에 붙어 있다면 backlog가 쌓일 수 있습니다.

2단계: autovacuum 지연의 대표 원인과 해결

원인 A: 긴 트랜잭션(오래 열린 세션)로 vacuum이 정리를 못 함

vacuum은 “아무도 참조하지 않는” 튜플만 제거할 수 있습니다. 오래 열린 트랜잭션이 있으면 xmin이 오래되어 dead tuple이 있어도 제거가 불가능합니다. 이 경우 autovacuum은 돌지만 효과가 없고 bloat만 커집니다.

오래 열린 트랜잭션 찾기

SELECT
  pid,
  usename,
  application_name,
  client_addr,
  state,
  xact_start,
  now() - xact_start AS xact_age,
  query
FROM pg_stat_activity
WHERE xact_start IS NOT NULL
ORDER BY xact_start ASC
LIMIT 20;

해결 체크리스트

  • 애플리케이션에서 커넥션 풀 사용 시, 트랜잭션을 열어둔 채 대기하는 코드 패턴이 없는지 확인합니다.
  • 배치 작업이 긴 트랜잭션으로 대량 업데이트를 수행한다면, 작은 배치로 쪼개 커밋합니다.
  • 운영 정책으로 idle_in_transaction_session_timeout을 설정해 “실수로 열린 트랜잭션”을 자동 종료합니다.
-- 예: 5분 이상 idle in transaction이면 종료
ALTER SYSTEM SET idle_in_transaction_session_timeout = '5min';
SELECT pg_reload_conf();

주의: 실제 서비스 특성상 긴 트랜잭션이 정상일 수 있으니, 먼저 영향 범위를 점검해야 합니다.

원인 B: vacuum 비용 제한(cost-based vacuum delay)로 너무 느림

PostgreSQL은 vacuum이 시스템을 과도하게 점유하지 않도록 비용 기반 제한을 둡니다. 기본값이 보수적이면, 쓰기 부하가 큰 테이블에서 autovacuum이 “항상 따라잡지 못하는” 상태가 됩니다.

관련 설정 확인

SHOW autovacuum_vacuum_cost_limit;
SHOW autovacuum_vacuum_cost_delay;
SHOW vacuum_cost_limit;
SHOW vacuum_cost_delay;

접근 방법

  • 전역 설정을 과감히 바꾸기보다, 먼저 문제가 되는 테이블에만 autovacuum 파라미터를 조정합니다.
  • 특히 대규모 업데이트가 자주 발생하는 테이블은 임계값과 비용 제한을 테이블 단위로 최적화하는 것이 안전합니다.

원인 C: autovacuum 트리거 임계값이 테이블 특성과 맞지 않음

autovacuum은 대략 “변경된 튜플 수가 임계값을 넘으면” 실행됩니다. 임계값은 다음 형태입니다.

  • threshold + scale_factor * reltuples

테이블이 매우 크면 scale_factor로 인해 트리거가 너무 늦게 걸릴 수 있고, 반대로 작은 테이블은 너무 자주 돌 수 있습니다.

테이블 단위로 autovacuum 트리거 조정

예를 들어 events 테이블이 업데이트/삭제가 많아 dead tuple이 빠르게 쌓인다면:

ALTER TABLE public.events SET (
  autovacuum_vacuum_scale_factor = 0.02,
  autovacuum_vacuum_threshold = 5000,
  autovacuum_analyze_scale_factor = 0.01,
  autovacuum_analyze_threshold = 3000
);
  • 핵심은 “너무 늦지 않게 자주 치우게” 만드는 것입니다.
  • ANALYZE도 같이 조정해야 플래너 통계가 최신으로 유지되어 성능이 안정적입니다.

원인 D: autovacuum 워커 수 부족, 또는 I/O 병목

변경이 많은 테이블이 여러 개면 autovacuum 큐가 밀립니다. 또한 스토리지 IOPS가 부족하거나 체크포인트 설정이 공격적이면 vacuum이 I/O 경합에서 밀려 느려집니다.

워커 수 조정(전역)

SHOW autovacuum_max_workers;
SHOW autovacuum_naptime;

-- 예: 워커 수 증가 (재시작 필요)
ALTER SYSTEM SET autovacuum_max_workers = 6;
  • 워커 수를 늘리면 좋아 보이지만, 스토리지 여력이 없으면 오히려 경합이 커질 수 있습니다.
  • autovacuum_naptime을 줄이면 더 자주 깨지만, 근본적으로 I/O가 부족하면 효과가 제한적입니다.

3단계: 이미 커진 bloat를 “되돌리는” 방법 선택

autovacuum과 일반 VACUUM은 기본적으로 공간을 OS에 반환하지 않습니다(재사용 가능 상태로만 만듭니다). 따라서 디스크를 실제로 줄이거나, 인덱스/테이블 물리 크기를 줄이려면 추가 작업이 필요합니다.

1) 인덱스 bloat가 크면 REINDEX CONCURRENTLY

서비스 중단을 최소화하려면 동시 재구성을 고려합니다.

REINDEX INDEX CONCURRENTLY public.events_created_at_idx;
  • 인덱스 bloat가 성능에 미치는 영향이 큰 경우가 많아, 체감 개선이 빠르게 나옵니다.
  • 단, 동시 재구성은 시간이 더 걸리고 임시 공간이 필요합니다.

2) 테이블 자체가 심하면 VACUUM (FULL) 또는 pg_repack 계열

VACUUM (FULL)은 테이블을 재작성하여 공간을 반환하지만, 강한 락이 필요합니다.

VACUUM (FULL, VERBOSE, ANALYZE) public.events;
  • 운영 중에는 사실상 점검 창이 필요합니다.
  • 다운타임이 어렵다면 외부 도구(예: pg_repack)를 검토할 수 있습니다.

3) 파티셔닝으로 bloat 영향 범위를 줄이기

시간 기반으로 쌓이는 대형 테이블(로그, 이벤트, 트래킹)은 파티셔닝이 bloat 대응에 유리합니다.

  • 오래된 파티션은 DROP으로 즉시 공간 회수
  • hot 파티션만 집중 vacuum/analyze

4단계: 재발 방지 운영 체크리스트

bloat를 한 번 줄였더라도, autovacuum 지연 원인이 남아 있으면 다시 커집니다. 아래를 “지표 기반”으로 운영 루틴에 넣는 것이 좋습니다.

핵심 지표

  • pg_stat_user_tables.n_dead_tup 상위 테이블
  • last_autovacuum이 오래된 테이블
  • pg_stat_activity의 오래 열린 트랜잭션
  • pg_stat_progress_vacuum에서 vacuum이 특정 테이블에서 오래 멈춰 있는지

권장 운영 루틴

  1. 변경이 많은 테이블을 식별하고 테이블 단위 autovacuum 파라미터를 조정
  2. 배치 작업은 작은 커밋 단위로 분할
  3. idle_in_transaction_session_timeout으로 실수 방지
  4. 인덱스 bloat가 커지기 전에 주기적으로 REINDEX CONCURRENTLY 계획
  5. 대형 로그성 테이블은 파티셔닝 또는 보관 정책(rollover) 도입

실전 예시: “autovacuum은 도는데 dead tuple이 줄지 않는다”

현장에서 자주 보는 패턴은 다음과 같습니다.

  • pg_stat_progress_vacuum에는 autovacuum이 보임
  • 그런데 n_dead_tup가 며칠째 상위권
  • 디스크 사용량 증가

이때 가장 먼저 확인할 것은 “긴 트랜잭션”입니다. 오래 열린 트랜잭션이 있으면 vacuum이 cleanup을 못 하고 계속 스캔만 하다가 끝나거나, 효과가 제한적입니다. 위에서 소개한 pg_stat_activity 쿼리로 xact_age가 비정상적으로 큰 세션을 찾아 원인을 제거하는 것이 1순위입니다.

그 다음으로는 테이블 단위 임계값 조정과 비용 제한 조정입니다. 특히 대형 테이블에서 autovacuum_vacuum_scale_factor가 기본값에 가까우면 트리거가 너무 늦게 걸려 “한 번 밀리면 계속 밀리는” 상태가 됩니다.

마무리

PostgreSQL의 bloat는 대개 autovacuum 자체의 문제가 아니라, autovacuum이 제 역할을 못 하게 만드는 운영 조건(긴 트랜잭션, 과도한 비용 제한, 워커/IO 병목, 테이블 특성과 안 맞는 임계값)에서 시작됩니다.

순서는 다음이 가장 안전합니다.

  1. pg_stat_user_tables, pg_stat_progress_vacuum, pg_stat_activity로 지연 원인 계측
  2. 긴 트랜잭션 제거와 테이블 단위 autovacuum 튜닝으로 “지연”을 해소
  3. 이미 커진 bloat는 REINDEX CONCURRENTLY 또는 점검 창을 통한 VACUUM (FULL) 등으로 “물리 크기”를 정리
  4. 파티셔닝/배치 커밋/타임아웃으로 재발 방지

이 과정을 밟으면 “디스크는 계속 늘고 쿼리는 계속 느려지는” 악순환을 끊고, autovacuum이 안정적으로 따라오는 상태로 되돌릴 수 있습니다.