본문 바로가기
카테고리 없음

복합 인덱스는 순서가 전부입니다

by 테크구루스 2026. 7. 7.

복합 인덱스는 순서가 전부입니다—leftmost prefix 규칙과 함께 컬럼 순서 설계를 정리한 글입니다.

 

복합 인덱스는 순서가 전부입니다
복합 인덱스는 순서가 전부입니다

 

데이터베이스를 15년 가까이 다루면서 가장 자주 마주친 성능 사고는 화려한 튜닝 실패가 아니라 복합 인덱스의 컬럼 순서 하나를 잘못 잡아 벌어진 일이었습니다. 인덱스를 분명히 만들었는데도 쿼리가 느리다는 제보를 받고 EXPLAIN을 열어보면, 인덱스가 아예 안 쓰이거나 딱 절반만 쓰이고 있던 경우가 부지기수였죠. 오늘은 그 순서라는 주제 하나만 붙잡고 이야기해 보려 합니다.

leftmost prefix, 인덱스가 왼쪽부터 읽히는 규칙

복합 인덱스를 이해하는 핵심은 딱 하나입니다. B-Tree 인덱스는 정의된 컬럼 순서대로 왼쪽부터 정렬되어 저장된다는 사실입니다. (a, b, c)로 인덱스를 만들면, 데이터는 먼저 a로 정렬되고, a가 같은 것끼리 b로, b까지 같은 것끼리 c로 정렬됩니다. 전화번호부를 떠올리면 쉽습니다. 성으로 먼저 정렬하고, 같은 성 안에서 이름으로 정렬하는 방식이죠.

이 구조 때문에 인덱스는 반드시 가장 왼쪽 컬럼부터 연속으로 조건이 주어져야 제대로 작동합니다. 이것을 leftmost prefix 규칙이라고 부릅니다.

-- 인덱스: (a, b, c)
WHERE a = 1                  -- 사용 O (prefix: a)
WHERE a = 1 AND b = 2        -- 사용 O (prefix: a, b)
WHERE a = 1 AND b = 2 AND c = 3  -- 사용 O (prefix 전체)
WHERE b = 2                  -- 사용 X (a를 건너뜀)
WHERE c = 3                  -- 사용 X (a, b를 건너뜀)
WHERE a = 1 AND c = 3        -- a만 사용, c는 필터링만

b만 주어진 조건에서 인덱스가 안 쓰이는 이유는 명확합니다. 전화번호부에서 성을 모른 채 이름만으로 사람을 찾으려면 결국 처음부터 끝까지 다 넘겨봐야 하죠. b는 a가 고정되었을 때만 정렬이 보장되기 때문에, a 없이 b만으로는 정렬의 이점을 전혀 누릴 수 없습니다.

a = 1 AND c = 3은 조금 미묘합니다. 인덱스로 a까지는 좁히지만, 중간의 b가 빠졌기 때문에 c는 인덱스 탐색(seek)에 못 쓰이고 남은 후보들을 훑으며 걸러내는 용도로만 쓰입니다. EXPLAIN에서 이런 상황은 key_len 값으로 드러나는데, a 컬럼 하나 분량만 잡히는 것을 보고 "아, 앞부분만 타는구나" 하고 판단하곤 했습니다.

여기서 초심자가 자주 오해하는 지점이 하나 있습니다. WHERE 절에 조건을 적은 순서는 아무 상관이 없다는 사실이죠. WHERE b = 2 AND a = 1이라고 적어도 옵티마이저가 알아서 재배열하기 때문에 (a, b) 인덱스는 정상적으로 탑니다. 중요한 것은 쿼리문에 적힌 순서가 아니라 인덱스에 정의된 컬럼 순서이며, 그 순서에 필요한 prefix가 조건으로 채워졌느냐 하는 것뿐입니다. 저도 처음엔 SQL 작성 순서를 바꿔가며 헤맨 적이 있는데, 알고 보니 애초에 인덱스 정의를 봐야 할 문제였죠.

 

등호·범위·정렬을 고려한 순서 설계

그렇다면 컬럼을 어떤 순서로 배치해야 할까요. 제가 현장에서 원칙으로 삼는 순서는 다음과 같습니다.

  1. 등호(=) 조건 컬럼을 맨 앞에 둡니다.
  2. 범위(>, <, BETWEEN, LIKE 'x%') 컬럼을 그 뒤에 둡니다.
  3. ORDER BY / GROUP BY 컬럼을 마지막에 붙여 정렬까지 인덱스로 해결합니다.

핵심은 범위 조건이 등장하는 순간, 그 뒤 컬럼들은 정렬 순서가 깨진다는 점입니다. (status, created_at, id) 인덱스에서 status = 'PAID' AND created_at > '2026-01-01'을 걸면, created_at부터는 범위라 그 뒤의 id는 더 이상 정렬된 상태로 읽히지 않습니다. 그래서 범위 컬럼은 반드시 뒤쪽에, 그중에서도 정렬에 쓸 컬럼 바로 앞까지만 배치하는 것이 안전하죠.

