인덱스를 여러 컬럼에 걸쳐 만들 때, 컬럼 순서를 바꾸면 성능이 완전히 달라지는 이유는 무엇일까요?

단일 컬럼 인덱스만으로는 실무의 복잡한 쿼리를 커버하기 어렵습니다. 복합 인덱스(Composite Index)는 여러 컬럼을 하나의 인덱스로 묶어 다양한 조건을 효율적으로 처리합니다. 하지만 컬럼 순서 하나가 성능을 좌우 하므로, 설계 원칙을 제대로 이해해야 합니다.

개념 정의

복합 인덱스는 두 개 이상의 컬럼 을 조합하여 만든 인덱스입니다.

SQL
CREATE INDEX idx_dept_age ON employees(department, age);

이 인덱스는 내부적으로 다음과 같이 정렬됩니다.

PLAINTEXT
(department='Engineering', age=25)
(department='Engineering', age=28)
(department='Engineering', age=32)
(department='Marketing',  age=22)
(department='Marketing',  age=27)
(department='Sales',      age=24)
(department='Sales',      age=31)

첫 번째 컬럼(department)으로 먼저 정렬 하고, 같은 값 안에서 두 번째 컬럼(age)으로 정렬 합니다. 이것이 복합 인덱스의 핵심 구조입니다.

최좌선 접두사 규칙 (Leftmost Prefix Rule)

복합 인덱스에서 가장 중요한 규칙입니다. 인덱스를 사용하려면 왼쪽부터 연속된 컬럼 이 조건에 있어야 합니다.

인덱스: (a, b, c)

WHERE 조건인덱스 사용사용되는 컬럼
a = 1Oa
a = 1 AND b = 2Oa, b
a = 1 AND b = 2 AND c = 3Oa, b, c
b = 2X-
b = 2 AND c = 3X-
a = 1 AND c = 3a만 (c는 필터링)
SQL
-- 인덱스: (department, age, salary)

-- 인덱스 사용 O: 선두 컬럼부터 연속
SELECT * FROM employees WHERE department = 'Engineering' AND age = 28;

-- 인덱스 사용 X: 선두 컬럼 없음
SELECT * FROM employees WHERE age = 28 AND salary > 5000;

-- 부분 사용: department만 인덱스 사용, salary는 필터
SELECT * FROM employees WHERE department = 'Engineering' AND salary > 5000;

왜 이런 규칙이 있을까?

B+Tree의 정렬 구조 때문입니다.

PLAINTEXT
인덱스 (department, age) 정렬:
Engineering, 25
Engineering, 28
Marketing,  22
Marketing,  27
Sales,      24

→ department 기준으로 정렬되어 있으므로, department 없이 age=22를 찾으려면
  전체를 스캔해야 합니다. age만으로는 정렬되어 있지 않기 때문입니다.

범위 조건과 인덱스 사용 범위

범위 조건(>, <, BETWEEN, LIKE 'prefix%')이 나오면, 그 **이후 컬럼은 인덱스 범위 스캔에 사용되지 않습니다 **.

SQL
-- 인덱스: (a, b, c)

-- a, b 모두 인덱스 범위 스캔 사용
WHERE a = 1 AND b = 2 AND c = 3

-- a만 인덱스 범위 스캔, b는 필터
WHERE a > 1 AND b = 2

-- a, b 인덱스 범위 스캔, c는 필터
WHERE a = 1 AND b > 2 AND c = 3

이 규칙을 이용한 인덱스 설계:

SQL
-- 나쁜 순서: 범위 조건 컬럼이 앞에 있음
CREATE INDEX idx_bad ON orders(created_at, status, user_id);
-- WHERE created_at > '2026-01-01' AND status = 'paid' AND user_id = 1
-- created_at만 인덱스 범위 스캔, status와 user_id는 필터

