13장 : 쿼리 최적화와 실행 계획
실행 계획 읽기
실행 계획은 옵티마이저가 선택한 데이터 접근 경로를 보여줍니다. 실행 계획을 읽을 수 있어야 쿼리가 왜 느린지, 인덱스를 타는지 알 수 있습니다. 튜닝의 출발점은 항상 실행 계획입니다.
Oracle EXPLAIN PLAN
-- 방법 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) 비교 가능!-------------------------------------------------------------------------
| 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 BATCHED | ROWID를 모아서 일괄 접근 | 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
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
-- 기본 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 실제 행 수, 실제 실행 시간 포함+----+------+-------+------+---------+------+------+-------------+
| 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행만 존재 | 최고 | - |
| const | PK/UNIQUE로 1건 상수 검색 | 최고 | INDEX UNIQUE SCAN |
| eq_ref | PK/UNIQUE로 조인 시 1건 | 매우 좋음 | INDEX UNIQUE SCAN |
| ref | 비유니크 인덱스로 검색 | 좋음 | INDEX RANGE SCAN |
| range | 인덱스 범위 스캔 | 좋음 | INDEX RANGE SCAN |
| index | 인덱스 풀 스캔 | 보통 | INDEX FULL SCAN |
| ALL | 테이블 풀 스캔 | 나쁨 | TABLE ACCESS FULL |
MySQL 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
-- 예상 실행 계획
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;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)
옵티마이저가 최적의 실행 계획을 선택하지 못할 때, 힌트로 강제할 수 있습니다.
-- 풀 스캔 강제
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;-- 인덱스 사용 강제
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 │ 실행 계획 확인 방법 │
├────────────────┼───────────────────────────────────────┤
│ 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 filter | access는 인덱스 접근, filter는 후필터링 |
| Rows | 예상 행 수, 실제와 차이 크면 통계 갱신 필요 |
| Cost | 상대적 실행 비용, 낮을수록 좋음 |
| 조인 방식 | NL(소량) / HASH(대량) / SORT MERGE(정렬됨) |
| 인덱스 스캔 | UNIQUE/RANGE/FULL/FAST FULL/SKIP |
| 힌트 | 옵티마이저 실행 계획 강제 (최후 수단) |
| 바인드 변수 | 실행 계획 재사용, 하드 파싱 방지 |
| 통계 정보 | 옵티마이저 판단 근거, 정기적 갱신 필수 |
실행 계획을 읽는 핵심은 데이터 접근 경로를 이해하는 것입니다. 전체 테이블 스캔인지 인덱스를 타는지, 조인 방식이 적절한지, 예상 행 수가 실제와 맞는지를 확인하면 대부분의 성능 문제를 진단할 수 있습니다.
다음 절에서는 느린 쿼리를 찾아 개선하는 쿼리 튜닝을 다루겠습니다.