icon

안동민 개발노트

13장 : 쿼리 최적화와 실행 계획

실행 계획 읽기


실행 계획은 옵티마이저가 선택한 데이터 접근 경로를 보여줍니다. 실행 계획을 읽을 수 있어야 쿼리가 왜 느린지, 인덱스를 타는지 알 수 있습니다. 튜닝의 출발점은 항상 실행 계획입니다.


Oracle EXPLAIN PLAN

실행 계획 확인 방법 (Oracle)
-- 방법 1: EXPLAIN PLAN + DBMS_XPLAN
EXPLAIN PLAN FOR
SELECT e.name, d.dept_name
FROM employees e
JOIN departments d ON e.dept_id = d.id
WHERE d.dept_name = '개발팀';

SELECT * FROM TABLE(DBMS_XPLAN.DISPLAY);

-- 방법 2: AUTOTRACE (SQL*Plus)
SET AUTOTRACE ON;
SELECT ... ;
-- → 실행 계획 + 실제 실행 통계 함께 표시

-- 방법 3: 실제 실행 통계 포함 (가장 정확)
SELECT /*+ GATHER_PLAN_STATISTICS */ e.name, d.dept_name
FROM employees e
JOIN departments d ON e.dept_id = d.id
WHERE d.dept_name = '개발팀';

SELECT * FROM TABLE(DBMS_XPLAN.DISPLAY_CURSOR(NULL, NULL, 'ALLSTATS LAST'));
-- → 예상 행 수(E-Rows) vs 실제 행 수(A-Rows) 비교 가능!
Oracle 실행 계획 예시
-------------------------------------------------------------------------
| Id | Operation                    | Name           | Rows | Cost (%CPU)|
-------------------------------------------------------------------------
|  0 | SELECT STATEMENT             |                |   15 |    4  (0)  |
|  1 |  NESTED LOOPS                |                |   15 |    4  (0)  |
|  2 |   TABLE ACCESS BY INDEX ROWID| DEPARTMENTS    |    1 |    2  (0)  |
|* 3 |    INDEX UNIQUE SCAN         | PK_DEPT        |    1 |    1  (0)  |
|  4 |   TABLE ACCESS BY INDEX ROWID| EMPLOYEES      |   15 |    2  (0)  |
|* 5 |    INDEX RANGE SCAN          | IDX_EMP_DEPT   |   15 |    1  (0)  |
-------------------------------------------------------------------------

Predicate Information:
  3 - access("D"."DEPT_NAME"='개발팀')
  5 - access("E"."DEPT_ID"="D"."ID")

실행 계획 읽는 순서

실행 계획 읽는 규칙
들여쓰기가 깊은 것 → 먼저 실행
같은 깊이면 위 → 아래 순서
부모 Operation은 자식 결과를 받아서 처리

| 0 | SELECT STATEMENT          |     실행 순서
| 1 |  NESTED LOOPS             |     3 → 2 → 5 → 4 → 1 → 0
| 2 |   TABLE ACCESS (DEPT)     |
|*3 |    INDEX UNIQUE SCAN      |     ① PK_DEPT 인덱스에서 '개발팀' 찾기
| 4 |   TABLE ACCESS (EMP)      |     ② DEPARTMENTS 테이블에서 행 데이터 읽기
|*5 |    INDEX RANGE SCAN       |     ③ IDX_EMP_DEPT에서 dept_id 매칭 행 찾기
                                      ④ EMPLOYEES 테이블에서 행 데이터 읽기
                                      ⑤ NESTED LOOPS로 결합
                                      ⑥ SELECT 결과 반환

실행 계획의 주요 항목

실행 계획 항목 설명
Id:    Operation의 고유 번호 (* 표시 = Predicate 존재)
Operation: 수행하는 작업 (스캔 방식, 조인 방식 등)
Name:  대상 객체 (테이블명, 인덱스명)
Rows:  옵티마이저가 예측한 반환 행 수 (Cardinality)
Bytes: 예측한 반환 데이터 크기
Cost:  실행 비용 (상대적 수치, 낮을수록 좋음)
Time:  예상 실행 시간

Predicate Information:
  access: 인덱스/조인 키로 사용된 조건 (효율적)
  filter: 데이터를 읽은 후 필터링하는 조건 (비효율적일 수 있음)

주요 Operation 해석

테이블 접근 방식

