---
title: "GROUP BY 최적화: Loose Index Scan과 임시 테이블·filesort 판단"
description: "MySQL GROUP BY의 Loose Index Scan 조건과 temporary table, filesort 발생 원인을 실행 계획과 운영 관점에서 정리한다."
tags: [ MySQL, 성능최적화, 인덱스, DBA ]
image: "mysql-report-bg.png"
published: "2026-07-11"
updated: "2026-07-11"
author: "MySQL 기술 노트"
source_url: ""
---

`GROUP BY`는 같은 값을 가진 행을 묶는 문법이지만, 실제 비용은 결과 그룹 수보다 **그룹을 찾기 위해 읽어야 하는 행 수**와 **중간 상태를 유지하는 방식**에 좌우된다. 적절한 인덱스를 이용하면 MySQL은 각 그룹의 경계만 건너뛰며 읽을 수 있다. 반대로 그룹 키를 인덱스로 처리할 수 없거나 정렬 요구가 추가되면 모든 후보 행을 읽고 내부 임시 테이블에 집계 상태를 저장하거나 filesort를 수행한다.

이 차이는 데이터가 적을 때는 드러나지 않지만, 수천만 행에서 그룹 수가 수백 개뿐인 보고서 쿼리에서는 읽기량과 지연 시간의 자릿수를 바꿀 수 있다. 이 글에서는 MySQL 8.0 이상을 기준으로 `Loose Index Scan`, 일반 인덱스 스캔, 내부 임시 테이블, filesort의 선택 조건을 구분하고 실행 계획을 운영 지표와 함께 해석한다.

## 1. GROUP BY 실행의 본질

MySQL이 그룹 집계를 처리하려면 다음 두 문제를 해결해야 한다.

1. 같은 그룹에 속하는 행을 어떻게 연속적으로 찾을 것인가?
2. `COUNT()`, `SUM()`, `MIN()`, `MAX()` 같은 집계 상태를 어디에 보관할 것인가?

입력이 그룹 키 순서로 들어오면 현재 그룹의 상태만 유지하다가 키가 바뀔 때 결과를 내보낼 수 있다. 입력 순서가 보장되지 않으면 그룹별 상태를 해시 또는 정렬 가능한 중간 구조에 저장해야 한다. MySQL 실행 계획에서 `Using temporary`는 주로 두 번째 방식이 필요하다는 신호다.

```mermaid
flowchart TD
    A[WHERE 조건으로 후보 행 결정] --> B{인덱스 순서가 GROUP BY를 지원하는가}
    B -->|예| C{그룹 경계만 읽을 수 있는가}
    C -->|예| D[Loose Index Scan]
    C -->|아니요| E[인덱스 순서대로 행을 읽으며 집계]
    B -->|아니요| F[내부 임시 테이블에 그룹 상태 저장]
    D --> G{별도 ORDER BY가 필요한가}
    E --> G
    F --> G
    G -->|아니요| H[집계 결과 반환]
    G -->|예| I[filesort로 결과 순서 결정]
    I --> H
```

여기서 `Using temporary`와 `Using filesort`는 서로 다른 작업이다. 임시 테이블은 그룹 상태나 중간 결과를 저장하는 수단이고, filesort는 결과를 요청된 순서로 배치하는 정렬 알고리즘이다. 한 쿼리에서 둘 다 나타날 수도 있고 하나만 나타날 수도 있다. 또한 filesort라는 이름이 항상 디스크 파일 사용을 뜻하지는 않는다. 메모리에서 완료되는 정렬도 실행 계획에는 `Using filesort`로 표시된다.

## 2. Loose Index Scan의 동작 원리

Loose Index Scan은 인덱스의 모든 엔트리를 순차적으로 읽지 않고, 각 그룹에서 필요한 첫 번째 또는 마지막 엔트리로 이동하는 방식이다. 예를 들어 `(region, sale_date)` 인덱스가 있고 지역별 최소 판매일만 구한다면, 각 `region`의 첫 인덱스 엔트리만 읽어도 결과를 만들 수 있다.

