DB Study

9장. 인덱스 구조와 활용

1억 건에서 한 건을 3~4번의 블록 읽기로 찾아내는 B*Tree. 그 구조를 이해하면 "왜 인덱스가 있는데 안 타지?"라는 질문에 스스로 답할 수 있게 됩니다.

중급 ⏱ 약 30분 🎬 애니메이션 3개 선수 지식: 5장 저장 구조
🧭
이 장에서 배우는 것

① B*Tree의 루트·브랜치·리프 구조와 탐색 원리 ② 인덱스 access path 5종(UNIQUE/RANGE/FULL/FAST FULL/SKIP SCAN) ③ ROWID로 테이블에 접근하는 비용과 Clustering Factor ④ 비트맵·함수기반·리버스 키·IOT 같은 특수 인덱스의 쓰임새

9.1 B*Tree — 루트, 브랜치, 리프

Oracle의 기본 인덱스는 B*Tree(Balanced Tree)입니다. 이름 그대로 균형 잡힌 트리라서, 어떤 키를 찾든 루트에서 리프까지 내려가는 깊이(height)가 항상 같습니다. 구성 요소는 세 층입니다.

ROWID는 "이 행이 어느 데이터파일, 어느 블록, 몇 번째 슬롯에 있는가"를 담은 물리 주소입니다. 인덱스에서 키를 찾으면 ROWID를 얻고, 그 주소로 테이블 블록을 단 한 번에 찍어서 갑니다. 전체 과정을 애니메이션으로 봅시다.

WHERE empno = 7788 root: <5000 | 5000~9000 | ≥9000 ← height 1 branch <5000 branch 5000~9000 branch ≥9000 1001..2999 키+ROWID 3000..4999 키+ROWID 5000..7999 7788 → ROWID 8000..8999 키+ROWID 리프끼리 양방향 링크 → 범위 스캔은 옆으로만 이동 테이블 (heap) 7566 JONES 7788 SCOTT ✔ 7934 MILLER 5000≤7788<9000 ROWID = 파일#·블록#·슬롯# ✨ 블록 I/O 총 4번(root+branch+leaf+table)으로 1억 건 중 1건 완료
💡
트리 깊이는 놀랄 만큼 얕다

B*Tree는 블록 하나에 수백 개의 키를 담기 때문에, 수억 건짜리 테이블도 height 3~4 수준인 경우가 대부분입니다. 인덱스 탐색 비용은 데이터가 늘어도 거의 늘지 않는다는 것이 B*Tree의 힘입니다. 반대로 말하면, 인덱스 스캔이 느린 원인은 트리 탐색이 아니라 대부분 그 다음 단계 — 테이블 접근(9.3절)에 있습니다.

9.2 인덱스 access path 5종 — 실행계획에서 만나는 이름들

같은 인덱스라도 옵티마이저가 사용하는 방식은 다양합니다. 실행계획에 등장하는 대표적인 5가지를 정리합니다.

Access Path동작언제 선택되나
INDEX UNIQUE SCAN루트→리프로 내려가 정확히 1건을 찾고 멈춤UNIQUE/PK 인덱스에 등호(=) 조건
INDEX RANGE SCAN시작 지점을 찾은 뒤 리프 링크를 따라 범위만큼 옆으로 스캔일반 인덱스의 =, BETWEEN, <, >, LIKE 'AB%'
INDEX FULL SCAN리프 전체를 키 정렬 순서대로 처음부터 끝까지 읽음(싱글 블록 I/O)정렬 순서가 필요할 때(ORDER BY 생략 등)
INDEX FAST FULL SCAN인덱스 세그먼트 전체를 멀티블록 I/O로 순서 무관하게 읽음쿼리에 필요한 컬럼이 전부 인덱스에 있을 때 — 테이블 대신 인덱스를 "얇은 테이블"처럼 풀스캔
INDEX SKIP SCAN결합 인덱스의 선두 컬럼이 조건에 없어도, 선두 컬럼의 값 종류별로 논리적 서브 인덱스를 건너뛰며 탐색선두 컬럼의 distinct 값이 아주 적을 때(예: 성별)
-- FAST FULL SCAN이 뜨는 전형적 상황: 인덱스만으로 답이 되는 쿼리
CREATE INDEX emp_dept_sal_idx ON emp(deptno, sal);

