DB Study

6장. 쿼리 플래너와 EXPLAIN

"인덱스가 있는데 왜 Seq Scan을?" — 그 답은 플래너의 머릿속에 있습니다. 비용 기반 옵티마이저가 계획을 고르는 원리와, EXPLAIN으로 그 속마음을 읽는 법을 배웁니다.

중급 ⏱ 약 30분 🎬 애니메이션 3개 선수 지식: 5장 인덱스
🧭
이 장에서 배우는 것

① 쿼리 처리 파이프라인 — 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)입니다. 가능한 실행 계획들을 열거하고 예상 비용을 계산해 가장 싸 보이는 하나를 고릅니다. "싸 보인다"는 판단이 통계와 파라미터에 의존한다는 점이 이 장 전체의 복선입니다.

📝 Parser 문법 분석 🔁 Rewriter 뷰·RULE 확장 🧮 Planner 비용 비교·선택 ▶ Executor 계획 실행 SELECT id FROM active_users WHERE country='KR' 문자열 → Parse Tree 문법 오류는 여기서 검출 뷰가 "펼쳐진다" active_users → users WHERE status='active' 후보 계획과 예상 비용 Seq Scan cost 20543 Index Scan ✓ cost 8.44 Bitmap Scan cost 112 📊 pg_statistic (통계) random_page_cost 등 GUC Volcano 모델: 노드가 튜플을 한 개씩 위로 올려보냄 계획의 품질 = 통계의 품질 × cost 파라미터의 현실성 둘 중 하나라도 낡으면 "인덱스가 있는데 Seq Scan" 같은 오판이 나온다

6.2 Cost 모델 — 숫자의 단위는 "순차 페이지 1장"

EXPLAIN에 나오는 cost는 초(second)가 아니라 단위 없는 상대값입니다. 기준은 "페이지 하나를 순차로 읽는 비용 = 1.0".

파라미터기본값의미
seq_page_cost1.0순차 페이지 읽기 (기준)
random_page_cost4.0무작위 페이지 읽기 — HDD 기준!
cpu_tuple_cost0.01튜플 1개 처리 CPU 비용
effective_cache_size4GB"이만큼은 캐시에 있을 것"이라는 힌트 (메모리 예약 아님)
⚠️
SSD인데 random_page_cost가 4.0이라면

기본값 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만 행 통계도 1만 행 기준 ✓ orders 테이블 오늘 새벽: +500만 행 적재! 통계는 여전히 1만 행 기준 ✗ last_analyze = 어제 🧮 플래너 통계를 믿고 계획 Nested Loop (rows=100 예상) actual rows=500000 5,000배 오판 → 폭망 🐌 ANALYZE orders; 통계 갱신 → 재계획 → Hash Join 선택, 정상 성능 회복 ✓ Hash Join (rows≈480000 예상) 현실에 맞는 계획 ✓
-- 통계 들여다보기
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 Outer 100행 Inner 인덱스 조회 외부 행마다 내부를 1회씩 100 × O(logN) ✓ 외부가 작고 내부에 인덱스 ✗ 외부가 크면 N×M 폭발 #️⃣ Hash Join 작은 쪽 Build 큰 쪽 Probe 해시 테이블 (work_mem 안) key → 행 큰 쪽을 훑으며 해시 lookup ✓ 대용량 equi-join의 왕 ✗ 메모리 초과 시 디스크 spill 🔀 Merge Join 1,3,5,8… 정렬됨 2,3,6,8… 정렬됨 두 정렬 스트림을 지퍼처럼 병합 ✓ 이미 정렬돼 있으면 최강 ✗ 정렬 비용이 있으면 부담 소량+인덱스 → Nested Loop · 대량 equi-join → Hash · 양쪽 정렬됨 → Merge 선택은 플래너의 몫 — 단, work_mem·통계가 정확해야 옳은 선택을 한다
💡
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 rows10배 이상 차이 = 통계 부정확. ANALYZE·SET STATISTICS·CREATE STATISTICS
loopsNested Loop 내부가 수만 회면 총비용 = loops × 1회 비용. Hash Join 검토
Rows Removed by Filter스캔 후 버려진 행. 크면 그 조건을 인덱스로 밀어넣을 후보
Buffers: shared hit / read캐시 적중 / 디스크 읽기. read가 계속 크면 캐시 부족·패턴 문제
Sort Methodexternal merge Disk가 보이면 work_mem 부족
🚨
EXPLAIN ANALYZE는 진짜로 실행됩니다

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)로 튜닝합니다.

✍️ 이해도 체크

EXPLAIN ANALYZE에서 (rows=100) 예상에 actual rows=500000이 나왔습니다. 가장 먼저 할 일은?
✅ 예상 rows와 실제 rows의 큰 괴리는 통계 부정확의 대표 신호입니다. ANALYZE 재수행 → 그래도 부정확하면 SET STATISTICS 상향, 컬럼 간 상관이 원인이면 CREATE STATISTICS가 순서입니다. 잘못된 rows 추정은 조인 방식·순서 선택을 연쇄적으로 망가뜨립니다.
"작은 dim 테이블(1천 행) ⋈ 대형 fact 테이블(1억 행)"의 equi-join에서 플래너가 보통 선택하는 조인과 이유는?
✅ 작은 쪽(1천 행)이 work_mem에 충분히 들어가므로 Build는 순식간이고, 1억 행은 단 한 번의 순차 스캔 + O(1) 해시 lookup으로 처리됩니다. Nested Loop이라면 내부 조회가 1억 번 반복됐을 것입니다. 단, 해시가 work_mem을 넘치면(Batches>1) 디스크 spill로 느려집니다.
📚
원문으로 더 깊이

이 장의 원문 문서: chapters/ch06_query_planner.md — cost 계산 공식, plan_cache_mode·GEQO, 자주 발생하는 오류 패턴 6종, 운영 체크리스트가 담겨 있습니다.