실행 계획 읽기
실행 계획은 옵티마이저가 선택했거나 선택할 것으로 예상한 데이터 접근 경로를 보여줍니다. 실행 계획을 읽을 수 있어야 쿼리가 왜 느린지, 인덱스가 사용되는지, 예상 행 수와 실제 행 수가 얼마나 다른지 판단할 수 있습니다. 튜닝의 출발점은 실행 계획과 실제 실행 통계를 함께 보는 것입니다.
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) 비교 가능!EXPLAIN PLAN은 실제 실행 없이 예상 계획을 plan table에 기록합니다. 바인드 값, 세션 설정, 실제 실행 시점의 커서 상태까지 완전히 반영하지 못할 수 있으므로, 실제 실행에 사용된 커서와 row source statistics를 보려면 DBMS_XPLAN.DISPLAY_CURSOR와 GATHER_PLAN_STATISTICS 또는 관련 세션 설정을 함께 사용합니다.
-------------------------------------------------------------------------
| 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 RANGE SCAN | IDX_DEPT_NAME | 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")실행 계획 읽는 순서
실행 계획의 주요 항목
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 찾고 테이블 접근 | 선택도가 높고 rowid 접근이 적을 때 유리 |
| TABLE ACCESS BY INDEX ROWID BATCHED | ROWID를 모아서 일괄 접근 | 테이블 블록 접근 순서를 개선하려는 방식 |
인덱스 접근 방식
| Operation | 설명 | 해석 포인트 |
|---|---|---|
| INDEX UNIQUE SCAN | 유니크 인덱스로 최대 1건 탐색 | PK/UNIQUE + 등호 조건 |
| INDEX RANGE SCAN | 인덱스 범위 탐색 | 범위 조건, 비유니크 인덱스 |
| INDEX RANGE SCAN DESCENDING | 역방향 인덱스 스캔 | 정렬 방향과 인덱스 순서 확인 |
| INDEX FULL SCAN | 인덱스 전체 스캔, 순서 활용 가능 | 정렬 회피나 커버링 여부 확인 |
| INDEX FAST FULL SCAN | 인덱스 전체 스캔, 테이블 대신 읽기 | 순서 보장은 없고 커버링 집계에 유리할 수 있음 |
| INDEX SKIP SCAN | 복합 인덱스 선두 컬럼을 건너뛰는 탐색 | 선두 컬럼 NDV와 비용에 영향받음 |
조인 방식
| Operation | 설명 | 적합한 상황 예시 |
|---|---|---|
| NESTED LOOPS | 외부 입력 행마다 내부 입력 탐색 | 외부 행이 적고 내부 접근이 빠를 때 |
| HASH JOIN | 한쪽 입력으로 해시 테이블 구축 후 매칭 | 큰 동등 조인, 충분한 메모리 |
| SORT MERGE JOIN | 양쪽을 정렬 후 병합 | 이미 정렬된 입력, merge 가능한 조건 |
기타 주요 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 실제 행 수, 실제 실행 시간 포함EXPLAIN ANALYZE는 실제로 쿼리를 실행합니다. MySQL에서는 버전과 문장 종류에 따라 지원 범위가 다르므로, 실제 운영 데이터에 부작용이 생기지 않는 조회 분석에 우선 사용합니다. JSON/TREE 출력은 전통적인 표 형태보다 조인 순서와 비용 추정 흐름을 이해하기 쉽습니다.
+----+------+-------+------+---------+------+------+-------------+
| 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 컬럼 해석
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;EXPLAIN ANALYZE는 실제 실행 결과를 붙여 보여주므로, 변경문을 대상으로 사용할 때는 트랜잭션 보호와 롤백을 전제로 검증합니다.
BEGIN;
EXPLAIN (ANALYZE, BUFFERS)
UPDATE orders SET status = 'done' WHERE id = 1001;
ROLLBACK;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블록 읽음PostgreSQL의 width=120은 실제 반환 행의 크기가 아니라, 해당 노드가 반환할 것으로 추정한 평균 행 폭입니다. actual time도 노드 자체의 첫 행/마지막 행 반환 시간으로 읽어야 하며, loops가 2 이상이면 행 수와 시간 해석에 반복 횟수를 함께 고려해야 합니다.
실행 계획 비교 분석
1. TABLE ACCESS FULL + 대량 행:
→ 인덱스가 없거나, 인덱스를 사용하는 비용이 더 크다고 판단했을 수 있음
→ WHERE에 함수 적용, 타입 불일치 등 확인
2. 예상 Rows와 실제 Rows의 큰 차이:
Oracle: E-Rows=1 vs A-Rows=100000
MySQL: EXPLAIN rows=1(검사 예상 행 수) vs EXPLAIN ANALYZE 실제 iterator rows
전통 EXPLAIN은 filtered까지 함께 봐야 다음 단계 전달 행 수를 추정 가능
→ 통계 정보, 히스토그램, 컬럼 상관관계, 바인드 값 분포 확인
→ 필요 시 ANALYZE TABLE, DBMS_STATS.GATHER_TABLE_STATS/GATHER_SCHEMA_STATS, ANALYZE 실행
3. NESTED LOOPS + 대량 데이터:
→ HASH JOIN이 더 적합할 수 있음
→ 내부 테이블 인덱스 누락 확인
4. SORT ORDER BY + 큰 Cost:
→ ORDER BY 컬럼에 인덱스 추가 검토
→ 정렬 방향과 WHERE 조건을 함께 만족하는 인덱스인지 확인
→ 커버링 인덱스로 테이블 접근과 정렬을 함께 줄일 수 있는지 확인
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 = NDV/컬럼 카디널리티)
* NULL 비율 (NUM_NULLS)
* 최소값/최대값 (LOW_VALUE, HIGH_VALUE)
* 히스토그램 (데이터 분포 정보)
인덱스 통계:
* 리프 블록 수 (LEAF_BLOCKS)
* 트리 높이 (BLEVEL)
* 클러스터링 팩터 (CLUSTERING_FACTOR)MySQL과 PostgreSQL은 통계 명칭과 저장 방식이 다르지만, 행 수, 값 분포, NULL 비율, 인덱스 선택도 같은 정보를 이용해 접근 경로와 조인 방식을 추정한다는 점은 같습니다.
힌트 (Hint)
옵티마이저가 통계와 비용 모델상 좋지 않은 계획을 선택할 때, 힌트로 특정 접근 경로나 조인 방식을 유도할 수 있습니다. 힌트는 DBMS별 지원 범위가 다르고, 잘못 쓰면 이후 데이터 변화에 취약해집니다. 가능하면 먼저 통계, 인덱스 설계, 조건식, 조인 순서 추정 오류를 확인한 뒤 마지막에 사용합니다.
-- 풀 스캔 강제
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;
-- Index-level Optimizer Hint (MySQL 8.0.20+)
SELECT /*+ NO_INDEX(e idx_dept) */ * FROM employees e;
-- Join-order Optimizer Hint (MySQL 8.0+)
SELECT /*+ JOIN_ORDER(d, e) */ e.name, d.dept_name
FROM departments d JOIN employees e ON e.dept_id = d.id;1. 힌트는 최후의 수단
→ 먼저 인덱스 추가, 통계 갱신, 쿼리 재작성 시도
2. 데이터 분포 변하면 힌트가 역효과
→ 소량일 때 NL → 데이터 증가 시 성능 저하
3. Oracle 힌트는 문법 오류 시 무시됨
→ 테이블 별칭과 일치해야 함!
4. PostgreSQL은 힌트 공식 미지원
→ pg_hint_plan 확장으로 유사 기능실행 계획 캐싱과 바인드 변수
-- 리터럴 값 직접 삽입:
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 텍스트로 재사용 가능성 증가실행 계획 캐싱 방식은 DBMS마다 다릅니다. Oracle은 커서 공유와 bind peeking/adaptive cursor sharing, PostgreSQL은 prepared statement의 generic/custom plan 선택, MySQL은 서버의 prepared statement와 드라이버/커넥션 풀의 statement cache 동작을 구분해서 봐야 합니다.
테이블: 1000행, 800행이 조건 해당
인덱스 스캔: 800번 랜덤 I/O → 800+ 블록
풀 스캔: Multi-Block 연속 읽기 → 50블록
→ 전체의 큰 비율을 읽으면 풀 스캔이 더 나을 수 있음
→ 인덱스 유리 여부는 선택도, 클러스터링, 커버링, 캐시 상태에 따라 달라짐
→ 고정 퍼센트 규칙보다 실제 실행 계획과 I/O 통계를 확인갑자기 느려진 쿼리 체크리스트:
1. 통계 정보 갱신 여부
2. 데이터 분포 변화 (히스토그램)
3. 인덱스 삭제/변경 여부
4. 옵티마이저 파라미터 변경
5. 실행 계획 이전과 비교 (AWR 등)EXPLAIN 결과로 보는 튜닝 포인트
DBMS별 실행 계획 확인 명령 비교
정리
| 개념 | 핵심 내용 |
|---|---|
| EXPLAIN | 예상 실행 계획과 접근 경로 확인 |
| 읽는 순서 | 자식 노드가 부모 노드에 행을 공급하는 흐름 확인 |
| access vs filter | access는 접근/조인 조건, filter는 추가 판정 조건 |
| Rows | 예상 행 수, 실제와 차이 크면 추정 근거 확인 |
| Cost | 같은 환경에서 계획 비교에 쓰는 상대 비용 |
| 조인 방식 | 데이터량, 인덱스, 메모리, 정렬 상태에 따라 선택 |
| 인덱스 스캔 | UNIQUE/RANGE/FULL/FAST FULL/SKIP |
| 힌트 | 계획 선택을 유도하는 DBMS별 지시, 신중히 사용 |
| 바인드 변수 | SQL 텍스트 안정화와 재사용 가능성 증가 |
| 통계 정보 | 옵티마이저 판단 근거, 데이터 변화 후 갱신 필요 |
실행 계획을 읽는 핵심은 데이터 접근 경로와 행 수 추정을 이해하는 것입니다. 전체 테이블 스캔인지 인덱스 접근인지, 조인 방식이 현재 데이터 분포에 맞는지, 예상 행 수가 실제와 맞는지를 보면 성능 문제의 원인을 더 좁힐 수 있습니다.
다음 절에서는 느린 쿼리를 찾아 개선하는 쿼리 튜닝을 다루겠습니다.