안녕하세요, 자바파커입니다.
"인덱스 걸었는데 왜 여전히 느리죠?"
인덱스는 "걸면 빨라지는 것"으로 배웁니다. 그런데 실무에서는 걸었는데 안 빨라지는 경우가 꽤 자주 나옵니다. 원리를 모르면 왜 안 먹는지도 알 수 없습니다.
결론부터 말씀드리면 — 인덱스는 "정렬된 사본"입니다. 정렬돼 있으니 이분 탐색처럼 몇 번 만에 도달할 수 있고, 정렬을 활용할 수 없는 형태로 질의하면 그 순간 무용지물이 됩니다. 안 먹는 경우들이 전부 여기서 나옵니다.
숫자로 먼저 보고 싶으시면 1분 15초짜리 영상으로도 정리했습니다. 100만 행에서 100만 번이 3번이 되는 과정과, 걸어도 안 먹는 5가지를 움직이는 그림으로 보여드립니다.
인덱스가 없으면 — 풀 테이블 스캔
100만 행짜리 회원 테이블에서 이메일 하나를 찾는다고 해보겠습니다.
SELECT * FROM users WHERE email = 'javapark@example.com';인덱스가 없으면 DB는 첫 행부터 끝까지 전부 읽어서 비교합니다. 이걸 풀 테이블 스캔이라고 합니다.
찾는 값이 999,999번째에 있으면 999,999번 비교합니다. 운 좋게 3번째에 있어도 DB는 멈추지 않습니다 — 이메일이 유일하다는 보장이 없으니 뒤에 또 있는지 끝까지 확인해야 합니다.
인덱스는 책 뒤의 "찾아보기"
인덱스는 책 뒤에 붙은 찾아보기와 같습니다. 본문을 처음부터 넘기는 대신, 가나다순으로 정렬된 목록에서 단어를 찾고 → 적힌 쪽수로 바로 넘어갑니다.
DB도 똑같습니다. email 컬럼만 정렬해서 따로 저장해 두고, 각 값에 "실제 행이 어디 있는지"를 붙여 둡니다.
| 인덱스 없음 | 인덱스 있음 | |
|---|---|---|
| 방식 | 100만 행 전부 비교 | 정렬된 구조를 3~4번 타고 내려감 |
| 읽는 양 | 테이블 전체 | 필요한 몇 페이지 |
| 100만 행 기준 | 1,000,000회 | 약 3회 |
왜 하필 3~4번인가 — B-Tree
인덱스는 보통 B-Tree(정확히는 B+Tree) 구조로 저장됩니다. 핵심은 한 노드가 자식을 아주 많이 갖는다는 점입니다.
이진 트리는 자식이 2개라 100만 건이면 20단계가 필요합니다. 하지만 B-Tree는 한 노드에 수백~수천 개의 키를 담습니다. 노드 하나가 자식을 1,000개 갖는다면:
- 1단계: 1,000개
- 2단계: 1,000,000개
- 3단계: 1,000,000,000개
3단계면 10억 건에 닿습니다. 100만 행이든 1,000만 행이든 깊이가 거의 안 변합니다. 데이터가 10배로 늘어도 조회 비용은 그대로인 것, 이게 인덱스의 진짜 가치입니다.
디스크는 페이지 단위로 읽기 때문에, 노드 하나가 페이지 하나에 딱 맞게 설계돼 있습니다. 단계 수 = 디스크 읽기 횟수라고 보면 됩니다.
실제로 확인하는 법 — EXPLAIN
추측하지 말고 실행 계획을 보면 됩니다.
EXPLAIN SELECT * FROM users WHERE email = 'javapark@example.com';| type | 의미 |
|---|---|
ALL |
풀 테이블 스캔 — 인덱스를 안 타고 있음 |
index |
인덱스 전체 스캔 (전부 읽음, 그나마 나음) |
range |
범위 스캔 — 정상 |
ref |
비고유 인덱스 일치 — 정상 |
const / eq_ref |
유일 값 한 건 — 가장 빠름 |
type: ALL이 보이면 인덱스가 안 먹고 있는 겁니다. rows 값도 함께 보세요 — DB가 몇 행을 읽을 거라 예상하는지 나옵니다.
인덱스를 만들어도 안 타는 5가지
여기가 실무에서 진짜 문제가 되는 지점입니다. 전부 "정렬을 활용할 수 없게 만들었다"는 하나의 이유로 설명됩니다.
1. 컬럼에 함수를 씌웠을 때
-- ❌ 인덱스 못 탐
SELECT * FROM orders WHERE YEAR(created_at) = 2026;
-- ✅ 범위로 바꾸면 탐
SELECT * FROM orders
WHERE created_at >= '2026-01-01' AND created_at < '2027-01-01';인덱스는 created_at 원본 값을 정렬해 둔 것입니다. YEAR()를 씌운 결과는 정렬돼 있지 않으니, 모든 행에 함수를 적용해봐야 알 수 있습니다.
MySQL 8.0.13+ 와 PostgreSQL은 함수 기반 인덱스를 지원해서
YEAR(created_at)자체에 인덱스를 걸 수도 있습니다. 다만 기본 인덱스로는 안 탄다는 원칙은 같습니다.
2. LIKE 앞에 와일드카드가 있을 때
SELECT * FROM users WHERE name LIKE '김%'; -- ✅ 탐
SELECT * FROM users WHERE name LIKE '%park'; -- ❌ 못 탐사전에서 "김"으로 시작하는 단어는 바로 찾지만, "park으로 끝나는 단어"는 전부 뒤져야 하는 것과 같습니다. 뒷부분 검색이 필요하면 전문 검색(Full-Text) 이나 별도 검색 엔진을 씁니다.
3. 카디널리티가 낮을 때
gender, is_deleted처럼 값 종류가 두세 개뿐인 컬럼은 인덱스를 걸어도 안 씁니다. 절반을 걸러내려고 인덱스를 거쳐 다시 테이블을 찾아가느니 그냥 전부 읽는 게 빠르다고 옵티마이저가 판단합니다.
인덱스는 선택도가 높은 컬럼(값이 다양한 컬럼)에 걸어야 의미가 있습니다.
4. 복합 인덱스의 왼쪽부터 안 썼을 때
-- INDEX (user_id, status, created_at) 가 있을 때
WHERE user_id = 1 -- ✅
WHERE user_id = 1 AND status = 'paid' -- ✅
WHERE status = 'paid' -- ❌ user_id 를 건너뜀복합 인덱스는 user_id로 먼저 정렬하고, 같은 값 안에서 status로 정렬합니다. 전화번호부가 성 → 이름 순인 것과 같아서, 성을 모르고 이름만으로는 찾을 수 없습니다. 이걸 왼쪽 접두사(leftmost prefix) 규칙이라고 합니다.
5. 타입이 안 맞을 때
-- phone 이 VARCHAR 인데 숫자로 비교
SELECT * FROM users WHERE phone = 01012345678; -- ❌ 암묵적 형변환
SELECT * FROM users WHERE phone = '01012345678'; -- ✅타입이 다르면 DB가 컬럼 쪽을 변환하는데, 이건 결국 1번(함수 적용)과 같은 상황이 됩니다. 조용히 느려지기 때문에 찾기가 가장 어려운 유형입니다.
인덱스는 공짜가 아니다
조회만 보면 무조건 이득이니 다 걸고 싶어집니다. 그런데 비용이 있습니다.
| 비용 | 내용 |
|---|---|
| 쓰기 성능 | INSERT·UPDATE·DELETE 때마다 인덱스도 같이 갱신 |
| 저장 공간 | 인덱스는 별도 저장 구조 — 테이블만큼 커지기도 함 |
| 옵티마이저 부담 | 후보가 많아지면 계획 수립이 느려지고 엉뚱한 걸 고르기도 함 |
인덱스가 10개인 테이블은 INSERT 한 번에 11군데를 갱신합니다. 쓰기가 많은 테이블에 인덱스를 남발하면 조회를 얻고 쓰기를 잃습니다.
그래서 언제 만드나
WHERE,JOIN,ORDER BY에 자주 등장하는 컬럼- 값이 다양한 컬럼 (선택도 높음)
- 실제로 느린 쿼리가 확인된 다음에 — 미리 깔지 않습니다
느린 쿼리 로그로 문제를 특정하고 → EXPLAIN으로 원인을 보고 → 인덱스를 만들고 → 다시 EXPLAIN으로 확인하는 순서가 정석입니다.
자주 묻는 질문 (FAQ)
Q. 인덱스를 걸었는데 EXPLAIN에 여전히 ALL이 나옵니다.
위 5가지를 먼저 확인하세요. 특히 함수 적용과 타입 불일치가 흔합니다. 그다음으로는 테이블이 너무 작은 경우입니다 — 수백 행짜리 테이블은 인덱스를 거치는 것보다 통째로 읽는 게 빨라서 옵티마이저가 일부러 무시합니다. 정상 동작입니다.
Q. 복합 인덱스 여러 개 vs 단일 인덱스 여러 개, 뭐가 낫나요?
함께 쓰이는 조건이면 복합 인덱스 하나가 낫습니다. (user_id, status) 복합 인덱스는 user_id 단독 조회도 커버하므로, user_id 단일 인덱스를 따로 둘 필요가 없습니다. 컬럼 순서는 선택도가 높은 것을 앞에 두는 게 기본입니다.
Q. PK에는 인덱스를 따로 안 걸어도 되나요?
네. Primary Key에는 자동으로 인덱스가 생깁니다. MySQL InnoDB는 PK를 기준으로 데이터 자체를 정렬해 저장하는데(클러스터형 인덱스), 그래서 PK 조회가 가장 빠릅니다. 반대로 PK를 UUID처럼 무작위 값으로 잡으면 삽입 위치가 매번 흩어져 쓰기 성능이 떨어집니다.
정리
- 인덱스는 정렬된 사본이고, B-Tree라서 데이터가 늘어도 깊이가 거의 안 변합니다
- 100만 행에서 1,000,000회 비교가 3회 남짓으로 줄어듭니다
- 정렬을 못 쓰게 만들면 안 탑니다 — 함수, 앞 와일드카드, 낮은 카디널리티, 왼쪽 접두사 위반, 타입 불일치
- 조회를 얻고 쓰기를 내주는 거래입니다. 느린 쿼리를 확인한 다음에 만드세요
인덱스를 걸었는데 안 빨라졌던 경험, 원인이 뭐였나요? 댓글로 공유해 주세요.