개념적인 접근 순서는 다음과 같다.

1. 인덱스의 첫 번째 `region` 값을 찾는다.
2. 그 그룹의 첫 번째 `sale_date`에서 `MIN()` 값을 얻는다.
3. 현재 `region` 범위를 건너뛰고 다음 `region`의 시작점으로 이동한다.
4. 마지막 그룹까지 반복한다.

행 수가 `N`, 그룹 수가 `G`이고 `G`가 `N`보다 훨씬 작다면, 전체 인덱스를 훑는 방식보다 읽기량을 크게 줄일 수 있다. 전통 형식 `EXPLAIN`의 `Extra`에는 보통 `Using index for group-by`가 나타난다.

### 2.1 대표 적용 조건

MySQL 8.0에서 Loose Index Scan을 기대하려면 일반적으로 다음 조건을 함께 만족해야 한다.

- 단일 테이블을 대상으로 한다.
- `GROUP BY` 컬럼이 사용할 BTREE 인덱스의 가장 왼쪽 선두 컬럼들(leftmost prefix)을 이룬다.
- 집계 함수가 없다면 `DISTINCT`와 유사한 그룹 경계 탐색이 가능해야 한다.
- 집계 함수가 있다면 대표적으로 `MIN()` 또는 `MAX()`를 사용하며, 대상 컬럼이 인덱스에서 그룹 키 바로 다음 위치에 있어야 한다.
- 같은 쿼리의 `MIN()`과 `MAX()`는 같은 인덱스 컬럼을 대상으로 해야 한다.
- 인덱스의 나머지 컬럼에 조건이 있다면 그룹 경계 탐색을 깨뜨리지 않는 상수 동등 조건 등 문서화된 제약을 만족해야 한다.
- 인덱스 컬럼의 prefix index만으로는 전체 값을 정확히 비교할 수 있으리라고 가정하지 않는다.

`SUM()`이나 `COUNT()`가 있다는 이유만으로 Loose Index Scan이 되는 것은 아니다. 이 함수들은 보통 그룹 내 모든 행을 확인해야 하므로, 인덱스 순서를 활용하더라도 각 인덱스 엔트리를 읽는 일반 집계가 필요하다. 실행 계획에서 단순히 `Using index`가 보이는 것과 `Using index for group-by`가 보이는 것은 구분해야 한다.

## 3. 재현용 테이블과 데이터

다음 예제는 MySQL 8.0 검증용 소규모 데이터다. `(region, sale_date)` 인덱스는 지역별 최소·최대 날짜를 그룹 경계에서 찾도록 설계했다. `channel`에는 별도 인덱스를 두지 않아 임시 테이블 방식과 비교한다.

```sql
DROP TABLE IF EXISTS group_sales;
CREATE TABLE group_sales (
    sale_id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
    region VARCHAR(20) NOT NULL,
    sale_date DATE NOT NULL,
    channel VARCHAR(20) NOT NULL,
    amount DECIMAL(10,2) NOT NULL,
    PRIMARY KEY (sale_id),
    KEY ix_region_date (region, sale_date)
) ENGINE=InnoDB;

INSERT INTO group_sales (region, sale_date, channel, amount) VALUES
('부산', '2026-07-01', '매장', 12000.00),
('부산', '2026-07-03', '온라인', 18000.00),
('부산', '2026-07-08', '온라인', 25000.00),
('서울', '2026-07-02', '매장', 15000.00),
('서울', '2026-07-05', '온라인', 32000.00),
('서울', '2026-07-09', '매장', 21000.00),
('제주', '2026-07-04', '온라인', 17000.00),
('제주', '2026-07-10', '매장', 23000.00);

ANALYZE TABLE group_sales;
```

실행 결과(MySQL 8.0.x):