정렬을 인덱스로 해결하는 것은 생각보다 큰 이득입니다. ORDER BY를 인덱스가 못 받아주면 DB는 결과를 모아 별도로 정렬(filesort)을 수행하는데, 대량 데이터에서는 이 filesort가 CPU와 임시 공간을 크게 잡아먹습니다.

조건 유형 인덱스 내 위치 이유
등호(=) 앞쪽 한 지점으로 좁혀 탐색 효율 최대
범위(>, <) 중간 뒤 컬럼 정렬을 깨므로 뒤로
ORDER BY 마지막 filesort 제거

한 가지 덧붙이면, 등호 컬럼이 여러 개일 때는 선택도(cardinality)가 높은, 즉 값의 종류가 많아 더 잘게 걸러주는 컬럼을 앞에 두는 편이 유리한 경우가 많습니다. 다만 이건 절대 규칙이 아니라, 실제 쿼리 패턴과 데이터 분포에 따라 EXPLAIN으로 검증하며 정해야 하는 부분이죠.

그리고 인덱스 순서는 특정 쿼리 하나가 아니라 서비스 전체의 쿼리 패턴을 놓고 결정해야 합니다. 어떤 화면은 상인별로 조회하고 어떤 배치는 날짜 구간으로만 조회한다면, 두 패턴을 모두 만족시키는 하나의 순서란 존재하지 않을 수도 있죠. 그럴 때는 무리하게 컬럼을 욱여넣기보다 목적이 다른 인덱스를 둘로 나누는 편이 낫습니다. 인덱스 하나로 모든 걸 해결하려는 욕심이 오히려 어느 쿼리도 제대로 못 타게 만드는 경우를 여러 번 봤습니다.

컬럼순서 설계원칙
컬럼순서 설계원칙

순서 하나로 갈린 실행계획

재작년 주문 정산 시스템을 운영할 때 겪은 일입니다. orders 테이블은 약 8천만 row였고, 다음 쿼리가 정산 배치의 병목이었습니다.

SELECT * FROM orders
WHERE merchant_id = 4021
  AND created_at BETWEEN '2026-03-01' AND '2026-03-31'
ORDER BY created_at DESC
LIMIT 100;

당시 인덱스는 (created_at, merchant_id)였습니다. 언뜻 날짜로 먼저 좁히니 괜찮아 보였지만, 실제로는 범위 조건인 created_at이 맨 앞이라 merchant_id는 탐색에 못 쓰이고 있었죠. EXPLAIN을 보니 type: rangekey_len은 created_at 분량뿐이었고, 3월 한 달치 전체 주문을 인덱스로 긁어온 뒤 merchant_id를 일일이 걸러내고 있었습니다. rows 추정치가 수십만이었고, 실측 응답은 평균 약 420ms였습니다.

원인은 명확했습니다. 등호 조건인 merchant_id가 뒤에 있으니 앞쪽 범위에서 이미 대량을 훑은 것이었죠. 그래서 인덱스를 (merchant_id, created_at)로 뒤집었습니다.

CREATE INDEX idx_orders_merchant_created
  ON orders (merchant_id, created_at);

순서를 바꾸자 실행계획이 완전히 달라졌습니다. merchant_id로 한 상인의 데이터만 정확히 좁힌 뒤, 그 안에서 created_at 범위를 정렬된 상태로 읽어 LIMIT 100에서 바로 끊었습니다. ORDER BY created_at DESC도 인덱스 역방향 스캔으로 해결되어 filesort가 사라졌죠. rows 추정치는 수백 단위로 줄었고, 응답 시간은 평균 약 38ms로 떨어졌습니다. 수백 ms에서 수십 ms로, 열 배 넘게 빨라진 셈입니다.

이 사건 이후로 저는 습관이 하나 생겼습니다. 복합 인덱스를 설계하거나 리뷰할 때 반드시 EXPLAIN의 key_len을 확인해, 내가 의도한 컬럼까지 실제로 인덱스가 타는지를 눈으로 검증하는 것이죠. 인덱스 정의에 컬럼 이름 세 개가 나란히 적혀 있다고 셋 다 쓰인다는 보장은 없습니다.

 

순서변경 전후비교
순서변경 전후비교

정리하면, 복합 인덱스에서 순서는 곁다리가 아니라 성능 그 자체입니다. 등호를 앞에, 범위를 뒤에, 정렬을 마지막에 두는 원칙만 지켜도 대부분의 쿼리는 인덱스를 온전히 탈 수 있습니다. 같은 컬럼 조합이라도 순서 하나로 실행계획이 갈리니, 인덱스를 만들었다고 안심하지 마시고 꼭 EXPLAIN으로 확인해 보시길 권합니다.