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

(status, created_at) 인덱스를 만들었는데 created_at 만으로 찾으면 인덱스를 안 타요. 그래서 인덱스를 자꾸 더 만들게 되죠. 컬럼 순서를 정하는 기준을 알면 오히려 개수가 줄어요.

(status, created_at) 인덱스를 만들어 뒀어요. 그런데 created_at 으로만 조회하는 쿼리가 인덱스를 안 타요.

컬럼은 분명히 인덱스에 들어 있는데요. 순서를 바꿔 (created_at, status) 로 다시 만들면 이번엔 status 단독 조회가 안 타고요.

그래서 결국 둘 다 만들게 되고, 인덱스가 계속 늘어납니다. 어느 쪽이 맞는지 판단할 기준이 없으니 그때그때 추가하는 거죠.

기준은 있어요. 그리고 그 기준을 알면 인덱스 개수가 오히려 줄어듭니다 — 잘 만든 복합 인덱스 하나가 여러 개를 대체하거든요.

왜 그런가

복합 인덱스는 사전과 같아요. 성과 이름 순으로 정렬된 명부를 떠올려 보세요.

"김"으로 시작하는 사람은 금방 찾아요. 정렬의 첫 기준이니까요. "김민수"도 빠르고요. 그런데 "이름이 민수인 사람"은 못 찾아요. 명부 전체를 훑어야 해요. 성을 모르면 정렬이 아무 도움이 안 되니까요.

인덱스도 똑같아요. (a, b) 인덱스는 a 로 먼저 정렬하고, a 가 같은 것들끼리 b 로 정렬해요. 그래서 a 조건이 있어야 범위를 좁힐 수 있어요. b 만으로는 못 좁혀요. 이걸 왼쪽 접두사 규칙이라고 불러요.

그래서 (a, b) 인덱스는 a 단독 조회에도 쓸 수 있어요. (a) 인덱스를 따로 만들 필요가 없다는 뜻이에요. 반대로 b 단독 조회에는 못 써요.

여기서 순서를 정하는 기준이 나와요. 등호 조건으로 쓰는 컬럼을 앞에, 범위 조건으로 쓰는 컬럼을 뒤에 두는 거예요.

왜냐면 등호로 좁힌 구간 안에서는 그다음 컬럼이 여전히 정렬돼 있거든요. 반대로 범위 조건이 앞에 오면 여러 구간에 걸치게 되고, 그 안에서 뒤 컬럼의 정렬은 구간마다 따로라 이어지지 않아요. 그래서 더 이상 탐색 범위를 좁히는 데 못 씁니다.

코드로 보면

등호를 앞에, 범위를 뒤에:

코드
· 이 쿼리를 위한 인덱스라면
SELECT * FROM orders
 WHERE status = 'paid'
   AND created_at >= '2026-08-01'
 ORDER BY created_at;

 CREATE INDEX idx ON orders (status, created_at);
· status 로 좁히고, 그 안에서 created_at 이 정렬돼 있다
· → 범위 조건도 ORDER BY 도 인덱스로 해결된다

 CREATE INDEX idx ON orders (created_at, status);
 범위가 앞이라 그 뒤 status 는 좁히는 데 못 쓴다
 ORDER BY 는 되지만 읽는 양이 훨씬 많다

· (a, b) 가 있으면 (a) 는 대개 필요 없다 — 중복이다
 DROP INDEX CONCURRENTLY idx_status_only;

✕ 문제 지점 · ✓ 해법 · 그대로 돌려 보기 전에 자기 환경에 맞게 고쳐 주세요.

실전에서는

순서를 정하는 규칙을 정리하면 이래요.

첫째, 등호 조건 컬럼을 앞에. 둘째, 그다음 범위 조건 컬럼. 셋째, 마지막에 정렬용 컬럼. 등호로 좁힌 뒤에는 그 안이 정렬돼 있으니 범위와 정렬을 같이 해결할 수 있어요.

흔히 "선택도가 높은 컬럼을 앞에"라고도 하는데, 이건 등호 조건들 사이에서만 맞는 얘기예요. 범위 조건 컬럼을 선택도가 높다고 앞에 두면 오히려 손해예요.