Operation설명성능 특성
TABLE ACCESS FULL전체 테이블 스캔대량 조회 시 사용, 소량 시 비효율
TABLE ACCESS BY INDEX ROWID인덱스로 ROWID 찾고 테이블 접근소량 데이터 시 매우 효율적
TABLE ACCESS BY INDEX ROWID BATCHEDROWID를 모아서 일괄 접근12c+, I/O 최적화

인덱스 접근 방식

Operation설명사용 조건
INDEX UNIQUE SCAN유니크 인덱스로 정확히 1건PK/UNIQUE + 등호 조건
INDEX RANGE SCAN인덱스 범위 탐색범위 조건, 비유니크 인덱스
INDEX RANGE SCAN DESCENDING역방향 인덱스 스캔ORDER BY DESC
INDEX FULL SCAN인덱스 전체 스캔 (순서 유지)ORDER BY + 인덱스 활용
INDEX FAST FULL SCAN인덱스 전체 스캔 (순서 무시)커버링 인덱스 집계
INDEX SKIP SCAN선두 컬럼 건너뛰기복합 인덱스, 선두 컬럼 조건 없을 때
인덱스 스캔 방식 비교
INDEX UNIQUE SCAN
  → WHERE id = 100 (PK)
  → 트리를 따라 정확히 1건 찾기 → 가장 빠름

INDEX RANGE SCAN
  → WHERE dept_id = 10
  → 시작점 찾고 끝점까지 순차 읽기

INDEX FULL SCAN
  → 인덱스 전체를 리프 노드 순서대로 읽기
  → ORDER BY에 인덱스 순서를 활용할 때

INDEX FAST FULL SCAN
  → 인덱스 전체를 Multi-Block I/O로 빠르게 읽기
  → 순서 보장 안 함, 집계(COUNT, SUM)에 유리

INDEX SKIP SCAN
  → 복합 인덱스 (A, B)에서 WHERE B = ? 만 있을 때
  → A의 각 고유 값에 대해 B를 탐색
  → A의 카디널리티가 낮을 때만 효과적

조인 방식

Operation설명적합한 상황
NESTED LOOPS외부 테이블 행마다 내부 테이블 탐색소량 + 인덱스 있을 때
HASH JOIN작은 테이블로 해시 테이블 구축 후 매칭대량 데이터, 등호 조인
SORT MERGE JOIN양쪽을 정렬 후 병합정렬된 대량 데이터, 비등호 조인
조인 방식 선택 기준
NESTED LOOPS
  외부 행: 10건 × 내부 인덱스 스캔: 각 1회 = 총 10회 I/O
  → 외부 행이 적고 내부에 인덱스가 있을 때 최적

HASH JOIN
  1단계: 작은 테이블(Build)로 메모리에 해시 테이블 구축
  2단계: 큰 테이블(Probe)을 읽으며 해시 매칭
  → 대량 + 대량 등호 조인에 최적
  → 메모리에 해시 테이블이 안 들어가면 Temp 디스크 사용

SORT MERGE JOIN
  1단계: 양쪽 테이블을 조인 키로 정렬
  2단계: 정렬된 결과를 한 번에 병합
  → 이미 정렬된 데이터 또는 비등호 조인(>=, <=)에 사용

기타 주요 Operation

자주 보이는 Operation
SORT ORDER BY:     ORDER BY 절 처리 (메모리/디스크 정렬)
SORT GROUP BY:     GROUP BY 절 처리
HASH GROUP BY:     해시 기반 GROUP BY (정렬 불필요)
SORT AGGREGATE:    집계 함수 (COUNT, SUM 등)
FILTER:            WHERE 조건으로 행 필터링
VIEW:              인라인 뷰 또는 뷰 처리
UNION-ALL:         UNION ALL 결합
HASH UNIQUE:       DISTINCT 처리 (해시 기반)
COUNT STOPKEY:     ROWNUM 조건으로 조기 종료
BUFFER SORT:       중간 결과를 임시 버퍼에 정렬
INLIST ITERATOR:   IN 리스트의 각 값에 대해 반복

MySQL EXPLAIN

실행 계획 확인 (MySQL)
-- 기본 EXPLAIN
EXPLAIN SELECT e.name, d.dept_name
FROM employees e
JOIN departments d ON e.dept_id = d.id
WHERE d.dept_name = '개발팀';

-- 상세 포맷
EXPLAIN FORMAT=JSON SELECT ...;
EXPLAIN FORMAT=TREE SELECT ...;

