인덱스
인덱스 종류, 인덱스 구조

인덱스는 추가적인 쓰기 작업과 저장 공간을 활용하여 데이터베이스 테이블의 검색 속도를 향상시키기 위한 자료구조이다.
읽기 성능을 향상시키기 위해 데이터를 테이블과 다른 새로운 자료구조에 정렬된 상태로 저장한다. 이를 통해 스캔 범위를 줄이고, 필요한 데이터에 빠르게 접근할 수 있다.
인덱스 생성의 목적인 만큼 읽기 작업의 성능이 향상된다.
비단 SELECT 작업만이 아니라 UPDATE나 DELETE의 조건 절에 사용되는 검색에도 적용되기 때문에 이러한 읽기 또한 포함된다.
새로운 공간에 데이터를 저장하는 것이고, 새로운 데이터가 추가되거나 변경, 삭제될 때에도 정렬을 유지하기 위해 추가적인 쓰기 작업이 발생한다.
대부분의 서비스는 읽기 작업이 압도적으로 많기 때문에 서비스의 성능을 위해서는 인덱스 활용이 불가피하다. 그렇다면 무엇을 고려하여 인덱스를 구성해야 하는가?
카디널리티는 집합의 본질적인 속성으로, 집합이 포함하는 개별 객체 수를 의미한다. 우리에게 조금 더 와닿게 표현하면 특정 컬럼의 중복되지 않는 고유한 값의 개수를 뜻한다.
테이블 간 관계를 생각할 때, 카디널리티를 따져본다. 1:1, 1:M 등이 있는데, 컬럼의 카디널리티와 본질적으로 같다.
한 테이블의 어떤 컬럼에서 중복을 뺐을 때 원소 개수가 몇 개인지를 말한다.
일반적으로 카디널리티가 높을수록 인덱스의 효율이 좋다. 스캔 범위가 줄어들기 때문이다.
쓰기(CUD) 작업이 많은 서비스, 그 중에서도 유독 많은 테이블과 컬럼에 인덱스를 걸 때는 신중할 필요가 있다. 인덱스를 걸기 가장 좋은 상황은 역시 읽기 작업이 많고, 쓰기 작업이 적은 상황이다. 그 중에서도 조건 절에 자주 사용되는 컬럼에 인덱스를 거는 것이 좋다.
아래와 같은 상황에서 인덱스를 걸면 좋다.
Balanced는 어떤 값으로 탐색하더라도 인덱스 루트에서 리프 블록에 도달하기까지 읽는 블록 수가 같음을 의미한다. 즉, 루트로부터 모든 리프 블록까지의 높이가 항상 같다.
트리 높이 - 인덱스 수직 탐색 - 랜덤 I/O
이진 트리는 하나의 노드가 두 개의 자식만 가질 수 있기 때문에 높이가 높다는 문제가 있다. 하나의 노드에 여러 개의 키를 저장하고, 두 개보다 많은 자식을 갖도록 하면 트리의 높이를 낮출 수 있다.