SELECT deptno, AVG(sal) FROM emp GROUP BY deptno;
-- 필요한 컬럼(deptno, sal)이 모두 인덱스 안에 있으므로
-- 테이블은 아예 안 읽고 INDEX FAST FULL SCAN 가능
⚠️
SKIP SCAN은 구조 신호

SKIP SCAN이 자주 보인다면 "선두 컬럼 없이 인덱스를 억지로 쓰고 있다"는 뜻입니다. 선두 컬럼의 값 종류가 적을 때만 그럭저럭 동작하며, 대개는 컬럼 순서를 바꾼 인덱스나 별도 인덱스를 만드는 편이 낫습니다. 결합 인덱스는 "등호 조건으로 자주 쓰는 컬럼을 앞에" 두는 것이 기본입니다.

9.3 Clustering Factor — 인덱스 성능의 숨은 지배자

인덱스 탐색 자체는 늘 빠릅니다. 문제는 리프에서 얻은 ROWID로 테이블 블록을 방문하는 횟수입니다. Oracle은 이를 Clustering Factor(CF)라는 통계로 측정합니다. 계산 방법은 단순합니다 — 리프의 키를 순서대로 따라가며 ROWID가 가리키는 테이블 블록이 직전과 달라질 때마다 카운트를 1 올립니다.

😊 좋은 CF (키 순서 ≈ 저장 순서) 😱 나쁜 CF (무작위 저장) 리프: 날짜 01 02 03 04 05 06 07 08 블록 A 01 02 03 블록 B 04 05 06 블록 C 07 08 8개 키 → 블록 방문 3번 리프: 날짜 01 02 03 04 05 06 07 08 블록 A 03 07 01 블록 B 05 02 08 블록 C 04 06 8개 키 → 블록 방문 8번 (매번 점프!) CF ≈ 블록 수 → 인덱스 유리 / CF ≈ 행 수 → 옵티마이저는 인덱스 비용을 높게 계산 같은 인덱스라도 CF가 나쁘면 Full Table Scan이 선택될 수 있다
🔬
CF는 어디서 확인하나

SELECT index_name, clustering_factor FROM user_indexes;로 확인하고, user_tablesblocks·num_rows와 비교해 보세요. 블록 수에 가까우면 건강, 행 수에 가까우면 그 인덱스의 넓은 범위 스캔은 비쌉니다. CF는 인덱스가 아니라 테이블의 저장 순서가 결정하므로, 인덱스를 재생성해도 좋아지지 않습니다.

9.4 Bitmap Index — 저(低)카디널리티의 무기

성별, 지역, 상태 코드처럼 값의 종류(카디널리티)가 적은 컬럼은 B*Tree로 만들면 효율이 떨어집니다. 이때 Oracle(Enterprise Edition)의 비트맵 인덱스가 등장합니다. 값 하나마다 "각 행이 이 값인가?"를 0/1 비트열로 저장하고, 여러 조건은 비트 연산(AND/OR)으로 순식간에 결합합니다.

WHERE gender = 'F' AND region = '서울' gender='F' 1 0 1 1 0 1 0 1 region='서울' 1 0 0 1 1 0 0 1 AND ⚡ 1 0 0 1 0 0 0 1 결과 비트맵 비트 위치 → ROWID 변환 → 1·4·8번째 행만 테이블 접근 3건만 읽음 ✔
🚨
DML이 잦은 테이블에 비트맵 인덱스 금지

비트맵 인덱스의 엔트리 하나는 수많은 행의 범위를 커버합니다. 한 행만 UPDATE해도 그 비트맵 조각 전체에 락이 걸려, 동시 DML이 서로를 광범위하게 블로킹하고 데드락까지 유발합니다. OLTP 테이블에는 절대 금물 — 야간 배치로 적재하는 DW/읽기 전용 성격의 테이블에서만 쓰세요.

