1. 복합 인덱스
- 지금까지 우리는 하나의 컬럼으로 구성된 **단일 인덱스(Single-column Index)**에 대해 주로 알아보았습니다.
- Index 문서를 참고해주세요.
- Index Optimizer 문서를 참고해주세요.
- 하지만 실제 쇼핑몰 운영 환경에서는 여러 조건을 조합해서 데이터를 검색하는 경우가 훨씬 더 많습니다.
- 예를 들어, "카테고리가 '전자기기'인 상품들 중에 가격이 100,000원 이상인 상품을 보여줘"와 같은 요구사항은 매우 흔합니다.
SELECT * FROM items
WHERE category = '전자기기' AND price >= 100000;
- 이런 다중 조건 쿼리의 성능을 최적화하기 위해 사용하는 것이 바로 복합 인덱스(Composite Index) 또는 **다중 컬럼 인덱스(Multi-column Index)**입니다.
- 복합 인덱스는 이름 그대로 두 개 이상의 컬럼을 묶어서 하나의 인덱스로 만드는 것입니다.
- Index Optimizer 문서에서
(price, item_name)인덱스를 만들었던 것을 기억하시나요? 그것이 바로 복합 인덱스입니다.
1.1 왜 컬럼 순서가 중요할까?
- 하지만 복합 인덱스를 제대로 사용하려면 한 가지 매우 중요한 규칙을 이해해야 합니다.
- 바로 **'컬럼의 순서'**입니다.
- 인덱스를 어떤 컬럼 순서로 만드느냐에 따라 쿼리 성능이 하늘과 땅 차이로 달라질 수 있습니다.
복합 인덱스의 동작 원리
- 복합 인덱스의 동작 원리는 우리가 실생활에서 사용하는 '전화번호부'나 '국어사전'과 똑같습니다.
- 전화번호부: '성(Last Name)'으로 먼저 정렬된 후, 같은 성 안에서 '이름(First Name)'으로 다시 정렬됩니다.
- 국어사전: '첫 번째 글자'로 먼저 정렬된 후, 같은 첫 글자로 시작하는 단어들끼리 '두 번째 글자'로 다시 정렬됩니다.
items테이블에(category, price)순서로 복합 인덱스를 만들었다고 상상해 보겠습니다.- 이 인덱스는 내부적으로 다음과 같이 정렬됩니다.
category를 기준으로 먼저 정렬합니다. ('도서', '생활용품', '전자기기', '패션', '헬스/뷰티' 순서)- 같은
category내에서는price를 기준으로 다시 정렬합니다.
idx_items_category_price 인덱스 예시
| category | price | item_id |
|---|---|---|
| 도서 | 18000 | 21 |
| 도서 | 22000 | 25 |
| 도서 | 28000 | 15 |
| 도서 | 30000 | 4 |
| 도서 | 35000 | 11 |
| 생활용품 | 5000 | 16 |
| 생활용품 | 15000 | 5 |
| 생활용품 | 40000 | 9 |
| 생활용품 | 60000 | 19 |
| 전자기기 | 80000 | 3 |
| 전자기기 | 90000 | 14 |
| 전자기기 | 120000 | 1 |
| 전자기기 | 200000 | 24 |
| 전자기기 | 250000 | 7 |
| 전자기기 | 300000 | 13 |
| 전자기기 | 350000 | 23 |
| 전자기기 | 450000 | 2 |
| 전자기기 | 800000 | 20 |
| 전자기기 | 1500000 | 10 |
| 패션 | 45000 | 18 |
| 패션 | 70000 | 6 |
| 패션 | 95000 | 8 |
| 패션 | 180000 | 17 |
| 헬스/뷰티 | 20000 | 12 |
| 헬스/뷰티 | 25000 | 22 |
- 이 구조를 보면 왜 컬럼 순서가 중요한지 감이 올 것입니다.
검색 효율성 비교
category로 검색: 매우 효율적입니다. 인덱스의 앞부분만 보고 빠르게 찾을 수 있습니다. (예: '전자기기' 섹션으로 바로 점프)category와price로 검색: 역시 매우 효율적입니다. '전자기기' 섹션을 찾은 뒤, 그 안에서price순으로 정렬된 데이터를 탐색하면 됩니다.price만으로 검색: 매우 비효율적입니다. 전화번호부에서 성은 모르고 이름만으로 찾는 것과 같습니다.price값은 각category섹션마다 흩어져 있기 때문에, 인덱스 전체를 다 훑어봐야 합니다. 이런 경우 옵티마이저는 차라리 풀 테이블 스캔을 선택할 수도 있습니다.
각각의 카테고리 안에서는 price가 항상 정렬된 상태를 유지합니다. 1차 정렬의 기준 안에서 2차 정렬은 항상 정렬된 상태를 유지합니다.
하지만 price만으로 검색하면 왜 인덱스를 사용하기 어려운지 자세히 알아보겠습니다.
idx_items_category_price 인덱스 예시에서 price 컬럼만 딱 분류해서 보면, 가격순으로 정렬이 되어 있는 것 같아 보이지만 중간에 정렬이 다시 틀어집니다. 결과적으로 price 컬럼만 보면 정렬이 되어 있지 않습니다. 결과적으로 1차 정렬의 기준 없이 2차 정렬의 값만 가지고는 정렬된 상태를 유지할 수 없습니다.
1.2 인덱스 왼쪽 접두어 규칙
- 이처럼 복합 인덱스는 첫 번째 컬럼을 기준으로 정렬된 상태에서만 제 역할을 할 수 있습니다.
- 이를 **인덱스 왼쪽 접두어 규칙(Index Left-Prefix Rule)**이라고 합니다.
- 인덱스를
(A, B, C)순서로 생성했다면,WHERE조건에 다음과 같이 사용될 때 효율적입니다.(A)(A, B)(A, B, C)- 하지만
(B),(C),(B, C)와 같이 첫 번째 기준인A가 빠진 조건으로는 인덱스를 제대로 활용할 수 없습니다.
2. 복합 인덱스 대원칙
- 복합 인덱스를 설계하고 사용할 때는 다음 세 가지 대원칙을 반드시 기억해야 합니다.
- 인덱스는 순서대로 사용하라! (왼쪽 접두어 규칙)
- 등호(=) 조건은 앞으로, 범위 조건(
<,>)은 뒤로! - 정렬(ORDER BY)도 인덱스 순서를 따르라!
- 복합 인덱스의 대원칙을 순서대로 하나씩 배워보겠습니다.
3. 복합 인덱스 준비 과정
SHOW INDEX FROM items;
- 실행 결과
idx로 시작하는 모든 인덱스를 제거하겠습니다. - 인덱스는 아쉽게도
IF EXISTS구문이 없습니다. 하나하나를 직접 제거해야 합니다.
DROP INDEX idx_items_item_name ON items;
DROP INDEX idx_items_price_name ON items;
DROP INDEX idx_items_price ON items;
DROP INDEX idx_items_price_category_temp ON items;
DROP INDEX idx_items_category_price ON items;
- 이전에 만들어진
idx로 시작하는 인덱스는 모두 제거해야 합니다. - 실습을 위해
(category, price)순서로 복합 인덱스를 생성 하겠습니다.
CREATE INDEX idx_items_category_price ON items (category, price);
- 만들어진 인덱스를 최종 확인하겠습니다.
SHOW INDEX FROM items;
- 결과는 반드시 다음과 같이
idx로 시작하는 인덱스가idx_items_category_price하나만 추가되어야 합니다.
| Table | Non_unique | Key_name | Seq_in_index | Column_name | Cardinality |
|---|---|---|---|---|---|
| items | 0 | PRIMARY | 1 | item_id | 25 |
| items | 1 | fk_items_sellers | 1 | seller_id | 10 |
| items | 1 | idx_items_category_price | 1 | category | 5 |
| items | 1 | idx_items_category_price | 2 | price | 25 |
idx_items_category_price는 하나의 복합 인덱스입니다.Seq_in_index는 복합 인덱스의 컬럼 순서를 나타냅니다.idx_items_category_price의 경우 1번은category, 2번은price순서로 만들어진 것을 확인할 수 있습니다.
4. 복합 인덱스 성공 예제1: category 사용
- 복합 인덱스의 첫 번째 컬럼만
WHERE절에 사용하는 경우입니다. - 이는 인덱스 왼쪽 접두어 규칙을 가장 잘 활용하는 기본적인 예시입니다.
- "카테고리가 '전자기기'인 모든 상품을 찾아보자."
EXPLAIN SELECT * FROM items WHERE category = '전자기기';
실행 결과
| id | type | key | rows | filtered | Extra |
|---|---|---|---|---|---|
| 1 | ref | idx_items_category_price | 10 | 100.00 | NULL |
- 실행 계획을 분석해 보겠습니다.
type: ref
category가 '전자기기'인 조건을 만족하는 데이터를 찾기 위해 동등 비교(=)나JOIN을 사용하고, 인덱스를 효율적으로 사용했음을 의미합니다.- 쉽게 이야기해서 Key 값 하나를 딱 집어서 찾은 것입니다.
key: idx_items_category_price
- 우리가 만든 복합 인덱스가 사용되었습니다.
rows: 10
- 옵티마이저는 '전자기기' 카테고리에 해당하는 상품이 약 10건 있을 것으로 정확히 예측하고, 그 부분만 탐색합니다.
- 이는
(category, price)로 정렬된 인덱스에서category가 '전자기기'인 첫 번째 위치를 빠르게 찾아낸 뒤, '전자기기'가 끝날 때까지 인덱스를 순차적으로 읽기만 하면 되므로 매우 효율적입니다. - 전화번호부에서 '김'씨를 찾는 것과 같이, 'ㄱ' 섹션으로 바로 이동해서 '김'씨가 끝날 때까지 읽는 것과 같습니다.
5. 복합 인덱스 성공 예제2: category, price 함께 사용
- 이번에는 복합 인덱스를 구성하는 모든 컬럼을
WHERE절에 사용하는 경우입니다. - 인덱스의 필터링 능력을 최대로 활용하는 상황입니다.
- "카테고리가 '전자기기'이면서, 가격이 정확히 120,000원인 상품을 찾아보자."
EXPLAIN SELECT * FROM items WHERE category = '전자기기' AND price = 120000;
실행 결과
| id | type | key | rows | filtered | Extra |
|---|---|---|---|---|---|
| 1 | ref | idx_items_category_price | 1 | 100.00 | NULL |
- 실행 계획은 훨씬 더 효율적으로 보입니다.
type: ref
- 두 개의 컬럼 조 건(
category,price)을 모두 만족하는 데이터를 찾기 위해 인덱스를 사용했습니다.
rows: 1
-
탐색할 행의 수가 단
1개로 예측됩니다. -
데이터베이스는 먼저
category가 '전자기기'인 섹션을 찾고, 그 안에서price가120000인 지점을 탐색합니다. -
'전자기기' 섹션 내부는 이미
price순으로 정렬되어 있으므로, 원하는 데이터를 매우 빠르게 특정할 수 있습니다. -
이는 전화번호부에서 '김수로'라는 사람을 찾는 것과 같습니다. '김'씨 섹션을 찾고, 그 안에서 '수로'라는 이름을 찾아내는 과정은 매우 빠릅니다.
-
이처럼 인덱스를 구성하는 모든 컬럼을 조건으로 사용하면 가장 효과적으로 데이터를 필터링할 수 있습니다.
6. 복합 인덱스 성공 예제3: 복합 인덱스와 정렬
- 복합 인덱스의 진정한 힘은 정렬(
ORDER BY) 작업을 피할 때 드러납니다. WHERE절의 필터링과ORDER BY의 정렬 방향이 인덱스 순서와 일치하면, 데이터베이스는 불필요한filesort작업을 생략해서 성능을 크게 향상시킬 수 있습니다.- "카테고리가 '전자기기'이면서 100,000원 초과인 상품을 가격 오름차순으로 정렬해서 보여줘."
EXPLAIN SELECT * FROM items
WHERE category = '전자기기' AND price > 100000
ORDER BY price;
실행 결과
| id | type | key | rows | filtered | Extra |
|---|---|---|---|---|---|
| 1 | range | idx_items_category_price | 8 | 100.00 | Using index condition |
- 가장 주목해야 할 부분은
Extra컬럼에Using filesort가 없다는 점입니다. - 이것이 어떻게 가능할까요?
데이터베이스의 동작 과정
idx_items_category_price인덱스를 사용해category가 '전자기기'인 섹션으로 빠르게 이동합니다.- 해당 섹션 내에서,
price가100000을 초과하는 첫 번째 데이터를 찾습니다. - 그 지점부터 '전자기기' 섹션이 끝날 때까지 인덱스를 순서대로 읽기만 하면 됩니다.
- 왜냐하면 인덱스의 '전자기기' 섹션은 이미
price순서로 완벽하게 정렬되어 있기 때문입니 다. - 따라서 데이터베이스는 별도로 데이터를 모아 다시 정렬할 필요 없이, 인덱스를 읽는 즉시
ORDER BY price조건을 만족하는 결과를 얻게 됩니다.
이처럼 WHERE 절의 조건과 ORDER BY 절의 조건이 복합 인덱스의 순서(category → price)와 일치하면, 데이터베이스는 가장 효율적인 방식으로 데이터를 찾고 정렬까지 한 번에 처리합니다. filesort를 피하는 것이야말로 복합 인덱스를 사용하는 핵심적인 이유 중 하나입니다.
- 쉽게 이야기하면
ORDER BY를 사용할 때 복합 인덱스의 순서대로 정렬하면 추가적인 정렬(filesort)을 피할 수 있다는 것입니다.
EXPLAIN SELECT * FROM items
WHERE category = '전자기기' AND price > 100000
ORDER BY category, price; -- category, price 순서로 정렬
- 여기서 복합 인덱스가
category, price순서이므로ORDER BY도 이 순서에 맞추어 사용하면 정렬을 최적화할 수 있습니다. - 그런데 여기서 선택된
category는 '전자기기' 단 하나이므로 이런 경우에는category를 정렬 조건에 넣을 필요가 없습니다. - 정렬은 최소 2개 이상 있을 때 의미가 있습니다.
- 이런 경우에는
ORDER BY에category를 두는 것이 의미가 없으므로 다음과 같이 생략합니다.
EXPLAIN SELECT * FROM items
WHERE category = '전자기기' AND price > 100000
ORDER BY price;
- 당연한 이야기지만 만약
ORDER BY item_name처럼 인덱스 순서와 다른 컬럼으로 정렬을 요청했다면, 조회한 결과를 다시 정렬해야 하기 때문에 Extra 컬럼에Using filesort가 발생할 것입니다.
EXPLAIN SELECT * FROM items
WHERE category = '전자기기' AND price > 100000
ORDER BY item_name;
실행 결과
| id | type | key | rows | filtered | Extra |
|---|---|---|---|---|---|
| 1 | range | idx_items_category_price | 8 | 100.00 | Using index condition; Using filesort |
7. 복합 인덱스 실패 예제1: 인덱스 순서 무시
- 이제부터 중요한 문제 상황입니다.
- 복합 인덱스의 첫 번째 컬럼(
category)을 건너뛰고, 두 번째 컬럼(price)만으로 데이터를 검색하면 어떻게 될까요? - "카테고리와 상관없이 가격이 80,000원인 상품을 찾아보자."
EXPLAIN SELECT * FROM items WHERE price = 80000;
실행 결과
| id | type | key | rows | filtered | Extra |
|---|---|---|---|---|---|
| 1 | ALL | NULL | 25 | 10.00 | Using where |
- 실행 계획은 처참합니다.
type: ALL
- 풀 테이블 스캔이 발생했습니다.
key: NULL
idx_items_category_price인덱스가 있음에도 불구하고, 옵티마이저는 이 인덱스를 사용하지 않았습니다.
인덱스 왼쪽 접두어 규칙 때문입니다.
idx_items_category_price 인덱스는 category로 먼저 정렬되어 있습니다. price가 80000인 상품은 '전자기기' 카테고리에도 있을 수 있고, 만약 있다면 '패션' 카테고리에도 있을 수 있습니다. 즉, price 값은 인덱스 전체에 흩어져 있습니다.
데이터베이스 입장에서 price만으로 데이터를 찾으려면, '도서' 카테고리 섹션을 처음부터 끝까지 다 보고, '생활용품' 섹션도 다 보고, '전자기기', '패션' 등 모든 카테고리 섹션을 다 뒤져봐야 합니다. 이는 인덱스 전체를 스캔하는 작업으로, 차라리 원본 테이블 전체를 스캔하는 것보다 나을 것이 없습니다.
전화번호부에서 성은 모르고 이름이 '수로'인 사람을 찾으려면, 가나다순으로 모든 페이지를 넘겨봐야 하는 것과 같은 이치입니다.
- 이처럼 복합 인덱스는 선행 컬럼 조건 없이는 제 역할을 하지 못합니다.
- 이 규칙을 반드시 기억해야 합니다.
8. 복합 인덱스 실패 예제2: 범위 조건을 먼저 사용
- 복합 인덱스 활용에는 한 가지 더 중요한 제약 조건이 있습니다.
- 바로 선행 컬럼에 범위 조건(
>,<,BETWEEN,LIKE %)이 사용되면, 그 뒤에 오는 컬럼은 인덱스를 제대로 활용할 수 없다는 점입니다. - "카테고리명이 '패션' 이상인 상품들 중에서, 가격이 정확히 20,000원인 상품을 찾아줘."
- 문자열 정렬 순서로 보면 카테고리명이 패션 이상인 것은 '패션', '헬스/뷰티'입니다.
EXPLAIN SELECT * FROM items WHERE category >= '패션' AND price = 20000;
실행 결과
| id | type | key | rows | filtered | Extra |
|---|---|---|---|---|---|
| 1 | range | idx_items_category_price | 6 | 10.00 | Using index condition |
- 실행 계획을 자세히 분석해 보겠습니다.
type이range이고,key에idx_items_category_price가 사용되었으니 언뜻 보기에는 인덱스를 잘 사용한 것처럼 보입니다.- 하지만 여기서 주목해야 할 것은
filtered컬럼의 값인10.00%와Extra컬럼의Using index condition입니다.
데이터베이스의 동작 과정
idx_items_category_price인덱스를 사용해category가 '패션'인 위치를 찾습니다. (>=조건의 시작점)- 거기서부터 인덱스의 끝까지 모든 데이터를 스캔합니다. ('패션', '헬스/뷰티' 카테고리에 해당하는 모든 데이터)
- 스캔하는 각 레코드마다
price = 20000조건을 만족하는지 하나하나 검사합니다. (인덱스 사용이 아니라 직접 필터링 합니다.)
- 이것이 바로
filtered: 10.00%의 의미입니다. - 옵티마이저는
category >= '패션'조건으로 약 6개의 행을 찾을 것으로 예상하고(rows: 6), 그 중에서price = 20000조건을 만족하는 데이터는 10% 정도일 것이라고 예측합니다. - 즉,
price컬럼은 데이터를 효율적으로 '찾는(seek)' 데 사용된 것이 아니라, 일단category조건으로 넓게 가져온 데이터를 '걸러내는(filter)' 데만 사용된 것입니다.
왜 이런 문제가 발생할까?
- 데이터베이스는
category >= '패션'조건에 따라 인덱스에서 '패션'과 '헬스/뷰티' 섹션을 찾았습니다. - 하지만 그 다음 조건인
price = 20000을 처리할 때 문제가 생깁니다.
패션 섹션의 price
| price | item_id |
|---|---|
| 45000 | 18 |
| 70000 | 6 |
| 95000 | 8 |
| 180000 | 17 |
헬스/뷰티 섹션의 price
| price | item_id |
|---|---|
| 20000 | 12 |
| 25000 | 22 |
패션 + 헬스/뷰티의 price (합쳐진 상태)
| price | item_id |
|---|---|
| 45000 | 18 |
| 70000 | 6 |
| 95000 | 8 |
| 180000 | 17 |
| 20000 | 12 |
| 25000 | 22 |
- '패션' 섹션은
price순으로 정렬되어 있습니다. - '헬스/뷰티' 섹션도
price순으로 정렬되어 있습니다. - 하지만 '패션' 섹션의 가격들과 '헬스/뷰티' 섹션의 가격들이 전체적으로 정렬된 것은 아닙니다.
- '패션'의 마지막 상품 가격이 '헬스/뷰티'의 첫 상품 가격보다 높을 수 있습니다.
- 합쳐진 가격은 이제 정렬된 상태가 아닙니다! 인덱스를 활용하려면 정렬된 상태여야 합니다!
- 따라서 데이터베이스는
category >= '패션'범위를 스캔하면서 가져온 각각의 데이터에 대해price = 20000인지 일일이 확인하는 추가 작업을 해야만 합니다.
이처럼 복합 인덱스에서 앞선 컬럼(category)에 범위 조건(>=)이 걸리는 순간, 데이터베이스는 더 이상 뒤따라오는 컬럼(price)의 정렬 순서를 활용할 수 없게 됩니다. category가 '패션'일 때의 price 정렬과 category가 '헬스/뷰티'일 때의 price 정렬은 둘을 합치는 순간 정렬이 깨지기 때문입니다.
따라서 데이터베이스는 category >= '패션'이라는 범위 조건만 사용해서 인덱스를 스캔하고, 나머지 price = 20000 조건은 빠르게 찾지 못하고, 필터링만 수행하는 것입니다. 이는 인덱스의 성능을 절반만 활용하는 셈입니다.
- 이처럼 복합 인덱스에서 어떤 컬럼에 범위 검색을 사용하는 순간, 그 뒤에 오는 컬럼들은 인덱스의 정렬 효과를 제대로 누릴 수 없게 됩니다.
- 따라서 인덱스를 설계할 때는
=조건으로 사용될 컬럼을 범위 조건으로 사용될 컬럼보다 앞에 배치하는 것이 일반적인 최적화 전략입니다.
9. 범위 검색은 마지막에 한 번만 사용!
- 이러한 제약 때문에 복합 인덱스를 설계할 때는 다음과 같은 순서를 따르는 것이 매우 중요합니다.
- 등호(=) 조건을 사용하는 컬럼을 앞에, 범위 조건을 사용하는 컬럼을 뒤에 둡니다.
- 예를 들어, 앞서 사용한 쿼리가 자주 사용된다면,
SELECT * FROM items
WHERE category >= '패션' AND price = 20000;
- 최적의 인덱스는 순서를 변경해서
(price, category)순서를 사용해야 합니다. - 왜냐하면
price = 20000이라는 등호(=) 조건을 먼저 처리해서 검색 대상을 크게 줄일 수 있기 때문입니다. - 이번에는
price,category순서의 인덱스를 추가해보겠습니다.
CREATE INDEX idx_items_price_category_temp ON items (price, category);
- 이번 예제에서만 임시로 사용할 예정입니다. 따라서 끝에
temp라는 이름을 붙여두었습니다.
EXPLAIN SELECT * FROM items
WHERE category >= '패션' AND price = 20000;