#10. 루스 인덱스 스캔
인덱스를 건너뛰며 읽기
# 루스 인덱스 스캔 — 인덱스를 건너뛰며 읽기
> 📘 **학습자료 11편 / 11편** | 연결 퀴즈: [**인덱스 응용1 풀러가기**](quiz.html?set=E)
> GROUP BY나 MIN/MAX 같은 집계 쿼리에서 인덱스를 **"듬성듬성"** 읽는 최적화.
> 알면 EXPLAIN의 한 줄짜리 신호(`Using index for group-by`)가 갑자기 의미 있어집니다.
> 모르면 같은 쿼리가 왜 빠른지 영영 모릅니다.
> 📚 **이전 편**: [10편 - 복합 인덱스 한 번 더]
> 10편에서 옵티마이저가 후보 인덱스를 어떻게 고르는지 봤다면,
> 이번 편은 **선택된 인덱스를 어떻게 더 효율적으로 읽는지** 입니다.
## 이 자료를 다 읽으면 알게 되는 것
- **루스 인덱스 스캔(Loose Index Scan)** 이 무엇이고, 왜 빠른지
- 어떤 쿼리는 되고, 어떤 쿼리는 안 되는 **조건 4가지**
- `SUM`은 안 되고 `SUM(DISTINCT)`은 되는 이유
- EXPLAIN에서 `Using index for group-by` 한 줄을 읽는 법
---
## 📑 목차
- [1. 인덱스를 다 읽지 않아도 될 때가 있다](#1-인덱스를-다-읽지-않아도-될-때가-있다)
- [⭐ 2. 루스 인덱스 스캔의 동작](#2-루스-인덱스-스캔의-동작)
- [3. SUM은 왜 안 될까: 건너뛸 수 있는 행이 있어야 한다](#3-sum은-왜-안-될까-건너뛸-수-있는-행이-있어야-한다)
- [4. DISTINCT 집계는 왜 되는가 (MySQL 8.0+)](#4-distinct-집계는-왜-되는가-mysql-80)
- [5. 부분 인덱스(prefix index)는 안 되는 이유](#5-부분-인덱스prefix-index는-안-되는-이유)
- [6. 루스 인덱스 스캔 적용 조건 정리](#6-루스-인덱스-스캔-적용-조건-정리)
- [핵심 요약](#핵심-요약)
---
## 1. 인덱스를 다 읽지 않아도 될 때가 있다
지금까지 다룬 인덱스 스캔 방식들을 떠올려봅시다.
- **인덱스 레인지 스캔** (7편): `WHERE a BETWEEN 1 AND 10` — 범위에 해당하는 인덱스 항목을 **순차적으로 다 읽음**
- **인덱스 풀 스캔**: 인덱스 전체를 처음부터 끝까지 다 읽음
둘 다 공통점은 **"해당 범위 안의 모든 인덱스 항목을 읽는다"** 는 것입니다.
그런데 어떤 쿼리들은 **인덱스의 모든 항목을 읽을 필요가 없습니다.** 예를 들면:
```sql
SELECT dept_no, MIN(emp_no)
FROM dept_emp
WHERE dept_no BETWEEN 'd002' AND 'd004'
GROUP BY dept_no;
```
각 부서별 **최소 사원번호만** 알면 됩니다. 인덱스 `(dept_no, emp_no)`가 있다고 하면, 인덱스는 이미:
- `dept_no` 1순위 정렬
- 같은 `dept_no` 안에서 `emp_no` 정렬
이렇게 정렬되어 있죠. 그럼 각 `dept_no`의 **첫 번째 항목만 보면 그게 곧 그 부서의 MIN(emp_no)** 입니다. 나머지는 읽을 필요가 없습니다.
이렇게 **필요한 항목만 점프하며 읽는 방식**이 **루스 인덱스 스캔**입니다.
---
## 2. 루스 인덱스 스캔의 동작
> ⭐ **이 자료에서 가장 중요한 섹션입니다.**
### 핵심 아이디어
> **인덱스가 정렬되어 있다는 사실을 이용해, 각 그룹의 대표값만 점프하며 읽는다.**
타이트(tight)하게 모든 항목을 읽지 않고 **느슨하게(loose)** 읽는다고 해서 "루스" 인덱스 스캔입니다.
### 사전 비유로 보면
10편의 사전 비유를 그대로 가져옵시다. 사전이 "성씨 → 이름" 순으로 정렬되어 있고, 각 성씨별로 가장 첫 번째 사람의 이름이 알고 싶다고 합시다.
- **풀 스캔**: 사전을 처음부터 끝까지 한 줄도 빠짐없이 읽기
- **레인지 스캔**: 김씨 페이지부터 박씨 페이지까지 한 줄도 빠짐없이 읽기
- **루스 스캔**: 김씨 페이지의 **첫 줄만** 보고 → 박씨 페이지의 **첫 줄만** 보고 → ... 다음으로 점프
루스 스캔이 압도적으로 적게 읽습니다. 대신 이걸 쓸 수 있는 쿼리가 한정되어 있습니다.
### 예시 쿼리와 EXPLAIN
```sql
SELECT dept_no, MIN(emp_no)
FROM dept_emp
WHERE dept_no BETWEEN 'd002' AND 'd004'
GROUP BY dept_no;
```

`d002`와 `d004` 범위 내에서 각 `dept_no`의 첫 행만 듬성듬성 스캔하고, 같은 부서번호의 나머지 인덱스는 무시합니다.
EXPLAIN 결과:
| id | type | key | rows | Extra |
|---|---|---|---|---|
| 1 | range | PRIMARY | 4 | **Using where; Using index for group-by** |
**`Using index for group-by`** — 이게 루스 인덱스 스캔의 명시적 신호입니다.
`rows`도 4밖에 안 됩니다. 부서가 3개니까 그룹별로 1~2개만 읽으면 끝났다는 뜻이죠.
> 💡 **실무 진단 팁**
> GROUP BY나 MIN/MAX가 들어간 쿼리에서 EXPLAIN을 찍었을 때 `Using index for group-by`가 보이면, 그 쿼리는 이미 옵티마이저가 가장 효율적인 방식으로 처리하고 있는 겁니다.
> 반대로 `Using temporary; Using filesort`가 보이면 개선 여지가 있다는 신호입니다.
---
## 3. SUM은 왜 안 될까: 건너뛸 수 있는 행이 있어야 한다
루스 인덱스 스캔은 **건너뛸 수 있는 행이 있을 때만** 가능합니다. 그 점이 가장 직관적으로 드러나는 게 `SUM`과 `MIN/MAX`의 차이입니다.
### 안 되는 케이스
```sql
SELECT col1, SUM(col2)
FROM test_table
GROUP BY col1;
```
`col1`로 그룹핑된 각 그룹마다 `col2`의 **모든 값을 다 더해야** 합을 구할 수 있습니다. 한 행이라도 빠뜨리면 합이 틀려집니다.
→ 인덱스를 듬성듬성 읽을 수 없습니다. **모든 항목을 다 읽어야 합니다.**
EXPLAIN 결과:
| id | type | key | rows | Extra |
|---|---|---|---|---|
| 1 | index | idx_col1_col2_col3 | 3,685 | **Using index** |
`Using index`(커버링 인덱스)는 적용됐지만, `Using index for group-by`는 없습니다. 인덱스 전체를 풀 스캔한 것이죠.
### 되는 케이스 — MIN/MAX
```sql
SELECT col1, MIN(col2)
FROM test_table
GROUP BY col1;
```
각 그룹의 **최솟값 1개만** 알면 됩니다. 인덱스가 `(col1, col2)`로 정렬되어 있으면, 각 `col1`의 **첫 항목**이 곧 `MIN(col2)`. 다른 항목은 읽을 필요가 없습니다.
→ 루스 인덱스 스캔 가능.
### 사전 비유로 정리
- **MIN/MAX**: 사전을 펼쳤을 때 "이 페이지의 첫 줄(또는 마지막 줄)"만 보면 됨 → 듬성듬성 읽기 가능
- **SUM**: 페이지 전체를 한 줄도 빠짐없이 다 읽어야 합을 구할 수 있음 → 듬성듬성 읽기 불가
> ⚠️ **헷갈리지 말 것**
> `SUM`이 인덱스를 못 쓴다는 게 아닙니다. 커버링 인덱스로 `Using index`는 충분히 적용됩니다.
> **루스 인덱스 스캔이라는 추가 최적화**가 안 된다는 뜻입니다.
---
## 4. DISTINCT 집계는 왜 되는가 (MySQL 8.0+)
다음 쿼리는 어떨까요?
```sql
SELECT col1, AVG(DISTINCT col2)
FROM test_table
GROUP BY col1;
```
`AVG(DISTINCT col2)`는 **중복을 제거한** `col2` 값들의 평균입니다. 평균을 구하려면 합과 개수가 필요한데, 합을 구하려면 모든 값을 더해야 하지 않을까요?
여기서 핵심은 **DISTINCT** 입니다.
### 중복은 건너뛸 수 있다
`col1`로 그룹핑된 안에서 `col2`의 **중복값은 어차피 무시됩니다.** 그렇다면 인덱스 `(col1, col2)`를 따라가면서 같은 `col2` 값이 연속으로 나오면 **첫 번째만 읽고 나머지는 건너뛰어도** 됩니다.
→ 듬성듬성 읽기 가능.
### MySQL 공식 지원
MySQL 8.0부터 다음 집계 함수에서 루스 인덱스 스캔이 적용됩니다:
| 집계 함수 | 루스 인덱스 스캔 |
|---|---|
| `MIN(col)`, `MAX(col)` | ✅ 가능 |
| `COUNT(DISTINCT col)` | ✅ 가능 |
| `SUM(DISTINCT col)` | ✅ 가능 |
| `AVG(DISTINCT col)` | ✅ 가능 |
| `SUM(col)`, `COUNT(col)`, `AVG(col)` | ❌ 불가 |
### EXPLAIN 결과
```sql
EXPLAIN
SELECT col1, AVG(DISTINCT col2)
FROM tb_test
GROUP BY col1;
```

| id | type | key | rows | Extra |
|---|---|---|---|---|
| 1 | range | idx_col1_col2_col3 | 3,680 | **Using index for group-by (scanning)** |
**`Using index for group-by (scanning)`** — `MIN/MAX`보다 더 정교한 형태의 루스 스캔이라는 표시입니다. 그룹 안에서 중복값을 건너뛰며 스캔하고 있다는 의미입니다.
---
## 5. 부분 인덱스(prefix index)는 안 되는 이유
마지막으로 같은 컬럼 구성이라도 **부분 인덱스**는 루스 인덱스 스캔의 대상이 아닙니다.
### 부분 인덱스란
```sql
CREATE INDEX idx_name_color ON broccoli (name(5), color);
```
`name` 컬럼 전체가 아니라 **앞 5글자만** 인덱스에 저장하는 방식입니다. 인덱스 크기를 줄이기 위해 흔히 쓰입니다.
### 왜 루스 스캔이 안 되나
루스 인덱스 스캔은 **인덱스의 정렬 순서를 신뢰해서** 점프합니다. "각 그룹의 첫 항목"이라는 개념이 성립하려면, 그룹의 경계가 인덱스 안에서 명확하게 구분되어야 합니다.
부분 인덱스는 그 경계를 흐립니다.
- `'broccoli'`, `'broccosprout'`, `'broccopuff'` — 모두 인덱스에는 `'brocc'`로 저장됨
- 인덱스 입장에서는 **다 같은 그룹처럼 보입니다**
- 실제로는 다른 값인지 아닌지 확인하려면 매번 테이블에 다녀와야 함
→ "그룹의 첫 항목"이라는 개념이 깨지므로 루스 스캔 불가.
> 💡 **MySQL 공식 문서의 표현**
> "인덱스의 컬럼은 prefix가 아니라 **전체 값**이 인덱싱되어야 한다.
> 예를 들어 `c1 VARCHAR(20), INDEX (c1(10))`은 c1의 prefix만 인덱싱하므로 루스 인덱스 스캔에 사용될 수 없다."
### EXPLAIN 결과
부분 인덱스가 걸려 있는 테이블에 GROUP BY + MIN 쿼리를 실행하면:
| id | type | key | rows | Extra |
|---|---|---|---|---|
| 1 | ALL | null | 1 | **Using temporary** |
`type: ALL` — 테이블 풀 스캔. 인덱스를 아예 안 탑니다. `Using temporary`가 보이는 건 GROUP BY를 위한 임시 테이블을 따로 만들었다는 뜻이고요.
---
## 6. 루스 인덱스 스캔 적용 조건 정리
MySQL 공식 문서가 정리한 루스 인덱스 스캔의 적용 조건은 다음과 같습니다.
| 조건 | 설명 |
|---|---|
| **단일 테이블 쿼리** | JOIN이 들어가면 안 됨 |
| **GROUP BY/DISTINCT 컬럼이 인덱스의 왼쪽부터 연속** | 인덱스 `(a, b, c)`라면 `GROUP BY a` 또는 `GROUP BY a, b` 가능. `GROUP BY b`만은 불가 |
| **지원되는 집계 함수만 사용** | `MIN`, `MAX`, `COUNT(DISTINCT)`, `SUM(DISTINCT)`, `AVG(DISTINCT)` |
| **부분 인덱스(prefix index)가 아님** | 인덱스가 컬럼 전체를 저장해야 함 |
신입~주니어가 실무에서 가장 자주 만나는 함정 두 가지:
1. `GROUP BY` 컬럼이 복합 인덱스의 가운데부터 시작 (Left-Most 위반)
2. `name(10)` 같은 부분 인덱스를 만들어놓고 GROUP BY를 기대
EXPLAIN을 찍었을 때 `Using index for group-by`가 안 보인다면 위 4가지 조건 중 어디에 걸렸는지 점검하세요.
---
## 핵심 요약
이번 편에서 꼭 가져가야 할 세 가지:
> 🎯 **루스 인덱스 스캔은 "건너뛸 수 있는 행"이 있을 때 일어난다**
> MIN/MAX는 그룹의 첫/끝 항목만 보면 되고, DISTINCT 집계는 중복을 건너뛰면 된다. SUM/COUNT/AVG는 모든 행을 다 봐야 하므로 불가.
> 🎯 **EXPLAIN의 신호는 `Using index for group-by`** (그리고 DISTINCT 케이스는 `(scanning)` 추가)
> 이 한 줄이 보이면 옵티마이저가 가장 효율적인 방식을 쓰고 있다는 뜻.
> 🎯 **부분 인덱스는 루스 스캔을 못 쓴다**
> 그룹의 경계가 흐려지기 때문. `name(5)` 같은 prefix index를 GROUP BY에 쓸 거라면 다시 생각해야 함.
체크리스트:
- [x] 루스 인덱스 스캔이 무엇이고, 왜 빠른지 비유로 설명할 수 있다
- [x] EXPLAIN의 `Using index for group-by`를 읽고 의미를 안다
- [ ] `SUM`은 안 되고 `SUM(DISTINCT)`은 되는 이유를 설명할 수 있다
- [ ] 루스 인덱스 스캔의 4가지 적용 조건을 안다
- [ ] 부분 인덱스가 왜 루스 스캔의 대상이 아닌지 안다
> 📝 체크리스트를 다 채울 자신이 있다면? [**인덱스 응용1 퀴즈 도전하기**](quiz.html?set=E)
---
## 이제 퀴즈에 도전하기
응용1 시리즈의 마지막 편이었습니다. 10편과 11편을 합쳐 응용1 퀴즈에 필요한 모든 개념을 다뤘습니다.
- PK 길이가 보조 인덱스 전체에 미치는 영향 (10편)
- 옵티마이저가 후보 인덱스를 고르는 기준: rows × filtered (10편)
- `Using index` vs `Using where` — 같은 인덱스, 다른 비용 (10편)
- 루스 인덱스 스캔 — `Using index for group-by` (11편)
- DISTINCT 집계는 되고, 일반 집계는 안 되는 이유 (11편)
- 부분 인덱스는 루스 스캔의 대상이 아니다 (11편)
> 🎯 [**인덱스 응용1 퀴즈 풀어보기**](quiz.html?set=E)
> 7문제를 풀면서 EXPLAIN 결과를 직접 해석해보세요.
> 막히는 게 있다면 해당 학습자료의 섹션으로 돌아와 다시 읽어보면 됩니다.
> 특히 **`Extra` 컬럼의 키워드들**(`Using index`, `Using where`, `Using index for group-by`)을 자신 있게 구분할 수 있어야 응용1 단계를 완주한 것입니다.
---
## Reference
- MySQL 공식 문서: [GROUP BY Optimization](https://dev.mysql.com/doc/refman/8.4/en/group-by-optimization.html)
- MySQL 공식 문서: [EXPLAIN Output Format](https://dev.mysql.com/doc/refman/8.4/en/explain-output.html)