9.5 특수 인덱스 3종 — 함수기반, 리버스 키, IOT

함수기반 인덱스 (Function-Based Index)

WHERE 절에서 컬럼을 가공하면(UPPER(name) 등) 일반 인덱스는 사용할 수 없습니다. 가공을 없애는 것이 최선이지만, 불가피하다면 가공된 결과 자체를 인덱싱할 수 있습니다.

-- UPPER(ename) 조건은 ename 인덱스를 못 쓴다 → 함수 결과를 인덱싱
CREATE INDEX emp_upper_ename_idx ON emp (UPPER(ename));

SELECT * FROM emp WHERE UPPER(ename) = 'SCOTT';  -- 이제 인덱스 사용 가능

리버스 키 인덱스 (Reverse Key Index)

시퀀스로 증가하는 PK는 항상 가장 오른쪽 리프 블록에만 INSERT가 몰립니다. 동시 INSERT가 많으면(특히 RAC 환경) 그 블록을 두고 경합이 발생하죠. 리버스 키 인덱스는 키의 바이트를 뒤집어 저장해(1234→4321) INSERT를 여러 리프로 분산시킵니다. 단, 키 순서가 파괴되므로 범위 스캔(BETWEEN, <, >)은 불가능해집니다. 등호 검색만 하는 컬럼에만 고려하세요.

IOT (Index-Organized Table)

보통은 "테이블 따로, 인덱스 따로"지만, IOT는 테이블 자체를 PK 순서의 B*Tree로 저장합니다. 리프 블록에 ROWID 대신 행 데이터 전체가 들어 있어, PK로 조회하면 테이블 접근 단계가 아예 없습니다.

CREATE TABLE code_master (
  code_id   NUMBER PRIMARY KEY,
  code_name VARCHAR2(100)
) ORGANIZATION INDEX;   -- 테이블이 곧 인덱스

PK 중심으로만 접근하는 코드성 테이블, 좁은 테이블에 적합합니다. 반대로 PK 외 컬럼 검색이 많거나 행이 큰 테이블에는 부적합합니다(보조 인덱스가 물리 ROWID를 쓸 수 없어 접근이 한 단계 더 복잡해집니다).

🐘
PostgreSQL과 비교하며 읽기

PostgreSQL의 B-Tree도 구조는 거의 같습니다(PG 5장 인덱스). Oracle의 Clustering Factor에 해당하는 것이 PG의 pg_stats.correlation입니다. 반면 비트맵 "인덱스"는 Oracle 고유이고, PG는 실행 시점에 임시 비트맵을 만드는 Bitmap Index Scan으로 비슷한 효과를 냅니다.

✍️ 이해도 체크

어떤 인덱스의 Clustering Factor가 테이블의 행 수에 가깝게 나왔습니다. 이것이 의미하는 것은?
✅ CF ≈ 행 수 = 리프의 키를 하나 따라갈 때마다 매번 다른 테이블 블록으로 점프한다는 뜻입니다. 옵티마이저는 이 값을 비용에 반영해 인덱스 대신 Full Table Scan을 선택하기도 합니다. CF는 테이블의 저장 순서가 결정하므로 인덱스 REBUILD로는 개선되지 않습니다.
주문 상태(status) 컬럼은 값이 5종류뿐입니다. 초당 수백 건씩 INSERT/UPDATE가 일어나는 주문 테이블에 비트맵 인덱스를 만들면?
✅ 비트맵 엔트리 하나가 많은 행의 범위를 커버하므로, 한 행의 변경이 그 범위 전체를 잠급니다. 동시 DML이 많은 OLTP 테이블에서는 블로킹·데드락의 원인이 됩니다. 비트맵 인덱스는 DML이 뜸한 DW성 테이블 전용입니다.
📚
원문으로 더 깊이

Oracle Database 19c 공식 문서: Database Concepts — Indexes and Index-Organized Tables, SQL Tuning Guide — Optimizer Access Paths