정렬을 인덱스로 해결하는 건 생각보다 큰 이득이에요. ORDER BY 를 인덱스가 못 맞추면 결과를 전부 모아 정렬해야 하고, 그게 메모리를 넘으면 디스크로 갑니다. 인덱스 순서가 정렬과 맞으면 그냥 읽는 순서대로 나오니 LIMIT 도 즉시 끝나요.

방향도 봐야 해요. ORDER BY a ASC, b DESC 같은 혼합 정렬은 인덱스도 같은 방향 조합이어야 해요. 한 방향으로만 만든 인덱스는 전체를 뒤집어 읽을 수는 있어도 섞인 방향은 못 맞춥니다.

커버링도 순서 설계의 일부예요. 인덱스에 필요한 컬럼을 다 담으면 테이블 본체를 안 읽어요. 조건에 안 쓰이고 결과로만 필요한 컬럼은 인덱스 뒤에 붙이거나, 포함 컬럼으로 넣는 게 좋아요. 정렬에는 참여하지 않으면서 값만 들고 있게 하는 방식이에요.

그리고 인덱스 개수를 줄이는 관점이 중요해요. (a, b, c) 하나가 (a), (a, b), (a, b, c) 세 가지 조회를 다 커버해요. 인덱스를 늘리기 전에 기존 복합 인덱스의 순서를 조정해서 커버할 수 있는지 먼저 보세요.

처방

  1. 순서 규칙을 팀의 기본으로 삼으세요. 등호 → 범위 → 정렬. 이것만으로 대부분의 설계가 정해져요.
  2. 새 인덱스를 만들기 전에 기존 복합 인덱스로 커버되는지 확인하세요. 왼쪽 접두사가 겹치면 새로 만들 필요가 없어요.
  3. 중복 인덱스를 걷어내세요. (a) 와 (a, b) 가 같이 있으면 대개 (a) 는 불필요해요. 크기 차이가 작다면 확실히 지워도 됩니다.
  4. ORDER BY 를 인덱스로 해결할 수 있는지 항상 확인하세요. EXPLAIN 에 정렬 단계가 보이면 인덱스가 정렬과 안 맞는 거예요.
  5. 자주 쓰는 쿼리 몇 개를 골라 그것들을 최대한 커버하는 복합 인덱스를 설계하세요. 쿼리마다 인덱스를 하나씩 만드는 방식은 금방 열두 개가 됩니다.
  6. 조건에 안 쓰이고 결과로만 필요한 컬럼은 포함 컬럼으로 붙이세요. 인덱스 크기는 늘지만 테이블 접근이 사라져요.
  7. 인덱스를 추가하기 전에 "이 쿼리 하나를 위한 것인가, 여러 쿼리를 커버하는가"를 물으세요. 전자가 반복되면 결국 열두 개가 됩니다.

지금 확인해 보세요

정렬이 인덱스로 해결되는지:

코드
EXPLAIN (ANALYZE)
SELECT * FROM orders
 WHERE status='paid' AND created_at >= '2026-08-01'
 ORDER BY created_at LIMIT 50;

 Index Scan using idx_status_created  ← 정렬 없이 끝
 Sort  ...  ← 인덱스가 정렬과 안 맞는다
 external merge Disk: ...  ← 정렬이 디스크로 넘어갔다

· 중복 인덱스 찾기 — 왼쪽 접두사가 겹치는 쌍
SELECT indexrelname,
       pg_get_indexdef(indexrelid)
  FROM pg_stat_user_indexes
 WHERE relname = 'orders'
 ORDER BY indexrelname;
 (a) 와 (a,b) 가 같이 있으면 (a) 는 대개 중복이다

핵심 한 줄복합 인덱스는 사전이다. 앞 글자를 모르면 아무 도움이 안 된다.

자주 묻는 것

복합 인덱스 (a, b) 가 있으면 a 단독 인덱스를 따로 만들어야 하나요?

아니에요. (a, b) 인덱스는 a 단독 조회에도 쓸 수 있어서 (a) 를 따로 만들 필요가 없어요. 반대로 b 단독 조회에는 못 써요.

브루니가 적어요

색을 바꾸며 숨는 카멜레온이에요. 운영에서 실제로 터지는 문제를 한 편씩 적어요. 같은 실수를 두 번 하지 않으려고 쓰는 기록이에요.

인덱스·쿼리 성능 글 20편 모두 보기 →