-- 좋은 순서: 등가 조건 컬럼을 앞에 배치
CREATE INDEX idx_good ON orders(status, user_id, created_at);
-- WHERE status = 'paid' AND user_id = 1 AND created_at > '2026-01-01'
-- 세 컬럼 모두 인덱스 사용

** 등가 조건(=) 컬럼을 앞에, 범위 조건 컬럼을 뒤에** 배치하는 것이 기본 원칙입니다.

카디널리티와 컬럼 순서

** 카디널리티(Cardinality)** 는 컬럼의 고유 값 개수입니다.

SQL
-- 카디널리티 확인
SELECT
    COUNT(DISTINCT status) AS status_card,        -- 5 (낮음)
    COUNT(DISTINCT department) AS dept_card,       -- 50 (중간)
    COUNT(DISTINCT user_id) AS user_card           -- 100,000 (높음)
FROM orders;

일반적인 원칙: ** 카디널리티가 높은 컬럼을 앞에** 배치합니다.

SQL
-- 좋은 예: 카디널리티 높은 → 낮은
CREATE INDEX idx_user_status ON orders(user_id, status);
-- user_id로 100,000개 중 1개를 찾고, 그 안에서 status 필터

-- 나쁜 예: 카디널리티 낮은 → 높은
CREATE INDEX idx_status_user ON orders(status, user_id);
-- status로 5개 중 1개(전체의 20%)를 찾고, 그 안에서 user_id 필터

하지만 이 원칙에는 ** 예외 **가 있습니다.

예외: 쿼리 패턴이 우선

SQL
-- 이 쿼리가 가장 빈번하다면:
SELECT * FROM orders WHERE status = 'pending' ORDER BY created_at LIMIT 10;

-- 카디널리티와 무관하게 (status, created_at) 인덱스가 최적
-- status로 필터 + created_at 정렬을 인덱스가 모두 처리
CREATE INDEX idx_status_created ON orders(status, created_at);

실제 쿼리 패턴 이 카디널리티보다 중요합니다. 인덱스는 특정 쿼리를 위해 설계하는 것이기 때문입니다.

인덱스 스킵 스캔 (MySQL 8.0.13+)

최좌선 접두사 규칙의 제약을 부분적으로 해소하는 최적화입니다.

SQL
-- 인덱스: (gender, age)
-- 전통적으로는 gender 없이 age 검색 시 인덱스 사용 불가

SELECT * FROM users WHERE age = 28;

인덱스 스킵 스캔의 동작:

PLAINTEXT
gender의 고유 값: 'M', 'F'

스킵 스캔은 내부적으로 다음과 같이 변환:
WHERE gender = 'M' AND age = 28
UNION
WHERE gender = 'F' AND age = 28

선두 컬럼의 고유 값이 적을 때 효과적입니다. 고유 값이 많으면 오히려 느려집니다.

SQL
-- EXPLAIN에서 확인
EXPLAIN SELECT * FROM users WHERE age = 28;
-- Extra: Using index for skip scan

스킵 스캔의 한계

  • 선두 컬럼의 카디널리티가 높으면 비효율적
  • 옵티마이저가 자동 판단하므로 직접 제어가 어려움
  • 커버링 인덱스가 아닌 경우 성능 이점이 줄어듦

복합 인덱스와 정렬 (ORDER BY)

복합 인덱스는 이미 정렬되어 있으므로, ORDER BY와 맞으면 **filesort를 피할 수 있습니다 **.

SQL
-- 인덱스: (department, created_at)

-- filesort 없음: 인덱스 정렬 그대로 사용
SELECT * FROM employees
WHERE department = 'Engineering'
ORDER BY created_at;

-- filesort 없음: DESC도 가능 (역방향 스캔)
SELECT * FROM employees
WHERE department = 'Engineering'
ORDER BY created_at DESC;

-- filesort 발생: 정렬 방향 혼합
SELECT * FROM employees
WHERE department = 'Engineering'
ORDER BY created_at ASC, name DESC;
-- MySQL 8.0에서는 DESC 인덱스로 해결 가능:
-- CREATE INDEX idx ON employees(department, created_at ASC, name DESC);

