인덱스 설계
복합 인덱스, leftmost prefix, 카디널리티
인덱스는 자주 쓰는 쿼리에만 선별적으로 걸어야 한다. 카디널리티 낮은 컬럼은 인덱스 이득이 없고, 복합 인덱스는 왼쪽부터 등호 조건이 연속될 때까지만 탐색에 쓰인다. 범위 조건은 뒤 컬럼의 정렬을 깨지만 IN은 등호 여러 개로 처리되어 뒤 컬럼을 살린다. ORDER BY도 인덱스 순서가 맞으면 별도 정렬 없이 처리된다.
개념
인덱스는 테이블 옆에 만들어지는 정렬된 B+tree 파일이다. 테이블 자체는 힙이라 순서 없이 쌓여 있지만, 인덱스는 특정 컬럼(들) 기준으로 정렬된 목차 파일이 하나 더 있는 셈. WHERE, JOIN ON, ORDER BY에 자주 쓰는 컬럼에 인덱스가 있으면 쿼리가 빠르다.
실무 인덱스 설계 실수의 대부분은 두 지점에서 나온다.
- 인덱스를 남발해서 쓰기 성능이 떨어짐
- 복합 인덱스 컬럼 순서를 잘못 정해서 막상 안 타게 됨
이 노트는 그 감각을 잡는 게 목표다.
인덱스가 공짜가 아닌 이유
인덱스를 하나 만들 때마다 드는 비용.
- 디스크 공간 — 테이블 크기의 10~30% 추가
- 쓰기 비용 — INSERT 1건이 테이블 1번 + 인덱스마다 1번씩 쓰기 유발
- 버퍼 풀 점유 — 인덱스 페이지가 RAM을 차지해 실제 데이터 페이지의 캐시 히트율을 낮춤
- 플래너 혼란 — 후보가 많아지면 옵티마이저의 잘못된 선택 확률도 올라감
그래서 자주 쓰는 쿼리 패턴에 선별적으로만 인덱스를 건다.
카디널리티와 selectivity
인덱스를 탄다는 건 "인덱스에서 리프 찾고 → 그 리프가 가리키는 페이지로 점프"다. 이 점프 비용을 정당화하려면 점프할 페이지 수가 적어야 한다.
카디널리티 — 컬럼의 유니크한 값 개수. Selectivity — 조건으로 걸러지고 남는 행의 비율.
-- users 1000만 행, gender는 'M'/'F' 두 값
SELECT * FROM users WHERE gender = 'M';
-- 500만 행 반환 (selectivity 50%)
이 쿼리에 idx_gender 만들면 옵티마이저는 인덱스를 안 쓴다. 왜?
- 인덱스 타면: 500만 개의 페이지 포인터로 500만 번 랜덤 점프
- Full Scan: 테이블을 처음부터 끝까지 순차 I/O
순차 I/O가 압도적으로 빨라서 옵티마이저가 Full Scan을 선택한다. 카디널리티 낮은 컬럼은 인덱스 이득이 없다.
규칙
- 카디널리티 높은 컬럼(email, user_id, 주문번호) → 인덱스 효과 큼
- 카디널리티 낮은 컬럼(gender, boolean, status 몇 가지) → 인덱스 무의미
- 경험적으로 selectivity 10% 이하여야 인덱스 이득
복합 인덱스와 leftmost prefix
여러 컬럼을 묶어 만드는 인덱스. 내부적으로 "왼쪽 컬럼부터 정렬되고, 같은 값 안에서 그 다음 컬럼이 정렬된" B+tree다.
CREATE INDEX idx ON orders(user_id, status, created_at);
인덱스 리프 순서:
(1, 'cancelled', 2025-01-01)
(1, 'paid', 2025-06-01)
(1, 'paid', 2026-02-01)
(1, 'pending', 2026-01-15)
(2, 'paid', 2025-11-01)
...
user_id가 같을 때만 status가 정렬되고, (user_id, status)가 같을 때만 created_at이 정렬된다. 전체로 보면 status나 created_at만으로는 정렬이 아니다.
Leftmost prefix 규칙
복합 인덱스는 왼쪽부터 등호 조건이 연속될 때까지만 인덱스 탐색에 쓰인다.
| 쿼리 조건 | 인덱스 사용 범위 |
|---|---|
user_id = ? |
user_id만 탐 |
user_id = ? AND status = ? |
user_id, status 탐 |
user_id = ? AND status = ? AND created_at = ? |
전부 탐 |
user_id = ? AND created_at = ? |
user_id만. status 빠져서 뒤는 못 씀 |
status = ? |
못 탐 (leftmost 없음) |
created_at = ? |
못 탐 |
중간 컬럼이 빠지면 그 뒤는 인덱스로 못 좁힌다. 전화번호부가 (성, 이름)으로 정렬돼 있을 때, "철수"만으로는 찾을 수 없는 것과 같다.
등호 vs 범위 규칙
더 정교한 규칙. 범위 조건이 붙는 순간 그 컬럼까지만 인덱스가 탐색에 쓰이고, 뒤 컬럼은 인덱스로 못 좁힘.
-- 인덱스: (a, b, c)
WHERE a = 1 AND b = 2 AND c = 3 -- 전체 사용
WHERE a = 1 AND b > 100 AND c = 5 -- a, b까지만. c는 필터
WHERE a = 1 AND b = 2 AND c > 100 -- 전체 사용 (범위가 맨 끝이라 OK)
왜? 범위가 걸리면 그 구간 안에서 다음 컬럼의 정렬이 깨진다.
(a=1, b=50, c=10)
(a=1, b=50, c=20)
(a=1, b=100, c=5) ← b > 100 구간
(a=1, b=100, c=15)
(a=1, b=200, c=5)
(a=1, b=200, c=30)
b > 100 구간의 c만 뽑으면 5, 15, 5, 30 — 정렬 안 됨. 그래서 c는 필터로만 처리되고 인덱스로 좁히지 못한다.
규칙: 복합 인덱스 컬럼 순서는 등호 조건 컬럼을 왼쪽에, 범위 조건 컬럼을 오른쪽에.
IN은 등호 취급
IN은 "등호 여러 번의 OR"로 처리되어 뒤 컬럼 정렬을 깨지 않는다.
WHERE a = 1 AND b IN ('paid', 'shipped') AND c > 100
옵티마이저는 이걸 두 번의 등호 탐색으로 쪼개고, 각 탐색 안에서 c 범위를 인덱스로 좁힌다. 세 컬럼 전부 사용됨.
=,IN— 뒤 컬럼 살림>,<,BETWEEN,LIKE 'x%'— 뒤 컬럼 죽임
ORDER BY와 인덱스
복합 인덱스는 ORDER BY에도 쓸 수 있어서 별도 정렬 작업을 생략시킨다.
CREATE INDEX idx ON posts(user_id, created_at);
SELECT * FROM posts WHERE user_id = 42 ORDER BY created_at DESC LIMIT 10;
user_id = 42 구간 안에서 created_at이 이미 정렬되어 있으니, 인덱스 리프를 역방향으로 따라가며 10개만 읽으면 끝. 추가 정렬 없음.
인덱스가 없거나 컬럼 순서가 맞지 않으면 다음 길을 간다.
- 조건에 맞는 행 전부 가져오고
- RAM에서
ORDER BY컬럼 기준 정렬 LIMIT적용
사용자 주문 10만 건이 있으면 10만 건을 전부 정렬해야 한다. LIMIT 10인데도.
ORDER BY의 leftmost 규칙
| 인덱스 (a, b, c) | ORDER BY | 정렬 생략 |
|---|---|---|
ORDER BY a |
✅ | |
ORDER BY a, b |
✅ | |
ORDER BY b |
❌ | |
WHERE a = ? ORDER BY b |
✅ (a 고정이라 b부터 가능) | |
WHERE a = ? AND b = ? ORDER BY c |
✅ |
WHERE로 등호로 고정된 앞 컬럼은 "무시하고 그 뒤부터" ORDER BY 가능. 이게 페이지네이션 쿼리의 핵심 원리.
실무 설계 체크리스트
- 자주 쓰는 쿼리 패턴을 먼저 파악 — 인덱스는 쿼리를 위한 도구지 테이블을 위한 게 아니다
- 등호 조건을 왼쪽에, 범위 조건을 오른쪽에 — leftmost + 범위 규칙
- 카디널리티 높은 컬럼을 왼쪽에 — 첫 단계에서 많이 좁혀야 이득
ORDER BY컬럼도 인덱스에 포함 — 정렬 생략 노리기- 하나의 복합 인덱스가 여러 쿼리를 커버하게 —
(a, b, c)는a,a+b,a+b+c쿼리 모두 사용 가능 EXPLAIN으로 실제 쓰이는지 확인 필수 — 만들어놓고 안 쓰이면 헛수고
더 보기
- DB-스토리지-레이아웃 — 인덱스와 테이블의 관계 (힙 vs clustered)
- B-tree와-LSM-tree — 인덱스의 기반 자료구조
- 커버링-인덱스와-특수-인덱스 — Index-Only Scan으로 테이블 접근 제거, GIN/GiST 같은 특수 인덱스