-- 실제 실행 통계 포함 (MySQL 8.0.18+)
EXPLAIN ANALYZE SELECT ...;
-- → 예상 행 수 vs 실제 행 수, 실제 실행 시간 포함
MySQL EXPLAIN 결과
+----+------+-------+------+---------+------+------+-------------+
| id | type | table | key  | key_len | ref  | rows | Extra       |
+----+------+-------+------+---------+------+------+-------------+
|  1 | ref  | d     |idx_nm| 102     |const |    1 | Using where |
|  1 | ref  | e     |idx_dp| 4       |d.id  |   15 | NULL        |
+----+------+-------+------+---------+------+------+-------------+

MySQL type 컬럼 해석

type의미성능Oracle 대응
system테이블에 1행만 존재최고-
constPK/UNIQUE로 1건 상수 검색최고INDEX UNIQUE SCAN
eq_refPK/UNIQUE로 조인 시 1건매우 좋음INDEX UNIQUE SCAN
ref비유니크 인덱스로 검색좋음INDEX RANGE SCAN
range인덱스 범위 스캔좋음INDEX RANGE SCAN
index인덱스 풀 스캔보통INDEX FULL SCAN
ALL테이블 풀 스캔나쁨TABLE ACCESS FULL

MySQL Extra 컬럼 해석

Extra 컬럼 주요 값
Using index:           커버링 인덱스 사용 (테이블 접근 불필요) ✓
Using where:           WHERE 조건으로 행 필터링
Using temporary:       임시 테이블 사용 (최적화 필요 가능) △
Using filesort:        추가 정렬 발생 (인덱스 순서와 불일치) △
Using index condition: 인덱스 컨디션 푸시다운 (ICP)
Using join buffer:     조인 버퍼 사용 (인덱스 없는 조인)
Using MRR:             Multi-Range Read 최적화
Backward index scan:   인덱스 역방향 스캔

성능 개선이 필요한 신호
  * ALL + rows가 큼 → 인덱스 추가 검토
  * Using filesort + 대량 데이터 → ORDER BY용 인덱스 검토
  * Using temporary + GROUP BY → 인덱스 컬럼으로 GROUP BY 검토

PostgreSQL EXPLAIN

PostgreSQL 실행 계획
-- 예상 실행 계획
EXPLAIN SELECT * FROM orders WHERE user_id = 1001;

-- 실행 계획 + 실제 실행 통계
EXPLAIN ANALYZE SELECT * FROM orders WHERE user_id = 1001;

-- 상세 출력
EXPLAIN (ANALYZE, BUFFERS, FORMAT TEXT) 
SELECT * FROM orders WHERE user_id = 1001;
PostgreSQL 실행 계획 예시
Index Scan using idx_orders_user on orders
    (cost=0.42..8.44 rows=15 width=120)
    (actual time=0.010..0.025 rows=12 loops=1)
    Index Cond: (user_id = 1001)
    Buffers: shared hit=4
Planning Time: 0.150 ms
Execution Time: 0.045 ms

해석:
  cost=0.42..8.44: 시작 비용..전체 비용
  rows=15:         예상 행 수
  actual rows=12:  실제 행 수
  loops=1:         실행 횟수
  Buffers shared hit=4: 버퍼 캐시에서 4블록 읽음

실행 계획 비교 분석

인덱스 유무에 따른 차이
쿼리: SELECT * FROM orders WHERE user_id = 1001;

인덱스 없음
  TABLE ACCESS FULL (ORDERS)  — 전체 100만 행 스캔
  Cost: 2,847

인덱스 있음 (user_id)
  INDEX RANGE SCAN (IDX_ORDERS_USER)
  TABLE ACCESS BY INDEX ROWID (ORDERS)
  Cost: 4

→ 700배 이상 성능 차이!
실행 계획에서 문제 징후 찾기
1. TABLE ACCESS FULL + 대량 행:
   → 인덱스가 없거나, 인덱스를 사용하지 못하는 쿼리
   → WHERE에 함수 적용, 타입 불일치 등 확인

2. 예상 Rows와 실제 Rows의 큰 차이:
   Oracle:  E-Rows=1 vs A-Rows=100000
   MySQL:   EXPLAIN rows=1 vs EXPLAIN ANALYZE rows=100000
   → 통계 정보가 오래되었을 가능성
   → ANALYZE TABLE 또는 DBMS_STATS.GATHER 실행

3. NESTED LOOPS + 대량 데이터:
   → HASH JOIN이 더 적합할 수 있음
   → 내부 테이블 인덱스 누락 확인

