복합 인덱스의 왼쪽 접두 규칙
복합 인덱스의 왼쪽 접두 규칙
(a, b, c) 복합 인덱스는 세 개의 독립 인덱스를 합친 것이 아니라 a로 먼저 정렬하고, 같은 a 안에서 b, 같은 (a, b) 안에서 c로 정렬한 하나의 키다. 그래서 앞 열을 건너뛰면 뒤 열만으로 연속된 탐색 범위를 만들기 어렵고, 첫 범위 조건 이후 열은 검색 범위 축소보다 필터나 커버링에 사용될 가능성이 커진다.
목차
- #문제가 되는 상황
- #복합 인덱스는 중첩된 정렬이다
- #왜 왼쪽 열부터 사용해야 할까
- #사용 가능과 효율적인 탐색은 다른 말이다
- #등호 조건이 이어질 때
- #첫 범위 조건을 만난 뒤 달라지는 것
- #ORDER BY까지 활용하려면
- #IN 조건은 등호 하나와 같지 않다
- #열 순서는 선택도 공식만으로 결정하지 않는다
- #왼쪽 접두 규칙에도 옵티마이저 예외는 있다
- #쿼리 패턴 표로 인덱스 후보를 만든다
- #결론
- #관련 노트
문제가 되는 상황
다음 인덱스를 만들면 세 열을 어떤 조합으로 조회하든 모두 빨라질 것처럼 보일 수 있다.
CREATE INDEX ix_orders_tenant_status_created
ON orders(tenant_id, status, created_at);
하지만 아래 쿼리는 같은 인덱스를 전혀 다르게 사용한다.
-- A. 앞 열부터 연속해서 사용
SELECT * FROM orders
WHERE tenant_id = 10
AND status = 'PAID';
-- B. 첫 열을 건너뜀
SELECT * FROM orders
WHERE status = 'PAID';
-- C. 첫 열 뒤에 범위 조건 사용
SELECT * FROM orders
WHERE tenant_id = 10
AND created_at >= '2026-08-01';
세 쿼리 모두 인덱스에 존재하는 열을 조건으로 사용하지만 B와 C가 A와 같은 방식으로 탐색 범위를 줄이는 것은 아니다. 이유는 인덱스가 열의 집합이 아니라 순서가 있는 정렬 구조이기 때문이다.
복합 인덱스는 중첩된 정렬이다
(tenant_id, status, created_at)의 엔트리를 단순화하면 다음 순서로 놓인다.
(10, CANCELLED, 2026-08-01)
(10, PAID, 2026-07-30)
(10, PAID, 2026-08-01)
(10, PAID, 2026-08-03)
(10, PENDING, 2026-08-02)
(20, CANCELLED, 2026-08-01)
(20, PAID, 2026-08-04)
먼저 tenant_id가 정렬 기준이다. 같은 tenant 안에서만 status가 정렬되고, 같은 (tenant_id, status) 그룹 안에서만 created_at이 정렬된다.
tenant_id
└── status
└── created_at
이 구조를 이해하면 왼쪽 접두 규칙은 외워야 할 DB 제약이 아니라 자연스러운 결과가 된다. 전체 목록에서 tenant_id를 무시하고 status = PAID만 보면 PAID 구간이 하나로 모여 있지 않다. tenant 10의 PAID와 tenant 20의 PAID 사이에는 다른 값들이 섞일 수 있다.
왜 왼쪽 열부터 사용해야 할까
전화번호부가 (성, 이름) 순으로 정렬되어 있다고 생각해 보자. 성이 김인 사람을 찾은 뒤 그 안에서 이름이 민수인 사람을 찾기는 쉽다. 반면 성을 모른 채 이름이 민수인 사람을 찾으려면 모든 성 구간을 확인해야 한다.
인덱스도 같다.
WHERE tenant_id = 10
AND status = 'PAID'
이 조건은 정렬 키의 연속된 접두 (tenant_id, status)를 고정한다. B+Tree는 (10, 'PAID')가 시작되는 리프로 곧바로 이동할 수 있다.
... (10, CANCELLED) | (10, PAID) ... (10, PAID) | (10, PENDING) ...
^ 시작 ^ 종료
반면 다음 조건은 첫 열을 알 수 없다.
WHERE status = 'PAID'
(10, PAID) ... (20, PAID) ... (30, PAID) ...
각 tenant 구간마다 PAID를 찾아야 하므로 하나의 연속 범위로 좁히기 어렵다. status 조회가 중요한 독립 접근 패턴이라면 (status, ...)로 시작하는 다른 인덱스를 검토해야 한다.
사용 가능과 효율적인 탐색은 다른 말이다
실행 계획에 인덱스 이름이 표시됐다고 해서 모든 조건이 B+Tree 탐색 경계를 만든 것은 아니다. 뒤 열은 여러 방식으로 사용될 수 있다.
| 사용 방식 | 의미 |
|---|---|
| access/range 조건 | 시작과 종료 위치를 정해 읽을 엔트리 범위를 줄임 |
| index filter | 인덱스 엔트리를 읽은 뒤 조건으로 걸러냄 |
| table filter | 본 테이블 행까지 읽은 뒤 걸러냄 |
| covering | 결과 열을 인덱스에서 얻어 테이블 접근을 줄임 |
| ordering | 인덱스 순서로 결과를 읽어 별도 정렬을 줄임 |
다음 쿼리를 보자.
SELECT id
FROM orders
WHERE tenant_id = 10
AND created_at >= '2026-08-01';
중간 status 조건이 없다. 인덱스는 tenant_id = 10의 범위를 찾는 데 사용할 수 있지만, 그 안에서는 status별로 created_at이 다시 시작된다. created_at이 tenant 10 전체에서 하나의 순서로 정렬된 것이 아니다.
DB가 인덱스 엔트리에서 created_at을 읽고 필터링할 수는 있다. 하지만 (tenant_id, created_at) 인덱스처럼 날짜 조건으로 시작과 끝을 직접 좁히는 것과는 읽는 양이 다르다. 인덱스 열이 조건 평가에 사용되는 것과 탐색 범위를 줄이는 것은 구분해야 한다.
등호 조건이 이어질 때
복합 인덱스 앞부분이 모두 등호 조건이면 순서는 논리적으로 한 지점 또는 연속된 좁은 구간을 만든다.
WHERE tenant_id = 10
AND status = 'PAID'
AND created_at = '2026-08-01 10:00:00'
(tenant_id=10, status=PAID, created_at=2026-08-01 10:00:00)
SQL WHERE 절에 조건을 적는 순서는 인덱스 열 순서와 같을 필요가 없다. 옵티마이저가 논리 조건을 분석하므로 다음 두 WHERE는 일반적으로 같은 의미다.
WHERE tenant_id = 10 AND status = 'PAID'
WHERE status = 'PAID' AND tenant_id = 10
중요한 것은 SQL 텍스트의 작성 순서가 아니라 인덱스 정의의 열 순서와 각 열에 적용된 연산이다.
첫 범위 조건을 만난 뒤 달라지는 것
복합 인덱스 설계에서 자주 쓰는 원칙은 “등호 조건을 먼저, 범위 조건을 뒤에 둔다”이다.
CREATE INDEX ix_orders_tenant_created_status
ON orders(tenant_id, created_at, status);
SELECT id
FROM orders
WHERE tenant_id = 10
AND created_at >= '2026-08-01'
AND created_at < '2026-09-01'
AND status = 'PAID';
tenant_id = 10은 첫 열을 고정하고, created_at은 그 안의 연속된 날짜 범위를 만든다. 그러나 이 날짜 범위 안에서 status 값은 날짜마다 다시 섞인다. status는 조건 평가에 사용할 수 있어도 하나의 추가 연속 범위로 줄이기 어렵다.
(10, 08-01, PAID)
(10, 08-01, PENDING)
(10, 08-02, CANCELLED)
(10, 08-02, PAID)
...
status를 날짜보다 앞에 두면 상황이 바뀐다.
CREATE INDEX ix_orders_tenant_status_created
ON orders(tenant_id, status, created_at);
(10, PAID, 08-01) ← 시작
(10, PAID, 08-02)
(10, PAID, 08-20) ← 종료
쿼리가 항상 status 등호 조건을 포함한다면 이 순서가 읽을 범위를 더 작게 만든다. 하지만 status 조건이 없는 날짜 목록도 중요하다면 created_at 앞의 status가 정렬 활용을 막는다. 인덱스 하나로 모든 쿼리를 완벽히 지원하려는 시도가 실패하는 지점이다.
뒤 열은 인덱스 조건 푸시다운, 필터, 커버링에 사용될 수 있다. 핵심은 첫 범위 조건 뒤에서 주요 연속 탐색 구간을 추가로 좁히기 어렵다는 것이다. 실제 역할은 실행 계획으로 확인한다.
ORDER BY까지 활용하려면
필터링뿐 아니라 정렬도 인덱스 키 순서와 맞아야 한다.
SELECT id, created_at
FROM orders
WHERE tenant_id = 10
AND status = 'PAID'
ORDER BY created_at DESC
LIMIT 20;
(tenant_id, status, created_at) 인덱스에서는 앞 두 열이 등호로 고정된다. 남은 구간은 created_at 순서이므로 DB가 최신 방향으로 읽어 20건에서 멈출 수 있다.
(10, PAID, 09-02)
(10, PAID, 09-01)
(10, PAID, 08-31)
...
status 조건이 빠지면 같은 인덱스의 tenant 10 구간은 status별로 먼저 묶인다.
WHERE tenant_id = 10
ORDER BY created_at DESC
LIMIT 20;
(10, CANCELLED, dates...)
(10, PAID, dates...)
(10, PENDING, dates...)
전체 status를 합친 created_at 순서가 아니므로 별도 정렬이 필요할 수 있다. 이 조회가 핵심이라면 (tenant_id, created_at) 인덱스가 별도로 필요할 수 있다.
정렬 방향이 여러 열에서 섞이는 경우도 확인해야 한다.
ORDER BY created_at DESC, id ASC
DB 버전과 인덱스의 ASC/DESC 정의, 역방향 스캔 지원에 따라 정렬 제거 여부가 달라질 수 있으므로 Using filesort 같은 실행 계획 신호와 실제 정렬 단계를 본다.
IN 조건은 등호 하나와 같지 않다
IN 목록은 값 하나의 등호가 아니라 여러 탐색 구간을 의미할 수 있다.
SELECT id, created_at
FROM orders
WHERE tenant_id = 10
AND status IN ('PAID', 'PENDING')
ORDER BY created_at DESC
LIMIT 20;
(tenant_id, status, created_at) 인덱스는 두 status 범위를 각각 찾는 데 유용하다. 하지만 PAID 구간과 PENDING 구간은 따로 정렬되어 있으므로 두 구간 전체의 created_at 순서를 얻으려면 병합이나 정렬이 필요할 수 있다.
PAID: 09-02, 09-01, 08-28 ...
PENDING: 09-03, 08-30, 08-29 ...
전체 최신순 결과를 위해 두 스트림을 합쳐야 함
값 목록이 작고 각 구간에서 일부만 읽는 계획과, (tenant_id, created_at, status)로 최신순을 먼저 읽으며 status를 필터링하는 계획 중 어느 것이 나은지는 데이터 분포와 LIMIT에 따라 달라진다.
열 순서는 선택도 공식만으로 결정하지 않는다
“선택도가 높은 열을 무조건 앞에 둔다”는 조언은 불완전하다. 다음 인덱스를 비교해 보자.
INDEX (tenant_id, status, created_at)
INDEX (status, tenant_id, created_at)
모든 쿼리가 tenant_id와 status를 모두 등호 조건으로 사용한다면 둘 다 세 번째 created_at 구간까지 좁힐 수 있다. 하지만 tenant_id만 사용하는 쿼리가 많으면 첫 번째 인덱스만 왼쪽 접두를 제공한다. 멀티테넌트 서비스에서는 테넌트 경계가 거의 모든 쿼리에 포함되므로 tenant_id가 앞에 오는 운영적 이유도 크다.
열 순서를 정할 때 함께 볼 항목은 다음과 같다.
- 실제 쿼리에서 어떤 조건 조합이 자주 등장하는가?
- 등호, IN, 범위 중 어떤 연산을 사용하는가?
- ORDER BY와 LIMIT을 인덱스로 끝낼 수 있는가?
- 각 조건이 실제로 몇 행을 남기는가?
- 이 인덱스의 접두가 다른 중요한 쿼리도 지원하는가?
- 이미 있는 인덱스와 얼마나 겹치는가?
선택도와 데이터 쏠림은 인덱스 선택도가 성능에 미치는 영향에서 별도로 다룬다.
왼쪽 접두 규칙에도 옵티마이저 예외는 있다
일부 DB와 버전은 첫 열의 고유 값 종류가 적을 때 각 첫 열 값의 구간을 반복 탐색하는 skip scan 계열 최적화를 지원할 수 있다. 인덱스 병합으로 여러 단일 인덱스를 조합할 수도 있다.
그렇다고 왼쪽 접두 규칙이 사라지는 것은 아니다. 이런 계획은 데이터 분포와 통계, DB 버전에 의존하며 전용 인덱스보다 많은 탐색을 수행할 수 있다. 핵심 쿼리의 성능을 우연한 옵티마이저 선택에 맡겨서는 안 된다.
“이 인덱스는 절대 사용되지 않는다”보다 “하나의 연속 범위를 만들지 못하며, 현재 실행 계획에서는 N개의 구간을 반복 탐색한다”라고 설명하는 편이 정확하다.
쿼리 패턴 표로 인덱스 후보를 만든다
스키마만 바라보지 말고 실제 접근 패턴을 표로 만든다.
| 쿼리 | 등호 조건 | 다중·범위 조건 | 정렬·LIMIT | 후보 인덱스 |
|---|---|---|---|---|
| 테넌트의 결제 완료 주문 최신 20건 | tenant, status | 없음 | created DESC, 20 | (tenant, status, created) |
| 테넌트의 기간별 전체 주문 | tenant | created 범위 | created DESC | (tenant, created) |
| 전체 실패 주문 배치 | status | created 범위 | created ASC | (status, created) |
| 주문 ID 단건 조회 | id | 없음 | 없음 | primary key |
후보를 만든 뒤 실제와 유사한 분포에서 확인한다.
EXPLAIN ANALYZE
SELECT id, created_at
FROM orders
WHERE tenant_id = 10
AND status = 'PAID'
ORDER BY created_at DESC
LIMIT 20;
다음 질문에 답한다.
- 어떤 열까지 탐색 조건으로 사용됐는가?
- 예상 행 수와 실제 행 수가 비슷한가?
- LIMIT 20을 얻기 위해 몇 엔트리를 읽었는가?
- 별도 정렬이 발생했는가?
- 뒤 열은 필터인가, 커버링인가?
- 기존 인덱스로 같은 계획을 만들 수 있는가?
이 과정은 프로젝트 쿼리를 중심으로 인덱스 중복까지 다룬 MySQL 복합 인덱스의 순서와 중복 인덱스 판단하기와 연결된다.
결론
복합 인덱스의 왼쪽 접두 규칙은 B+Tree가 앞 열부터 중첩해 정렬된 하나의 키라는 사실에서 나온다. 앞 열이 등호 조건으로 고정될수록 뒤 열은 연속된 작은 구간을 만들 수 있다. 앞 열을 건너뛰거나 첫 범위 조건을 만나면 뒤 열은 탐색 경계보다 필터와 커버링 역할에 머물 가능성이 커진다.
하지만 이를 “범위 뒤 열은 사용 불가”, “선택도 높은 열을 무조건 먼저” 같은 한 줄 공식으로 줄이면 실제 쿼리를 잘못 설계하기 쉽다. WHERE의 연산 종류, ORDER BY, LIMIT, 데이터 분포를 쿼리 패턴별로 적고 실행 계획에서 각 열이 맡은 역할과 실제 읽은 행 수를 확인해야 한다.