대량 적재 다음 날 쿼리가 느려진 진짜 이유를, 통계(ANALYZE)와 플래너의 관계로 정리한 글입니다.

플래너는 무엇을 보고 플랜을 고르는가
같은 SQL을 던져도 어제는 30ms에 끝나던 쿼리가 오늘은 20초를 잡아먹는 일이 있습니다. 인덱스도 그대로, 데이터도 크게 다르지 않은데 말이죠. 이 수수께끼를 풀려면 먼저 옵티마이저, 그중에서도 플래너(planner) 가 어떻게 실행 계획을 고르는지부터 짚어야 합니다.
플래너는 테이블을 직접 열어 보고 판단하지 않습니다. 매번 수백만 행을 훑어 "이 조건에 몇 건이 걸리나"를 세는 건 너무 비싸니까요. 대신 데이터베이스가 미리 요약해 둔 통계(statistics) 를 근거로 추정합니다. 지도를 보고 길을 정하는 내비게이션과 비슷하죠. 실제 도로를 달려 보지 않아도 지도만 정확하면 최적 경로를 뽑아낼 수 있습니다. 문제는 지도가 낡으면 엉뚱한 길로 안내한다는 데 있습니다.
PostgreSQL을 예로 들면, 통계는 pg_statistic에 저장되고 pg_stats 뷰로 들여다볼 수 있습니다. 컬럼마다 다음 값들이 담깁니다.
- n_distinct — 컬럼의 고유값 개수 추정치
- null_frac — 전체 중 null이 차지하는 비율
- most_common_vals / most_common_freqs — 가장 자주 나오는 값과 그 빈도
- histogram_bounds — 값의 분포를 구간으로 나눈 히스토그램 경계
플래너는 이 값들로 선택도(selectivity), 즉 "조건을 통과하는 행의 비율"을 계산합니다. 예를 들어 WHERE status = 'DONE'이라면 most_common_freqs에서 DONE의 빈도를 찾아 곱하고, WHERE created_at > '2026-06-01'이라면 히스토그램 구간 중 몇 칸이 걸리는지로 비율을 뽑죠. 여기에 테이블 전체 행 수(reltuples)를 곱하면 예상 행 수가 나옵니다.
이렇게 추정한 행 수가 플랜 선택의 갈림길이 됩니다. 조건에 걸리는 행이 100건쯤이라 추정되면 플래너는 인덱스로 콕 집어 오는 index scan을 고르죠. 반대로 테이블의 절반이 걸린다고 보면 인덱스를 왔다 갔다 하느니 통째로 읽는 sequential scan이 싸다고 판단합니다. join 방식도 마찬가지입니다. 한쪽이 작다고 추정하면 nested loop을, 양쪽이 크다고 보면 hash join을 택하죠. 결국 플래너의 모든 결정이 통계라는 지도 위에서 이뤄지는 셈입니다. EXPLAIN에 찍히는 rows= 값이 바로 이 추정치인데, 저는 이 숫자와 실제 행 수의 괴리를 보는 습관이 진단의 출발점이라고 생각합니다.
통계가 낡으면 벌어지는 일
지도가 정확할 때는 이 구조가 아름답게 돌아갑니다. 문제는 데이터가 통계보다 빠르게 변할 때입니다. 통계는 실시간으로 갱신되지 않고, ANALYZE가 돌 때에만 다시 수집됩니다. 그 사이 대량 INSERT나 DELETE가 지나가면 지도와 실제 지형이 어긋나기 시작하죠.
가장 극적인 경우가 대량 적재 직후입니다. 어제 10만 행이던 테이블에 밤사이 배치가 500만 행을 밀어 넣었다고 해 보죠. 그런데 통계상 reltuples는 여전히 10만이고, 히스토그램도 예전 분포 그대로입니다. 이제 플래너는 실제로 수백만 건이 걸리는 조건을 보고도 "몇백 건이겠지" 하고 추정합니다. 그 결과 index scan을 골랐다가, 실제로는 수백만 번 인덱스와 본체를 오가며 처참하게 느려지죠.
반대 방향도 있습니다. 오래된 데이터를 대량으로 DELETE했는데 통계가 그대로면, 플래너는 없는 행이 아직 있다고 믿고 엉뚱한 join 순서를 고릅니다. 새로 들어온 값이 통계의 히스토그램 범위를 벗어나는 경우도 골칫거리입니다. 이른바 out-of-range 문제인데, created_at의 통계상 최댓값이 6월 1일인데 오늘 날짜로 조회하면 플래너는 "그 구간엔 데이터가 거의 없다"고 판단해 선택도를 0에 가깝게 잡죠. 실제로는 오늘 들어온 데이터가 가장 많은데도 말입니다.

