본문으로 건너뛰기
김신건의 로그

[DB] EXPLAIN 읽기: scan, join, sort, cost

· 수정 · 📖 약 3분 · 1,024자/단어 #explain #query-plan #postgresql #performance #database
EXPLAIN, EXPLAIN ANALYZE, query plan, Seq Scan, Index Scan, Hash Join, Nested Loop, Merge Join

정의

EXPLAIN = 쿼리 실행 계획 표시. EXPLAIN ANALYZE = 실제로 실행 + 실제 시간 측정.

EXPLAIN (ANALYZE, BUFFERS, VERBOSE, FORMAT TEXT)
SELECT u.name, COUNT(o.id)
FROM users u LEFT JOIN orders o ON o.user_id = u.id
WHERE u.created_at > '2026-01-01'
GROUP BY u.id, u.name;

Scan 종류

flowchart TD
    Scan{Scan 종류}
    Scan --> Seq["Seq Scan<br/>전체 테이블 순회"]
    Scan --> Idx["Index Scan<br/>인덱스 → 테이블"]
    Scan --> IdxOnly["Index Only Scan<br/>인덱스에서만"]
    Scan --> Bitmap["Bitmap Scan<br/>bitmap 으로 페이지 모음"]
Scan의미적합
Seq Scan전체 테이블작은 테이블, 대부분 row 반환
Index Scan인덱스 → row선택적 (적은 row)
Index Only Scan인덱스 only (heap 안 봄)covering index
Bitmap Index Scan여러 인덱스 결합 + bitmap다 인덱스 조합
Bitmap Heap Scanbitmap → 페이지 fetch위와 짝
Tid Scan직접 tid 접근극히 드뭄

Join 종류

Join동작적합
Nested Loopfor outer { for inner }작은 inner + 인덱스
Hash Joinhash 빌드 → 룩업큰 양쪽, 등가 조건
Merge Join정렬된 양쪽 머지양쪽 정렬됨, 범위 조건

위 애니메이션은 Grace Hash Join (메모리 부족 시 분할 hash). 대용량 join 의 일반 직관.

EXPLAIN ANALYZE 출력 읽기

Aggregate  (cost=1024.50..1024.51 rows=1 width=8) (actual time=15.234..15.236 rows=1 loops=1)
  ->  Hash Join  (cost=12.50..1020.50 rows=1600 width=4) (actual time=0.234..14.123 rows=1542 loops=1)
        Hash Cond: (o.user_id = u.id)
        ->  Seq Scan on orders o  (cost=0.00..900.00 rows=50000 width=8) (actual time=0.012..5.234 rows=49832 loops=1)
        ->  Hash  (cost=10.00..10.00 rows=200 width=8) (actual time=0.190..0.190 rows=215 loops=1)
              ->  Index Scan using idx_users_created on users u  (cost=0.42..10.00 rows=200 width=8) (actual time=0.012..0.123 rows=215 loops=1)
                    Index Cond: (created_at > '2026-01-01'::date)
Planning Time: 0.345 ms
Execution Time: 15.456 ms
필드의미
cost=A..BA = 시작 비용, B = 총 비용 (페이지 수 단위)
rows=N예상 row 수
width=N평균 row 바이트
actual time=A..B실제 시작/종료 ms
rows=actual실제 row 수
loops=N이 노드가 N번 실행
Planning Timeoptimizer 시간
Execution Time실행 시간

IMPORTANT

예상 vs 실제 row 수 차이가 수십배통계 부정확ANALYZE table_name 으로 갱신.

자주 보는 안티 패턴

1. Seq Scan on 큰 테이블

Seq Scan on big_table  (rows=10000000)
  Filter: (status = 'pending')
  Rows Removed by Filter: 9990000

→ 인덱스 추가 또는 partial index (WHERE status = 'pending').

2. Nested Loop on 큰 양쪽

Nested Loop  (loops=100000)
  ->  Index Scan on table_a
  ->  Index Scan on table_b

→ 한쪽이 수만 rowHash Join 으로 가야 함. set enable_nestloop = off 로 테스트.