```text
mysql> DROP TABLE IF EXISTS group_sales;

Query OK, 0 rows affected (0.00 sec)

mysql> CREATE TABLE group_sales (
    ->     sale_id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
    ->     region VARCHAR(20) NOT NULL,
    ->     sale_date DATE NOT NULL,
    ->     channel VARCHAR(20) NOT NULL,
    ->     amount DECIMAL(10,2) NOT NULL,
    ->     PRIMARY KEY (sale_id),
    ->     KEY ix_region_date (region, sale_date)
    -> ) ENGINE=InnoDB;

Query OK, 0 rows affected (0.00 sec)

mysql> INSERT INTO group_sales (region, sale_date, channel, amount) VALUES
    -> ('부산', '2026-07-01', '매장', 12000.00),
    -> ('부산', '2026-07-03', '온라인', 18000.00),
    -> ('부산', '2026-07-08', '온라인', 25000.00),
    -> ('서울', '2026-07-02', '매장', 15000.00),
    -> ('서울', '2026-07-05', '온라인', 32000.00),
    -> ('서울', '2026-07-09', '매장', 21000.00),
    -> ('제주', '2026-07-04', '온라인', 17000.00),
    -> ('제주', '2026-07-10', '매장', 23000.00);

Query OK, 8 rows affected (0.01 sec)
Records: 8  Duplicates: 0  Warnings: 0

mysql> ANALYZE TABLE group_sales;

+-----------------------------+---------+----------+----------+
| Table                       | Op      | Msg_type | Msg_text |
+-----------------------------+---------+----------+----------+
| mysql_tech_note.group_sales | analyze | status   | OK       |
+-----------------------------+---------+----------+----------+
1 row in set (0.00 sec)
```

이 준비 SQL은 임시 검증 데이터베이스에서 실행한다. 운영 환경에서는 기존 테이블을 삭제하는 예제로 사용하지 말아야 한다.

## 4. Loose Index Scan 실행 계획 확인

지역별 최초·최종 판매일은 인덱스에서 각 지역 그룹의 양 끝만 확인하면 된다.

```sql
EXPLAIN
SELECT region, MIN(sale_date) AS first_sale_date, MAX(sale_date) AS last_sale_date
FROM group_sales
GROUP BY region;
```

실행 결과(MySQL 8.0.x):

```text
mysql> EXPLAIN
    -> SELECT region, MIN(sale_date) AS first_sale_date, MAX(sale_date) AS last_sale_date
    -> FROM group_sales
    -> GROUP BY region;

+----+-------------+-------------+------------+-------+----------------+----------------+---------+------+------+----------+--------------------------+
| id | select_type | table       | partitions | type  | possible_keys  | key            | key_len | ref  | rows | filtered | Extra                    |
+----+-------------+-------------+------------+-------+----------------+----------------+---------+------+------+----------+--------------------------+
|  1 | SIMPLE      | group_sales | NULL       | range | ix_region_date | ix_region_date | 82      | NULL |    4 |   100.00 | Using index for group-by |
+----+-------------+-------------+------------+-------+----------------+----------------+---------+------+------+----------+--------------------------+
1 row in set (0.00 sec)
```

핵심은 `key`가 `ix_region_date`이고 `Extra`가 `Using index for group-by`인지 확인하는 것이다. 테스트 데이터의 `rows` 추정치는 통계 샘플링과 버전에 따라 달라질 수 있으므로 고정값 자체보다 접근 방식과 `Extra`를 우선 해석한다.

실제 결과도 그룹별 경계를 정확히 반환한다.

```sql
SELECT region, MIN(sale_date) AS first_sale_date, MAX(sale_date) AS last_sale_date
FROM group_sales
GROUP BY region
ORDER BY region;
```

실행 결과(MySQL 8.0.x):

```text
mysql> SELECT region, MIN(sale_date) AS first_sale_date, MAX(sale_date) AS last_sale_date
    -> FROM group_sales
    -> GROUP BY region
    -> ORDER BY region;

+--------+-----------------+----------------+
| region | first_sale_date | last_sale_date |
+--------+-----------------+----------------+
| 부산 | 2026-07-01      | 2026-07-08     |
| 제주 | 2026-07-04      | 2026-07-10     |
| 서울 | 2026-07-02      | 2026-07-09     |
+--------+-----------------+----------------+
3 rows in set (0.00 sec)
```

