- Published on
PostgreSQL VACUUM 안 하면 터지는 성능 장애들
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
운영 중인 PostgreSQL이 어느 날부터 갑자기 느려지고, 디스크 사용량이 꾸준히 증가하며, 평소 잘 돌던 쿼리가 랜덤하게 타임아웃 나기 시작했다면 가장 먼저 의심해야 할 것 중 하나가 VACUUM(정확히는 autovacuum 포함)입니다.
PostgreSQL은 MVCC(다중 버전 동시성 제어) 구조 때문에 UPDATE/DELETE가 “제자리 수정”이 아니라 “새 버전 생성”으로 동작합니다. 이때 기존 버전은 즉시 사라지지 않고 “죽은 튜플(dead tuple)”로 남습니다. 이 죽은 튜플을 정리하고, 통계 정보를 갱신하고, 트랜잭션 ID를 안전하게 유지하는 핵심 작업이 VACUUM입니다.
VACUUM을 안 하면 단순히 “디스크가 좀 늘어난다” 수준이 아니라, 성능 장애로 직결되는 여러 연쇄 효과가 발생합니다.
VACUUM을 안 하면 생기는 대표 장애 시나리오
1) 테이블/인덱스 팽창(bloat)으로 인한 쿼리 지연
죽은 튜플이 쌓이면 테이블 파일이 커지고(팽창), 인덱스도 함께 비대해집니다. 그 결과:
- 같은 쿼리를 실행해도 읽어야 할 페이지 수가 증가
- 캐시 적중률 하락(버퍼 캐시에 “쓸모 없는 페이지”가 자리 차지)
- 디스크 I/O 증가로 전체 시스템 지연
특히 인덱스 팽창은 체감이 큽니다. 인덱스가 커지면 B-Tree 깊이 증가, 랜덤 I/O 증가, 페이지 분할 증가로 이어져, 조회가 느려지고 쓰기 성능도 덩달아 나빠집니다.
2) 실행 계획이 틀어져서 갑자기 느려짐(통계 부정확)
VACUUM 자체도 의미가 있지만, 실무에서 더 자주 보는 문제는 ANALYZE가 충분히 돌지 않아 통계가 낡는 경우입니다.
통계가 낡으면 옵티마이저가 잘못된 카디널리티를 가정하고,
- 인덱스를 타야 하는데 Seq Scan을 선택
- Nested Loop로는 감당 안 되는 조인을 선택
- 정렬/해시 연산 메모리 추정이 틀어져 디스크 스필 발생
같은 식으로 “어제까지 50ms였던 쿼리가 오늘 5초” 같은 현상이 생깁니다.
3) autovacuum이 뒤늦게 폭주해 I/O 스파이크 유발
초기에 조금씩 청소했으면 좋았을 작업이 누적되면, 어느 순간 autovacuum worker가 한꺼번에 많은 테이블을 처리하려고 달라붙습니다.
이때 흔한 증상:
- 평소보다
read IOPS/write IOPS가 급증 - 애플리케이션 p95/p99 지연이 튀기 시작
- 체크포인트/백그라운드 라이터와 맞물려 스토리지 큐가 길어짐
즉 “VACUUM을 안 해서” 느려진 것도 있지만, “VACUUM이 너무 늦게 돌아서” 느려지는 경우도 많습니다.
4) 트랜잭션 ID 래핑(wraparound)으로 인한 사실상 서비스 중단
이건 가장 위험합니다.
PostgreSQL은 트랜잭션 ID(XID)를 32비트로 관리하고, 일정 시점이 지나면 래핑을 막기 위해 강제 조치를 취합니다. VACUUM(정확히는 freeze)이 제때 수행되지 않으면 다음과 같은 상황이 발생할 수 있습니다.
- 경고가 누적
- 임계점에 도달하면 DB가 쓰기를 사실상 막고 “반드시 vacuum 하라” 상태로 진입
이 단계는 “느려짐”이 아니라 “업데이트/인서트가 안 됨”으로 이어져 장애가 됩니다.
장애 징후: 지금 VACUUM이 밀리고 있는지 확인하기
아래 쿼리들은 운영 점검에 자주 쓰입니다.
테이블별 dead tuple 비율 확인
SELECT
relname AS table_name,
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_vacuum,
last_autovacuum,
last_analyze,
last_autoanalyze
FROM pg_stat_user_tables
ORDER BY n_dead_tup DESC
LIMIT 30;
n_dead_tup가 크고last_autovacuum이 오래 전이면 위험 신호입니다.dead_pct가 높은 테이블은 팽창과 성능 저하의 진원지일 가능성이 큽니다.
freeze 위험(나이) 확인
SELECT
c.relname AS table_name,
age(c.relfrozenxid) AS xid_age,
pg_size_pretty(pg_total_relation_size(c.oid)) AS total_size
FROM pg_class c
JOIN pg_namespace n ON n.oid = c.relnamespace
WHERE n.nspname NOT IN ('pg_catalog', 'information_schema')
AND c.relkind = 'r'
ORDER BY xid_age DESC
LIMIT 30;
xid_age가 큰 테이블은 wraparound 리스크가 커집니다.
autovacuum 활동 확인
SELECT
pid,
datname,
relid::regclass AS relation,
phase,
heap_blks_scanned,
heap_blks_vacuumed,
index_vacuum_count,
now() - xact_start AS xact_age
FROM pg_stat_progress_vacuum
ORDER BY xact_age DESC;
- 특정 테이블에서 오랫동안 vacuum이 진행 중이면 I/O 병목과 연관지어 봐야 합니다.
왜 autovacuum이 있는데도 문제가 생길까
“기본 설정이면 알아서 되는 것 아닌가?”가 흔한 오해입니다. autovacuum은 훌륭하지만, 워크로드와 테이블 특성에 따라 기본값이 부족할 수 있습니다.
대표 케이스:
- 대량 업데이트/삭제가 빈번한 테이블(이벤트, 로그, 큐)
- 핫 테이블이 몇 개에 쓰기가 집중되는 구조
- 테이블이 커졌는데 scale factor가 그대로라 vacuum 트리거가 너무 늦음
- 장기 트랜잭션(혹은 idle in transaction)이 dead tuple 정리를 막음
특히 “오래 열린 트랜잭션”은 vacuum이 지워도 되는 튜플을 지우지 못하게 만들어 팽창을 가속합니다.
해결 전략 1: 일단 급한 불 끄기(운영 대응)
1) 문제 테이블에 수동 VACUUM/ANALYZE
VACUUM (ANALYZE) public.big_table;
- 통계 갱신이 필요한 경우
ANALYZE를 같이 수행합니다. - 단, 대형 테이블에서 vacuum이 오래 걸리면 I/O 영향이 커질 수 있어 트래픽이 낮은 시간대가 좋습니다.
2) 인덱스/테이블 팽창이 심하면 재작성 고려
일반 VACUUM은 “공간을 OS에 반환”하지 않습니다(재사용 가능한 빈 공간으로 남김). 실제 파일 크기를 줄이려면 재작성 계열이 필요합니다.
VACUUM FULL(테이블 재작성, 강한 락)CLUSTER(인덱스 기준 재정렬, 재작성)REINDEX(인덱스 재작성)
예:
REINDEX INDEX CONCURRENTLY public.big_table_some_idx;
CONCURRENTLY는 락을 줄이지만 더 오래 걸리고 리소스를 더 씁니다.
3) 장기 트랜잭션부터 제거
vacuum이 “못 치우는” 상황이면 아무리 vacuum을 돌려도 효과가 제한됩니다.
SELECT
pid,
usename,
state,
now() - xact_start AS xact_age,
query
FROM pg_stat_activity
WHERE xact_start IS NOT NULL
ORDER BY xact_age DESC
LIMIT 20;
idle in transaction이 길면 애플리케이션 커넥션 관리(풀 설정, 트랜잭션 범위)를 의심하세요.
운영 장애를 “원인 진단 → 조치 → 재발 방지”로 끊어내는 접근은 인프라 전반에 통합니다. 재시작 루프를 빠르게 진단하는 접근 방식도 유사하니, 장애 대응 관점에서는 systemd 서비스 재시작 루프 10분 진단 가이드 같은 글의 프레임을 참고해도 좋습니다.
해결 전략 2: 재발 방지(autovacuum 튜닝)
autovacuum 튜닝은 “전역 설정”과 “테이블별 설정”을 함께 봐야 합니다. 특히 핫 테이블은 테이블별로 더 공격적으로 vacuum/analyze가 돌도록 설정하는 게 효과적입니다.
테이블별 autovacuum 트리거를 앞당기기
ALTER TABLE public.big_table SET (
autovacuum_vacuum_scale_factor = 0.02,
autovacuum_vacuum_threshold = 5000,
autovacuum_analyze_scale_factor = 0.01,
autovacuum_analyze_threshold = 3000
);
- scale factor는 “테이블 크기에 비례해 트리거”되는 비율입니다.
- 큰 테이블일수록 기본값(예:
0.2)은 너무 늦게 발동합니다.
autovacuum 작업량/동시성 조정
환경에 따라 다르지만, 다음 파라미터들을 같이 검토합니다.
autovacuum_max_workersautovacuum_naptimeautovacuum_vacuum_cost_limitautovacuum_vacuum_cost_delay
핵심은 “너무 약해서 밀리는” 것도 문제지만, “너무 강해서 피크 시간에 I/O를 잡아먹는” 것도 문제라는 점입니다. 트래픽 패턴이 뚜렷하다면 비용 기반 딜레이를 적절히 두고, 오프피크에 더 적극적으로 돌도록 운영 정책을 잡는 편이 안전합니다.
해결 전략 3: 파티셔닝/보관 정책으로 구조를 바꾸기
로그성 테이블, 이벤트 테이블처럼 “쓰기 많고, 일정 기간 지나면 거의 조회 안 함”이라면 vacuum 최적화보다 구조 개선이 더 큰 효과를 냅니다.
- 월/일 단위 파티셔닝 후 오래된 파티션
DROP - TTL에 가까운 데이터는 별도 스토리지로 아카이빙
이렇게 하면 죽은 튜플을 “청소”하는 대신 “파티션 단위 삭제”로 대체할 수 있어 vacuum 부담이 급감합니다.
운영 체크리스트(장애로 번지기 전에)
pg_stat_user_tables에서n_dead_tup상위 테이블을 주기적으로 점검age(relfrozenxid)상위 테이블 알람 설정(wraparound 예방)- 장기 트랜잭션(특히
idle in transaction) 모니터링 및 강제 종료 정책 - 핫 테이블은 테이블별 autovacuum 파라미터를 별도로 설정
- 대형 인덱스는 주기적으로
REINDEX CONCURRENTLY가능 여부 검토
디스크가 “분명 남아 있는데도” 장애가 나는 케이스는 실제로 자주 발생합니다. PostgreSQL 팽창도 결국 스토리지/파일시스템 레벨 증상으로 이어질 수 있으니, 디스크 관점 트러블슈팅은 No space left on device인데 용량 남을 때 - inode 0% 해결도 함께 참고하면 원인 분리가 빨라집니다.
마무리
PostgreSQL에서 VACUUM은 선택이 아니라 “성능과 안정성을 유지하기 위한 필수 백그라운드 작업”입니다. 방치하면 팽창으로 인한 만성 성능 저하, 통계 부정확으로 인한 실행 계획 붕괴, autovacuum 폭주로 인한 I/O 스파이크, 최악의 경우 트랜잭션 ID 래핑에 따른 서비스 중단까지 이어질 수 있습니다.
가장 현실적인 접근은 다음 순서입니다.
- dead tuple과 freeze 위험을 수치로 확인
- 핫 테이블부터 수동 vacuum/analyze로 응급 처치
- 장기 트랜잭션을 제거해 vacuum이 “일할 수 있는 상태”를 만들기
- 테이블별 autovacuum 튜닝과 파티셔닝으로 재발 방지
이 루틴을 운영 표준으로 만들면, “어느 날 갑자기 DB가 느려지는” 종류의 장애를 상당 부분 예방할 수 있습니다.