“인덱스 걸었는데 왜 느리죠?”
개발하다 보면 이런 상황을 만난다. 분명히 인덱스를 걸었는데 쿼리가 여전히 느리다. EXPLAIN을 돌려보면 type: ALL이 찍혀 있다. 풀스캔이다.
인덱스를 걸었는데 왜 안 탈까? 이 글에서는 그 이유를 포함해서, 인덱스가 실제로 어떻게 동작하는지 처음부터 짚어본다.
인덱스는 책의 목차다
테이블에서 특정 행을 찾는 방법은 두 가지다. 첫 번째는 처음부터 끝까지 전부 읽는 것(Full Table Scan), 두 번째는 목차를 먼저 보고 해당 페이지로 바로 가는 것(Index Scan)이다.
100만 건짜리 테이블에서 member_id = 123인 주문을 찾는다고 해보자.
-- 인덱스 없음: 100만 건 전부 스캔
SELECT * FROM orders WHERE member_id = 123;
-- 인덱스 있음: B-Tree로 O(log N) 탐색
CREATE INDEX idx_member_id ON orders(member_id);
SELECT * FROM orders WHERE member_id = 123;
인덱스는 조회를 빠르게 하지만 쓰기를 느리게 한다. 행을 삽입·수정·삭제할 때마다 인덱스도 같이 업데이트되기 때문이다. 트레이드오프를 이해하고 써야 한다.
B-Tree — MySQL 인덱스의 기본 자료구조
MySQL InnoDB의 기본 인덱스는 **B-Tree(Balanced Tree)**다.
[ 루트 노드: 50 ]
/ \
[ 내부 노드: 20, 35 ] [ 내부 노드: 65, 80 ]
/ | \ / | \
[리프] [리프] [리프] [리프] [리프] [리프]
10,15 20,25 35,40 50,55 65,70 80,90
리프 노드끼리는 이중 연결 리스트로 연결되어 있다. 덕분에 범위 검색(BETWEEN, >, <)이 효율적이다. 50 이상을 찾으면 50이 있는 리프 노드에서 오른쪽으로 쭉 따라가면 된다.
Clustered Index vs Secondary Index
InnoDB에는 두 가지 인덱스가 있다.
Clustered Index (기본키 인덱스)
→ 리프 노드에 실제 행 데이터 저장
→ 테이블당 1개, 기본키 기준으로 물리적 정렬
Secondary Index (일반 인덱스)
→ 리프 노드에 기본키 값만 저장
→ 조회 시: 인덱스에서 PK 찾기 → PK로 다시 Clustered Index 조회 (2단계)
Secondary Index 조회는 2단계를 거친다. 이 2단계 과정이 “인덱스 랜덤 I/O"다. 후술할 커버링 인덱스가 이 비용을 없애는 방법이다.
복합 인덱스 — 순서가 전부다
여러 컬럼을 하나의 인덱스로 묶을 때, 컬럼 순서가 핵심이다.
CREATE INDEX idx_member_status ON orders(member_id, status);
인덱스는 왼쪽부터 순서대로 사용된다. 이걸 “선두 컬럼 규칙"이라 한다.
-- ✅ member_id + status 둘 다 인덱스 사용
SELECT * FROM orders WHERE member_id = 1 AND status = 'DONE';
-- ✅ member_id만 인덱스 사용 (선두 컬럼)
SELECT * FROM orders WHERE member_id = 1;
-- ❌ 인덱스 사용 불가 (선두 컬럼 없음)
SELECT * FROM orders WHERE status = 'DONE';
컬럼 순서 결정 기준:
- 카디널리티 높은 것 먼저 — 값이 다양한 컬럼.
member_id(수백만 종류)가status(5종류)보다 앞에 - 등호(
=) 먼저, 범위(>,<) 나중 — 범위 조건이 앞에 오면 뒤 컬럼은 인덱스를 못 씀
커버링 인덱스 — 테이블을 아예 안 보는 방법
쿼리에 필요한 모든 컬럼이 인덱스 안에 있으면, 테이블 본체를 읽지 않아도 된다. 이것이 커버링 인덱스다.
CREATE INDEX idx_cover ON orders(member_id, status, amount);
-- 커버링 인덱스 적용
-- SELECT 절의 member_id, status, amount가 모두 인덱스에 있음
SELECT member_id, status, amount
FROM orders
WHERE member_id = 1 AND status = 'DONE';
EXPLAIN에서 Extra: Using index가 보이면 커버링 인덱스가 작동 중이다. 테이블 랜덤 I/O가 없으니 성능이 극적으로 좋아진다.
JPA + QueryDSL을 쓴다면 페이징 COUNT 쿼리에서 커버링 인덱스를 활용할 수 있다. COUNT(*)는 실제 행 데이터가 필요 없으니 인덱스만으로 처리 가능하다.
인덱스가 무력화되는 6가지 패턴
인덱스를 걸었는데도 안 타는 경우가 있다. 실수하기 쉬운 패턴들이다.
-- 1. 컬럼에 함수 적용
WHERE YEAR(created_at) = 2026 -- ❌ 인덱스 무력화
WHERE created_at >= '2026-01-01' -- ✅ 범위로 변환
-- 2. 앞쪽 와일드카드 LIKE
WHERE name LIKE '%홍길동' -- ❌
WHERE name LIKE '홍길동%' -- ✅ 뒤 와일드카드는 인덱스 사용
-- 3. 컬럼이 다른 OR 조건
WHERE member_id = 1 OR status = 'DONE' -- ❌ (보통 풀스캔)
-- → UNION으로 분리하거나 각각 인덱스 조회 후 합치는 방식 검토
-- 4. 데이터 타입 불일치
WHERE member_id = '123' -- ❌ INT 컬럼에 문자열 → 묵시적 형변환
WHERE member_id = 123 -- ✅
-- 5. 부정 조건
WHERE status != 'DONE' -- ❌ 대부분 풀스캔
WHERE status IN ('PENDING', 'PROCESSING') -- ✅ 이 패턴으로 변환
-- 6. 카디널리티 낮은 컬럼
-- status가 'Y'/'N' 두 가지뿐이면 옵티마이저가 인덱스 대신 풀스캔 선택
-- (50% 이상 행을 어차피 읽어야 하니 풀스캔이 더 효율적이라 판단)
인덱스를 걸어야 할 때, 피해야 할 때
✅ 인덱스를 걸어야 하는 경우
- WHERE 절에 자주 등장하는 컬럼
- JOIN 조건으로 쓰이는 컬럼 (FK)
- ORDER BY, GROUP BY에 사용되는 컬럼
- 카디널리티가 높은 컬럼
❌ 인덱스를 피해야 하는 경우
- 카디널리티가 낮은 컬럼 (성별, Y/N 등)
- 자주 변경되는 컬럼 (INSERT/UPDATE/DELETE 비용 증가)
- 테이블 데이터가 아주 적은 경우 (풀스캔이 오히려 빠름)
- 인덱스가 너무 많으면 쓰기 성능 전체 저하
마치며
“쿼리가 느리다"는 증상의 원인 대부분은 인덱스에 있다. 인덱스가 없거나, 있어도 무력화되거나, 복합 인덱스 순서가 잘못됐거나.
느린 쿼리를 만나면 가장 먼저 EXPLAIN을 돌려보자. type: ALL이 보이면 인덱스 문제다. 다음 편에서는 EXPLAIN을 읽는 방법과 트랜잭션·락 동작 원리를 다룬다.