"autovacuum이 알아서 해 주지 않나요?"라고 물으실 수 있습니다. 맞습니다. PostgreSQL에는 auto-analyze가 있어서 변경된 행이 일정 비율을 넘으면 자동으로 통계를 다시 수집하죠. 다만 여기에는 한계가 분명합니다.
- 임계치까지 기다립니다 — 기본값 기준으로
autovacuum_analyze_scale_factor가 0.1이라, 변경분이 테이블의 10%를 넘어야 트리거됩니다. 큰 테이블일수록 이 10%가 어마어마한 양이 되죠. - 타이밍이 늦습니다 — 배치가 새벽 3시에 끝나도 auto-analyze는 그 뒤 어느 시점에 돌지 알 수 없습니다. 아침 트래픽이 통계가 갱신되기 전에 밀려들면, 그 몇 시간 동안 모두가 낡은 지도로 달리는 셈입니다.
- 워커가 밀립니다 — autovacuum 워커 수는 제한적이라, 다른 테이블 vacuum과 경합하면 우리 테이블 차례가 한참 뒤로 밀리기도 합니다.
그래서 auto-analyze는 평상시의 완만한 변화에는 충분하지만, 한 번에 수백만 행이 쏟아지는 배치 상황에는 구조적으로 늦습니다. 통계 갱신을 운에 맡기게 되는 것이죠.
대량 적재 직후 ANALYZE
이 이야기를 이렇게 길게 하는 건, 제가 실제로 이 함정에 정확히 빠졌기 때문입니다. 작년 초 물류 정산 시스템에서 겪은 일입니다.
매일 새벽 배치가 전날 배송 이벤트를 delivery_events 테이블에 적재했습니다. 평소 하루 증가분은 수십만 건 정도였는데, 어느 날 지연됐던 데이터가 한꺼번에 풀리면서 하룻밤에 약 620만 행이 들어왔습니다. 다음 날 아침, 특정 기간의 배송 현황을 뽑는 대시보드 쿼리가 평소 40ms에서 갑자기 18초로 치솟았죠. 알림이 울렸고 저는 원인부터 좁혀 나갔습니다.
가장 먼저 EXPLAIN ANALYZE를 열었습니다. 결정적 단서가 여기 있었습니다.
EXPLAIN ANALYZE
SELECT * FROM delivery_events
WHERE created_at >= '2026-01-14' AND status = 'PENDING';
-- 추정: rows=52 실제: rows=1,940,000
-- Index Scan using idx_created_at ... 실제 실행시간 18s
추정 행 수는 52건인데 실제로는 194만 건이었습니다. 무려 3만 배 넘게 빗나간 것이죠. 플래너는 52건인 줄 알고 index scan을 골랐지만, 실제로는 194만 번 인덱스와 테이블 본체를 오가며 랜덤 I/O를 쏟아 냈습니다. pg_stats를 확인해 보니 histogram_bounds의 created_at 최댓값이 며칠 전 날짜에 멈춰 있었고, reltuples도 적재 전 수치 그대로였습니다. 지도가 620만 행어치나 낡아 있었던 겁니다. 밤새 데이터는 폭증했는데 auto-analyze는 아직 돌지 않은 상태였죠.
원인을 확인한 뒤 조치는 간단했습니다. 수동으로 통계를 갱신했습니다.
ANALYZE delivery_events; -- 통계 재수집, 약 4초 소요
ANALYZE 한 줄이 끝나자 같은 쿼리의 추정 행 수가 190만 건대로 바로잡혔고, 플래너는 그제야 index scan을 버리고 sequential scan을 택했습니다. 응답 시간은 18초에서 220ms로 떨어졌습니다. 지도 한 장을 새로 그렸을 뿐인데 내비게이션이 정상 경로를 찾은 셈이죠.
진짜 교훈은 그다음이었습니다. 근본 원인이 "적재는 했는데 통계를 갱신하지 않은 것"이었으니, 재발을 막으려면 적재와 통계 갱신을 한 몸으로 묶어야 했습니다. 그래서 배치 파이프라인의 마지막 단계에 ANALYZE를 명시적으로 넣었습니다.
-- 대량 적재 종료 후 통계 갱신 단계
COPY delivery_events FROM ...; -- 대량 적재
ANALYZE delivery_events; -- 적재 직후 통계 재수집

이 한 줄을 추가한 비용은 배치 시간에 4~5초를 더한 게 전부였습니다. 그 대가로 아침마다 통계가 낡아 플랜이 뒤집히던 사고가 완전히 사라졌죠. 이후 반년 넘게 같은 유형의 지연은 재발하지 않았습니다.
개인적으로 이 사건이 남긴 원칙은 분명합니다. 대량으로 데이터를 넣거나 지웠다면, autovacuum을 믿고 기다리지 말고 그 자리에서 ANALYZE를 직접 돌리자는 것이죠. 플래너는 통계라는 지도로만 세상을 봅니다. 그러니 데이터를 크게 바꿨으면 지도부터 다시 그려 주는 게 우리 몫입니다. 저는 지금도 배치 스크립트를 볼 때마다 마지막 줄에 ANALYZE가 있는지부터 확인하곤 합니다.