6장. 쿼리 플래너와 EXPLAIN
"인덱스가 있는데 왜 Seq Scan을?" — 그 답은 플래너의 머릿속에 있습니다. 비용 기반 옵티마이저가 계획을 고르는 원리와, EXPLAIN으로 그 속마음을 읽는 법을 배웁니다.
① 쿼리 처리 파이프라인 — Parse→Rewrite→Plan→Execute ② cost 모델과 SSD 시대의 random_page_cost ③ 통계(pg_stats)와 ANALYZE — 플래너 오판의 진원지 ④ 조인 3종(Nested Loop·Hash·Merge)과 EXPLAIN 출력 해석
6.1 파이프라인 — SQL 한 줄이 계획이 되기까지
PostgreSQL은 비용 기반 옵티마이저(cost-based optimizer)입니다. 가능한 실행 계획들을 열거하고 예상 비용을 계산해 가장 싸 보이는 하나를 고릅니다. "싸 보인다"는 판단이 통계와 파라미터에 의존한다는 점이 이 장 전체의 복선입니다.
Parse: SQL 문자열을 토큰화·문법 분석해 Parse Tree로 만듭니다. 오타·문법 오류는 이 단계에서 걸러집니다.
Rewrite: 뷰와 RULE을 펼칩니다. active_users 뷰가 기반 테이블 users 쿼리로 치환됩니다 — "뷰라서 느린" 게 아니라 펼친 결과가 느린 것입니다.
Plan: 진짜 승부처. Seq Scan·Index Scan·Bitmap Scan… 후보마다 비용을 계산해 최저가를 선택합니다. 이때 입력이 통계(pg_statistic)와 cost 파라미터입니다.
Execute: 선택된 Plan Tree를 Volcano(iterator) 모델로 실행합니다. 루트 노드가 자식에게 "튜플 하나 줘"를 재귀적으로 요청하는 구조입니다.
기억할 것 하나: 계획의 품질은 통계와 파라미터의 품질입니다. 플래너는 거짓말하지 않습니다 — 낡은 정보로 정직하게 오판할 뿐이죠.
6.2 Cost 모델 — 숫자의 단위는 "순차 페이지 1장"
EXPLAIN에 나오는 cost는 초(second)가 아니라 단위 없는 상대값입니다. 기준은 "페이지 하나를 순차로 읽는 비용 = 1.0".
| 파라미터 | 기본값 | 의미 |
|---|---|---|
seq_page_cost | 1.0 | 순차 페이지 읽기 (기준) |
random_page_cost | 4.0 | 무작위 페이지 읽기 — HDD 기준! |
cpu_tuple_cost | 0.01 | 튜플 1개 처리 CPU 비용 |
effective_cache_size | 4GB | "이만큼은 캐시에 있을 것"이라는 힌트 (메모리 예약 아님) |
기본값 4.0은 회전 디스크 전제입니다. SSD/NVMe에서는 무작위·순차 접근 차이가 작으므로 1.1~2.0으로 낮추는 것이 표준 튜닝입니다. 안 낮추면 플래너가 Index Scan을 과대평가된 비용으로 보고 Seq Scan으로 도망갑니다.
# postgresql.conf — SSD/NVMe 권장 출발점
random_page_cost = 1.1
effective_cache_size = '12GB' # RAM의 50~75% 수준
6.3 통계 — 플래너의 눈, ANALYZE로 닦아준다
선택도 추정의 근거는 ANALYZE가 수집해 pg_statistic(뷰: pg_stats)에 저장한 통계입니다: 고유값 수(n_distinct), 최빈값(most_common_vals), 히스토그램, 물리 정렬 상관(correlation)…. 이 통계가 낡으면 무슨 일이 벌어지는지 봅시다.
어제의 orders 테이블은 1만 행이었고, 통계도 그 기준으로 수집되어 있습니다. 평화롭죠.
새벽 배치가 500만 행을 적재했습니다. 그런데 통계는 아직 어제 것 — 플래너는 여전히 "작은 테이블"이라고 믿습니다.
플래너가 "100행쯤 나오겠네"라며 Nested Loop을 고릅니다. 실제로는 50만 행 — rows=100 vs actual rows=500000, 이 괴리가 EXPLAIN ANALYZE에 고스란히 찍힙니다.
처방은 간단합니다: ANALYZE orders;. 통계가 갱신되면 플래너는 Hash Join으로 갈아타고 성능이 돌아옵니다. 예상 rows와 actual rows가 10배 이상 벌어지면 통계부터 의심하세요.
-- 통계 들여다보기
SELECT attname, n_distinct, most_common_vals, correlation
FROM pg_stats WHERE tablename = 'orders' AND attname = 'user_id';
-- 분포가 복잡한 컬럼은 표본을 늘린다 (기본 100)
ALTER TABLE orders ALTER COLUMN user_id SET STATISTICS 1000;
ANALYZE orders;
-- 컬럼 간 상관(예: city ↔ zip_code)은 확장 통계로 (10+)
CREATE STATISTICS s_orders_region (ndistinct, dependencies, mcv)
ON city, zip_code FROM orders;
6.4 조인 3종 세트 — Nested Loop · Hash · Merge
두 테이블을 묶는 방법은 세 가지뿐입니다. 각자의 동작을 한 화면에서 비교해 봅시다.
Nested Loop의 재료: 작은 외부 집합과 인덱스 있는 내부 테이블.
외부 100행 각각에 대해 내부를 인덱스로 한 번씩 조회합니다. 소량 조인에선 최적이지만, 외부가 커지면 곱셈(N×M)이 폭발합니다. EXPLAIN에서 loops=100000 같은 숫자가 그 신호입니다.
Hash Join 1단계(Build): 작은 쪽 테이블로 메모리(work_mem) 안에 해시 테이블을 만듭니다.
2단계(Probe): 큰 쪽을 한 번 훑으며 해시 lookup. 대용량 equi-join의 왕입니다. 단 해시가 work_mem을 넘치면 디스크 spill(Batches > 1)로 급감속합니다.
Merge Join: 양쪽이 조인 키로 정렬되어 있다면(또는 인덱스가 정렬을 제공하면) 두 스트림을 지퍼 잠그듯 한 번씩만 스캔합니다. 정렬을 새로 해야 한다면 그 비용이 관건입니다.
정리하면 — 소량+인덱스는 Nested Loop, 대량 동등 조인은 Hash, 정렬된 입력은 Merge. 플래너가 자동 선택하지만, 그 판단의 재료(통계·work_mem)는 우리가 관리해야 합니다.
기본 4MB는 대개 너무 작지만, work_mem은 백엔드당·정렬/해시 노드당 할당됩니다. 전역으로 크게 잡으면 연결 수 × 노드 수 × work_mem으로 메모리가 증발할 수 있으니, 큰 쿼리 전에 SET work_mem = '128MB'처럼 세션 단위로 올리는 것이 안전합니다.
6.5 EXPLAIN 읽기 — 다섯 가지만 먼저 본다
EXPLAIN (ANALYZE, BUFFERS)
SELECT u.name, SUM(o.amount)
FROM users u JOIN orders o ON o.user_id = u.id
WHERE u.signup_date >= '2026-01-01'
GROUP BY u.name ORDER BY 2 DESC LIMIT 20;
Limit (cost=12345.67..12345.72 rows=20) (actual time=234.5..234.6 rows=20 loops=1)
Buffers: shared hit=1234 read=56
-> Sort (Sort Method: top-N heapsort Memory: 31kB)
-> HashAggregate
-> Hash Join (Hash Cond: o.user_id = u.id)
-> Seq Scan on orders o
-> Hash (Buckets: 2048 Batches: 1 Memory Usage: 85kB)
-> Seq Scan on users u
Filter: signup_date >= '2026-01-01'
Rows Removed by Filter: 19200
Planning Time: 0.42 ms
Execution Time: 235.1 ms
| 체크 포인트 | 읽는 법 |
|---|---|
rows vs actual rows | 10배 이상 차이 = 통계 부정확. ANALYZE·SET STATISTICS·CREATE STATISTICS |
loops | Nested Loop 내부가 수만 회면 총비용 = loops × 1회 비용. Hash Join 검토 |
Rows Removed by Filter | 스캔 후 버려진 행. 크면 그 조건을 인덱스로 밀어넣을 후보 |
Buffers: shared hit / read | 캐시 적중 / 디스크 읽기. read가 계속 크면 캐시 부족·패턴 문제 |
Sort Method | external merge Disk가 보이면 work_mem 부족 |
DELETE/UPDATE에 EXPLAIN ANALYZE를 걸면 실제로 지워지고 수정됩니다. 반드시 BEGIN; EXPLAIN (ANALYZE) DELETE …; ROLLBACK;으로 감싸세요.
6.6 운영에서는 auto_explain — 느린 쿼리만 자동 채집
# postgresql.conf
shared_preload_libraries = 'auto_explain' # 재시작 필요
auto_explain.log_min_duration = '500ms' # 이보다 느린 쿼리만 로그
auto_explain.log_analyze = on
auto_explain.log_buffers = on
auto_explain.sample_rate = 1.0 # 고TPS면 0.01~0.1로
추천 조합: pg_stat_statements로 "빈도 × 평균 시간"이 큰 문제 쿼리 후보를 찾고 → auto_explain 로그에서 실제 계획을 확인하고 → 재현 환경에서 EXPLAIN (ANALYZE, BUFFERS)로 튜닝합니다.
✍️ 이해도 체크
(rows=100) 예상에 actual rows=500000이 나왔습니다. 가장 먼저 할 일은?ANALYZE 재수행 → 그래도 부정확하면 SET STATISTICS 상향, 컬럼 간 상관이 원인이면 CREATE STATISTICS가 순서입니다. 잘못된 rows 추정은 조인 방식·순서 선택을 연쇄적으로 망가뜨립니다.이 장의 원문 문서: chapters/ch06_query_planner.md — cost 계산 공식, plan_cache_mode·GEQO, 자주 발생하는 오류 패턴 6종, 운영 체크리스트가 담겨 있습니다.