지난 편은 컬럼 하나의 통계가 틀리는 경우였다. 이번 편은 컬럼 하나하나는 정확한데 함께 쓰면 틀리는 경우다. 이쪽이 더 자주, 더 크게 빗나간다.
독립 가정
주소 테이블이 있다. sido(시도)와 sigungu(시군구) 컬럼이 있다.
SELECT * FROM addresses
WHERE sido = '서울특별시' AND sigungu = '강남구';
플래너는 이렇게 계산한다.
sido = '서울특별시'인 비율: 최빈값 목록에서 찾음. 20%라고 하자.sigungu = '강남구'인 비율: 최빈값 목록에서 찾음. 2%라고 하자.- 둘 다 만족하는 비율: 20% × 2% = 0.4%
그런데 강남구는 서울에만 있다. sigungu = '강남구'인 행은 전부 sido = '서울특별시'다. 실제 비율은 2%다. 플래너는 5배 적게 추정했다.
조건이 하나 더 붙으면 어떻게 되나. AND road_name = '테헤란로'. 테헤란로는 강남구에만 있다. 실제로는 0.1%인데, 플래너는 20% × 2% × 0.1% = 0.0004%로 본다. 250배다.
이게 독립 가정이다. 플래너는 WHERE의 조건들이 서로 무관하다고 가정하고 선택도를 곱한다. 조건이 서로를 함축하면 곱셈이 과소추정을 만들고, 조건이 늘수록 누적된다.
왜 문제가 되나
행 수를 250배 적게 추정하면 계획이 바뀐다.
몇 행 안 나온다고 생각하면 플래너는 중첩 루프 조인을 고른다. 바깥에서 몇 행 뽑아서 안쪽 인덱스를 몇 번 찌르는 방식이다. 몇 행일 때는 가장 빠르다.
그런데 실제로 만 행이 나온다. 안쪽 인덱스를 만 번 찌른다. 해시 조인이었으면 몇백 밀리초에 끝날 걸 몇십 초 걸린다.
EXPLAIN ANALYZE에서 이렇게 보인다.
Nested Loop (cost=... rows=40 width=...) (actual time=... rows=10000 loops=1)
-> Index Scan on addresses (rows=40) (actual rows=10000)
-> Index Scan on buildings (rows=1) (actual rows=1 loops=10000)
rows=40과 actual rows=10000. 그리고 안쪽의 loops=10000. 이 모양이 나오면 독립 가정을 의심한다.
확장 통계
PostgreSQL 10부터 컬럼 사이의 관계를 통계로 수집할 수 있다.
CREATE STATISTICS st_addr_region (dependencies, ndistinct, mcv)
ON sido, sigungu, road_name
FROM addresses;
ANALYZE addresses;
ANALYZE를 돌려야 값이 채워진다. 만들기만 하면 비어 있다.
세 종류를 지정했는데 각각 다른 걸 잡는다.
| 종류 | 무엇을 기록하나 | 어디에 쓰이나 |
|---|---|---|
dependencies | A가 정해지면 B가 얼마나 정해지는지 (0~1) | 등호 조건의 AND |
ndistinct | 컬럼 조합의 고유값 수 | GROUP BY, DISTINCT 결과 크기 |
mcv (12+) | 자주 나오는 값의 조합과 비율 | 등호·IS NULL 조건, 조합이 편중된 경우 |
주소 예시에서는 dependencies가 sigungu → sido를 거의 1로 기록한다. 그러면 플래너가 sido 조건의 선택도를 곱하지 않는다. 이미 sigungu로 결정됐으니까.
수집된 값은 이렇게 본다.
SELECT statistics_name, dependencies, n_distinct
FROM pg_stats_ext
WHERE tablename = 'addresses';
dependencies에 "2 => 1": 0.98 같은 게 나오면 2번 컬럼이 1번을 98% 결정한다는 뜻이다. 번호는 ON 절의 컬럼 순서다.
어디까지 되고 어디부터 안 되나
같은 테이블 안에서만 된다. addresses.sido와 buildings.type의 관계는 못 잡는다. 조인 결과의 크기 추정은 여전히 독립 가정이다.
dependencies는 등호 조건에만 쓰인다. sido = '서울' AND created_at > '2026-01-01'에서 두 컬럼이 상관돼 있어도 dependencies로는 못 잡는다. 등호끼리만 본다. 범위 조건이 섞이면 mcv가 일부 도움이 되지만 완전하진 않다.
표현식은 14부터. ON lower(city), zip처럼 함수를 넣는 건 PostgreSQL 14 이상이다. 그 전에는 컬럼 이름만 된다.
모든 컬럼 조합에 걸 수는 없다. 통계 하나에 컬럼 최대 8개고, 조합이 많아지면 ANALYZE 비용이 는다. 실제로 함께 조건에 걸리면서 서로 얽힌 컬럼에만 건다. 대개 지역 계층, 코드 계층, 분류 계층처럼 한쪽이 다른 쪽을 포함하는 관계다.
하나 헷갈리는 것
pg_stats에도 correlation이라는 컬럼이 있는데, 이건 다른 이야기다. 컬럼 하나의 값 순서와 디스크 저장 순서 사이의 상관이다. 1에 가까우면 값 순서대로 저장돼 있다는 뜻이고, 플래너는 이걸로 인덱스 스캔이 디스크를 얼마나 순차적으로 읽을지 추정한다.
이름이 비슷해서 섞이는데, 컬럼 사이의 관계는 pg_stats_ext고, 컬럼과 디스크 순서의 관계는 pg_stats.correlation이다.
확인 순서
EXPLAIN ANALYZE에서 추정과 실제가 크게 갈리는 노드를 찾는다.- 그 노드의 WHERE에 조건이 둘 이상 AND로 묶여 있는지 본다.
- 각 조건을 따로 실행했을 때는 추정이 맞는지 본다. 따로는 맞는데 같이 쓰면 틀리면 독립 가정 문제다.
- 그 컬럼들에
CREATE STATISTICS후ANALYZE. - 다시
EXPLAIN ANALYZE로 추정이 실제에 가까워졌는지 확인한다.
여기까지 하면 행 수 추정은 대체로 맞는다. 그런데 행 수를 맞게 추정하고도 인덱스를 안 쓰는 경우가 있다. 그건 추정의 문제가 아니라 비용 계산의 문제고, 마지막 편에서 다룬다.
자주 묻는 질문
Q. WHERE에 조건을 하나 더 붙였더니 쿼리가 오히려 느려진 이유는 무엇인가요?
플래너는 각 조건의 선택도를 독립적으로 곱해 결과 행 수를 추정합니다. 두 조건이 사실상 같은 것을 가리키면 실제 행 수는 줄지 않는데 추정치만 곱셈으로 크게 줄어들어, 소량 결과에 맞는 계획(예: 중첩 루프 조인)이 대량 결과에 적용됩니다. 조건이 추가될수록 과소추정이 누적됩니다.
Q. CREATE STATISTICS는 무엇을 하나요?
여러 컬럼 사이의 관계를 통계로 수집하게 합니다. dependencies는 한 컬럼이 다른 컬럼을 얼마나 결정하는지, ndistinct는 컬럼 조합의 고유값 수, mcv는 자주 나오는 값 조합을 기록합니다. 생성 후 ANALYZE를 실행해야 실제 값이 채워지며, 그 뒤부터 플래너가 독립 가정 대신 이 통계를 씁니다.
Q. 확장 통계는 어떤 경우에 효과가 없나요?
같은 테이블 안의 컬럼 조합에만 적용됩니다. 조인 조건이나 서로 다른 테이블의 컬럼 사이 관계는 다루지 않습니다. 또한 dependencies 유형은 등호 조건에만 쓰이므로 범위 조건이 섞인 경우에는 mcv 유형이 필요하고, 조건에 함수나 표현식이 있으면 PostgreSQL 14 이상에서 표현식 통계를 따로 만들어야 합니다.