autovacuum 이야기를 하면 대개 두 가지 반응이 나온다. 하나는 "그거 알아서 도는 거 아니냐"고, 다른 하나는 "우린 껐다"다. 둘 다 나름의 이유가 있는데, 결국 내부 동작을 잘 모르는 상태에서 기본값과 부딪히다 지친 사람들의 흔적이다. 사실 autovacuum은 상당히 정교한 스케줄러와 워커 조합이고, 각 파라미터가 어디에 걸려서 동작하는지 알면 튜닝이 아니라 그냥 논리적인 조합 문제가 된다.
우리 팀에서는 최근 300GB 규모의 이벤트 로그 테이블에서 autovacuum이 "안 도는 것처럼" 보이는 문제를 파고들었다. 결론부터 말하면 안 돌던 게 아니라 계속 시작했다가 중간에 죽고 있었다. 이 이야기를 하려면 결국 내부 흐름부터 짚고 가야 한다.
launcher와 worker의 관계
Postgres 프로세스 트리를 ps로 보면 autovacuum launcher라는 프로세스 하나가 항상 살아있다. 이 녀석은 실제로 vacuum을 돌리는 애가 아니다. 어떤 데이터베이스를 언제 방문할지 스케줄링만 한다.
launcher는 autovacuum_naptime(기본 1분) 마다 깨어나서 다음 방문할 데이터베이스를 고른다. 데이터베이스가 여러 개면 naptime을 데이터베이스 수로 나눠서 방문 주기가 결정된다. 그러니까 DB가 10개면 실제로는 한 DB당 10분에 한 번씩 살펴본다는 뜻이다. 이걸 모르고 naptime을 5초로 낮추는 사람들이 종종 있는데, 방문 주기는 짧아져도 실제 vacuum 판단은 별개라서 큰 효과가 없다.
방문 시점에 launcher는 autovacuum worker를 fork한다. worker는 해당 DB에 접속한 뒤 pg_class와 pg_stat_all_tables를 뒤져서 "이번에 처리해야 할 테이블 목록"을 만든다. 여기가 실제로 튜닝 파라미터들이 개입하는 지점이다.
worker는 autovacuum_max_workers(기본 3) 만큼 동시에 존재할 수 있다. 여기서 오해가 하나 생기는데, 이 3이라는 숫자는 "동시에 3개 테이블을 병렬 처리한다"는 뜻이 아니다. 각 worker는 하나의 DB에 붙어서 그 안의 여러 테이블을 순차적으로 처리한다. 3개 DB가 동시에 처리될 수는 있지만, 한 DB 안에서 큰 테이블 하나가 걸리면 그 DB의 다른 테이블들은 다음 worker cycle까지 기다린다.
트리거 판단: 왜 도는지, 왜 안 도는지
worker가 테이블 목록을 훑을 때 각 테이블마다 세 가지 판단을 한다. VACUUM이 필요한가, ANALYZE가 필요한가, wraparound 방지 emergency VACUUM이 필요한가.
VACUUM 필요 여부는 이 공식으로 결정된다.
threshold = autovacuum_vacuum_threshold
+ autovacuum_vacuum_scale_factor * pg_class.reltuples
기본값이 threshold 50, scale_factor 0.2다. 그러니까 100만 row 테이블은 20만 row가 dead tuple로 쌓여야 vacuum이 트리거된다. 1억 row 테이블이라면? 2000만 row다. 이게 대용량 테이블에서 autovacuum이 "안 도는 것처럼" 보이는 가장 흔한 이유다. 안 도는 게 아니라 임계값에 도달하는 데 몇 시간, 며칠이 걸리는 것뿐이다.
Postgres 18부터는 autovacuum_vacuum_max_threshold가 추가돼서 이 문제를 완화한다. scale_factor 계산과 별개로 dead tuple 절대값이 이 값을 넘으면 무조건 vacuum이 돌게 된다. 우리도 18로 올린 뒤로는 이 파라미터에 크게 의존하는 편인데, 그 전까지는 큰 테이블마다 ALTER TABLE ... SET (autovacuum_vacuum_scale_factor = 0.02) 같은 per-table 오버라이드를 걸어두는 게 정석이었다.
pg_stat_all_tables에서 n_dead_tup 값이 위 threshold를 넘는지 실시간으로 확인할 수 있다. 이 값이 threshold를 훨씬 넘고 있는데도 last_autovacuum이 며칠 전이라면? 트리거는 됐는데 실행이 실패하고 있다는 뜻이다. 다음 얘기가 그거다.
cost delay와 vacuum이 죽는 지점
일단 worker가 특정 테이블에 대해 VACUUM을 시작하면, 이제 다른 파라미터가 개입한다. autovacuum_vacuum_cost_delay와 autovacuum_vacuum_cost_limit다.
내부적으로 vacuum은 각 페이지를 읽고 처리할 때마다 "비용 점수"를 누적한다. shared buffer에 있는 페이지를 읽으면 1점, 디스크에서 읽으면 10점, dirty 페이지를 새로 만들면 20점. 이 누적 점수가 cost_limit(기본 200)에 도달하면 cost_delay(기본 2ms) 만큼 잠시 쉰다.
이 값이 왜 이렇게 짜게 잡혔냐면, 원래 이건 스피닝 디스크가 표준이던 시절에 만들어진 방어책이기 때문이다. vacuum이 IO를 너무 많이 쓰면 OLTP 워크로드에 영향이 가니까 일부러 느리게 도는 것이다. NVMe SSD 시대에는 이게 오히려 vacuum이 dead tuple 증가 속도를 못 따라가게 만든다.
우리는 SSD 클러스터에서 다음과 같이 튜닝했다.
autovacuum_vacuum_cost_limit = 3000
autovacuum_vacuum_cost_delay = 2ms
cost_limit을 15배 올려서 한 사이클에 처리 가능한 페이지 수를 늘렸다. cost_delay를 낮추는 것도 방법인데, 0으로 만들면 진짜로 IO를 다 쓸어버리기 때문에 delay는 유지하고 limit 쪽을 조절하는 게 안정적이다. 이 조합으로 300GB 테이블 하나 vacuum 도는 시간이 하루 반에서 3시간으로 줄었다.
그리고 여기서 우리가 겪은 진짜 문제. worker는 한 번의 방문 사이클 안에서 자기 담당 테이블들을 순회하는데, 중간에 lock 충돌이나 statement_timeout 관련 조건으로 죽으면 진행 상황이 사라진다. VACUUM은 대체로 재시작해도 이어서 하지만, 프로세스가 죽었다 다시 뜨는 데 시간이 들고 pg_stat 상에서는 "계속 시작하고 계속 실패"하는 패턴이 나타난다. 우리 경우 백업 스크립트가 lock 잡는 시간대와 autovacuum이 겹치면서 매일 밤 vacuum이 도중에 죽고 있었다. 백업 스케줄 옮기고 나서야 정상화됐다.
HOT update와 fillfactor, 그리고 index bloat
여기서 하나 짚고 넘어갈 게 있다. autovacuum이 아무리 잘 돌아도, 애초에 dead tuple이 덜 생기는 게 낫다. Heap-Only Tuple(HOT) update가 그 역할을 한다.
일반 UPDATE는 원본 row를 dead로 만들고 새 row를 어딘가에 쓴다. 그리고 그 테이블의 모든 인덱스에 새 row에 대한 엔트리를 추가한다. 인덱스가 5개면 인덱스 엔트리도 5개가 새로 생긴다. HOT update는 이 대신 새 row를 원본과 같은 페이지에 쓰고, 인덱스는 건드리지 않는다. 조건이 있다. 인덱스로 색인된 컬럼이 변경되지 않아야 하고, 같은 페이지에 여유 공간이 있어야 한다.
같은 페이지에 여유 공간을 확보하는 게 fillfactor다. 기본값은 100인데 이건 페이지를 꽉 채운다는 뜻이다. 업데이트가 많은 테이블에서는 이걸 80이나 70으로 낮춰서 페이지에 여유를 남긴다.
ALTER TABLE orders SET (fillfactor = 80);
VACUUM FULL orders; -- 기존 데이터도 새 fillfactor로 재배치
이거 한 번 해두면 UPDATE 헤비한 테이블에서 HOT update 비율이 5%에서 60% 넘게 올라가는 경우가 있다. 인덱스 bloat이 그만큼 안 생기고, vacuum이 할 일도 줄어든다. pg_stat_all_tables의 n_tup_hot_upd / n_tup_upd 비율로 확인 가능하다.
그래서 뭘 봐야 하나
정기적으로 봐야 할 뷰가 몇 개 있다. pg_stat_all_tables의 n_dead_tup과 last_autovacuum, autovacuum_count. 그리고 pg_stat_progress_vacuum. 이 뷰는 현재 돌고 있는 vacuum이 어느 단계에 있는지, 몇 페이지를 스캔했고 얼마나 남았는지 보여준다. 이거 없이 vacuum 튜닝하는 건 눈 감고 운전하는 것과 비슷하다.
우리 팀은 autovacuum이 도는 시간대, 한 번에 몇 페이지를 처리하는지, 어느 테이블에서 시간을 많이 쓰는지 Grafana 대시보드로 뽑아두고 매주 본다. 대시보드가 있으면 튜닝의 효과가 눈에 보이고, 없으면 그냥 "느낌"으로 파라미터를 만지게 된다.
autovacuum은 껐다가 켤 만한 물건이 아니다. 껐다는 팀들 얘기 들어보면 결국 crontab에 VACUUM 스크립트 걸어둔 경우가 많은데, 그럴 바에는 autovacuum을 이해하고 살살 조절하는 쪽이 훨씬 낫다. 파라미터 몇 개가 어디에 걸리는지만 알면 되는 문제다.
다음 글에서는 vacuum이 아니라 index-only scan을 위한 visibility map과 그 조작 방법을 다뤄볼까 한다. 이거랑도 얽혀있는 이야기라 뭐, 이어서 쓰기 좋다.
'IT > DB 운영' 카테고리의 다른 글
| Redis Sentinel이 failover를 안 해준 새벽 이야기 (0) | 2026.07.20 |
|---|---|
| idle_in_transaction_session_timeout, 이거 하나로 커넥션 풀이 살아난다 (0) | 2026.07.16 |
| PostgreSQL 17 autovacuum, 이제 maintenance_work_mem 상한 신경 안 써도 된다 (0) | 2026.07.12 |
| session pooling에서 transaction pooling으로 갈아탄 후 3주간 벌어진 일 (0) | 2026.07.05 |
| PgBouncer transaction vs session 모드, 뭘 쓸까 (0) | 2026.06.29 |