#7. 인덱스를 타도 느린 이유
레인지 스캔과 Random I/O의 비용 이해
# 인덱스 레인지 스캔과 Random I/O
> 📘 **학습자료 7편 / 7편** | 연결 퀴즈: [**인덱스 활용1 풀러가기**](quiz.html?set=C)
> "인덱스를 탔는데도 왜 느릴 수 있을까?"
> 보이지 않는 비용 — Random I/O를 이해하면, 옵티마이저가 왜 종종 인덱스를 포기하는지 알게 됩니다.
> 📚 **이전 편**: [6편 - 인덱스가 통하는 쿼리, 안 통하는 쿼리]
> 6편에서 어떤 쿼리가 인덱스를 타는지를 봤다면, 이번 편은 **인덱스를 타도 비용이 있다**는 한 단계 깊은 이야기입니다.
## 이 자료를 다 읽으면 알게 되는 것
- **인덱스 레인지 스캔**의 동작 단계 (탐색 → 스캔 → 데이터 접근)
- 어떤 연산자가 레인지 스캔을 유발하는지
- 레인지 스캔의 진짜 비용 — **Random I/O**
- 옵티마이저가 **인덱스 사용을 포기하는 임계점**(흔히 말하는 "30% 룰")
---
## 📑 목차
- [1. 인덱스 레인지 스캔이란](#1-인덱스-레인지-스캔이란)
- [2. 언제 레인지 스캔이 쓰이는가](#2-언제-레인지-스캔이-쓰이는가)
- [3. 구체적인 동작 방식 (3단계)](#3-구체적인-동작-방식-3단계)
- [⭐ 4. 숨은 비용: Random I/O](#4-숨은-비용-random-io)
- [5. 옵티마이저의 판단: 30% 임계점](#5-옵티마이저의-판단-30-임계점)
- [핵심 요약](#핵심-요약)
---
## 1. 인덱스 레인지 스캔이란
다음과 같은 범위 검색 쿼리를 봅시다.
```sql
SELECT * FROM employees WHERE first_name BETWEEN 'Ebbe' AND 'Gad';
```
이름이 알파벳 순으로 `'Ebbe'`와 `'Gad'` 사이인 직원을 찾는 쿼리입니다.
**인덱스 레인지 스캔(Index Range Scan)** 이란 B+Tree 인덱스에서 **일정 범위에 해당하는 값만 탐색**하는 동작입니다. 즉, 인덱스의 처음부터 끝까지 다 보는 게 아니라 **조건에 지정된 '범위'에 대한 인덱스만 순회**합니다.
EXPLAIN 결과:
```sql
EXPLAIN SELECT * FROM employees WHERE first_name BETWEEN 'Ebbe' AND 'Gad';
```
| id | select_type | table | type | key | rows | Extra |
|----|-------------|-------|------|-----|------|-------|
| 1 | SIMPLE | employees | **range** | ix_firstname | 27714 | Using index condition |
`type = range`. 이게 인덱스 레인지 스캔이 일어났다는 신호입니다.
> 💡 **`type` 값 위계 다시 보기 (6편 참고)**
> ```
> const > eq_ref > ref > range > index > ALL
> ```
> `range`는 중간 등급입니다. `ref`(동등조건)보다 보통 비용이 더 들고, `ALL`(풀 스캔)보단 훨씬 빠릅니다.
> 다만 뒤에서 보겠지만, 항상 그런 건 아닙니다.
---
## 2. 언제 레인지 스캔이 쓰이는가
다음과 같은 범위 조건이 있을 때 옵티마이저는 레인지 스캔을 시도합니다.
| 연산자 | 예시 |
|---|---|
| `>`, `<`, `>=`, `<=` | `WHERE age > 30` |
| `BETWEEN` | `WHERE salary BETWEEN 5000 AND 8000` |
| `LIKE 'abc%'` | `WHERE name LIKE 'Kim%'` (접두사만 가능, 6편 참고) |
| `IN` | `WHERE id IN (1, 2, 3)` (내부적으로 여러 `=`을 union) |
이 연산자들이 공통으로 가진 특징은 **"인덱스의 정렬 순서를 활용할 수 있다"** 는 점입니다. 시작점을 찾고 나면 옆으로 쭉 읽기만 하면 되니까요. (B+Tree 리프 노드의 양방향 연결 — 2편 참고)
---
## 3. 구체적인 동작 방식 (3단계)
레인지 스캔은 명시적으로 **3단계**를 거칩니다.
1. **인덱스 탐색**: 인덱스에서 조건을 만족하는 값이 저장된 위치(시작점)를 찾는다
2. **인덱스 스캔**: 시작점부터 필요한 만큼 인덱스를 차례대로 쭉 읽는다
3. **데이터 접근**: 읽어 들인 인덱스 키와 클러스터링 키를 활용해, 한 건 한 건 **랜덤 I/O**로 실제 레코드를 가져온다
위 예시 쿼리(`first_name BETWEEN 'Ebbe' AND 'Gad'`)에 대입해보면:

1. **인덱스 탐색**: B+Tree에서 `first_name = 'Ebbe'`가 처음 나오는 리프 노드 위치를 찾는다
2. **인덱스 스캔**: 'Ebbe'부터 'Gad'까지 리프 노드를 옆으로 쭉 따라가며 읽는다
3. **데이터 접근**: 각 리프 항목의 클러스터링 키로 실제 데이터 페이지를 한 건씩 찾아간다
이 3단계 중 **3번째**가 이번 편의 핵심입니다. "한 건 한 건 랜덤 I/O" — 이게 무슨 뜻인지, 왜 문제가 되는지 다음 섹션에서 깊이 다룹니다.
> 💡 여기까지 읽었다면 레인지 스캔의 구조를 충분히 이해한 것입니다. [**퀴즈로 확인해보세요!**](quiz.html?set=C)
---
## 4. 숨은 비용: Random I/O
> ⭐ **이 자료에서 가장 중요한 섹션입니다.** 이 한 가지를 이해하면 옵티마이저의 결정을 비로소 납득할 수 있습니다.
### Random I/O vs Sequential I/O
먼저 짚고 갈 두 용어:
- **Sequential I/O (순차 I/O)**: 디스크에서 **연속된 블록을 차례대로** 읽음. 빠름.
- **Random I/O (랜덤 I/O)**: 디스크에서 **흩어진 블록을 띄엄띄엄** 읽음. 느림.
비유하자면, 책을 1장부터 100장까지 차례로 읽는 것 vs. 색인을 보고 1장, 47장, 89장, 12장... 이렇게 띄엄띄엄 펼쳐 읽는 것의 차이입니다. 같은 페이지 수를 읽어도 후자가 훨씬 오래 걸립니다.
### 보조 인덱스 레인지 스캔의 함정
보조 인덱스로 레인지 스캔을 할 때:
- **인덱스 부분**(2단계까지)은 정렬되어 있으니 비교적 순차적으로 읽을 수 있음
- 그런데 **실제 데이터 부분**(3단계)은 **흩어져 있음**
왜 흩어져 있을까요? 보조 인덱스의 리프 노드에는 **인덱스 키와 클러스터링 키만** 들어있습니다 (3편 참고). 실제 데이터를 가져오려면 **클러스터링 인덱스를 한 번 더 탐색**해야 합니다. 그리고 이때:
- 보조 인덱스에서는 `'Ebbe'`, `'Edward'`, `'Felix'`... 순서로 정렬되어 있지만
- 이들의 클러스터링 키(보통 PK)는 `1247`, `89`, `5302`... 처럼 **제멋대로**
- → 실제 데이터 페이지에 접근할 때 **매번 다른 위치로 점프**
- → **Random I/O 발생**
### 흔한 오해 정리
그럼 보조 인덱스 접근 과정에서 가장 자주 오해하는 부분들을 정리해두겠습니다.
> ❌ **"레인지 스캔은 항상 디스크의 연속적인 블록만 읽기 때문에 I/O 병목이 없다"**
> 인덱스 자체는 어느 정도 순차적이지만, **실제 데이터는 디스크에 흩어져 있을 수 있습니다.**
> 보조 인덱스를 거쳐 실제 데이터에 접근할 때 Random I/O가 발생합니다.
> ❌ **"레인지 스캔은 모든 테이블 데이터를 정렬한 후 메모리에 적재하므로 I/O가 발생하지 않는다"**
> 레인지 스캔은 **조건에 맞는 일부 인덱스 범위만** 조회합니다.
> 모든 데이터를 메모리에 올리지 않으며, 필요한 블록이 버퍼풀에 없으면 디스크 I/O가 발생합니다.
> ❌ **"InnoDB는 클러스터형 인덱스를 사용하지 않기 때문에 레인지 스캔은 의미가 없다"**
> 사실은 정반대입니다. **InnoDB는 클러스터링 인덱스를 기본으로** 사용합니다 (3편 참고).
> 클러스터링 인덱스 자체에 대한 레인지 스캔(예: PK 범위 검색)은 리프 노드에 데이터가 있어 매우 빠릅니다.
> 보조 인덱스 레인지 스캔에서만 두 번째 탐색 비용이 추가됩니다.
> ✅ **정확한 이해: "보조 인덱스 레인지 스캔 후, 각 레코드의 실제 데이터 접근에서 Random I/O가 발생할 수 있다"**
---
## 5. 옵티마이저의 판단: 30% 임계점
레인지 스캔에서 결과로 가져올 행이 너무 많으면 어떻게 될까요?
- 1000개 행 → 1000번의 Random I/O
- 100,000개 행 → 100,000번의 Random I/O
이쯤 되면 **인덱스를 안 쓰고 처음부터 데이터를 순차로 다 읽는 게 (풀 스캔)** 더 빠를 수도 있습니다. 같은 100,000개를 Random I/O로 띄엄띄엄 읽는 것보다, Sequential I/O로 한 번에 쓱 읽는 게 빠르니까요.
### 옵티마이저의 결정
MySQL 옵티마이저는 이 비용을 계산합니다. 일반적으로 통용되는 경험칙은:
> **레인지 스캔으로 가져올 결과가 전체 데이터의 약 30%를 넘기면, 옵티마이저는 인덱스를 무시하고 풀 테이블 스캔을 선택할 수 있다.**
정확한 임계값은 데이터 크기, 인덱스 선택도, 통계 정보, 버퍼풀 상태 등 여러 요인에 따라 달라집니다. **30%는 절대값이 아니라 가이드라인**이라고 이해하면 좋습니다.
---
## 핵심 요약
이번 편에서 꼭 가져가야 할 한 가지:
> 🎯 **인덱스 레인지 스캔의 진짜 비용은 Random I/O**
> 보조 인덱스로 범위를 좁혀도, 실제 데이터에 접근할 때 매 행마다 흩어진 위치로 점프하는 비용이 발생합니다.
> 이 비용이 충분히 커지면 옵티마이저는 인덱스를 포기하고 풀 스캔을 선택합니다.
체크리스트:
- [x] 인덱스 레인지 스캔의 **3단계 동작**(탐색 → 스캔 → 데이터 접근)을 설명할 수 있다
- [x] `type = range`가 EXPLAIN에서 무엇을 의미하는지 안다
- [ ] **Random I/O와 Sequential I/O**의 차이를 안다
- [ ] 보조 인덱스 레인지 스캔에서 **왜 Random I/O가 발생하는지** 설명할 수 있다 (클러스터링 키 재탐색)
- [ ] 옵티마이저가 **인덱스를 의도적으로 무시**하는 경우가 있다는 것, 그리고 그 이유(30% 임계점 등)를 안다
> 📝 체크리스트를 다 채울 자신이 있다면? [**인덱스 활용1 퀴즈 도전하기**](quiz.html?set=C)
---
## 이제 퀴즈에 도전하기
2편에 걸쳐 인덱스 활용1의 모든 개념을 다뤘습니다.
- Left-Most 원칙과 LIKE 패턴 — 인덱스가 통하는 쿼리 (6편)
- 동등조건 vs 비동등조건 — `type` 값으로 보는 활용도 (6편)
- 묵시적 형변환의 함정 — 안전한 케이스와 위험한 케이스 (6편)
- 인덱스 레인지 스캔의 3단계 동작 (7편)
- Random I/O와 30% 임계점 — 옵티마이저의 판단 (7편)
> 🎯 [**인덱스 활용1 퀴즈 풀어보기**](quiz.html?set=C)
> 7문제 중 막히는 게 있다면, 해당 학습자료의 섹션으로 돌아와 다시 읽어보세요.
> 특히 "왜 그렇게 동작하는지" — 정렬 원리, Random I/O — 를 떠올리면 답이 자연스럽게 따라옵니다.
> 단순히 "어떤 쿼리는 인덱스를 탄다"를 외우는 것보다 훨씬 오래갑니다.
---
## 다음 학습자료
인덱스 활용1을 모두 마쳤습니다. 다음 학습자료에서는 **인덱스 활용2** 주제를 다룹니다.
---
## Reference
- RealMySQL 8.0 - 8.3.4.1 인덱스 레인지 스캔 (p. 230-232)
- MySQL 공식 문서: [EXPLAIN Output Format](https://dev.mysql.com/doc/refman/8.4/en/explain-output.html)