Loose Index Scan의 장점은 결과 행이 적다는 사실이 아니라, 결과를 만들기 위해 방문하는 인덱스 엔트리가 그룹 수에 가까워질 수 있다는 점이다. 다만 `WHERE sale_date >= ...`처럼 선두 그룹 키를 건너뛰고 후속 컬럼에 범위 조건을 주면 항상 같은 최적화가 유지된다고 단정해서는 안 된다. 실제 인덱스 정의와 실행 계획을 함께 확인해야 한다.

## 5. 인덱스가 없는 그룹 키와 내부 임시 테이블

이번에는 인덱스가 없는 `channel`로 그룹화한다. 스토리지 엔진에서 읽은 순서만으로는 같은 채널의 행이 연속한다는 보장이 없으므로 서버 계층은 그룹별 카운터를 중간 구조에 유지해야 한다.

```sql
EXPLAIN
SELECT channel, COUNT(*) AS order_count, SUM(amount) AS total_amount
FROM group_sales
GROUP BY channel;
```

실행 결과(MySQL 8.0.x):

```text
mysql> EXPLAIN
    -> SELECT channel, COUNT(*) AS order_count, SUM(amount) AS total_amount
    -> FROM group_sales
    -> GROUP BY channel;

+----+-------------+-------------+------------+------+---------------+------+---------+------+------+----------+-----------------+
| id | select_type | table       | partitions | type | possible_keys | key  | key_len | ref  | rows | filtered | Extra           |
+----+-------------+-------------+------------+------+---------------+------+---------+------+------+----------+-----------------+
|  1 | SIMPLE      | group_sales | NULL       | ALL  | NULL          | NULL | NULL    | NULL |    8 |   100.00 | Using temporary |
+----+-------------+-------------+------------+------+---------------+------+---------+------+------+----------+-----------------+
1 row in set (0.00 sec)
```

이 실행 계획에서는 일반적으로 `type=ALL`과 `Using temporary`를 확인할 수 있다. `COUNT(*)`와 `SUM(amount)`는 각 그룹의 모든 행을 반영해야 하므로 Loose Index Scan의 대표적인 `MIN()`·`MAX()` 최적화와도 성격이 다르다.

MySQL 8.0의 내부 임시 테이블은 기본적으로 `TempTable` 엔진을 사용할 수 있으며, 가능한 범위에서는 메모리를 이용하다가 데이터 크기와 설정에 따라 디스크 기반 저장으로 전환될 수 있다. 운영자는 `Using temporary`만 보고 즉시 장애라고 판단해서는 안 된다. 다음 요소를 함께 봐야 한다.

- 입력 행 수와 필터링 후 행 수
- 그룹 수와 그룹 키의 폭
- 집계 결과에 포함되는 가변 길이 컬럼의 크기
- 동시 실행 수
- `tmp_table_size`와 TempTable 관련 메모리·디스크 설정
- `Created_tmp_tables`, `Created_tmp_disk_tables`의 증가율
- 쿼리 지연 시간과 임시 저장 공간 I/O

상태 카운터는 서버 시작 이후 누적값이므로 한 번의 스냅샷만으로 특정 쿼리를 지목할 수 없다. 짧은 구간의 증가량을 비교하거나 Performance Schema의 statement digest 통계와 함께 해석하는 편이 안전하다.

```sql
SHOW GLOBAL STATUS WHERE Variable_name IN
('Created_tmp_tables', 'Created_tmp_disk_tables');
```

실행 결과(MySQL 8.0.x):

```text
mysql> SHOW GLOBAL STATUS WHERE Variable_name IN
    -> ('Created_tmp_tables', 'Created_tmp_disk_tables');

+-------------------------+-------+
| Variable_name           | Value |
+-------------------------+-------+
| Created_tmp_disk_tables | 0     |
| Created_tmp_tables      | 1     |
+-------------------------+-------+
2 rows in set (0.00 sec)
```

이 값은 검증 컨테이너의 짧은 생명주기에서 나온 수치와 운영 서버의 수치를 직접 비교할 대상이 아니다. 운영에서는 같은 시간 창의 쿼리 처리량과 함께 비율 및 증가 속도를 관찰한다.

## 6. GROUP BY와 filesort의 관계

MySQL 8.0의 `GROUP BY`는 결과 정렬을 암묵적으로 보장하지 않는다. 정렬된 결과가 필요하면 반드시 `ORDER BY`를 명시해야 한다. 요청한 순서가 그룹화에 사용한 인덱스 순서와 다르면 집계가 끝난 결과에 별도 filesort가 필요할 수 있다.

다음 쿼리는 지역별 건수를 계산한 뒤 건수가 큰 그룹부터 정렬한다. `COUNT(*)` 결과는 테이블에 저장된 인덱스 컬럼이 아니므로 인덱스 순서만으로 `ORDER BY order_count DESC`를 충족할 수 없다.

```sql
EXPLAIN
SELECT region, COUNT(*) AS order_count
FROM group_sales
GROUP BY region
ORDER BY order_count DESC, region;
```

실행 결과(MySQL 8.0.x):

```text
mysql> EXPLAIN
    -> SELECT region, COUNT(*) AS order_count
    -> FROM group_sales
    -> GROUP BY region
    -> ORDER BY order_count DESC, region;

+----+-------------+-------------+------------+-------+----------------+----------------+---------+------+------+----------+----------------------------------------------+
| id | select_type | table       | partitions | type  | possible_keys  | key            | key_len | ref  | rows | filtered | Extra                                        |
+----+-------------+-------------+------------+-------+----------------+----------------+---------+------+------+----------+----------------------------------------------+
|  1 | SIMPLE      | group_sales | NULL       | index | ix_region_date | ix_region_date | 85      | NULL |    8 |   100.00 | Using index; Using temporary; Using filesort |
+----+-------------+-------------+------------+-------+----------------+----------------+---------+------+------+----------+----------------------------------------------+
1 row in set (0.00 sec)
```

`Extra`에서 `Using temporary`와 `Using filesort`가 함께 나타날 수 있다. 전자는 그룹별 카운터 유지, 후자는 집계 결과의 재정렬을 뜻한다. 정렬 대상은 원본 전체 행이 아니라 집계된 그룹 결과일 수 있으므로, 같은 `Using filesort`라도 비용은 입력 행 수와 그룹 수에 따라 크게 다르다.

다음 두 오해를 피해야 한다.

- **`Using filesort`가 보이면 반드시 디스크 정렬이다.** 그렇지 않다. filesort는 인덱스 순서로 정렬을 해결하지 못했다는 뜻이며 메모리에서 끝날 수 있다.
- **`GROUP BY` 컬럼에 인덱스를 만들면 모든 집계가 빨라진다.** 인덱스가 스캔 순서를 제공할 수는 있지만 `SUM()`과 `COUNT()`가 요구하는 모든 행 읽기를 제거하지 못할 수 있다. 필터 조건, projection, 인덱스 폭, 랜덤 I/O 비용까지 고려해야 한다.

## 7. 인덱스 설계 판단 기준

### 7.1 WHERE, GROUP BY, 집계 컬럼의 순서를 함께 본다

인덱스는 `GROUP BY`만 보고 설계하지 않는다. 예를 들어 특정 기간의 지역별 합계를 반복 조회한다면 `(sale_date, region)`은 기간 범위 탐색에는 유리하지만, 범위 조건 뒤의 `region` 순서를 그룹화에 온전히 활용하기 어려울 수 있다. 반대로 `(region, sale_date)`는 지역별 경계 탐색에는 적합하지만 전체 지역에 대한 날짜 범위 필터가 비효율적일 수 있다.

따라서 다음 질문에 답해야 한다.

1. 가장 강한 선택도를 가진 조건은 무엇인가?
2. 조회 범위는 일부 지역인가, 일부 기간인가?
3. 집계는 `MIN()`·`MAX()`인가, 모든 행을 읽어야 하는 `SUM()`·`COUNT()`인가?
4. 결과 그룹 수는 원본 행 수에 비해 얼마나 작은가?
5. 인덱스를 넓혀 covering 효과를 얻을 가치가 쓰기 비용보다 큰가?

