--- id: db-postgres-explain title: Postgres EXPLAIN — Plan / Index / Cost 분석 category: Coding status: draft source_trust_level: B verification_status: conceptual created_at: 2026-05-09 updated_at: 2026-05-09 tags: [database, postgres, explain, vibe-coding] tech_stack: { language: "SQL / Postgres", applicable_to: ["Backend"] } applied_in: [] aliases: [EXPLAIN, EXPLAIN ANALYZE, query plan, sequential scan, index scan, nested loop, hash join] --- # Postgres EXPLAIN > 느린 query = plan 부터. **`EXPLAIN ANALYZE BUFFERS`** 가 표준. Sequential scan / index scan / join type / actual time 분석. **explain.dalibo.com** 또는 pgMustard 가 시각화. ## 📖 핵심 개념 - EXPLAIN: plan 만 (실행 X). - EXPLAIN ANALYZE: 실제 실행 + 시간. - BUFFERS: 디스크 vs cache hit. - VERBOSE: 상세 정보. ## 💻 코드 패턴 ### 기본 ```sql EXPLAIN ANALYZE SELECT * FROM orders WHERE user_id = 'u1' ORDER BY created_at DESC LIMIT 10; ``` ``` Limit (cost=0.42..1.20 rows=10 width=128) (actual time=0.05..0.15 rows=10 loops=1) -> Index Scan using orders_user_created on orders (cost=0.42..123.45 rows=1500 width=128) (actual time=0.04..0.14 rows=10 loops=1) Index Cond: (user_id = 'u1') Planning Time: 0.12 ms Execution Time: 0.18 ms ``` → Index Scan + Index Cond + actual rows 적음 = OK. ### Sequential scan = warning ``` Seq Scan on orders (cost=0.00..50000.00 rows=1000000 width=128) Filter: (user_id = 'u1') Rows Removed by Filter: 999999 ``` → 100만 row 모두 읽음. Index 필요. ### Buffers 확인 ```sql EXPLAIN (ANALYZE, BUFFERS) SELECT ...; # Output: Buffers: shared hit=42 read=8 # hit = cache, read = 디스크 # 큰 read = 디스크 I/O bottleneck ``` ### Index 추가 + 재실행 ```sql CREATE INDEX orders_user_created ON orders (user_id, created_at DESC); EXPLAIN ANALYZE SELECT ... -- 다시 -- Index Scan 으로 변환 ``` ### 자주 쓰는 plan node ``` Seq Scan: 풀 스캔 — 큰 테이블 = 느림 Index Scan: index 로 row lookup — 빠름 Index Only Scan: index 만 — heap 접근 X (covering index) Bitmap Heap Scan: 여러 row, bitmap 으로 batch Hash Join: 양쪽 hash 후 조인 — 큰 join 빠름 Nested Loop: 각 row 마다 다른 쪽 lookup — 작은 한쪽 Merge Join: 양쪽 sort 후 merge Sort: sort 작업 — work_mem 초과 시 디스크 Aggregate: group by / sum HashAggregate: hash 기반 aggregate ``` ### Statistics — planner 가 잘못 추측 ``` rows=1000 (실제 100만) → planner 가 잘못 — Seq Scan 선택할 수 있음 ``` ```sql ANALYZE orders; -- statistics update ``` → 자주 쓰는 큰 table = autovacuum 외 명시. ### Index 사용 안 하는 경우 ```sql -- ❌ 함수 / 변환 SELECT * FROM users WHERE LOWER(email) = 'a@b.com'; -- email 인덱스 못 씀 -- ✅ Functional index CREATE INDEX users_email_lower ON users ((LOWER(email))); -- ❌ LIKE leading % WHERE email LIKE '%@gmail.com' -- ❌ Type cast WHERE id::text = '42' -- ❌ OR with different columns WHERE name = 'a' OR email = 'b' -- → UNION 으로 분리 ``` ### Composite index 순서 ```sql -- (user_id, created_at) 인덱스 SELECT ... WHERE user_id = $1 -- ✅ 사용 SELECT ... WHERE user_id = $1 AND created_at > $2 -- ✅ SELECT ... WHERE created_at > $2 -- ❌ leading 안 맞음 ``` ### LIMIT + ORDER BY = matching index ```sql SELECT * FROM events ORDER BY ts DESC LIMIT 10; -- (ts DESC) index = O(log n) -- 또는 (user_id, ts DESC) WHERE user_id = $1 ORDER BY ts DESC LIMIT 10; ``` ### EXPLAIN 시각화 ``` explain.dalibo.com explain.depesz.com pgMustard (paid) ``` → 큰 plan tree 가 직관적. ### auto_explain (slow query log) ```sql -- postgresql.conf shared_preload_libraries = 'auto_explain' auto_explain.log_min_duration = '500ms' auto_explain.log_analyze = on auto_explain.log_buffers = on ``` → 500ms+ query 자동 plan log. ### pg_stat_statements ```sql CREATE EXTENSION pg_stat_statements; SELECT query, calls, total_exec_time, mean_exec_time, rows FROM pg_stat_statements ORDER BY total_exec_time DESC LIMIT 20; ``` → "어떤 query 가 가장 비싸" ### Common 문제 + fix ``` 1. Seq Scan + 큰 table → CREATE INDEX 2. Sort 가 외부 디스크 (Sort Method: external merge) → work_mem 증가 3. Nested Loop + 큰 양쪽 → ANALYZE 후 hash join 으로 4. 큰 IN list → 임시 테이블 또는 ANY array 5. Subquery 가 매 row 실행 → JOIN 또는 LATERAL ``` ### work_mem ```sql SET work_mem = '64MB'; -- 세션 -- 또는 query SELECT /*+ work_mem='64MB' */ ...; -- pg_hint_plan ``` → Sort / Hash 가 메모리 안 들어가면 외부 디스크. ### Index size 검사 ```sql SELECT schemaname, tablename, indexname, pg_size_pretty(pg_relation_size(indexrelid)) AS size FROM pg_indexes JOIN pg_class ON pg_class.relname = indexname ORDER BY pg_relation_size(indexrelid) DESC LIMIT 10; ``` ### Unused index ```sql SELECT schemaname, relname, indexrelname, idx_scan, pg_size_pretty(pg_relation_size(indexrelid)) AS size FROM pg_stat_user_indexes WHERE idx_scan = 0 AND indexrelname NOT LIKE '%_pkey' ORDER BY pg_relation_size(indexrelid) DESC; ``` → 사용 안 되는 index = drop. ## 🤔 의사결정 기준 | 증상 | 액션 | |---|---| | 느린 query | EXPLAIN ANALYZE | | Seq Scan 큰 table | Index 추가 | | Sort 외부 디스크 | work_mem 또는 인덱스 | | Wrong rows estimate | ANALYZE | | 매번 느림 | pg_stat_statements | | Unused index | DROP | ## ❌ 안티패턴 - **EXPLAIN 만 + ANALYZE 안 함**: 실제 시간 모름. - **Plan 만 보고 OK 가정**: row estimate 가 wrong 일 수 있음. - **SELECT ***: column 다 read. - **함수 / cast index 컬럼**: index 못 씀. - **Statistics 안 update**: planner 잘못. - **Unused index 방치**: 디스크 + INSERT 비용. - **Production 에 ANALYZE 큰 table**: lock. CONCURRENTLY. ## 🤖 LLM 활용 힌트 - `EXPLAIN (ANALYZE, BUFFERS)` 표준. - explain.dalibo.com 시각화. - pg_stat_statements + auto_explain prod. ## 🔗 관련 문서 - [[DB_Index_Strategy]] - [[DB_Query_Optimization]] - [[DB_Vacuum_Autovacuum]]