E 세트

#10. 루스 인덱스 스캔

인덱스를 건너뛰며 읽기

학습자료를 불러오는 중...

#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; ``` ![dept_no 'd002'~'d004' 범위에서 첫 행만 듬성듬성 스캔](https://quiz-solution-images.s3.ap-northeast-2.amazonaws.com/quiz32/32-1.png) `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; ``` ![AVG(DISTINCT) 쿼리에서 중복값을 건너뛰며 루스 스캔](https://quiz-solution-images.s3.ap-northeast-2.amazonaws.com/quiz34/34-1.png) | 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)

이 세트 퀴즈 풀기 | 홈으로 돌아가기