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
histogram_bounds는 나머지 값의 분포를 담습니다 — MCV에 들지 못한 값들이 대상입니다. 공식 문서 정의는 “컬럼 값들을 거의 같은 개수의 그룹으로 나누는 경계값 목록"입니다. 즉 equi-depth histogram이라, 각 구간(bucket)이 데이터의 거의 같은 비율을 담습니다. 기본 타깃 100이면 경계가 약 101개 잡혀 구간마다 전체의 약 1%를 덮습니다.
중요한 성질이 두 가지 있습니다. 첫째, MCV에 들어간 값은 histogram 계산에서 빠집니다. 흔한 값은 MCV가 맡고 나머지는 histogram이 맡습니다. 둘째, 컬럼 타입에 < 연산자가 없거나 MCV가 전체를 다 덮으면 이 값은 NULL입니다.
WHERE signup_date < '2026-03-01' 같은 범위 조건의 추정이 여기서 나옵니다. 경계가 촘촘할수록 범위 추정이 정확해집니다.
correlation
correlation으로는 index scan이 쌀지 가늠합니다 — 물리적 행 순서와 논리적 값 순서가 얼마나 일치하는지를 -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=)가 크게 벌어지는 노드가 통계 문제의 출발점입니다.
통계가 틀어질 때의 운영 점검
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를 손봐요. 이 흐름만 손에 익으면 “왜 이 계획이 나왔지"의 절반은 통계 한 군데에서 답이 나와요.
참고한 출처: