안동민 개발노트 아이콘

안동민 개발노트

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) 비교 가능!

EXPLAIN PLAN은 실제 실행 없이 예상 계획을 plan table에 기록합니다. 바인드 값, 세션 설정, 실제 실행 시점의 커서 상태까지 완전히 반영하지 못할 수 있으므로, 실제 실행에 사용된 커서와 row source statistics를 보려면 DBMS_XPLAN.DISPLAY_CURSORGATHER_PLAN_STATISTICS 또는 관련 세션 설정을 함께 사용합니다.

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 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 BATCHEDROWID를 모아서 일괄 접근테이블 블록 접근 순서를 개선하려는 방식

인덱스 접근 방식

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

자주 보이는 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 실제 행 수, 실제 실행 시간 포함

EXPLAIN ANALYZE는 실제로 쿼리를 실행합니다. MySQL에서는 버전과 문장 종류에 따라 지원 범위가 다르므로, 실제 운영 데이터에 부작용이 생기지 않는 조회 분석에 우선 사용합니다. JSON/TREE 출력은 전통적인 표 형태보다 조인 순서와 비용 추정 흐름을 이해하기 쉽습니다.

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 컬럼 해석


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;

EXPLAIN ANALYZE는 실제 실행 결과를 붙여 보여주므로, 변경문을 대상으로 사용할 때는 트랜잭션 보호와 롤백을 전제로 검증합니다.

PostgreSQL 변경문 분석 시 보호 패턴
BEGIN;
EXPLAIN (ANALYZE, BUFFERS)
UPDATE orders SET status = 'done' WHERE id = 1001;
ROLLBACK;
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블록 읽음

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과 동시에)
통계 정보 포함 내용 (Oracle 예시)
테이블 통계:
  * 행 수 (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별 지원 범위가 다르고, 잘못 쓰면 이후 데이터 변화에 취약해집니다. 가능하면 먼저 통계, 인덱스 설계, 조건식, 조인 순서 추정 오류를 확인한 뒤 마지막에 사용합니다.

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;

-- 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 filteraccess는 접근/조인 조건, filter는 추가 판정 조건
Rows예상 행 수, 실제와 차이 크면 추정 근거 확인
Cost같은 환경에서 계획 비교에 쓰는 상대 비용
조인 방식데이터량, 인덱스, 메모리, 정렬 상태에 따라 선택
인덱스 스캔UNIQUE/RANGE/FULL/FAST FULL/SKIP
힌트계획 선택을 유도하는 DBMS별 지시, 신중히 사용
바인드 변수SQL 텍스트 안정화와 재사용 가능성 증가
통계 정보옵티마이저 판단 근거, 데이터 변화 후 갱신 필요

실행 계획을 읽는 핵심은 데이터 접근 경로행 수 추정을 이해하는 것입니다. 전체 테이블 스캔인지 인덱스 접근인지, 조인 방식이 현재 데이터 분포에 맞는지, 예상 행 수가 실제와 맞는지를 보면 성능 문제의 원인을 더 좁힐 수 있습니다.

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