테마
02화 · 파고들기
필요할 때 찾아보는 문서다. 처음 읽을 때는
1_사건.md만으로 충분하다.
| 찾는 것 | 바로 가기 |
|---|---|
| B*Tree는 어떻게 생겼나 | §1 구조 |
| 스캔 방식 6종 구분 | §2 스캔 |
| 인덱스 무력화 6종 + 교정형 | §3 교정표 |
| ☠️ 날짜 범위: 고치다가 답이 틀어지는 자리 | §3.5 날짜 |
| 커버링 인덱스 | §4 커버링 |
| 클러스터링 팩터 (맛보기) | §5 CF |
| 측정값 전체 | §6 측정 |
| 공식문서 좌표 | §7 좌표 |
1. B*Tree — 정렬된 목록
인덱스는 빨리 찾는 마법이 아니라 정렬된 목록이다. 정렬되어 있으므로 "여기부터 여기까지"를 지목할 수 있고, 그래서 나머지는 읽지 않아도 된다.
[루트] ← blevel 2 면 루트+브랜치 2단
/ \
[브랜치] [브랜치]
/ \ / \
[리프]──[리프]──[리프]──[리프] ← 리프끼리 양방향으로 이어져 있다
│ │
(키값, ROWID) ← 키는 정렬, ROWID 는 테이블 위치| 용어 | 뜻 | 02화 실측 (IX_ORD_BIG_DT) |
|---|---|---|
BLEVEL | 루트에서 리프까지 내려가는 깊이 | 2 |
LEAF_BLOCKS | 리프 블록 수 = 인덱스의 실제 크기 | 2,653 (테이블은 5,923) |
NUM_ROWS | 인덱스에 들어 있는 키 개수 | 1,000,000 |
탐색은 두 단계로 나뉜다.
- 수직 탐색: 루트에서 리프까지
BLEVEL만큼 내려간다. 100만 건이어도 3~4블록이면 리프에 닿는다. - 수평 탐색: 리프를 옆으로 따라가며 훑는다. 범위가 넓을수록 길어진다.
02화에서 INDEX RANGE SCAN이 읽은 76블록도 이렇게 나뉜다. 수직 탐색 3~4블록에 수평 탐색 70여 블록이다.
⚠️ NULL은 저장되지 않는다
B*Tree는 인덱스 키가 전부 NULL인 행을 아예 저장하지 않는다. 그래서 WHERE city IS NULL은 인덱스로 찾을 수 없다. 실측에서도 TABLE ACCESS FULL이 나왔다.
단일 컬럼 인덱스라면 "그 컬럼이 NULL인 행"이 통째로 빠진다. 복합 인덱스는 키 컬럼 중 하나라도 NULL이 아니면 저장된다. 그래서
(col, 0)처럼 상수를 끼워 넣어 NULL인 행까지 인덱스에 담는 기법이 있다.
2. 인덱스 스캔 6종
이름이 비슷해서 헷갈리지만, "찾아가는가"와 "훑는가"로 나눠 보면 정리된다.
| 스캔 | 성격 | 언제 나오나 | 02화에서 본 것 |
|---|---|---|---|
INDEX UNIQUE SCAN | 찾아감 · 1건 | PK/UK 등치 조건 | — |
INDEX RANGE SCAN | 찾아감 · 범위 | 선두 컬럼에 범위·등치 조건 | ✅ 76 / 91블록 |
INDEX FULL SCAN | 훑음 · 정렬 순서 유지 | ORDER BY를 인덱스로 대체 | — |
INDEX FAST FULL SCAN | 훑음 · 순서 무시, 멀티블록 | 인덱스 전체를 읽어야 하지만 순서는 필요 없을 때 | ✅ 56 / 3,227블록 |
INDEX SKIP SCAN | 찾아감 · 반복 | 선두 컬럼의 값 종류가 적을 때 건너뛰며 | — |
INDEX RANGE SCAN DESCENDING | 찾아감 · 역순 | ORDER BY ... DESC | — |
📌 "인덱스를 못 탄다"는 말이 부정확한 이유
선두
%LIKE, 부정형, 선두 컬럼 누락에서도 인덱스는 쓰였다. 다만RANGE SCAN(찾아가기)이 아니라FAST FULL SCAN(전부 훑기)이었다. 인덱스가 테이블보다 작으니 테이블 전체 스캔보다는 쌀 수 있다. 정확히 말하면 "찾아갈 수 없어서 훑는다"가 맞다.실측 비교(같은 조건,
CUSTOMERS.CITY):
조건 스캔 Buffers city = 'SEOUL'INDEX RANGE SCAN10 city LIKE '%OUL'INDEX FAST FULL SCAN56 UPPER(city) = 'SEOUL'TABLE ACCESS FULL126
3. Sargable 교정표 — 무력화 6종
Sargable은 Search ARGument able의 줄임말로, "검색 인자로 쓸 수 있는" 조건이라는 뜻이다. 판정 기준은 하나다.
컬럼을 가공하지 않은 채로, 인덱스의 정렬 순서에서 범위를 지목할 수 있는가?
아래 값은 모두 실측값이다. ①은 T_ORD_BIG, ②~⑤는 CUSTOMERS.CITY(20,000행 · CUST_X_CITY), ⑥은 T_ORD_BIG의 복합 인덱스에서 쟀다.
| # | 무력화 패턴 | 결과 | 교정형 | 결과 |
|---|---|---|---|---|
| ① | 컬럼 가공TO_CHAR(ORD_DT,'YYYYMMDD') BETWEEN …UPPER(city) = 'SEOUL' | FULL 5,826 / 126 | ORD_DT >= … AND ORD_DT < …city = 'SEOUL' | RANGE SCAN 91 / 10 |
| ② | 묵시적 형변환 문자 컬럼과 숫자 리터럴을 비교하면 오라클이 컬럼 쪽에 TO_NUMBER를 씌운다 | 컬럼 가공과 같다 | 리터럴을 컬럼 타입에 맞춘다order_date = DATE '2024-06-01' | RANGE SCAN 4 |
| ③ | 선두 % LIKEcity LIKE '%OUL' | FAST FULL SCAN 56 | %를 뒤쪽으로city LIKE 'SE%' | RANGE SCAN 10 |
| ④ | 부정형city != 'SEOUL' | FAST FULL SCAN 56 | 긍정형 IN으로 뒤집는다(애초에 범위가 커서 FULL이 옳을 수도 있다) | — |
| ⑤ | IS NULLcity IS NULL | FULL 126 | NOT NULL 제약 + 기본값, 또는 (col, 0) 복합 인덱스 | — |
| ⑥ | 선두 컬럼 누락 인덱스 (ORD_DT, ORD_AMT)에 WHERE ORD_AMT = 50000 | FAST FULL SCAN 3,227 | 선두 컬럼에도 조건을 주거나, 그 컬럼을 선두로 둔 인덱스를 만든다 | — |
② 묵시적 형변환 — 어느 쪽이 변환되는지가 핵심이다
sql
-- 멀쩡하다: 리터럴이 변환된다. 컬럼은 그대로
WHERE order_date = DATE '2024-06-01' -- INDEX RANGE SCAN, 4 블록
-- 망가진다: 컬럼이 변환된다
WHERE varchar_col = 12345 -- 내부적으로 TO_NUMBER(varchar_col) = 12345규칙: 문자와 숫자를 비교하면 오라클은 문자 쪽을 숫자로 바꾼다. 그 문자가 컬럼이면 컬럼을 가공한 셈이 되어 인덱스가 무력화된다. 게다가 숫자가 아닌 값이 섞여 있으면 ORA-01722로 쿼리가 실패한다. 느려지는 정도가 아니라 장애다.
교정이 불가능할 때 — 함수 기반 인덱스
대소문자를 무시하고 검색할 때처럼 컬럼 가공을 없앨 수 없는 경우가 있다. 그럴 때는 가공된 값 자체를 인덱싱한다.
sql
CREATE INDEX IX_CUST_CITY_UP ON CUSTOMERS(UPPER(city)); -- 함수 기반 인덱스(FBI)
-- 이후 WHERE UPPER(city) = 'SEOUL' 이 RANGE SCAN 을 탄다⚠️ 대가가 있다. ⓐ 인덱스를 만든 함수 형태와 정확히 일치하는 조건에서만 탄다. ⓑ 인덱스가 하나 더 늘어나 DML이 느려진다. ⓒ 함수가
DETERMINISTIC이어야 한다. 먼저 SQL을 고칠 수 있는지 보고, 정말 안 될 때만 FBI를 쓴다.
3.5 날짜 범위 조건 — 고치다가 답이 틀어지는 자리
컬럼 가공을 없애다가 가장 흔히 내는 사고다.
sql
-- ☠️ 빠르지만 틀릴 수 있다
WHERE ORD_DT BETWEEN DATE '2024-06-01' AND DATE '2024-06-30'
-- ✅ 올바름
WHERE ORD_DT >= DATE '2024-06-01' AND ORD_DT < DATE '2024-07-01'BETWEEN의 끝값은 6월 30일 00:00:00이다. 그래서 그날 00:00 이후에 들어온 행은 전부 빠진다.
실측 — 같은 코드, 다른 테이블
| 테이블 | 시분초 있는 행 | >= AND < | BETWEEN | 차이 |
|---|---|---|---|---|
T_ORD_BIG | 0 / 1,000,000 | 27,390 | 27,390 | 없음 ← 버그가 숨는다 |
T_ORD | 99,999 / 100,000 | 4,110 | 3,973 | 137건 / 34,159,700원 |
📌 이 표가 02화에서 가장 중요하다
T_ORD_BIG에서는 테스트가 통과한다. 코드가 옳아서가 아니라 데이터가 전부 자정이라서다. 같은 코드를T_ORD로 복사하는 순간 정산에서 3,416만 원이 사라진다.반면 강도현의
TO_CHAR원본은T_ORD에서도 4,110건으로 정확하다. 강도현의 코드는 느릴 뿐 답은 맞다.BETWEEN으로 고친 코드는 빠르지만 틀린다.느린 코드를 빠르게 만드는 것이 튜닝이고, 맞는 코드를 틀리게 만드는 것은 장애다.
규칙
날짜 범위에 BETWEEN을 쓰지 않는다 | 끝 경계가 자정으로 잘린다 |
>= 시작 AND < 다음 구간 시작 | 시분초가 있든 없든 안전하다 |
TRUNC(ORD_DT) = …로 피해 가지 않는다 | 그것도 결국 컬럼 가공이다(§3 ①) |
| 성능을 고쳤으면 경계값으로 회귀 검증한다 | "이 테이블에서 결과가 같다"가 "맞다"는 뜻은 아니다 |
확인 쿼리:
sql
SELECT COUNT(*) FROM <테이블> WHERE <날짜컬럼> <> TRUNC(<날짜컬럼>); -- 0이 아니면 시분초가 있다4. 커버링 인덱스
쿼리에 필요한 컬럼이 전부 인덱스에 있으면 테이블을 읽으러 갈 이유가 없다.
sql
SELECT COUNT(*), SUM(ORD_AMT) FROM T_ORD_BIG
WHERE ORD_DT >= DATE '2024-06-01' AND ORD_DT < DATE '2024-07-01';이 쿼리가 쓰는 컬럼은 ORD_DT(조건)와 ORD_AMT(집계) 둘뿐이다.
| 인덱스 | 계획 | Buffers |
|---|---|---|
| 없음 | TABLE ACCESS FULL | 5,827 |
(ORD_DT) | TABLE ACCESS FULL (옵티마이저가 안 씀) | 5,827 |
(ORD_DT) + 힌트 강제 | INDEX RANGE SCAN → TABLE ACCESS BY INDEX ROWID | 27,466 |
(ORD_DT, ORD_AMT) | INDEX RANGE SCAN만 | 91 |
실행계획에서 TABLE ACCESS BY INDEX ROWID 줄이 사라졌는지를 보면 커버링 여부를 알 수 있다.
대가
| 대가 | 어디서 청구되나 |
|---|---|
인덱스가 커진다(LEAF_BLOCKS 2,653 → 3,206) | 저장 공간 |
| DML마다 인덱스를 갱신해야 한다 | 14화: 야간 배치가 아침을 넘긴다 |
| 컬럼을 더 넣을수록 커버 범위는 넓어지지만 효율은 떨어진다 | 설계 판단 |
선배의 규칙: 커버링 인덱스는 그 쿼리가 자주 돌고, 필요한 컬럼이 적고, 그 테이블에 DML이 드물 때 쓴다. 세 조건 중 하나라도 어긋나면 비용이 이득보다 커질 수 있다.
5. 클러스터링 팩터 — 04화 예고
여기서는 "27,466이 왜 나왔는가"만 설명한다. 본격적인 이야기는 04화에서 한다.
sql
SELECT index_name, clustering_factor, num_rows, leaf_blocks
FROM user_indexes WHERE table_name = 'T_ORD_BIG';INDEX_NAME CLUSTERING_FACTOR NUM_ROWS LEAF_BLOCKS
------------------ ------------------ ----------- -------------
IX_ORD_BIG_DT 1,000,000 1,000,000 2,653CF(클러스터링 팩터)는 인덱스 순서대로 읽어 갈 때 테이블 블록을 몇 번 바꿔 타는지 센 값이다.
| CF 값 | 의미 |
|---|---|
| ≈ 테이블 블록 수 (5,923) | 최선. 인덱스 순서와 저장 순서가 비슷하다 |
| ≈ 행수 (1,000,000) | 최악. 행마다 블록이 바뀐다. 캐리어 하나에 한 잔씩 담긴 꼴이다 |
데이터가 날짜순으로 쌓이지 않아서 T_ORD_BIG은 최악 쪽에 해당한다.
인덱스를 거쳐 읽을 때의 비용 근사식
비용 ≈ BLEVEL + (LEAF_BLOCKS × 선택도) + (CLUSTERING_FACTOR × 선택도)
└ 수직 ┘ └──── 수평 ────┘ └──── 테이블 랜덤 액세스 ────┘
← 여기가 전부를 잡아먹는다선택도 2.74%를 넣으면 2 + 73 + 27,400 ≈ 27,475가 나온다. 실측값 27,466과 거의 같다.
커버링 인덱스로 극적인 차이가 나는 것도 세 번째 항이 통째로 사라지기 때문이다.
6. 이 화의 측정값 전부
모두 ora-hanorder에서 잰 값이다(2026-10-18).
Q1 리비전별
| rev | SQL | 인덱스 | 계획 | Buffers | E-Rows |
|---|---|---|---|---|---|
| rev00 | TO_CHAR(ORD_DT,…) BETWEEN | 없음 | TABLE ACCESS FULL | 5,826 | 2,500 |
| rev00 | 〃 | (ORD_DT) | TABLE ACCESS FULL | 5,826 | 2,500 |
| rev00 | 〃 + INDEX 힌트 | (ORD_DT) | TABLE ACCESS FULL (힌트 무시) | 5,826 | 2,500 |
| rev01 | ORD_DT >= … AND < … | (ORD_DT) | TABLE ACCESS FULL | 5,827 | 28,336 |
| rev01 | 〃 + INDEX 힌트 | (ORD_DT) | INDEX RANGE SCAN + ROWID | 27,466 | 28,336 |
| rev02 | ORD_DT >= … AND < … | (ORD_DT, ORD_AMT) | INDEX RANGE SCAN만 | 91 | 28,336 |
실제 결과는 27,390행으로, 전체 1,000,000행의 2.74%다.
무력화 6종
| 조건 | 스캔 | Buffers | A-Rows |
|---|---|---|---|
UPPER(city)='SEOUL' | TABLE ACCESS FULL | 126 | 3,333 |
city='SEOUL' | INDEX RANGE SCAN | 10 | 3,333 |
city LIKE '%OUL' | INDEX FAST FULL SCAN | 56 | 3,333 |
city LIKE 'SE%' | INDEX RANGE SCAN | 10 | 3,333 |
city != 'SEOUL' | INDEX FAST FULL SCAN | 56 | 16,667 |
city IS NULL | TABLE ACCESS FULL | 126 | 0 |
ORD_AMT=50000 (선두 누락) | INDEX FAST FULL SCAN | 3,227 | 223 |
order_date = DATE '2024-06-01' | INDEX RANGE SCAN | 4 | 274 |
UPPER(city)의E-Rows는 200인데 실제는 3,333이다. 컬럼을 가공하면 추정도 함께 망가진다는 두 번째 증거다.
7. 원천 좌표
전체 지도 →
00_자료준비/06_원서와_공식문서_지도.mdPDF 페이지 = 책 페이지 − 2 ·성능고도화II_목차색인.md(공개 사이트에는 없음)
성능고도화 (주 원천)
| 주제 | 권-장-절 |
|---|---|
| 인덱스 기본 원리 · B*Tree 수직/수평 탐색 | II권 Ch1 01절 |
| 인덱스 기본 사용법 · 스캔 6종 | II권 Ch1 02절 |
| 인덱스 사용이 불가능한 경우 (무력화) | II권 Ch1 03절 |
| 함수 기반 인덱스 · 가상 컬럼 | II권 Ch1 04절 |
| 클러스터링 팩터 | II권 Ch1 05절 (→ 04화) |
오라클 공식문서
| 주제 | 매뉴얼 | 장 제목 |
|---|---|---|
| 인덱스 선택 기준 | tgsql | Optimizer Access Paths |
| B*Tree 구조 | cncpt | Indexes and Index-Organized Tables |
| 함수 기반 인덱스 | admin | Managing Indexes |
| 힌트 문법과 무시되는 조건 | tgsql | Influencing the Optimizer |
이 화에서 꼭 확인할 뷰
sql
USER_INDEXES -- status · blevel · leaf_blocks · clustering_factor · num_rows
USER_IND_COLUMNS -- 인덱스가 어느 컬럼을 어느 순서로 쓰는가 (선두 컬럼 확인)
USER_IND_EXPRESSIONS -- 함수 기반 인덱스의 식다음 화가 여기서 가져가는 것
| 화 | 가져가는 것 |
|---|---|
| 03 | Q1_rev02의 날짜 조건. 고칠 수 없는 경우를 다룬다. ora-dist에는 날짜가 VARCHAR2(8)로 박혀 있다 |
| 04 | CF = 1,000,000과 손익분기점. 오늘 만든 인덱스 두 개로 범위를 넓혀 가며 잰다 |
| 10 | E-Rows가 정확해졌는데도 틀리는 경우 (히스토그램) |
| 14 | 인덱스 2개의 적재 비용 청구서 |