[노드 내부 구조]
┌─────────────────────────────────────┐
│ P0 | K1 | P1 | K2 | P2 | ... | Pn │
└─────────────────────────────────────┘
P: Pointer (자식 노드 포인터)
K: Key (실제 데이터 또는 데이터 포인터)
모든 노드(Root, Branch, Leaf)에 키와 데이터가 함께 저장된다.
초기 트리:
[40]
/ \
[10,20] [50,60]
/ | \ / | \
[5] [15] [25][45][55][65]
예시: 15 검색
1. Root [40] 비교:
15 < 40 → 왼쪽 포인터로 이동
2. Branch [10,20] 비교:
10 < 15 < 20 → 중간 포인터로 이동
3. Leaf [15] 도착:
15 == 15 → 찾음! ✅
데이터 반환
과정:
시간 복잡도: O(log M/2 N) = O(log N)
Case 1: 노드에 공간이 있는 경우
예시: 12 삽입
삽입 전:
[40]
/ \
[10,20] [50,60]
/ | \
[5] [15] [25]
과정:
1. Root [40]: 12 < 40 → 왼쪽
2. Branch [10,20]: 10 < 12 < 20 → 중간
3. Leaf [15]: 공간 있음 → 삽입
삽입 후:
[40]
/ \
[10,20] [50,60]
/ | \
[5] [12,15] [25]
↑ 삽입됨
Case 2: 노드가 가득 찬 경우 (분할 발생)
예시: 17 삽입 (M=3, 최대 키 2개)
삽입 전:
[40]
/ \
[10,20] [50,60]
/ | \
[5] [15,16] [25]
↑ 이미 가득 참 (2개)
과정:
1. [15,16]에 17 삽입 시도
2. 임시로 [15,16,17] 생성 (오버플로우!)
3. 중앙값 16을 부모로 올림
4. [15] | [17]로 분할
삽입 후:
[40]
/ \
[10,16,20] [50,60]
/ | | \
[5] [15][17][25]
↑ 새로 생긴 노드
예시: 연속 삽입으로 Root까지 분할
삽입 전:
[20,40] ← Root 가득 참
/ | \
[10] [30] [50]
60 삽입:
1. [50]에 삽입 → [50,60]
2. 70 삽입 → [50,60,70] 오버플로우
3. 중앙값 60을 부모로
4. 부모 [20,40]도 [20,40,60] 오버플로우!
5. Root 분할 발생
삽입 후:
[40] ← 새 Root
/ \
[20] [60]
/ \ / \
[10] [30] [50] [70]
트리 높이 증가! (유일하게 높이가 증가하는 경우)
Case 1: Leaf 노드에서 삭제 (키가 충분한 경우)
삭제 전:
[40]
/ \
[10,20] [50,60]
/ | \
[5] [15] [25]
15 삭제:
1. Leaf [15] 찾기
2. 단순 제거
삭제 후:
[40]
/ \
[10,20] [50,60]
/ | \
[5] [ ] [25]
↑ 비어있지만 최소 조건 만족
Case 2: 키가 부족해지는 경우 (언더플로우)
삭제 전:
[20]
/ \
[10] [30]
10 삭제:
1. [10] 제거 → [ ] 빈 노드 (언더플로우!)
2. 형제 [30]에서 빌려오기 (재분배)
재분배 과정:
- 부모 20을 내려받고
- 형제 30을 부모로 올림
삭제 후:
[30]
/ \
[20] [ ]
Case 3: Branch 노드에서 삭제
삭제 전:
[40]
/ \
[10,20] [50,60]
/ | \
[5] [15] [25]
20 삭제 (Branch 노드):
1. Successor(후속자) 찾기: [25] (오른쪽 서브트리 최소값)
2. 20을 25로 대체
3. Leaf에서 25 삭제
삭제 후:
[40]
/ \
[10,25] [50,60]
/ | \
[5] [15] [ ]
Case 4 : 병합 (Merge)
삭제 전:
[20,40]
/ | \
[10] [30] [50]
10 삭제:
1. [10] 제거 → [ ]
2. 형제 [30]도 최소 키만 가짐 (빌려줄 수 없음)
3. 병합 수행
병합 과정:
- 부모 20을 내려받고
- [30]과 합침 → [20,30]
삭제 후:
[40]
/ \
[20,30] [50]
B+Tree는 B-Tree의 문제점을 해결하고자 만든 개선 버전이다. 리프 노드를 연결하고, 루트, 브랜치에서 데이터를 제거하여 키만 저장하는 등의 방법으로 단점을 개선하고자 한다.

루트, 브랜치 노드에는 Key, Pointer / 리프 노드에는 Data, Pointer를 저장한다.
B-Tree의 삽입 과정에서 분열, 삭제 과정에서의 합병 등 보조 연산을 줄이기 위해 나타났다.
노드 개수의 제한을 바꾸는 아이디어로 연산을 지연시킨다.
최소 채움률 증가
B+Tree:
- 최소 채움률: 50% (⌈M/2⌉)
- 노드가 반만 차도 유지
B*Tree:
- 최소 채움률: 66.7% (⌈2M/3⌉)
- 노드가 2/3 이상 차야 유지
형제 노드로 재분배 우선
B+Tree:
노드 가득 참 → 즉시 분할
B*Tree:
노드 가득 참 → 형제로 재분배 시도
형제도 가득 참 → 그때 분할
2-to-3 분할
B+Tree 분할:
[A,B,C,D] → [A,B] | [C,D] (1개 → 2개)
B*Tree 분할:
[A,B,C,D,E,F] → [A,B] | [C,D] | [E,F] (2개 → 3개)
해시 함수를 이용해 키를 버킷 위치로 직접 변환하여 데이터에 접근하는 인덱스 구조다. 동등 비교(=) 검색에 최적화되어 있으며, 이론적으로 O(1) 시간 복잡도를 제공한다. 하지만 범위 검색, 정렬, 부분 일치 검색은 불가능하다.