3. Sort + Disk

Sort  (Sort Method: external merge  Disk: 1234kB)

work_mem 부족. session 또는 query 단위 증가.

외부 정렬 (disk spill) 동작 직관. 임시 파일이 디스크에 쓰임.

4. Bitmap Heap Scan + 작은 rows

Bitmap Heap Scan  (rows=10)
  ->  Bitmap Index Scan

→ 좋은 패턴. 인덱스가 적절히 작동.

자주 쓰는 옵션

EXPLAIN (
  ANALYZE,      -- 실제 실행
  BUFFERS,      -- shared/local/temp buffer hits
  VERBOSE,      -- 컬럼 / 함수 상세
  COSTS OFF,    -- cost 숨김 (clean diff)
  FORMAT JSON   -- 도구 분석
) ...

시각화 도구

도구비고
explain.depesz.com색상 코딩
pev2 (Dalibo)tree 시각화
pg_stat_statements누적 통계
auto_explain느린 쿼리 자동 로깅

흔한 함정

WARNING

  1. EXPLAIN (without ANALYZE) 만 신뢰 = optimizer 의 추정. 실제 행동은 다를 수 있다.
  2. actual rowsrows= 차이 무시 = stale 통계. ANALYZE 필요.
  3. Hash Join큰 memory = work_mem 부족 시 disk spill. 그래도 Nested Loop 보다 빠를 수 있다.
  4. planner hint 부재 = PostgreSQL 은 공식 hint 없음. SET 변수로 간접 제어. (MySQL 은 STRAIGHT_JOIN 등 hint 직접.)

PostgreSQL Cost Model

플래너가 계획을 선택하는 비용 단위:

파라미터기본값의미
seq_page_cost1.0순차 페이지 읽기 단위 비용
random_page_cost4.0랜덤 페이지 읽기 (SSD 환경 1.1 권장)
cpu_tuple_cost0.01row 처리 비용
cpu_index_tuple_cost0.005인덱스 항목 처리 비용
cpu_operator_cost0.0025연산자 실행 비용

TIP

SSD 환경: random_page_cost = 1.1 로 낮추면 Index Scan 선호. HDD 기본값 4.0 은 Seq Scan 을 과도하게 선호할 수 있음. postgresql.conf 또는 session 단위 설정 가능.

통계 (Statistics)

플래너는 통계 기반으로 plan 선택. 통계가 오래되면 잘못된 plan 선택.

flowchart LR
    Table["테이블 변경<br/>INSERT / UPDATE / DELETE"]
    Table --> AutoVacuum["autovacuum analyze<br/>자동 통계 갱신"]
    AutoVacuum --> PgStats["pg_statistic<br/>pg_stats 뷰"]
    PgStats --> Planner["Query Planner<br/>Plan 선택"]
-- 수동 통계 갱신
ANALYZE users;

-- 특정 컬럼만
ANALYZE users (created_at, status);

-- 통계 확인
SELECT tablename, attname, n_distinct, correlation
FROM pg_stats
WHERE tablename = 'users';
지표의미
n_distinct유니크 값 수 추정 (-1 = 선형 비례)
correlation물리 순서와 논리 순서 상관 (1 = 완전 일치)
most_common_vals가장 흔한 값 목록
histogram_bounds균등 분포 히스토그램 경계

IMPORTANT

EXPLAIN rows=100 인데 actual rows=100000통계 부정확. ANALYZE + default_statistics_target 증가 (기본 100, 최대 10000) 로 해결.

Parallel Query

PostgreSQL 9.6+ 에서 대형 Seq Scan / Aggregate / Join 의 병렬 실행.

flowchart TD
    Gather["Gather<br/>결과 수집 (Leader)"]
    Gather --> W1["Worker 1<br/>Partial Seq Scan"]
    Gather --> W2["Worker 2<br/>Partial Seq Scan"]
    Gather --> W3["Worker 3<br/>Partial Seq Scan"]
Gather  (cost=1000.00..5000.00 rows=50000 width=8)
  Workers Planned: 3
  ->  Parallel Seq Scan on big_table
        Filter: (status = 'active')
        Rows Removed by Filter: 1234