4. SORT ORDER BY + 큰 Cost:
   → ORDER BY 컬럼에 인덱스 추가 검토
   → 커버링 인덱스로 정렬 회피 가능 여부 확인

5. filter 조건이 access 대신 사용:
   → 인덱스를 통해 접근하지 않고 전체 스캔 후 필터링
   → 인덱스 설계 재검토 필요

통계 정보 관리

옵티마이저는 통계 정보를 기반으로 실행 계획을 결정합니다. 통계가 부정확하면 나쁜 실행 계획이 선택될 수 있습니다.

통계 정보 수집
-- Oracle
EXEC DBMS_STATS.GATHER_TABLE_STATS('SCHEMA', 'ORDERS');
EXEC DBMS_STATS.GATHER_INDEX_STATS('SCHEMA', 'IDX_ORDERS');
EXEC DBMS_STATS.GATHER_SCHEMA_STATS('SCHEMA');

-- MySQL
ANALYZE TABLE orders;

-- PostgreSQL
ANALYZE orders;
-- VACUUM ANALYZE orders; (VACUUM과 동시에)
통계 정보 포함 내용
테이블 통계:
  * 행 수 (NUM_ROWS)
  * 블록 수 (BLOCKS)
  * 평균 행 길이 (AVG_ROW_LEN)

컬럼 통계:
  * 고유 값 수 (NUM_DISTINCT = 카디널리티)
  * NULL 비율 (NUM_NULLS)
  * 최소값/최대값 (LOW_VALUE, HIGH_VALUE)
  * 히스토그램 (데이터 분포 정보)

인덱스 통계:
  * 리프 블록 수 (LEAF_BLOCKS)
  * 트리 높이 (BLEVEL)
  * 클러스터링 팩터 (CLUSTERING_FACTOR)

힌트 (Hint)

옵티마이저가 최적의 실행 계획을 선택하지 못할 때, 힌트로 강제할 수 있습니다.

Oracle 힌트
-- 풀 스캔 강제
SELECT /*+ FULL(e) */ * FROM employees e WHERE dept_id = 10;

-- 인덱스 사용 강제
SELECT /*+ INDEX(e IDX_EMP_DEPT) */ * FROM employees e WHERE dept_id = 10;

-- 조인 방식 강제
SELECT /*+ USE_NL(e d) */ e.name, d.dept_name
FROM employees e JOIN departments d ON e.dept_id = d.id;

SELECT /*+ USE_HASH(e d) */ e.name, d.dept_name
FROM employees e JOIN departments d ON e.dept_id = d.id;

-- 조인 순서 강제
SELECT /*+ ORDERED */ e.name, d.dept_name
FROM departments d JOIN employees e ON e.dept_id = d.id;

-- 병렬 처리
SELECT /*+ PARALLEL(orders, 4) */ COUNT(*) FROM orders;
MySQL 힌트
-- 인덱스 사용 강제
SELECT * FROM employees FORCE INDEX (idx_dept) WHERE dept_id = 10;

-- 인덱스 무시
SELECT * FROM employees IGNORE INDEX (idx_dept) WHERE dept_id = 10;

-- Optimizer Hint (MySQL 8.0+)
SELECT /*+ NO_INDEX(e idx_dept) */ * FROM employees e;
SELECT /*+ HASH_JOIN(e d) */ e.name, d.dept_name
FROM employees e JOIN departments d ON e.dept_id = d.id;
힌트 사용 주의사항
1. 힌트는 최후의 수단
   → 먼저 인덱스 추가, 통계 갱신, 쿼리 재작성 시도

2. 데이터 분포 변하면 힌트가 역효과
   → 소량일 때 NL → 데이터 증가 시 성능 저하

3. Oracle 힌트는 문법 오류 시 무시됨
   → 테이블 별칭과 일치해야 함!

4. PostgreSQL은 힌트 공식 미지원
   → pg_hint_plan 확장으로 유사 기능

실행 계획 캐싱과 바인드 변수

실행 계획 캐싱
SQL 실행 과정
  1. 파싱 (구문 검사, 의미 분석)
  2. 최적화 (실행 계획 생성)
  3. 실행

소프트 파싱: 동일 SQL이 캐시에 있으면 재사용 → 빠름
하드 파싱: 새 SQL → 파싱 + 최적화 필요 → 느림, CPU 사용
바인드 변수의 중요성
-- 리터럴 (매번 하드 파싱):
SELECT * FROM orders WHERE user_id = 1001;  -- SQL #1
SELECT * FROM orders WHERE user_id = 1002;  -- SQL #2 (다른 SQL!)

