DB Study

10장. 옵티마이저와 실행계획

SQL은 "무엇을"만 말하고 "어떻게"는 옵티마이저가 정합니다. 그 판단의 근거(통계와 cardinality)와 결과물(실행계획)을 읽을 수 있어야 튜닝이 시작됩니다.

고급 ⏱ 약 35분 🎬 애니메이션 3개 선수 지식: 9장 인덱스
🧭
이 장에서 배우는 것

① 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개월 전 수집) num_rows = 10,000 "작은 테이블이군" 💾 실제 테이블 (오늘) 10,000,000 행 매일 적재로 1000배 성장! 🧠 CBO의 추정 E-Rows = 100 "100건? NL 조인이 최적!" 💥 실행: A-Rows = 100만 NL 조인 100만 회 반복 3초 쿼리가 40분으로 🔧 DBMS_STATS.GATHER_TABLE_STATS 실행 num_rows=10,000,000 → E-Rows=100만 CBO: "대량이네 → Full Scan + Hash 조인" ✔ 정상화

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를 보려면 조건이 있다

단계별 실제 행 수(A-Rows)와 시간은 공짜로 기록되지 않습니다. SQL에 /*+ GATHER_PLAN_STATISTICS */ 힌트를 넣거나, 세션에서 ALTER SESSION SET statistics_level = ALL;을 켠 뒤 실행해야 채워집니다. 잊으면 A-Rows 칸이 비어 나옵니다.

10.4 실행계획 트리 읽는 법 — 들여쓰기가 곧 구조

DBMS_XPLAN 출력은 사실 트리입니다. 들여쓰기가 깊을수록 자식 노드이고, 자식이 먼저 실행되어 부모에게 행을 올려보냅니다. 읽는 규칙은 하나만 기억하면 됩니다: "위에서 아래로 내려가되, 자식이 있으면 가장 깊은 곳부터" — 같은 부모의 자식들은 위에 있는 쪽이 먼저입니다.

0 SELECT STATEMENT 1 NESTED LOOPS 2 TABLE ACCESS BY INDEX ROWID│DEPT 3 INDEX RANGE SCAN│DEPT_LOC_IDX 4 TABLE ACCESS BY INDEX ROWID│EMP 5 INDEX RANGE SCAN│EMP_DEPTNO_IDX 들여쓰기 = 부모-자식 관계 1 2 3 4 5 트리로 보면 NESTED LOOPS DEPT 테이블 EMP 테이블 DEPT_LOC_IDX EMP_DEPTNO_IDX ✨ 실행 순서: 3 → 2 → 5 → 4 → 1 — "가장 깊이 들여쓰인 첫 줄"이 출발점 자식이 행을 만들어 부모에게 올려보내는 상향식 파이프라인

10.5 조인 방법 3종 — NL, Hash, Sort Merge

두 테이블을 잇는 물리적 방법은 셋뿐입니다. 옵티마이저는 cardinality와 인덱스 유무를 보고 고릅니다.

🔁 Nested Loop #️⃣ Hash Join outer (driving) 행 1 행 2 행 3 … inner (인덱스 필수!) 🌲 B*Tree 탐색 행마다 트리 탐색 반복 = 랜덤 I/O 누적 ✔ 소량 결합·첫 행 빨리·OLTP의 왕 ✘ 건수가 많아지면 반복 비용 폭발 작은 집합 (build) DEPT 4행 해시 테이블 (PGA 메모리) hash(deptno) → 행 build 큰 집합 (probe) EMP 1000만행 Full Scan 1회 probe (해시 조회) ✔ 대량 결합에 압도적 ✘ 등호 조인만 · 메모리 필요

세 번째인 Sort Merge Join은 양쪽을 조인 키로 정렬한 뒤 병합하는 방식입니다. Hash Join이 등장한 이후 선택 빈도는 줄었지만, 부등호 범위 조인(t1.a BETWEEN t2.x AND t2.y 등)이나 이미 정렬된 입력에서는 여전히 유효합니다.

Nested LoopHashSort 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 기능은 안전망이지 면죄부가 아닙니다. skew가 심한 컬럼에는 히스토그램을 수집하고, E-Rows와 A-Rows의 격차를 줄이는 것이 항상 첫 번째 처방입니다. 자세한 실전 요령은 쿼리 튜닝 팁 모음에서 다룹니다.

✍️ 이해도 체크

DBMS_XPLAN.DISPLAY_CURSOR의 ALLSTATS LAST 출력에서 어떤 단계의 E-Rows=10, A-Rows=2,000,000이었습니다. 가장 합리적인 첫 조치는?
✅ E-Rows(예측)와 A-Rows(실제)의 20만 배 격차는 옵티마이저가 잘못된 정보로 판단했다는 뜻이고, 가장 흔한 원인은 낡거나 부족한 통계입니다. 예측이 정확해지면 옵티마이저가 스스로 올바른 조인 방법·접근 경로를 고릅니다. 힌트 고정은 원인을 덮는 진통제일 뿐입니다.
실행계획에서 NESTED LOOPS의 첫 번째 자식(driving) 집합이 예상보다 100만 배 많은 행을 반환한다면 무슨 일이 벌어지나?
✅ NL 조인의 총비용 ≈ driving 행 수 × (inner 탐색 비용)입니다. driving 집합이 커지면 그 배수만큼 반복되므로, 대량 결합에서는 옵티마이저가 Hash Join을 선택하도록 통계를 바로잡거나 쿼리를 조정해야 합니다.
📚
원문으로 더 깊이

Oracle Database 19c 공식 문서: SQL Tuning Guide — Query Optimizer Concepts, Generating and Displaying Execution Plans, Joins