### 7.2 ORDER BY까지 같은 인덱스로 해결 가능한지 확인한다

`GROUP BY region ORDER BY region`처럼 그룹 키와 정렬 키가 호환되면 추가 정렬을 피할 가능성이 높다. 그러나 `ORDER BY SUM(amount) DESC`처럼 계산된 집계값으로 정렬하면 일반적으로 결과 집합에 대한 filesort가 필요하다. 이 경우 억지로 복잡한 인덱스를 추가하기보다, 그룹 수를 줄이는 필터와 적절한 사전 집계 전략이 더 효과적일 수 있다.

### 7.3 사전 집계는 읽기와 쓰기의 교환이다

대규모 이력 테이블에서 동일한 일·지역·상품 집계를 반복한다면 요약 테이블이나 비동기 집계 파이프라인을 고려할 수 있다. 다만 사전 집계는 다음 비용을 만든다.

- 원본과 요약 데이터의 일관성 관리
- 지연 도착·수정 데이터 재처리
- 집계 차원 변경 시 스키마와 배치 재설계
- 중복 집계 및 재시도에 대한 멱등성 보장

단발성 느린 쿼리를 이유로 즉시 요약 테이블을 만드는 대신, digest별 실행 빈도와 총 DB 시간을 확인한 뒤 결정해야 한다.

## 8. 운영 진단 절차

`GROUP BY`가 느릴 때는 다음 순서로 범위를 좁힌다.

1. `EXPLAIN`에서 접근 테이블 순서, `type`, `key`, `rows`, `Extra`를 확인한다.
2. 가능하면 비운영 환경 또는 안전한 읽기 쿼리에 `EXPLAIN ANALYZE`를 적용해 추정 행과 실제 행을 비교한다.
3. `Using index for group-by`, `Using temporary`, `Using filesort`를 각각 독립된 신호로 해석한다.
4. WHERE 필터 적용 전후 행 수와 최종 그룹 수를 비교한다.
5. 내부 임시 테이블 누적 카운터의 구간 증가량을 확인한다.
6. Performance Schema digest에서 호출 횟수, 총 지연 시간, 검사 행 수, 임시 테이블 관련 통계를 확인한다.
7. 후보 인덱스는 쓰기 부하와 버퍼 풀 점유까지 포함해 검증한다.

`EXPLAIN ANALYZE`는 쿼리를 실제로 실행한다. 대규모 스캔이나 잠금 영향을 일으킬 수 있는 문장을 운영 서버에서 무심코 실행하지 말아야 한다. 먼저 일반 `EXPLAIN`, 복제본 또는 스테이징 환경, 제한된 조건으로 검증하는 순서를 권장한다.

## 9. Aurora MySQL에서의 해석

Aurora MySQL도 MySQL 호환 옵티마이저와 실행 계획을 사용하므로 `Using index for group-by`, `Using temporary`, `Using filesort`의 기본 해석은 같다. 그러나 운영 환경에서는 다음 차이를 함께 고려해야 한다.

- Aurora의 분산 스토리지가 로컬 InnoDB 파일 시스템과 같은 I/O 특성을 가진다고 가정하지 않는다.
- 임시 객체와 정렬 작업은 인스턴스의 로컬 메모리 및 임시 저장 공간 압력과 연관될 수 있으므로 인스턴스 클래스별 한계를 확인한다.
- 파라미터는 DB cluster parameter group과 DB parameter group의 적용 범위를 구분한다.
- Performance Insights 또는 Database Insights의 상위 SQL, 대기 이벤트, 호출 빈도를 실행 계획과 함께 본다.
- Reader 인스턴스로 분석 쿼리를 분리해도 복제 지연, 캐시 상태, 인스턴스 크기 차이 때문에 Writer와 동일한 성능을 보장하지 않는다.