파라미터의미
max_parallel_workers_per_gathergather 당 최대 worker (default 2)
max_parallel_workers전체 최대 병렬 worker
parallel_tuple_cost병렬 row 전달 비용
min_parallel_table_scan_size병렬 스캔 최소 테이블 크기

NOTE

병렬 쿼리는 읽기 전용 쿼리. INSERT, UPDATE, DELETE 는 병렬 불가. GATHER MERGE정렬된 결과 병렬 병합.

인덱스 선택 팁

-- Partial Index: 조건 좁히기
CREATE INDEX idx_pending ON orders (created_at)
WHERE status = 'pending';

-- Covering Index: Index Only Scan 유도
CREATE INDEX idx_cover ON users (created_at) INCLUDE (name, email);

-- 복합 인덱스: 카디널리티 높은 컬럼 먼저
CREATE INDEX idx_multi ON logs (user_id, created_at);
-- user_id 조건으로 범위 좁힌 후 created_at 정렬
인덱스 종류언제 쓰나
B-Tree (기본)등가, 범위, 정렬
GIN배열, JSONB, 전문 검색
GiST지리, 범위 타입
Hash등가만 (PostgreSQL 10+)

자세히는 btree-indexing, gin-gist-hash-indexes.

EXPLAIN 실전 워크플로

flowchart TD
    Start["느린 쿼리 발견<br/>pg_stat_statements"]
    Start --> E1["EXPLAIN ANALYZE BUFFERS 실행"]
    E1 --> Check{"예상 vs 실제 rows<br/>10배 이상 차이?"}
    Check -->|"예"| Analyze["ANALYZE table_name<br/>통계 갱신"]
    Check -->|"아니오"| Scan{"Seq Scan on 큰 테이블?"}
    Analyze --> E1
    Scan -->|"예"| Index["인덱스 추가 또는<br/>Partial Index 검토"]
    Scan -->|"아니오"| Sort{"Sort Disk spill?"}
    Sort -->|"예"| WorkMem["work_mem 증가"]
    Sort -->|"아니오"| Done["최적화 완료"]

관련 위키

이 글의 용어 (6개)
[DB Internals] B-Tree와 B+Tree 인덱싱database-internals
정의 B-Tree (B+ Tree) 는 거의 모든 RDB 의 인덱스 자료구조. balanced tree, 디스크 I/O 최소화 설계. 특징: - 균형 (balanced): 모든 …
[DB Internals] MVCC: Multi-Version Concurrency Controldatabase-internals
정의 MVCC (Multi-Versioning Concurrency Control) 는 트랜잭션 간 격리를 여러 버전의 row 로 구현. 동시 read / write 가 락 없이…
[DB] GIN / GiST / Hash / BRIN: 비-B-tree 인덱스database-internals
정의 B-tree 외 PostgreSQL 의 특수 인덱스 들. 자세한 B-tree 는 참고. | 인덱스 | 적합 | |---|---| | B-tree | = < > BETWEEN…
[DB] MySQL / InnoDB: clustered index, redo log, MVCCdatabase-internals
정의 MySQL + InnoDB (기본 스토리지 엔진) 의 clustered index + redo log 기반 행 기반 RDBMS. 웹 / SaaS 의 가장 흔한 backbon…
[DB] PostgreSQL: 프로세스 모델, MVCC, WAL, 확장성database-internals
정의 PostgreSQL 은 오픈소스 ORDBMS. 1986 UC Berkeley POSTGRES 의 후예. MVCC, 확장 가능 타입, JSONB, full-text searc…
[DB] Transaction Isolation Levels: 완벽 가이드database-internals
정의 Transaction Isolation Level (트랜잭션 격리 수준) = 동시에 실행되는 여러 트랜잭션이 서로의 변경을 어디까지 볼 수 있는지 규정하는 수준. ACID …

💬 댓글

사이트 검색 / 명령어

검색

스크롤 = 확대/축소 · 드래그 = 이동 · 0 = 원래 크기 · ESC = 닫기