PostgreSQL planner는 테이블의 실제 데이터를 들여다보지 않는다. 100만 건짜리 테이블에 조건을 걸어도, planner는 그 100만 건을 세어 보는 대신 pg_statistic에 저장된 통계 요약만 읽고 “이 조건이면 대략 몇 row가 나오겠다"고 추정한다. 이 추정값이 맞으면 좋은 계획이 나오고, 틀어지면 seq scan을 해야 할 자리에 index scan을 고르거나 nested loop가 터지는 계획이 나온다. 그래서 쿼리가 느릴 때 인덱스보다 먼저 의심해야 할 게 통계다.
이 글은 그 통계가 어디서 와서 어디에 저장되고 planner가 어떻게 읽는지를 따라간다. 시드 출처(richyen.com)와 PostgreSQL 공식 문서를 교차 확인하며 한국어 실무 관점으로 다시 엮었다.
왜 통계가 실행계획을 좌우하나
planner의 일은 같은 쿼리를 실행하는 여러 방법(seq scan vs index scan, nested loop vs hash join, 조인 순서) 중 비용이 가장 싼 것을 고르는 것이다. 비용을 계산하려면 각 단계에서 몇 row가 흘러갈지를 알아야 한다. 그런데 쿼리를 실제로 돌려 보고 정할 수는 없으니, 미리 떠둔 통계로 추정한다.
추정이 틀어지는 전형적인 예가 이렇다. 어떤 조건이 실제로는 4000 row를 반환하는데 planner가 8 row로 추정했다면, planner는 “8 row니까 index scan으로 한 건씩 찾아오는 게 싸겠다"고 판단한다. 막상 실행하면 4000번을 random access로 긁느라 seq scan보다 훨씬 느려진다. 계획 자체는 통계가 맞다는 가정 아래 합리적이었다. 틀린 건 통계였다.
그래서 row 추정(row estimation)이 쿼리 성능의 출발점이다. 시드 글의 표현을 빌리면 “planner는 쥐여 준 통계만큼만 똑똑하다”.
ANALYZE가 통계를 만드는 과정
통계를 채우는 명령은 ANALYZE다. autovacuum이 백그라운드에서 알아서 돌려 주지만, 대량 적재나 마이그레이션 직후에는 통계가 옛날 값이라 직접 실행해 주는 게 안전하다.
ANALYZE customers; -- 테이블 전체 컬럼 통계 갱신
ANALYZE customers (state); -- 특정 컬럼만
핵심은 ANALYZE가 테이블 전체를 읽지 않는다는 점이다. 큰 테이블을 매번 통째로 스캔하면 비용이 감당되지 않으니, 무작위 샘플을 떠서 그걸로 분포를 추정한다.
샘플 크기는 통계 타깃(statistics target)에 비례한다. default_statistics_target이 기본값 100일 때 ANALYZE는 약 30,000개의 row를 샘플로 뽑는다(테이블이 작으면 그보다 적게). 무작위성을 보장하기 위해 Vitter의 reservoir sampling 알고리즘을 쓴다. 샘플을 다 모으면 컬럼별로 분포를 계산해 pg_statistic에 저장한다.
이때 테이블 전체 규모를 나타내는 reltuples(행 수)와 relpages(블록 수)는 pg_class에 따로 저장된다. 이 값들은 실시간으로 갱신되지 않고 VACUUM·ANALYZE·일부 DDL 시점에만 갱신되며, 전체를 스캔하지 않은 경우 스캔한 부분으로부터 근사값을 추정해 갱신한다.
pg_statistic은 슬롯 구조라 사람이 직접 읽기 까다롭다. 그래서 PostgreSQL은 같은 내용을 사람이 읽기 좋게 펼친 pg_stats 뷰를 제공하고, 일반 사용자도 자기 권한 안에서 조회할 수 있다. 실무에서 보는 건 거의 항상 pg_stats다.
아래는 ANALYZE가 통계를 만들어 planner에 닿기까지의 흐름이다.
flowchart TD
A[ANALYZE 실행] --> B[무작위 샘플 추출]
B --> C[컬럼별 분포 계산]
C --> D[(pg_statistic 저장)]
D --> E[pg_stats 뷰로 노출]
F[쿼리 도착] --> G[planner가 통계 조회]
D --> G
G --> H[row 추정 → 계획 선택]
pg_stats 주요 컬럼 읽는 법
pg_stats 한 행은 한 컬럼의 통계 요약이다. 컬럼이 여럿이지만 실무에서 손이 가는 건 다섯 개 정도다.
| 컬럼 | 의미 | 무엇을 말해 주나 |
|---|---|---|
null_frac | NULL인 행의 비율 | NULL이 얼마나 흔한가 |
avg_width | 값의 평균 바이트 폭 | 행 크기·메모리 추정 |
n_distinct | 서로 다른 값의 수(또는 비율) | 카디널리티 |
most_common_vals | 가장 흔한 값 목록(MCV) | 편향된 값 |
most_common_freqs | MCV 각 값의 빈도 | 그 값의 점유율 |
histogram_bounds | MCV를 뺀 나머지 분포의 경계 | 범위 조건 추정 |
correlation | 물리 순서와 논리 순서의 상관 | index scan 효율 |
조회는 이렇게 한다.
SELECT attname, n_distinct, null_frac,
most_common_vals, most_common_freqs,
correlation
FROM pg_stats
WHERE tablename = 'customers' AND attname = 'state';
n_distinct — 음수의 의미
n_distinct는 서로 다른 값의 추정 개수인데, 부호 규칙이 있다. 공식 문서 정의는 이렇다.
0보다 크면 컬럼의 distinct 값 추정 개수. 0보다 작으면 distinct 값 개수를 행 수로 나눈 값의 음수. (음수 형태는 테이블이 커질수록 distinct 값도 늘어날 것으로 ANALYZE가 판단할 때 쓰이고, 양수 형태는 가능한 값의 수가 고정돼 보일 때 쓰인다.) 예를 들어 -1은 distinct 값 수가 행 수와 같은 unique 컬럼을 뜻한다.
즉 state 컬럼이 50으로 나오면 “값이 50종이고 테이블이 커져도 50종일 것"이라는 뜻이고, 기본키처럼 -1이면 “모든 값이 다 다르다"는 뜻이다. -0.5라면 “행 두 개당 distinct 값 하나꼴"이다.
이 값이 실제와 어긋나면 추정이 통째로 흔들린다. 실제로는 값이 만 종인데 통계가 100종으로 잡혀 있으면 planner는 100분의 1만 걸러질 조건을 만 분의 1로 착각한다.
most_common_vals와 most_common_freqs — 편향된 값
분포가 한쪽으로 쏠린 컬럼에서는 흔한 값 몇 개가 통계를 지배한다. most_common_vals(MCV)는 그 흔한 값들의 목록이고, most_common_freqs는 각 값이 차지하는 비율이다. 두 배열은 같은 순서로 짝을 이룬다.
예를 들어 state 컬럼에서 CA가 0.174, TX가 0.116으로 나온다면, planner는 WHERE state = 'CA'에 대해 “전체의 17.4%“라고 정확히 추정할 수 있다. 흔한 값은 빈도를 직접 들고 있으니 추측할 필요가 없다.
histogram_bounds — 나머지의 분포
MCV에 들지 못한 나머지 값들의 분포는 histogram_bounds가 담는다. 공식 문서 정의는 “컬럼 값들을 거의 같은 개수의 그룹으로 나누는 경계값 목록"이다. 즉 equi-depth histogram이라, 각 구간(bucket)이 데이터의 거의 같은 비율을 담는다. 기본 타깃 100이면 경계가 약 101개 잡혀 구간마다 전체의 약 1%를 덮는다.
중요한 성질이 두 가지다. 첫째, MCV에 들어간 값은 histogram 계산에서 빠진다. 흔한 값은 MCV가, 나머지는 histogram이 나눠 맡는 구조다. 둘째, 컬럼 타입에 < 연산자가 없거나 MCV가 전체를 다 덮으면 이 값은 NULL이다.
WHERE signup_date < '2026-03-01' 같은 범위 조건의 추정이 여기서 나온다. 경계가 촘촘할수록 범위 추정이 정확해진다.
correlation — index scan이 쌀지
correlation은 물리적 행 순서와 논리적 값 순서가 얼마나 일치하는지를 -1에서 +1로 나타낸다. 공식 문서는 “값이 -1이나 +1에 가까우면 그 컬럼의 index scan이 0에 가까울 때보다 싸게 추정된다. random access가 줄어들기 때문"이라고 설명한다.
값이 디스크에 정렬된 순서로 쌓여 있으면(예: 시간순 append) correlation이 1에 가깝고, index로 범위를 긁어도 디스크를 거의 순차로 읽는다. 값이 무작위로 흩어져 있으면 0에 가깝고, index scan은 매번 다른 블록으로 점프해야 해서 비싸진다. 같은 인덱스라도 이 값에 따라 planner의 선택이 갈린다.
planner가 통계로 row를 추정하는 예
추정의 뼈대 공식은 하나다.
추정 row = reltuples × selectivity
reltuples는 테이블 전체 행 수, selectivity는 조건이 걸러 내는 비율(0~1)이다. selectivity를 어떻게 구하느냐가 통계가 쓰이는 지점이다. 공식 문서의 Row Estimation Examples에 나온 tenk1(1만 행) 예시로 본다.
등치 조건 — MCV에 있을 때
조건 값이 most_common_vals에 있으면 그 빈도를 그대로 selectivity로 쓴다.
SELECT * FROM tenk1 WHERE stringu1 = 'CRAAAA';
'CRAAAA'의 most_common_freqs가 0.003이면 selectivity는 0.003, 추정 row는 10000 × 0.003 = 30이다.
등치 조건 — MCV에 없을 때
조건 값이 MCV 목록에 없으면, MCV가 차지하지 않은 나머지를 남은 distinct 값들이 고르게 나눠 가진다고 가정한다.
selectivity = (1 - sum(mcv_freqs)) / (n_distinct - num_mcv)
SELECT * FROM tenk1 WHERE stringu1 = 'xxx';
MCV 빈도 합이 0.03333, n_distinct가 676, MCV 개수가 10이면,
selectivity = (1 - 0.03333) / (676 - 10) = 0.0014559
추정 row = 10000 × 0.0014559 ≈ 15
n_distinct가 추정에 직접 들어가는 게 여기서 보인다. 이 값이 틀리면 비-MCV 등치 조건이 통째로 빗나간다.
범위 조건 — histogram 사용
<, > 같은 범위 조건은 histogram_bounds로 값이 어느 구간에 떨어지는지를 보고 비율을 보간한다.
SELECT * FROM tenk1 WHERE unique1 < 1000;
histogram 경계가 {0, 993, 1997, 3050, ...}(10구간)이고 1000이 993~1997 구간 안에 있다면,
selectivity = (1 + (1000 - 993) / (1997 - 993)) / 10 = 0.100697
추정 row = 10000 × 0.100697 ≈ 1007
AND 결합 — 독립 가정
여러 조건이 AND로 묶이면 planner는 기본적으로 각 조건이 서로 독립이라 보고 selectivity를 곱한다.
SELECT * FROM tenk1 WHERE unique1 < 1000 AND stringu1 = 'xxx';
selectivity = 0.100697 × 0.0014559 = 0.0001466
추정 row = 10000 × 0.0001466 ≈ 1
이 독립 가정이 다음 절의 함정으로 이어진다. 추정값과 실제값은 EXPLAIN으로 바로 대볼 수 있다.
EXPLAIN ANALYZE
SELECT * FROM customers WHERE state = 'CA';
-- Seq Scan ... (rows=1740 ...) (actual ... rows=1736 ...)
-- ↑ 추정 ↑ 실제
추정(rows=)과 실제(actual ... rows=)가 크게 벌어지는 노드가 통계 문제의 출발점이다.
운영 callout — 통계가 틀어질 때
default_statistics_target
pg_statistic에 담기는 MCV·histogram 배열의 최대 길이는 컬럼별 ALTER TABLE ... SET STATISTICS로, 또는 전역 default_statistics_target으로 정한다. 공식 문서 기준 기본 한도는 100이다. 값을 키우면 샘플이 늘고 배열이 길어져 분포가 불규칙한 컬럼에서 추정이 정밀해지지만, pg_statistic 공간과 ANALYZE 시간을 더 쓴다.
-- 특정 컬럼만 정밀하게
ALTER TABLE customers ALTER COLUMN signup_date SET STATISTICS 1000;
ANALYZE customers;
특정 컬럼의 단일 추정이 계속 빗나가면, 전역값을 올리기 전에 그 컬럼만 타깃을 올리는 게 비용 대비 효과가 좋다.
| 조정 대상 | 효과 | 비용 |
|---|---|---|
전역 default_statistics_target 상향 | 모든 컬럼 추정 정밀 | ANALYZE 시간·공간 전반 증가 |
컬럼 SET STATISTICS 상향 | 해당 컬럼만 정밀 | 그 컬럼만 비용 증가 |
| 컬럼별 다중 통계 | 상관된 컬럼 조합 추정 | ANALYZE 시 추가 계산 |
다중 컬럼 상관 — extended statistics
AND 결합의 독립 가정은 컬럼들이 실제로 상관돼 있으면 깨진다. 시드 글의 예가 명확하다. WHERE city = 'Cheyenne' AND state = 'WY'는 도시가 정해지면 주는 사실상 결정되는데, planner는 둘을 독립으로 보고 곱해 8 row로 추정한다. 실제는 4012 row, 약 500배 오차다.
PostgreSQL 10부터의 extended statistics가 이를 푼다. CREATE STATISTICS로 관심 컬럼 조합을 등록하면 ANALYZE가 그 조합의 통계를 함께 모은다.
CREATE STATISTICS customers_city_state (dependencies, ndistinct)
ON city, state FROM customers;
ANALYZE customers;
이후 같은 쿼리의 추정은 4087 row로, 실제(4012)에 거의 붙는다. 단, dependencies(함수 종속성)는 컬럼을 상수와 비교하는 단순 등치 조건과 상수 IN 절에만 적용되고, 두 컬럼끼리 비교하거나 범위·LIKE 조건에는 쓰이지 않는다는 한계가 있다. 한 가지 더, extended statistics도 일반 단일 컬럼 통계와 같은 샘플로 계산되므로, 통계 타깃을 올리면 extended statistics도 함께 정밀해진다.
통계가 의심될 때의 점검 순서
추정과 실제가 벌어지는 노드를 찾았다면 순서대로 본다.
EXPLAIN ANALYZE로 추정 row와 실제 row의 차이를 짚는다.- 문제 컬럼의
pg_stats에서n_distinct와 MCV를 확인한다. - 통계가 낡았으면
ANALYZE를 먼저 실행한다. - 단일 컬럼 추정이 계속 빗나가면 그 컬럼의
SET STATISTICS를 올린다. - 다중 컬럼 상관이 원인이면 extended statistics를 만든다.
- 그래도 안 되면 그때 쿼리 재작성을 검토한다.
대량 적재나 마이그레이션 직후, 파티션 추가 직후처럼 데이터가 급변한 시점에는 autovacuum의 ANALYZE를 기다리지 말고 직접 실행해 두는 습관이 추정 사고를 가장 많이 막아 준다.
정리
planner는 데이터를 보지 않고 통계를 본다. ANALYZE가 무작위 샘플로 분포를 떠서 pg_statistic에 채우고, pg_stats가 그걸 읽기 좋게 펼친다. 등치 조건은 MCV의 빈도나 n_distinct로, 범위 조건은 histogram_bounds로, index scan 여부는 correlation으로 추정한다. 추정이 빗나가면 인덱스를 의심하기 전에 pg_stats를 먼저 열어 보고, 단일 컬럼이면 통계 타깃을, 상관된 컬럼이면 extended statistics를 손본다. 이 흐름만 손에 익으면 “왜 이 계획이 나왔지"의 절반은 통계 한 군데에서 답이 나온다.
참고한 출처: