Composite Index - Equality Before Range
복합 인덱스(composite index)를 설계할 때, 컬럼 순서는 대체로 동등 비교(=, IN)를 앞에, 범위 비교(>, <, BETWEEN, LIKE 'prefix%')를 뒤에 두는 것이 원칙입니다. 이 글은 그 이유를 B+Tree의 정렬 구조 관점에서 정리합니다.
범위(scope): 아래 설명은 일반적인 B-tree range seek 모델 기준이며,
EXPLAIN등 확인 예시는 MySQL/InnoDB 기준입니다. optimizer별 예외 전략은 3절 끝에 따로 정리합니다.
1. 복합 인덱스는 하나의 정렬 순서다
INDEX (a, b, c)를 만들면, 인덱스 엔트리는 사전식(lexicographic)으로 단 하나의 순서로 정렬됩니다. 즉 a로 먼저 정렬하고, a가 같은 것끼리 b로 정렬하고, 그 안에서 다시 c로 정렬합니다.
(a, b, c)
(1, 10, 100)
(1, 10, 200)
(1, 20, 100)
(2, 10, 100)
(2, 10, 300)
핵심은 b의 정렬은 "같은 a 안에서만" 보장된다는 점입니다. a가 1인 구간과 2인 구간을 합쳐서 보면 b는 전역적으로 정렬되어 있지 않습니다(10, 10, 20, 10, 10). 이 "조건부 정렬" 구조가 컬럼 순서 규칙의 모든 이유입니다.
2. 동등 비교는 다음 컬럼의 정렬을 보존한다
a = 1 같은 동등 조건은 인덱스를 한 점(prefix)으로 고정합니다. a = 1로 좁히고 나면, 그 구간 안에서 b는 여전히 정렬된 상태입니다.
a = 1 로 고정 →
(1, 10, 100)
(1, 10, 200)
(1, 20, 100)
↑ 이 구간에서 b는 정렬되어 있음 → b로 또 탐색 가능
그래서 a = 1 AND b = 10처럼 동등 조건을 연달아 걸면, 엔진은 a로 좁히고 → 그 안에서 b로 또 좁히고 → 그 안에서 c까지 binary search로 파고들 수 있습니다. 동등 비교는 인덱스를 한 점으로 줄이므로 뒤따르는 컬럼의 정렬이 그대로 살아있습니다.
3. 범위 비교는 그 지점에서 탐색을 멈춘다
반면 b > 10 같은 범위 조건은 인덱스의 연속된 구간(span) 을 잡습니다. 문제는 이 구간 안에서 다음 컬럼 c가 정렬되어 있지 않다는 것입니다.
a = 1 AND b > 10 →
(1, 20, 100)
(1, 20, 200)
(1, 30, 50)
(1, 30, 400)
↑ b가 여러 값에 걸쳐 있어, c는 이 구간에서 정렬 안 됨
→ c로는 binary search 불가 (스캔만 가능)
b가 한 값이 아니라 여러 값에 퍼져 있으므로, 그 안에서 c는 전역 정렬이 깨집니다. 따라서 범위 컬럼 이후의 컬럼은 seek 범위를 더 좁히는 데에는 쓰이지 못합니다. 인덱스를 이용한 탐색 범위 축소는 사실상 그 범위 컬럼에서 끝납니다.
단, 이는 "seek 범위 축소"에 한정된 이야기입니다. 범위 뒤의 컬럼도 covering index, index condition pushdown(ICP), 필터링, 일부 정렬/그룹핑에는 여전히 활용될 수 있습니다(6절 참고). "탐색에 아예 못 쓴다"가 아니라 "seek 범위를 좁히는 데 못 쓴다"가 정확한 표현입니다.
이것이 규칙의 본질입니다.
(일반적인 B-tree range seek 기준) 인덱스는 "동등 비교들 → 마지막에 범위 비교 하나"까지만 seek 범위 축소에 활용된다. 범위 컬럼 뒤의 컬럼은 seek 범위를 좁히는 대상이 되지 못한다.
optimizer 예외 전략
위 모델은 단순 B-tree range seek 기준입니다. 실제 optimizer에는 이를 벗어나는 전략이 있으니, "동등 먼저"를 절대 규칙으로 받아들이지는 않습니다.
- Skip Scan (MySQL 8.0+의 Skip Scan Range Access): 선행 컬럼에 동등 조건이 없어도, 그 컬럼의 distinct 값이 적으면 값마다 범위 스캔을 반복해 복합 인덱스를 활용. "선행 동등 필수" 전제의 예외.
- Index Merge: 여러 단일 인덱스의 결과를 교집합/합집합으로 병합.
- Bitmap Index Scan (예: PostgreSQL): 비트맵으로 여러 인덱스/조건을 결합.
4. 그래서 컬럼 순서는
- 쿼리에서
=/IN으로 쓰는 컬럼들을 앞쪽에 배치합니다. - 범위(
>,<,BETWEEN,LIKE 'x%')로 쓰는 컬럼을 그 다음에 둡니다. - 범위 컬럼은 인덱스 탐색의 사실상 마지막 단계이므로, 그 뒤에 또 다른 탐색용 컬럼을 두어도 seek에는 쓰이지 않습니다.
예를 들어 다음 쿼리라면
SELECT * FROM orders
WHERE user_id = 42
AND status = 'PAID'
AND created_at > '2026-01-01';
INDEX (user_id, status, created_at)가 이상적입니다. 동등(user_id, status)으로 한 점까지 좁힌 뒤, 마지막에 범위(created_at)로 연속 구간을 읽습니다. 만약 INDEX (created_at, user_id, status)로 만들면, 범위인 created_at이 맨 앞이라 그 뒤 user_id·status는 탐색에 활용되지 못하고 넓은 구간을 스캔하게 됩니다.
5. IN은 동등 비교에 가깝지만 주의
IN (v1, v2, ...)은 여러 개의 동등 비교로 볼 수 있어, 범위보다 앞에 두어도 됩니다. 엔진은 각 값마다 별도의 seek를 수행합니다(MySQL의 경우 "range" 접근이지만 동등 점들의 묶음에 가깝게 동작).
다만 IN 목록이 커지면 그만큼 seek 횟수가 늘어나고, IN 뒤에 오는 컬럼의 정렬 활용이 제한될 수 있습니다. 동등 하나처럼 완벽히 깔끔하지는 않다는 점을 염두에 둡니다.
6. 확인 방법 (MySQL/InnoDB)
아래 EXPLAIN 필드는 MySQL/InnoDB 기준입니다(다른 RDBMS는 명칭·동작이 다름). 인덱스가 어디까지 탐색에 쓰였는지 가늠할 수 있습니다.
key_len: 인덱스에서 실제 탐색에 사용된 바이트 길이. 범위 컬럼까지만 반영되고 그 뒤 컬럼은 빠지는 것을 확인할 수 있습니다.Extra의Using index condition(ICP): 범위 뒤 컬럼이 seek는 아니지만 인덱스 레벨 필터로 쓰이는 경우.Using where: 인덱스로 못 거른 조건을 테이블 단계에서 거르는 경우.
7. 정리
복합 인덱스는 하나의 사전식 정렬이고, 다음 컬럼의 정렬은 앞 컬럼이 "한 값으로 고정"될 때만 보존됩니다. 동등 비교는 한 점으로 고정해 정렬을 살리고, 범위 비교는 여러 값에 퍼져 다음 컬럼의 정렬을 깨뜨립니다. 그래서 동등 → 범위 순서가 인덱스 탐색을 가장 길게 활용하는 배치입니다.
Document History
- 2026-06-18T10:58:04+09:00 - Written by Claude Opus 4.8 (1M context) via Claude Code
- 2026-06-18T16:23:22+09:00 - Edited by Claude Opus 4.8 (1M context) via Claude Code: 제목을 영어로 변경
- 2026-06-19T09:33:03+09:00 - Edited by Claude Opus 4.8 (1M context) via Claude Code: 리뷰 반영 — 범위(scope) 명시(B-tree/MySQL), 동등/IN "대체로" 헤지, "seek 범위 축소" 한정 표현, covering/ICP 단서, optimizer 예외(skip scan·index merge·bitmap) 추가