위 그림처럼 B Tree를 직접 탐색하지 않고, 해시 함수 버킷을 알아낸 후 바로 접근할 수 있다.
InnoDB가 자주 사용되는 데이터 페이지에 대해 자동으로 생성하는 해시 인덱스다. 사용자가 직접 생성하거나 관리할 수 없으며, InnoDB가 워크로드 패턴을 분석해 동적으로 구성한다. B+Tree 인덱스 위에 추가 계층으로 작동하여 특정 조회를 가속화한다.
문서의 내용 전체를 인덱스화해서 특정 키워드가 포함된 문서를 검색하는 전문 검색에는 일반적인 용도의 B Tree를 사용할 수 없다. 문서 전체에 대한 분석과 검색을 위한 인덱싱 알고리즘을 전문 검색 인덱스라고 한다.
전문 검색에서는 문서 본문의 내용에서 사용자가 검색하게 될 키워드를 분석해 내고, 빠른 검색용으로 사용할 수 있게 이러한 키워드로 인덱스를 구축한다.
B Tree와 다르게 역 인덱스 구조를 사용한다.
단어 → 문서 목록
"MySQL": [문서1, 문서3]
"데이터베이스": [문서1, 문서2, 문서3]
"최적화": [문서1, 문서4]
어근 분석 알고리즘
MySQL 서버의 전문 검색 인덱스는 아래 두 가지 중요한 과정을 거쳐 색인 작업이 수행된다.
n-gram 알고리즘
MeCab은 매우 전문적이라 많은 시간과 노력이 필요함.
전문 검색 인덱스 사용 조건
MySQL에는 공간 인덱스(Spatial Index)가 있다. 공간 인덱스는 R-Tree 인덱스 알고리즘을 이용해 2차원의 데이터를 인덱싱하고 검색하는 목적의 인덱스다. B-Tree와 흡사한데, R-Tree는 컬럼의 값이 2차원 공간 개념 값이다.
데이터 타입
💡MySQL에는 Point(점), Line(선), Polygon(다각형), Geometry 타입이 존재한다.
Geometry 타입은 나머지 3개의 슈퍼 타입으로, 세 객체를 모두 저장할 수 있다.
MBR (Minimum Bounding Rectangle)