Aurora에서는 스토리지 계층이 다르다는 이유만으로 비효율적인 그룹 집계가 사라지지 않는다. 읽는 행 수, 중간 결과 크기, CPU 정렬 비용, 동시성 압력은 여전히 쿼리와 인덱스 설계의 문제다.

## 10. 실패 양상과 주의 사항

### 통계가 달라 실행 계획이 바뀐다

소규모 검증 데이터에서는 인덱스 최적화가 명확해도 운영 데이터의 분포, 그룹 수, 조건 선택도에 따라 옵티마이저가 다른 계획을 선택할 수 있다. `ANALYZE TABLE`, Histogram, 지속 통계 상태를 확인하되 통계 갱신 자체가 운영 부하와 계획 변화를 일으킬 수 있음을 고려한다.

### 넓은 covering index를 무조건 추가한다

집계 쿼리 하나를 위해 많은 컬럼을 포함한 인덱스를 추가하면 버퍼 풀 점유, INSERT/UPDATE 비용, redo와 복제량이 증가한다. 읽기 절감 효과는 실제 호출 빈도와 총 DB 시간으로 입증해야 한다.

### `ORDER BY NULL`을 오래된 습관으로 사용한다

과거 버전에서는 `GROUP BY`의 암묵 정렬을 억제하려고 `ORDER BY NULL`을 사용하기도 했다. MySQL 8.0에서는 `GROUP BY`가 암묵 정렬을 보장하지 않으므로 이를 최신 기본 튜닝 규칙처럼 적용하지 않는다. 필요한 정렬만 명시적으로 작성한다.

### 임시 테이블 카운터 하나로 원인을 단정한다

`Created_tmp_disk_tables`는 전역 누적 카운터이며 여러 세션과 쿼리의 영향을 받는다. 트래픽이 증가하면 비율이 같아도 절대값은 증가한다. 시간 구간, 처리량, digest별 통계, 지연 시간, 임시 공간 사용량을 함께 비교한다.

## 11. DBA 점검표

- [ ] `GROUP BY` 컬럼이 후보 인덱스의 leftmost prefix인지 확인했다.
- [ ] `Using index`와 `Using index for group-by`를 구분했다.
- [ ] `MIN()`·`MAX()` 대상 컬럼이 그룹 키 바로 다음 인덱스 컬럼인지 확인했다.
- [ ] `COUNT()`·`SUM()`이 모든 그룹 행 읽기를 요구할 수 있음을 반영했다.
- [ ] `Using temporary`와 `Using filesort`를 별개 작업으로 해석했다.
- [ ] `ORDER BY`가 정말 필요한지, 그룹 키 순서와 호환되는지 확인했다.
- [ ] 추정 `rows`뿐 아니라 안전한 환경에서 실제 처리 행 수를 검증했다.
- [ ] 임시 테이블 상태값을 단일 누적값이 아닌 구간 증가량으로 관찰했다.
- [ ] 새 인덱스의 쓰기 비용, 크기, 버퍼 풀 영향까지 평가했다.
- [ ] Aurora에서는 임시 저장 공간, 인스턴스 클래스, 상위 SQL과 대기 이벤트를 함께 확인했다.

## 12. 결론

`GROUP BY` 최적화의 핵심은 문법을 바꾸는 요령이 아니라, 입력이 어떤 순서로 공급되고 그룹 상태를 어디에 유지하며 결과를 다시 정렬해야 하는지 이해하는 데 있다. `Using index for group-by`는 그룹 경계를 건너뛰는 매우 효율적인 접근을 뜻할 수 있지만 적용 조건은 제한적이다. `Using temporary`와 `Using filesort`는 각각 중간 집계와 정렬 필요성을 나타내며, 표시 자체보다 처리 행 수·그룹 수·실행 빈도·메모리와 I/O 압력을 함께 해석해야 한다.

다음 단계에서는 `DISTINCT`가 `GROUP BY`와 유사한 최적화 경로를 사용하는 경우와, 중복 제거가 조인 순서 및 임시 테이블 비용에 미치는 영향을 살펴볼 수 있다.
