Theme:

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

단일 컬럼 인덱스만으로는 실무의 복잡한 쿼리를 커버하기 어렵습니다. 복합 인덱스(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개 이내가 권장됩니다.

정리

  • 복합 인덱스는 최좌선 접두사 규칙을 따르며, 왼쪽부터 연속된 컬럼만 인덱스를 활용합니다
  • 등가 조건 컬럼을 앞에, 범위 조건 컬럼을 뒤에 배치하는 것이 기본 원칙입니다
  • 카디널리티가 높은 컬럼을 앞에 놓되, 실제 쿼리 패턴이 더 중요합니다
  • 인덱스 스킵 스캔(8.0.13+)은 선두 컬럼 없이도 인덱스를 활용할 수 있지만, 제한적입니다
  • ORDER BY/GROUP BY도 인덱스 순서와 맞추면 filesort를 피할 수 있습니다
  • sys.schema_redundant_indexes로 중복 인덱스를 찾아 제거합니다
댓글 로딩 중...