3개의 레벨
인덱스 구조를 이해하려면 도형들의 MBR과, 3개의 레벨을 이해해야 한다.
순서대로 루트 - 브랜치 - 리프 노드라고 생각하면 된다. 리프 노드는 각 도형의 개별 MBR이다.
일반적으로 위도, 경도 좌표 기반 정보 저장에 사용된다.
반경 5km 검색
현재 위치 P(x, y) 기준으로 5km 원을 그린 후, 해당 원의 MBR 내에 있는 점들을 찾아낸다.
간혹, MBR과 원 사이 공간에 존재하는 점들이 있을 수 있는데, 이 경우 조금 더 복잡한 비교가 필요하다. 서비스에 따라 큰 문제가 없다면 조금의 오차는 허용해도 될 것이다.
테이블의 레코드를 프라이머리 키를 기준으로 묶어서 저장하는 형태로, PK 값에 의해 레코드의 저장 위치가 결정된다.
MySQL은 클러스터링 인덱스로 저장한다.
클러스터링 인덱스는 PK 값에 의해 레코드의 저장 위치가 결정되므로 테이블 레코드의 저장 방식이라고 볼 수 있다.
InnoDB는 PK 기준 클러스터링 인덱스로 데이터를 저장한다고 했다. PK가 없다면 아래 우선순위에 따라 대체할 컬럼을 선택해야 한다.
자동으로 만들어지는 경우 PK는 사용자에게 노출되지 않으며, 쿼리에 명시적으로 사용할 수 없다.
세컨더리 인덱스의 리프 노드는 PK 값을 저장한다.
클러스터링 인덱스만 단독으로 사용하는 경우가 아니라면 세컨더리 인덱스 → 클러스터링 인덱스 검색해야 하니까 더 느리지 않나? 그리고 손해라고 생각했다.
하지만 중점을 두는 건 PK의 활용도 인 듯 하다. 많은 조건 절에 PK를 기재하고, SELECT 절에서 조회하는데, 커버링 인덱스와 같이 그 활용도를 높일 수 있는 상황이 많기 때문에 이득이 큰 것으로 보인다.
유니크는 테이블이나 인덱스에 같은 값이 2개 이상 저장될 수 없음을 말하는데, MySQL에서는 인덱스 없이 유니크 제약만 설정할 수 없다. 또한, NULL도 저장 되는데, 2개 이상 저장될 수 있다.
Unique에 Nullable을 붙이면 PK와 같지만, 클러스터링 인덱스의 역할을 하지 못한다는 점이 크게 다르다.
외래 키 제약이 설정되면 자동으로 연관되는 테이블의 컬럼에 인덱스까지 생성된다. 외래 키가 제거되지 않은 상태에서는 자동으로 생성된 인덱스를 삭제할 수 없다.
인덱스 탐색 과정은 수직적 탐색과 수평적 탐색 단 두 단계로 이루어진다.
데이터베이스 테이블에서 데이터를 찾는 방법도 테이블 전체 스캔과 인덱스 이용 두 가지 방법이 있다.
인덱스는 큰 테이블에서 소량 데이터를 검색할 때 사용한다.
튜닝의 핵심 요소는 크게 두 가지로 나뉜다.
카디널리티와 중복도, 선택도 중요! SQL 튜닝은 랜덤 I/O와의 전쟁!

정렬된 인덱스 레코드 중 조건을 만족하는 첫 번째 레코드를 찾는 과정이다. 즉, 인덱스 스캔 시작 지점을 찾는 과정이다.

인덱스를 수직적으로 탐색할 때, 루트를 포함한 브랜치 블록은 등산 푯말과 같은 역할을 한다. 이 푯말을 따라가며 어딘가에 있는 리프 노드인 ‘조건을 만족하는 첫 번째 레코드’를 찾는 것이 목표다.
수직적 탐색을 통해 스캔 시작점을 찾았으면, 찾고자 하는 데이터가 더 안 나타날 때까지 인덱스 리프 블록을 수평적으로 스캔한다. 인덱스에서 본격적으로 데이터를 찾는 과정이다.
인덱스 리프 블록끼리는 서로 앞뒤 블록에 대한 주소값을 갖는 양방향 연결 리스트 구조다.

수평 탐색의 이유
필요한 컬럼을 인덱스가 모두 갖고 있어 인덱스만 스캔하고 끝나는 경우도 있지만, 일반적으로 인덱스를 스캔하고서 테이블도 액세스하는데, 이때 RowId가 필요하다.
‘인덱스를 정상적으로 사용한다’는 건 리프 블록에서 스캔 시작점을 찾아 거기서부터 스캔하다가 중간에 멈추는 것을 의미한다.
아래와 같은 조건절에는 인덱스를 정상적으로 사용할 수 없다!
WHERE SUBSTR(생년월일, 5, 2) = '05'
WHERE NVL(주문수량, 0) < 100
WHERE 업체명 LIKE '%대한%'
WHERE (전화번호 = :tel_no OR 고객명 = :cust_mm)
WHERE 전화번호 IN (:tel_no1, :tel_no2)
인덱스를 Range Scan하기 위한 가장 첫 번째 조건은 인덱스 선두 컬럼이 가공되지 않은 상태로 조건절에 있어야 한다는 사실이다.
Index Range Scan을 한다고 인덱스를 잘 사용하고 있는 건 아니다.
SELECT *
FROM 주문상품
WHERE 주문일자 = :ord_dt
AND 상품번호 LIKE '%PING';
SELECT *
FROM 주문상품
WHERE 주문일자 = :ord_dt
AND SUBSTR(상품번호, 1, 4) = 'PING';