-- 바인드 변수 (소프트 파싱):
SELECT * FROM orders WHERE user_id = :uid;  -- 항상 동일 SQL
실습 — 풀 스캔이 더 나은 경우
테이블: 1000행, 800행이 조건 해당

인덱스 스캔: 800번 랜덤 I/O → 800+ 블록
풀 스캔: Multi-Block 연속 읽기 → 50블록

→ 전체의 80%를 읽으면 풀 스캔이 효율적!
→ 전체 행의 ~5-15% 이하 → 인덱스 유리
→ 전체 행의 ~15% 이상 → 풀 스캔 유리
실습 — 실행 계획 변경 원인 분석
갑자기 느려진 쿼리 체크리스트:
  1. 통계 정보 갱신 여부
  2. 데이터 분포 변화 (히스토그램)
  3. 인덱스 삭제/변경 여부
  4. 옵티마이저 파라미터 변경
  5. 실행 계획 이전과 비교 (AWR 등)

EXPLAIN 결과로 보는 튜닝 포인트

튜닝이 필요한 실행 계획 패턴
패턴 1: 풀 스캔 + 대량 행
  → TABLE ACCESS FULL + Rows=1000000
  → WHERE 컬럼에 인덱스 추가 검토

패턴 2: 인덱스 스캔 + 높은 Cost
  → INDEX RANGE SCAN + 많은 건수 → TABLE ACCESS BY ROWID 반복
  → 커버링 인덱스로 테이블 접근 제거 검토

패턴 3: NESTED LOOPS + 내부 테이블 풀 스캔
  → 내부 테이블의 조인 키에 인덱스 필요

패턴 4: SORT ORDER BY + 높은 Cost
  → ORDER BY 컬럼 포함 인덱스 추가
  → 이미 정렬된 인덱스 활용 가능 여부 확인

패턴 5: Rows 예측 오차 (E-Rows vs A-Rows)
  → GATHER_PLAN_STATISTICS로 실제 행 확인
  → 통계 재수집 또는 히스토그램 생성

DBMS별 실행 계획 확인 명령 비교

DBMS별 실행 계획 확인
┌────────────────┬───────────────────────────────────────┐
│      DBMS      │ 실행 계획 확인 방법                   │
├────────────────┼───────────────────────────────────────┤
│ Oracle         │ EXPLAIN PLAN + DBMS_XPLAN.DISPLAY     │
│                │ AUTOTRACE (SQL*Plus)                  │
│                │GATHER_PLAN_STATISTICS + DISPLAY_CURSOR│
│ MySQL          │ EXPLAIN / EXPLAIN ANALYZE (8.0.18+)   │
│                │ EXPLAIN FORMAT=JSON/TREE              │
│ PostgreSQL     │ EXPLAIN / EXPLAIN ANALYZE             │
│                │EXPLAIN (ANALYZE, BUFFERS, FORMAT TEXT)│
│ SQL Server     │ SET SHOWPLAN_ALL ON                   │
│                │ SET STATISTICS PROFILE ON             │
│                │ Actual Execution Plan (SSMS)          │
└────────────────┴───────────────────────────────────────┘

정리

개념핵심 내용
EXPLAIN옵티마이저가 선택한 실행 계획 확인
읽는 순서들여쓰기 깊은 것부터, 같은 레벨은 위에서 아래
access vs filteraccess는 인덱스 접근, filter는 후필터링
Rows예상 행 수, 실제와 차이 크면 통계 갱신 필요
Cost상대적 실행 비용, 낮을수록 좋음
조인 방식NL(소량) / HASH(대량) / SORT MERGE(정렬됨)
인덱스 스캔UNIQUE/RANGE/FULL/FAST FULL/SKIP
힌트옵티마이저 실행 계획 강제 (최후 수단)
바인드 변수실행 계획 재사용, 하드 파싱 방지
통계 정보옵티마이저 판단 근거, 정기적 갱신 필수

실행 계획을 읽는 핵심은 데이터 접근 경로를 이해하는 것입니다. 전체 테이블 스캔인지 인덱스를 타는지, 조인 방식이 적절한지, 예상 행 수가 실제와 맞는지를 확인하면 대부분의 성능 문제를 진단할 수 있습니다.

다음 절에서는 느린 쿼리를 찾아 개선하는 쿼리 튜닝을 다루겠습니다.

목차