9장. 인덱스 구조와 활용
1억 건에서 한 건을 3~4번의 블록 읽기로 찾아내는 B*Tree. 그 구조를 이해하면 "왜 인덱스가 있는데 안 타지?"라는 질문에 스스로 답할 수 있게 됩니다.
① 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)가 항상 같습니다. 구성 요소는 세 층입니다.
- 루트(root) 블록 — 트리의 꼭대기. 어느 브랜치로 내려갈지 알려주는 이정표.
- 브랜치(branch) 블록 — 중간 안내판. "이 범위의 키는 이쪽 아래로"라는 정보만 담습니다.
- 리프(leaf) 블록 — 실제 인덱스 키 값 + ROWID가 정렬된 채로 저장되는 곳. 그리고 리프끼리는 양방향 링크(doubly linked list)로 연결되어 있어, 범위 검색 시 트리를 다시 오르지 않고 옆으로 쭉 읽어나갈 수 있습니다.
ROWID는 "이 행이 어느 데이터파일, 어느 블록, 몇 번째 슬롯에 있는가"를 담은 물리 주소입니다. 인덱스에서 키를 찾으면 ROWID를 얻고, 그 주소로 테이블 블록을 단 한 번에 찍어서 갑니다. 전체 과정을 애니메이션으로 봅시다.
empno = 7788을 찾습니다. 탐색은 언제나 루트 블록에서 시작합니다. 루트에는 "어느 브랜치로 갈지" 범위 이정표만 있습니다.
7788은 5000~9000 구간이므로 가운데 브랜치로 내려갑니다. 블록 1번 읽기 추가.
브랜치가 가리키는 리프 블록에 도착. 리프에는 정렬된 키 + ROWID 쌍이 있고, 여기서 7788의 ROWID를 얻습니다.
ROWID는 물리 주소(파일#·블록#·슬롯#)이므로 테이블 블록을 정확히 한 번에 찍어갑니다. 이것이 실행계획의 TABLE ACCESS BY INDEX ROWID입니다.
총 4번의 블록 읽기로 끝. 그리고 리프끼리는 양방향 링크로 연결되어 있어 BETWEEN 같은 범위 검색은 리프를 옆으로 죽 따라가기만 하면 됩니다.
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이 자주 보인다면 "선두 컬럼 없이 인덱스를 억지로 쓰고 있다"는 뜻입니다. 선두 컬럼의 값 종류가 적을 때만 그럭저럭 동작하며, 대개는 컬럼 순서를 바꾼 인덱스나 별도 인덱스를 만드는 편이 낫습니다. 결합 인덱스는 "등호 조건으로 자주 쓰는 컬럼을 앞에" 두는 것이 기본입니다.
9.3 Clustering Factor — 인덱스 성능의 숨은 지배자
인덱스 탐색 자체는 늘 빠릅니다. 문제는 리프에서 얻은 ROWID로 테이블 블록을 방문하는 횟수입니다. Oracle은 이를 Clustering Factor(CF)라는 통계로 측정합니다. 계산 방법은 단순합니다 — 리프의 키를 순서대로 따라가며 ROWID가 가리키는 테이블 블록이 직전과 달라질 때마다 카운트를 1 올립니다.
- CF ≈ 테이블 블록 수 → 이웃한 키들이 같은 블록에 모여 있음. 좋음.
- CF ≈ 테이블 행 수 → 키마다 매번 다른 블록으로 점프. 나쁨.
양쪽 모두 같은 인덱스입니다. 리프에는 날짜 키가 01~08 순서로 정렬되어 있습니다. 차이는 테이블 쪽 저장 순서뿐입니다.
왼쪽 테이블은 입력 순서가 키 순서와 비슷합니다(예: 날짜순으로 쌓이는 로그). 리프를 따라가면 같은 블록을 연달아 방문 — 8건을 읽는데 블록 3개면 충분합니다.
오른쪽은 행이 무작위로 흩어져 있습니다. 키를 하나 읽을 때마다 다른 블록으로 점프 — 8건에 블록 8번. 범위가 커질수록 이 차이는 수백 배로 벌어집니다.
옵티마이저는 CF를 인덱스 비용 계산에 직접 반영합니다. "인덱스가 있는데 풀스캔을 하네?"의 흔한 범인이 바로 나쁜 CF입니다.
SELECT index_name, clustering_factor FROM user_indexes;로 확인하고, user_tables의 blocks·num_rows와 비교해 보세요. 블록 수에 가까우면 건강, 행 수에 가까우면 그 인덱스의 넓은 범위 스캔은 비쌉니다. CF는 인덱스가 아니라 테이블의 저장 순서가 결정하므로, 인덱스를 재생성해도 좋아지지 않습니다.
9.4 Bitmap Index — 저(低)카디널리티의 무기
성별, 지역, 상태 코드처럼 값의 종류(카디널리티)가 적은 컬럼은 B*Tree로 만들면 효율이 떨어집니다. 이때 Oracle(Enterprise Edition)의 비트맵 인덱스가 등장합니다. 값 하나마다 "각 행이 이 값인가?"를 0/1 비트열로 저장하고, 여러 조건은 비트 연산(AND/OR)으로 순식간에 결합합니다.
gender='F'의 비트맵. 8개 행 각각에 대해 "여성이면 1"인 비트열이 인덱스에 압축 저장되어 있습니다.
region='서울'의 비트맵도 같은 방식으로 존재합니다. 두 조건의 결합은 트리 탐색 두 번이 아니라, CPU가 가장 잘하는 비트 AND 연산입니다.
AND 결과: 두 비트가 모두 1인 위치만 남습니다. 수백만 행이라도 비트 연산은 순식간입니다.
남은 비트 위치를 ROWID로 변환해 그 행들만 테이블에서 읽습니다. 이런 다중 조건 조합 검색(DW의 별표 스키마 등)이 비트맵 인덱스의 주 무대입니다.
비트맵 인덱스의 엔트리 하나는 수많은 행의 범위를 커버합니다. 한 행만 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의 B-Tree도 구조는 거의 같습니다(PG 5장 인덱스). Oracle의 Clustering Factor에 해당하는 것이 PG의 pg_stats.correlation입니다. 반면 비트맵 "인덱스"는 Oracle 고유이고, PG는 실행 시점에 임시 비트맵을 만드는 Bitmap Index Scan으로 비슷한 효과를 냅니다.
✍️ 이해도 체크
Oracle Database 19c 공식 문서: Database Concepts — Indexes and Index-Organized Tables, SQL Tuning Guide — Optimizer Access Paths