GROUP BY와 인덱스

SQL
-- 인덱스: (department, age)

-- 인덱스 사용: GROUP BY가 인덱스 순서와 일치
SELECT department, AVG(age) FROM employees GROUP BY department;

-- 인덱스 사용 불가: GROUP BY 순서가 다름
SELECT age, COUNT(*) FROM employees GROUP BY age;
-- age가 인덱스 선두가 아니므로 임시 테이블 필요

복합 인덱스 설계 실전 가이드

1. 가장 빈번한 쿼리 파악

SQL
-- 슬로우 쿼리 로그 분석
-- 또는 performance_schema에서 확인
SELECT DIGEST_TEXT, COUNT_STAR, AVG_TIMER_WAIT
FROM performance_schema.events_statements_summary_by_digest
ORDER BY COUNT_STAR DESC
LIMIT 20;

2. 등가 → 범위 → 정렬 순서로 컬럼 배치

SQL
-- 쿼리 패턴:
-- WHERE status = ? AND user_id = ? AND created_at > ? ORDER BY created_at

-- 최적 인덱스:
CREATE INDEX idx_optimal ON orders(status, user_id, created_at);
-- 등가(status) → 등가(user_id) → 범위+정렬(created_at)

3. 중복 인덱스 제거

SQL
-- 이 두 인덱스 중 하나는 불필요
CREATE INDEX idx_a ON orders(status);
CREATE INDEX idx_ab ON orders(status, user_id);
-- idx_ab가 idx_a의 역할도 수행하므로 idx_a 삭제 가능

-- 인덱스 사용 현황 확인
SELECT * FROM sys.schema_unused_indexes;
SELECT * FROM sys.schema_redundant_indexes;

4. 인덱스 개수 제한

인덱스가 많으면:

  • INSERT/UPDATE/DELETE 성능 저하 (인덱스도 갱신해야 함)
  • 옵티마이저 선택 시간 증가
  • 디스크 공간 증가

일반적으로 테이블당 5개 이내 가 권장됩니다.

주의할 점

범위 조건 이후의 컬럼은 인덱스 범위 스캔에 사용되지 않는다

인덱스 (a, b, c)에서 WHERE a = 1 AND b > 10 AND c = 5라면, a와 b까지만 인덱스 범위 스캔에 사용되고 c는 필터링만 됩니다. 등가 조건을 앞에, 범위 조건을 뒤에 배치하는 것이 핵심입니다.

카디널리티보다 쿼리 패턴이 우선이다

"카디널리티 높은 컬럼을 앞에"라는 일반 원칙이 있지만, 실제 가장 빈번한 쿼리의 WHERE + ORDER BY 패턴에 맞추는 것이 더 중요합니다. 인덱스는 특정 쿼리를 위해 설계하는 것이기 때문입니다.

인덱스가 너무 많으면 쓰기 성능이 떨어진다

인덱스마다 INSERT/UPDATE/DELETE 시 갱신이 필요합니다. 테이블당 5개 이내가 일반적인 권장이며, sys.schema_unused_indexessys.schema_redundant_indexes로 정리 대상을 찾을 수 있습니다.

정리

항목설명
최좌선 접두사 규칙왼쪽부터 연속된 컬럼만 인덱스 활용 가능
컬럼 배치 원칙등가(=) → 범위(>, <) → 정렬(ORDER BY)
카디널리티높은 컬럼을 앞에 놓되, 쿼리 패턴이 더 중요
인덱스 스킵 스캔선두 컬럼 고유 값이 적을 때만 효과적 (8.0.13+)
ORDER BY/GROUP BY인덱스 순서와 맞추면 filesort/임시 테이블 회피
인덱스 개수테이블당 5개 이내 권장, 중복 인덱스 정리 필수
댓글 로딩 중...