10장. 옵티마이저와 실행계획
SQL은 "무엇을"만 말하고 "어떻게"는 옵티마이저가 정합니다. 그 판단의 근거(통계와 cardinality)와 결과물(실행계획)을 읽을 수 있어야 튜닝이 시작됩니다.
① CBO가 통계로 비용을 계산하는 원리와 cardinality의 결정적 역할 ② EXPLAIN PLAN과 DBMS_XPLAN으로 계획·실제 실행 통계를 보는 법 ③ 실행계획 트리의 실행 순서 읽기 ④ NL·Hash·Sort Merge 조인의 동작과 선택 기준, 힌트와 bind peeking
10.1 CBO — 비용으로 계획을 고르는 옵티마이저
하나의 SQL을 실행하는 방법은 수없이 많습니다. 어느 테이블부터 읽을지, 인덱스를 탈지 풀스캔할지, 어떤 조인 방법을 쓸지… Oracle의 CBO(Cost-Based Optimizer)는 후보 계획들의 비용(cost) — 예상 I/O·CPU·메모리 사용량을 하나의 숫자로 환산한 값 — 을 계산해 가장 싼 계획을 고릅니다.
비용 계산의 원재료가 통계(statistics)입니다. 테이블의 행 수·블록 수, 컬럼의 distinct 값 개수(NDV)·최소/최대값·NULL 비율, 인덱스의 height·Clustering Factor, 그리고 값 분포가 치우친 컬럼의 히스토그램까지. 통계는 DBMS_STATS 패키지로 수집합니다.
-- 테이블 + 컬럼 + 인덱스 통계 수집 (19c 권장 기본형)
BEGIN
DBMS_STATS.GATHER_TABLE_STATS(
ownname => 'SALES_APP',
tabname => 'ORDERS',
cascade => TRUE -- 인덱스 통계도 함께
);
END;
/
-- 통계가 언제 수집됐는지 확인
SELECT table_name, num_rows, blocks, last_analyzed
FROM user_tables WHERE table_name = 'ORDERS';
Oracle은 야간 유지보수 창(maintenance window)에 자동 통계 수집 작업을 실행해 변경이 많았던 객체의 통계를 갱신합니다. 문제는 낮에 대량 적재/삭제가 일어난 직후 — 밤까지 낡은 통계로 버텨야 하는 이 공백이 사고의 단골 시간대입니다. 대량 배치 후에는 수동 수집을 배치의 마지막 단계로 넣는 것이 정석입니다.
10.2 Cardinality — 플랜을 결정하는 단 하나의 숫자
Cardinality는 "이 단계에서 몇 행이 나올 것인가"라는 옵티마이저의 예측치입니다. 이 숫자가 거의 모든 것을 결정합니다 — 예상 행 수가 적으면 인덱스+NL 조인, 많으면 풀스캔+Hash 조인 쪽으로 기웁니다. 즉, cardinality 추정이 틀리면 플랜이 틀립니다. 그리고 추정이 틀리는 가장 흔한 이유가 낡은 통계입니다.
통계는 3개월 전 "1만 행" 시절의 것. 그 사이 테이블은 1000만 행으로 자랐습니다. 옵티마이저는 실제 데이터가 아니라 통계라는 지도만 봅니다.
CBO는 낡은 지도로 cardinality를 계산합니다: "조건에 맞는 행은 100건쯤(E-Rows=100)" → 소량이면 인덱스 + NL 조인이 최적이라고 판단합니다.
실행해 보니 실제(A-Rows)는 100만 건. NL 조인은 행마다 상대 테이블을 탐색하므로 100만 번 반복 — 쿼리가 수십 분짜리로 폭주합니다. E-Rows와 A-Rows의 큰 격차가 바로 "통계가 낡았다"는 증거입니다.
통계를 다시 수집하면 CBO는 정확한 cardinality로 Hash 조인 + Full Scan을 선택합니다. 쿼리는 못 고쳐도 통계만 고쳐서 해결되는 사고가 현장에는 정말 많습니다.
10.3 실행계획 보기 — EXPLAIN PLAN과 DBMS_XPLAN
실행계획을 보는 방법은 크게 두 가지이고, 둘의 차이를 아는 것이 중요합니다.
📝 EXPLAIN PLAN — "예상" 계획
SQL을 실행하지 않고 옵티마이저가 세울 계획만 봅니다. 빠르고 안전하지만, 어디까지나 예측입니다. bind 변수 값에 따라 실제 계획과 다를 수 있습니다.
🎯 DISPLAY_CURSOR — "실제" 계획
방금 실제로 실행된 커서의 계획을 라이브러리 캐시에서 꺼내 봅니다. ALLSTATS LAST 옵션이면 단계별 실제 행 수(A-Rows)·시간까지 — 튜닝의 표준 도구입니다.
-- 방법 1: 예상 계획
EXPLAIN PLAN FOR
SELECT e.ename, d.dname FROM emp e JOIN dept d ON e.deptno = d.deptno
WHERE d.loc = 'DALLAS';
SELECT * FROM TABLE(DBMS_XPLAN.display);
-- 방법 2: 실제 실행 통계까지 (튜닝의 정석)
SELECT /*+ GATHER_PLAN_STATISTICS */ e.ename, d.dname
FROM emp e JOIN dept d ON e.deptno = d.deptno
WHERE d.loc = 'DALLAS';
SELECT * FROM TABLE(DBMS_XPLAN.display_cursor(NULL, NULL, 'ALLSTATS LAST'));
-- NULL, NULL = "이 세션에서 방금 실행한 SQL"
단계별 실제 행 수(A-Rows)와 시간은 공짜로 기록되지 않습니다. SQL에 /*+ GATHER_PLAN_STATISTICS */ 힌트를 넣거나, 세션에서 ALTER SESSION SET statistics_level = ALL;을 켠 뒤 실행해야 채워집니다. 잊으면 A-Rows 칸이 비어 나옵니다.
10.4 실행계획 트리 읽는 법 — 들여쓰기가 곧 구조
DBMS_XPLAN 출력은 사실 트리입니다. 들여쓰기가 깊을수록 자식 노드이고, 자식이 먼저 실행되어 부모에게 행을 올려보냅니다. 읽는 규칙은 하나만 기억하면 됩니다: "위에서 아래로 내려가되, 자식이 있으면 가장 깊은 곳부터" — 같은 부모의 자식들은 위에 있는 쪽이 먼저입니다.
출발점은 가장 깊이 들여쓰인 줄 중 첫 번째 — Id 3, DEPT_LOC_IDX 인덱스 스캔입니다. loc='DALLAS'인 부서의 ROWID를 찾습니다.
찾은 ROWID는 부모(Id 2)에게 올라가 DEPT 테이블 접근에 쓰입니다. 자식 → 부모 방향으로 행이 흐릅니다.
NESTED LOOPS의 첫째 자식(DEPT 쪽)에서 행이 나올 때마다, 둘째 자식으로 내려갑니다. 그 부서번호로 Id 5의 EMP_DEPTNO_IDX를 탐색합니다.
얻은 ROWID로 EMP 테이블(Id 4)에서 사원 행을 읽습니다. DEPT에서 나온 행 수만큼 이 과정이 반복됩니다.
모든 행이 NESTED LOOPS(Id 1)에서 결합되어 결과로 반환됩니다. 전체 순서 3→2→5→4→1. 이 규칙 하나면 아무리 긴 계획도 읽을 수 있습니다.
10.5 조인 방법 3종 — NL, Hash, Sort Merge
두 테이블을 잇는 물리적 방법은 셋뿐입니다. 옵티마이저는 cardinality와 인덱스 유무를 보고 고릅니다.
두 방식의 재료는 같습니다: 작은 테이블과 큰 테이블. Nested Loop는 왼쪽(outer/driving)에서 행을 하나 꺼내는 것으로 시작합니다.
outer의 행 1을 들고 inner 테이블의 인덱스를 탐색해 짝을 찾습니다. NL 조인의 inner 쪽에 조인 컬럼 인덱스가 없으면 행마다 풀스캔이 벌어지는 재앙이 됩니다.
행 2, 행 3… outer의 행 수만큼 트리 탐색을 반복합니다. 몇십 건이면 즉답이지만, 100만 건이면 100만 번의 랜덤 I/O입니다.
Hash Join은 다르게 시작합니다. 먼저 작은 집합을 읽어 PGA 메모리에 해시 테이블을 build합니다. 이 준비 비용 때문에 첫 행 응답은 NL보다 늦습니다.
그다음 큰 집합을 순차 Full Scan 1회 하면서 해시 테이블에 probe. 행당 비용이 극히 작아 대량 조인에서 압승입니다. 단, 등호(=) 조인에만 쓸 수 있습니다.
세 번째인 Sort Merge Join은 양쪽을 조인 키로 정렬한 뒤 병합하는 방식입니다. Hash Join이 등장한 이후 선택 빈도는 줄었지만, 부등호 범위 조인(t1.a BETWEEN t2.x AND t2.y 등)이나 이미 정렬된 입력에서는 여전히 유효합니다.
| Nested Loop | Hash | Sort Merge | |
|---|---|---|---|
| 적합한 규모 | 소량 (OLTP 단건·페이지 조회) | 대량 (배치·집계) | 대량 + 정렬 활용 가능 시 |
| 필수 조건 | inner 조인 컬럼 인덱스 | 등호 조인, PGA 메모리 | 정렬 비용 감당 |
| 첫 행 응답 | 매우 빠름 | build 후부터 | 정렬 후부터 |
| 실패 패턴 | 대량 반복 → 랜덤 I/O 폭발 | 메모리 부족 → TEMP 쓰기 | 정렬 비용 과다 |
10.6 힌트 — 옵티마이저에게 보내는 지시서
힌트(hint)는 /*+ ... */ 주석 형태로 옵티마이저의 결정을 사람이 덮어쓰는 장치입니다.
SELECT /*+ LEADING(d) USE_NL(e) INDEX(e emp_deptno_idx) */
e.ename, d.dname
FROM dept d, emp e
WHERE e.deptno = d.deptno
AND d.loc = 'DALLAS';
-- LEADING(d): dept부터 읽어라(조인 순서)
-- USE_NL(e): emp와는 NL 조인으로
-- INDEX(e ...): emp 접근 시 이 인덱스를 써라
힌트로 고정한 플랜은 데이터가 변해도 그대로입니다. 오늘의 최적이 1년 뒤의 재앙이 되곤 합니다. 또 문법이 틀리거나 불가능한 지시(존재하지 않는 인덱스 등)면 에러 없이 조용히 무시되므로, 힌트가 실제로 먹었는지는 반드시 실행계획으로 확인해야 합니다. 순서는 항상 "통계·인덱스·SQL을 먼저 고치고, 힌트는 최후에"입니다.
10.7 Bind Peeking과 Adaptive 기능 — 옵티마이저의 자기 보정
바인드 변수(:id)를 쓰면 옵티마이저는 값을 모른 채 계획을 세워야 합니다. 그래서 Oracle은 최초 하드 파싱 시점의 실제 바인드 값을 살짝 훔쳐봅니다(bind peeking). 문제는 데이터 분포가 치우친(skew) 컬럼 — 첫 실행이 "희귀한 값"이었다면 인덱스 플랜이 만들어져 캐시되고, 이후 "흔한 값"이 와도 같은 플랜을 재사용해 느려집니다.
이 부작용을 줄이기 위한 장치들이 버전을 거치며 추가됐습니다.
- Adaptive Cursor Sharing — 같은 SQL이라도 바인드 값의 선택도에 따라 커서(플랜)를 여러 개 유지하고 골라 씁니다.
- Adaptive Plan — 실행을 시작한 뒤 실제 행 수를 관찰해, 예상과 크게 다르면 실행 중에 조인 방법을 전환합니다(예: NL → Hash). 실행계획 Note 영역에 "this is an adaptive plan"으로 표시됩니다.
- 통계 피드백 — 실행에서 관찰한 실제 cardinality를 기억해 다음 파싱에 반영합니다.
Adaptive 기능은 안전망이지 면죄부가 아닙니다. skew가 심한 컬럼에는 히스토그램을 수집하고, E-Rows와 A-Rows의 격차를 줄이는 것이 항상 첫 번째 처방입니다. 자세한 실전 요령은 쿼리 튜닝 팁 모음에서 다룹니다.
✍️ 이해도 체크
Oracle Database 19c 공식 문서: SQL Tuning Guide — Query Optimizer Concepts, Generating and Displaying Execution Plans, Joins