---
title: "MySQL EXPLAIN 기본 해석: type, key, rows, filtered, Extra의 의미"
description: "MySQL EXPLAIN의 핵심 컬럼을 실행 경로, 인덱스 선택, row 추정, Extra 메시지 중심으로 운영 관점에서 해석한다."
tags: [ MySQL, 성능최적화, 인덱스, 운영, DBA ]
image: "mysql-report-bg.png"
published: "2026-07-03"
updated: "2026-07-03"
author: "MySQL 기술 노트"
source_url: ""
---

## 1. 왜 EXPLAIN 해석이 운영자의 기본 언어인가

MySQL 성능 문제를 다룰 때 가장 먼저 확인하는 도구는 대개 `EXPLAIN`이다. 느린 SQL을 만났을 때 실행 계획을 보지 않고 인덱스를 추가하거나 쿼리를 고치는 것은, 대기 이벤트와 로그를 보지 않고 장애 원인을 단정하는 것과 비슷하다. `EXPLAIN`은 MySQL Optimizer가 선택한 접근 경로를 보여 주며, 그 안에는 어떤 테이블을 먼저 읽는지, 어떤 인덱스를 쓰는지, 얼마나 많은 row를 읽을 것으로 추정하는지, 정렬이나 임시 테이블이 필요한지에 대한 단서가 들어 있다.

그러나 `EXPLAIN` 결과는 처음 보면 오해하기 쉽다. `type`이 `ALL`이면 항상 나쁜가, `key`가 보이면 무조건 인덱스를 잘 탄 것인가, `rows`는 실제 읽은 row 수인가, `filtered`가 낮으면 반드시 문제가 있는가 같은 질문이 반복된다. 운영 환경에서는 이런 단순 해석이 잘못된 대응으로 이어질 수 있다. 작은 차원 테이블의 full scan은 정상일 수 있고, 인덱스를 사용했더라도 선택도가 낮으면 전체 성능은 나쁠 수 있다. 반대로 `Extra`에 `Using filesort`가 보인다고 해서 항상 디스크 정렬 장애를 의미하지도 않는다.

이 글은 `EXPLAIN`의 기본 컬럼인 `type`, `key`, `rows`, `filtered`, `Extra`를 중심으로 해석 기준을 정리한다. 목표는 컬럼 이름을 암기하는 것이 아니라, 실행 계획을 통해 Optimizer의 판단을 역추적하고 운영 의사결정으로 연결하는 것이다.

## 2. EXPLAIN이 보여 주는 것과 보여 주지 않는 것

`EXPLAIN`은 SQL을 실제로 끝까지 실행한 결과가 아니라, Optimizer가 선택한 실행 계획의 설명이다. 일반적인 `EXPLAIN SELECT ...`는 “이 SQL을 실행한다면 이런 접근 경로를 사용할 것이다”에 가깝다. 따라서 `rows`와 `filtered`는 실제 측정값이 아니라 통계 기반 추정값이다. MySQL 8.0의 `EXPLAIN ANALYZE`는 실제 실행 시간과 row 흐름을 보여 주지만, 대상 SQL을 실제로 실행하므로 운영 환경에서는 더 조심해서 사용해야 한다.

기본 흐름은 다음과 같다.

```mermaid
flowchart TD
    A[SQL 입력] --> B[Parser와 Resolver]
    B --> C[Optimizer 후보 계획 생성]
    C --> D[통계와 Cost Model로 비용 계산]
    D --> E[선택된 실행 계획]
    E --> F[EXPLAIN 결과]
    E --> G[Executor 실제 실행]
    G --> H[실제 row 수와 대기 시간]
    F -.추정값.-> I[rows, filtered, key, Extra]
    H -.실측값.-> J[slow log, Performance Schema, EXPLAIN ANALYZE]
```

`EXPLAIN`을 읽을 때는 다음 구분이 중요하다.

| 구분 | 의미 | 운영상 해석 |
|---|---|---|
| 계획 정보 | `type`, `possible_keys`, `key`, `Extra` | Optimizer가 선택한 접근 경로 |
| 추정 정보 | `rows`, `filtered` | 통계와 조건식으로 계산한 예상 row 흐름 |
| 실제 실행 정보 | slow log, Performance Schema, `EXPLAIN ANALYZE` | 실제 읽기량, 지연 시간, 대기 이벤트 |

즉, `EXPLAIN`은 성능 분석의 출발점이지 최종 판결문이 아니다. 좋은 운영 절차는 `EXPLAIN`으로 가설을 세우고, slow log·Performance Schema·실제 실행 시간·데이터 분포로 검증하는 방식이다.

## 3. 기본 EXPLAIN 출력 구조 확인

먼저 MySQL 8.0에서 전통적인 `EXPLAIN` 결과가 어떤 컬럼을 갖는지 확인한다. 아래 예제는 별도 테이블 없이 실행할 수 있는 최소 예제다.

```sql
SELECT VERSION() AS mysql_version;
EXPLAIN SELECT 1 AS value_one;
```

실행 결과(MySQL 8.0.x):

```text
mysql> SELECT VERSION() AS mysql_version;

+---------------+
| mysql_version |
+---------------+
| 8.0.46        |
+---------------+
1 row in set (0.00 sec)

mysql> EXPLAIN SELECT 1 AS value_one;

+----+-------------+-------+------------+------+---------------+------+---------+------+------+----------+----------------+
| id | select_type | table | partitions | type | possible_keys | key  | key_len | ref  | rows | filtered | Extra          |
+----+-------------+-------+------------+------+---------------+------+---------+------+------+----------+----------------+
|  1 | SIMPLE      | NULL  | NULL       | NULL | NULL          | NULL | NULL    | NULL | NULL |     NULL | No tables used |
+----+-------------+-------+------------+------+---------------+------+---------+------+------+----------+----------------+
1 row in set (0.00 sec)
```

`EXPLAIN`의 전통 출력은 보통 다음 컬럼을 포함한다.

- `id`: SELECT 단위 또는 query block의 식별자다.
- `select_type`: 단순 SELECT, derived table, union 등 SELECT의 형태다.
- `table`: 접근 대상 테이블 또는 derived object다.
- `partitions`: partition pruning 결과다. partition을 쓰지 않으면 `NULL`일 수 있다.
- `type`: 접근 방식이다. 인덱스와 row 접근 효율을 빠르게 판단하는 핵심 컬럼이다.
- `possible_keys`: 사용할 가능성이 있었던 인덱스 목록이다.
- `key`: 실제 선택된 인덱스다.
- `key_len`: 선택된 인덱스에서 사용된 key part 길이다.
- `ref`: 인덱스 lookup에 사용된 상수 또는 컬럼이다.
- `rows`: 읽을 것으로 추정한 row 수다.
- `filtered`: 해당 테이블에서 조건을 통과할 것으로 추정한 비율이다.
- `Extra`: 정렬, 임시 테이블, covering index, where 적용 등 추가 실행 특성이다.

운영자는 모든 컬럼을 같은 비중으로 보지 않는다. 초기 진단에서는 `table → type → possible_keys/key → rows/filtered → Extra` 순서로 읽는 편이 실용적이다. 어떤 테이블을 어떤 순서로 읽는지 확인하고, 접근 방식과 인덱스 선택을 본 뒤, row 추정과 부가 작업을 확인하는 것이다.

## 4. type: 접근 방식의 압축 요약

`type`은 테이블에서 row를 찾는 방법을 압축해서 보여 준다. 이름은 `type`이지만 실제 의미는 access type 또는 join type에 가깝다. 자주 보는 값은 다음과 같다.

| type | 대략적 의미 | 해석 기준 |
|---|---|---|
| `system`, `const` | 한 번만 읽으면 되는 단일 row | 매우 좋은 접근이다. Primary key 또는 unique key 동등 조건에서 자주 나온다. |
| `eq_ref` | 조인에서 앞 테이블의 각 row마다 정확히 한 row lookup | PK/Unique key 조인에서 기대하는 좋은 형태다. |
| `ref` | non-unique index 동등 조건 lookup | 일반적인 인덱스 조건 접근이다. 선택도가 중요하다. |
| `range` | 인덱스 범위 scan | 범위 조건, `BETWEEN`, prefix range, 일부 `IN` 조건에서 나온다. 읽는 범위가 크면 비용이 커진다. |
| `index` | 인덱스 전체 scan | 테이블 전체 scan보다 나을 수도 있지만, 많은 인덱스 엔트리를 읽는다. |
| `ALL` | 테이블 전체 scan | 큰 테이블에서는 위험 신호지만, 작은 테이블이나 선택도가 낮은 조건에서는 합리적일 수 있다. |

중요한 점은 `type`만으로 좋고 나쁨을 확정하지 않는 것이다. `ALL`이어도 20 row짜리 코드 테이블이면 문제가 아닐 수 있다. `ref`라도 조건이 전체의 80%를 반환하면 비효율적일 수 있다. `range`도 범위가 작으면 좋지만, 월 단위 파티션 없는 대형 로그 테이블에서 6개월 범위를 읽는다면 매우 비싸다.

## 5. key와 possible_keys: 인덱스 후보와 실제 선택

`possible_keys`는 Optimizer가 조건식과 조인 조건을 보고 사용할 수 있다고 판단한 인덱스 후보 목록이다. `key`는 그중 실제로 선택한 인덱스다. 다음 세 가지 상황을 구분해야 한다.

1. `possible_keys`와 `key`가 모두 `NULL`이다. 조건에 맞는 인덱스 후보가 없거나, 조건식이 인덱스를 사용할 수 없는 형태일 가능성이 높다.
2. `possible_keys`에는 후보가 있지만 `key`가 `NULL`이다. 인덱스 후보는 있었지만 full scan이 더 싸다고 판단했을 수 있다.
3. `key`가 표시된다. 인덱스를 선택했지만, 그 인덱스가 충분히 좋은지는 `type`, `rows`, `filtered`, `Extra`와 함께 봐야 한다.

`key_len`도 중요하다. 복합 인덱스에서 어느 key part까지 사용했는지 추정하는 단서가 되기 때문이다. 예를 들어 `(customer_id, status, created_at)` 인덱스가 있는데 `customer_id`까지만 사용되었는지, `status`까지 사용되었는지에 따라 읽는 범위가 크게 달라질 수 있다.

## 6. rows와 filtered: 실제값이 아니라 row 흐름 추정치

`rows`는 해당 접근 방식으로 읽을 것으로 예상한 row 수다. `filtered`는 읽은 row 중 조건을 통과할 것으로 예상한 비율이다. 두 값을 곱하면 다음 단계로 넘어갈 row 수를 대략적으로 추정할 수 있다.

```text
다음 단계 예상 row 수 ≒ rows × filtered / 100
```

예를 들어 `rows=100000`, `filtered=1.0`이면 10만 row를 읽어 약 1천 row가 남는다는 뜻이다. 반대로 `rows=100`, `filtered=100.0`이면 100 row를 읽어 대부분 다음 단계로 넘긴다는 뜻이다. 둘 중 어느 쪽이 나쁜지는 쿼리 목적, 조인 순서, 정렬 여부, row 크기에 따라 달라진다.

운영에서 주의할 점은 `rows`가 실제 읽은 row 수가 아니라는 것이다. 통계가 오래되었거나 데이터 분포가 치우쳐 있으면 `rows` 추정이 크게 틀릴 수 있다. 앞선 글에서 다룬 Persistent Statistics, Histogram, Cost Model과 연결되는 지점이 바로 여기다. `EXPLAIN`이 이상하게 보일 때는 인덱스만 볼 것이 아니라 통계가 현재 데이터를 반영하는지도 확인해야 한다.

## 7. Extra: 부가 작업과 최적화 단서

`Extra`는 짧은 문자열이지만 매우 중요한 힌트를 제공한다. 자주 보는 메시지는 다음과 같다.

| Extra | 의미 | 주의점 |
|---|---|---|
| `Using where` | 스토리지 엔진에서 가져온 row에 서버 계층 조건을 적용한다 | 정상적인 경우도 많다. 단독으로 문제라고 보지 않는다. |
| `Using index` | covering index로 테이블 row 접근 없이 인덱스만으로 처리한다 | 보통 좋은 신호다. 단, 인덱스 전체 scan이면 읽기량은 여전히 클 수 있다. |
| `Using index condition` | Index Condition Pushdown을 사용한다 | InnoDB가 인덱스 조건 일부를 더 아래에서 평가한다. |
| `Using filesort` | 정렬 작업이 필요하다 | 반드시 디스크 파일 정렬을 뜻하지 않는다. 메모리 정렬도 포함하는 용어다. |
| `Using temporary` | 임시 테이블이 필요하다 | `GROUP BY`, `DISTINCT`, 일부 정렬과 조합될 때 비용이 커질 수 있다. |

특히 `Using filesort`라는 이름은 자주 오해된다. MySQL의 filesort는 “인덱스 순서만으로 결과를 만들 수 없어 별도 정렬 알고리즘을 사용한다”는 의미에 가깝다. 항상 파일 시스템에 큰 임시 파일을 쓴다는 뜻은 아니다. 정렬 대상 row 수, row width, `sort_buffer_size`, LIMIT 여부, 임시 테이블 사용 여부를 함께 봐야 한다.

## 8. 예제로 보는 key, rows, Extra 해석

다음 예제는 주문 테이블을 만들고 두 가지 조건의 실행 계획을 비교한다. 첫 번째 쿼리는 `customer_id`와 `status` 조건을 사용한다. 두 번째 쿼리는 `status`와 `created_at` 인덱스를 이용해 최근 주문을 정렬 없이 가져올 수 있는지 확인한다.

```sql
DROP TABLE IF EXISTS explain_orders;
CREATE TABLE explain_orders (
  order_id BIGINT PRIMARY KEY,
  customer_id BIGINT NOT NULL,
  status VARCHAR(20) NOT NULL,
  created_at DATETIME NOT NULL,
  amount DECIMAL(10,2) NOT NULL,
  KEY idx_customer_status (customer_id, status),
  KEY idx_status_created (status, created_at)
) ENGINE=InnoDB;

INSERT INTO explain_orders
  (order_id, customer_id, status, created_at, amount)
VALUES
  (1, 101, 'PAID', '2026-07-01 10:00:00', 120.00),
  (2, 101, 'READY', '2026-07-01 11:00:00', 80.00),
  (3, 101, 'PAID', '2026-07-02 09:00:00', 200.00),
  (4, 102, 'CANCELLED', '2026-07-02 10:00:00', 30.00),
  (5, 103, 'PAID', '2026-07-03 12:00:00', 300.00),
  (6, 104, 'READY', '2026-07-03 13:00:00', 70.00);

ANALYZE TABLE explain_orders;

EXPLAIN
SELECT order_id, amount
FROM explain_orders
WHERE customer_id = 101
  AND status = 'PAID';

EXPLAIN
SELECT order_id, customer_id, created_at
FROM explain_orders
WHERE status = 'PAID'
ORDER BY created_at
LIMIT 3;

DROP TABLE explain_orders;
```

실행 결과(MySQL 8.0.x):

```text
mysql> CREATE TABLE explain_orders (...);
Query OK, 0 rows affected (0.00 sec)

mysql> INSERT INTO explain_orders ...;
Query OK, 6 rows affected (0.01 sec)
Records: 6  Duplicates: 0  Warnings: 0

mysql> ANALYZE TABLE explain_orders;
+--------------------------------+---------+----------+----------+
| Table                          | Op      | Msg_type | Msg_text |
+--------------------------------+---------+----------+----------+
| mysql_tech_note.explain_orders | analyze | status   | OK       |
+--------------------------------+---------+----------+----------+
1 row in set (0.00 sec)

mysql> EXPLAIN
    -> SELECT order_id, amount
    -> FROM explain_orders
    -> WHERE customer_id = 101
    ->   AND status = 'PAID';
+----+-------------+----------------+------------+------+----------------------------------------+---------------------+---------+-------------+------+----------+-------+
| id | select_type | table          | partitions | type | possible_keys                          | key                 | key_len | ref         | rows | filtered | Extra |
+----+-------------+----------------+------------+------+----------------------------------------+---------------------+---------+-------------+------+----------+-------+
|  1 | SIMPLE      | explain_orders | NULL       | ref  | idx_customer_status,idx_status_created | idx_customer_status | 90      | const,const |    2 |   100.00 | NULL  |
+----+-------------+----------------+------------+------+----------------------------------------+---------------------+---------+-------------+------+----------+-------+
1 row in set (0.00 sec)

mysql> EXPLAIN
    -> SELECT order_id, customer_id, created_at
    -> FROM explain_orders
    -> WHERE status = 'PAID'
    -> ORDER BY created_at
    -> LIMIT 3;
+----+-------------+----------------+------------+------+--------------------+--------------------+---------+-------+------+----------+-------+
| id | select_type | table          | partitions | type | possible_keys      | key                | key_len | ref   | rows | filtered | Extra |
+----+-------------+----------------+------------+------+--------------------+--------------------+---------+-------+------+----------+-------+
|  1 | SIMPLE      | explain_orders | NULL       | ref  | idx_status_created | idx_status_created | 82      | const |    3 |   100.00 | NULL  |
+----+-------------+----------------+------------+------+--------------------+--------------------+---------+-------+------+----------+-------+
1 row in set (0.00 sec)

mysql> DROP TABLE explain_orders;
Query OK, 0 rows affected (0.00 sec)
```

이 예제에서 첫 번째 `EXPLAIN`은 `idx_customer_status`를 선택하는 것이 자연스럽다. `customer_id`와 `status`가 복합 인덱스의 선두부터 맞기 때문이다. 두 번째 `EXPLAIN`은 `idx_status_created`가 `status` 필터링과 `created_at` 정렬 순서에 모두 도움을 줄 수 있다. 실제 운영에서는 데이터 분포와 LIMIT 크기에 따라 다른 계획이 나올 수 있지만, 해석 방향은 같다.

읽을 때는 다음 순서를 권장한다.

1. `table`이 어떤 순서로 나오는지 본다.
2. 각 테이블의 `type`이 `ref`, `range`, `ALL` 중 어디에 가까운지 본다.
3. `possible_keys`와 `key`를 비교해 후보와 실제 선택이 어긋났는지 확인한다.
4. `rows`와 `filtered`로 다음 단계 row 수를 추정한다.
5. `Extra`에서 정렬, 임시 테이블, covering index 여부를 확인한다.

## 9. 조인 계획에서 EXPLAIN을 읽는 방법

조인 SQL에서는 `EXPLAIN`의 각 행이 테이블 접근 단계를 나타낸다. 먼저 읽는 테이블의 row 수 추정이 틀리면 뒤 테이블 lookup 횟수도 함께 틀어진다. 아래 예제는 고객과 주문을 조인할 때 PK lookup과 보조 인덱스 lookup이 어떻게 표시되는지 확인한다.

```sql
DROP TABLE IF EXISTS explain_join_orders;
DROP TABLE IF EXISTS explain_customers;

CREATE TABLE explain_customers (
  customer_id BIGINT PRIMARY KEY,
  customer_name VARCHAR(100) NOT NULL,
  grade VARCHAR(20) NOT NULL,
  KEY idx_grade (grade)
) ENGINE=InnoDB;

CREATE TABLE explain_join_orders (
  order_id BIGINT PRIMARY KEY,
  customer_id BIGINT NOT NULL,
  status VARCHAR(20) NOT NULL,
  amount DECIMAL(10,2) NOT NULL,
  KEY idx_customer_status (customer_id, status)
) ENGINE=InnoDB;

INSERT INTO explain_customers VALUES
  (101, 'customer-a', 'GOLD'),
  (102, 'customer-b', 'SILVER'),
  (103, 'customer-c', 'GOLD'),
  (104, 'customer-d', 'BRONZE');

INSERT INTO explain_join_orders VALUES
  (1, 101, 'PAID', 120.00),
  (2, 101, 'READY', 80.00),
  (3, 103, 'PAID', 300.00),
  (4, 104, 'CANCELLED', 30.00);

ANALYZE TABLE explain_customers, explain_join_orders;

EXPLAIN
SELECT c.customer_id, c.customer_name, o.order_id, o.amount
FROM explain_customers c
JOIN explain_join_orders o
  ON o.customer_id = c.customer_id
WHERE c.grade = 'GOLD'
  AND o.status = 'PAID';

DROP TABLE explain_join_orders;
DROP TABLE explain_customers;
```

실행 결과(MySQL 8.0.x):

```text
mysql> CREATE TABLE explain_customers (...);
Query OK, 0 rows affected (0.00 sec)

mysql> CREATE TABLE explain_join_orders (...);
Query OK, 0 rows affected (0.00 sec)

mysql> INSERT INTO explain_customers VALUES (...);
Query OK, 4 rows affected (0.00 sec)
Records: 4  Duplicates: 0  Warnings: 0

mysql> INSERT INTO explain_join_orders VALUES (...);
Query OK, 4 rows affected (0.01 sec)
Records: 4  Duplicates: 0  Warnings: 0

mysql> ANALYZE TABLE explain_customers, explain_join_orders;
+-------------------------------------+---------+----------+----------+
| Table                               | Op      | Msg_type | Msg_text |
+-------------------------------------+---------+----------+----------+
| mysql_tech_note.explain_customers   | analyze | status   | OK       |
| mysql_tech_note.explain_join_orders | analyze | status   | OK       |
+-------------------------------------+---------+----------+----------+
2 rows in set (0.00 sec)

mysql> EXPLAIN
    -> SELECT c.customer_id, c.customer_name, o.order_id, o.amount
    -> FROM explain_customers c
    -> JOIN explain_join_orders o
    ->   ON o.customer_id = c.customer_id
    -> WHERE c.grade = 'GOLD'
    ->   AND o.status = 'PAID';
+----+-------------+-------+------------+--------+---------------------+---------+---------+-------------------------------+------+----------+-------------+
| id | select_type | table | partitions | type   | possible_keys       | key     | key_len | ref                           | rows | filtered | Extra       |
+----+-------------+-------+------------+--------+---------------------+---------+---------+-------------------------------+------+----------+-------------+
|  1 | SIMPLE      | o     | NULL       | ALL    | idx_customer_status | NULL    | NULL    | NULL                          |    4 |    25.00 | Using where |
|  1 | SIMPLE      | c     | NULL       | eq_ref | PRIMARY,idx_grade   | PRIMARY | 8       | mysql_tech_note.o.customer_id |    1 |    50.00 | Using where |
+----+-------------+-------+------------+--------+---------------------+---------+---------+-------------------------------+------+----------+-------------+
2 rows in set (0.00 sec)

mysql> DROP TABLE explain_join_orders;
Query OK, 0 rows affected (0.00 sec)

mysql> DROP TABLE explain_customers;
Query OK, 0 rows affected (0.00 sec)
```

조인 계획에서는 `eq_ref`와 `ref`의 차이를 특히 자주 본다. `eq_ref`는 앞 테이블의 각 row마다 뒤 테이블에서 unique key 또는 primary key로 최대 한 row를 찾는다는 뜻이다. `ref`는 non-unique index로 여러 row가 나올 수 있는 lookup이다. 둘 다 인덱스 기반 접근이지만, 뒤 테이블에서 몇 row가 나올지에 따라 비용 차이가 커질 수 있다.

또 하나의 중요한 지점은 조인 순서다. SQL에 쓴 테이블 순서가 실제 실행 순서와 항상 같지는 않다. Optimizer는 조건 선택도, 인덱스, 통계, 조인 비용을 보고 순서를 바꿀 수 있다. 따라서 `EXPLAIN` 행의 순서를 보면서 “왜 이 테이블을 먼저 읽는가”를 질문해야 한다. 먼저 읽는 테이블이 충분히 row를 줄이지 못하면 뒤 테이블 lookup이 폭증할 수 있다.

## 10. 운영 환경에서의 해석 절차

실제 장애나 성능 저하 상황에서는 다음 절차로 접근하는 것이 안전하다.

### 10.1 SQL과 바인드 값의 대표성 확인

같은 SQL이라도 바인드 값에 따라 선택도가 크게 달라질 수 있다. 예를 들어 `tenant_id = 1`이 전체 데이터의 70%를 차지하고 `tenant_id = 999`가 0.01%만 차지한다면, 같은 인덱스라도 성능 특성이 다르다. slow log에서 실제 느렸던 값, 애플리케이션에서 사용한 조건, 시간 범위를 함께 확인해야 한다.

### 10.2 EXPLAIN으로 계획의 큰 방향 확인

먼저 full scan, 잘못된 조인 순서, 과도한 `rows`, `Using temporary`, `Using filesort`가 있는지 본다. 이 단계에서는 결론을 내리기보다 의심 지점을 좁힌다.

### 10.3 실제 실행 정보와 대조

`EXPLAIN`의 `rows`는 추정값이다. 실제 문제를 확인하려면 slow log의 `Rows_examined`, Performance Schema의 statement 통계, 애플리케이션 지연 시간, 필요 시 안전한 환경의 `EXPLAIN ANALYZE`를 함께 본다.

### 10.4 대응책을 분류

대응은 보통 네 가지로 나뉜다.

- **통계 보정**: `ANALYZE TABLE`, Histogram, persistent statistics 확인.
- **인덱스 조정**: 조건식과 정렬/조인 순서를 반영한 복합 인덱스 설계.
- **SQL 재작성**: 비SARGable 조건 제거, 불필요한 함수 적용 제거, 조인 조건 명확화.
- **업무 조건 조정**: 과도한 기간 조회 제한, pagination 방식 변경, 배치 처리 분리.

운영에서는 인덱스 추가가 가장 눈에 잘 띄지만 항상 정답은 아니다. 잘못된 인덱스는 쓰기 비용과 버퍼 풀 압박을 늘리고, 통계가 틀린 상태에서는 새 인덱스도 기대한 대로 선택되지 않을 수 있다.

## 11. Aurora MySQL에서의 해석 차이

Aurora MySQL은 MySQL 호환 Optimizer를 사용하므로 `EXPLAIN`의 기본 의미는 Community MySQL과 크게 다르지 않다. `type`, `key`, `rows`, `filtered`, `Extra`를 읽는 방식도 동일한 기준에서 출발한다. 다만 운영 해석에서는 다음 차이를 고려해야 한다.

- Aurora의 스토리지 계층은 분산 스토리지 구조이므로 I/O 지연과 캐시 상태의 양상이 self-managed InnoDB와 다를 수 있다.
- Writer와 Reader 인스턴스의 버퍼 캐시 상태, 통계 갱신 시점, 파라미터 그룹 차이가 실행 시간 차이를 만들 수 있다.
- Performance Insights, Enhanced Monitoring, CloudWatch 지표를 함께 보면 `EXPLAIN`이 말하지 않는 대기 이벤트를 확인할 수 있다.
- 읽기 복제본에서만 느린 쿼리는 실행 계획뿐 아니라 replication lag, reader class, 캐시 warm-up, 병렬 부하를 함께 점검해야 한다.

즉, Aurora에서도 `EXPLAIN`은 계획 해석의 핵심 도구지만, 스토리지와 인스턴스 운영 지표를 함께 보아야 한다. 계획은 같아도 지연 원인은 다를 수 있다.

## 12. 흔한 오해와 주의사항

### 12.1 key가 있으면 좋은 계획이라는 오해

`key`가 표시되면 인덱스를 사용했다는 뜻이다. 그러나 인덱스를 사용했다는 사실과 좋은 계획이라는 판단은 다르다. 선택도가 낮은 인덱스를 통해 대량 row를 읽고 다시 테이블 row를 lookup하면, full scan보다 더 비쌀 수도 있다.

### 12.2 rows를 실제 row 수로 보는 오해

`rows`는 추정값이다. 실제 row 수가 아니다. 실제값과 추정값이 크게 다르면 통계, 데이터 분포, 조건식 형태, Histogram 필요 여부를 의심해야 한다.

### 12.3 Using filesort를 디스크 장애로 단정하는 오해

`Using filesort`는 별도 정렬 알고리즘 사용을 뜻한다. 반드시 디스크 파일을 쓴다는 의미가 아니다. 정렬 대상이 작고 LIMIT가 작으면 큰 문제가 아닐 수 있다. 반대로 대량 row 정렬과 임시 테이블이 결합되면 심각한 병목이 될 수 있다.

### 12.4 작은 테이블의 ALL을 과잉 대응하는 오해

작은 lookup table이나 매우 작은 dimension table은 full scan이 더 단순하고 빠를 수 있다. 모든 `ALL`을 없애려는 접근은 불필요한 인덱스와 복잡도를 만들 수 있다.

### 12.5 EXPLAIN만 보고 운영 변경을 확정하는 오해

실행 계획은 중요한 증거지만 전체 증거는 아니다. 실제 지연 시간, row examined, lock wait, I/O wait, CPU 사용률, 애플리케이션 호출 패턴을 함께 보아야 한다.

## 13. DBA 점검 체크리스트

`EXPLAIN`을 볼 때 다음 항목을 순서대로 점검한다.

- [ ] 느린 SQL의 실제 바인드 값과 시간 범위를 확보했는가?
- [ ] `EXPLAIN` 대상 SQL이 운영에서 실행된 SQL과 구조적으로 같은가?
- [ ] 테이블 접근 순서가 예상과 다른 경우, 그 이유를 `rows`와 인덱스 선택으로 설명할 수 있는가?
- [ ] 큰 테이블에서 `type=ALL` 또는 넓은 `range`가 발생하는가?
- [ ] `possible_keys`는 있는데 `key=NULL`인 이유가 선택도나 비용 관점에서 설명되는가?
- [ ] 선택된 `key`가 조건식, 조인 조건, 정렬 조건을 모두 고려할 때 합리적인가?
- [ ] `rows × filtered / 100`으로 다음 단계 row 흐름을 대략 추정했는가?
- [ ] `Using temporary`, `Using filesort`가 대량 row와 결합되어 있는가?
- [ ] slow log의 `Rows_examined`와 `EXPLAIN rows` 추정이 크게 어긋나는가?
- [ ] 통계가 오래되었거나 데이터 분포가 치우쳐 Histogram이 필요한 상황인가?
- [ ] 인덱스 추가 전 쓰기 비용, 버퍼 풀 사용량, 기존 인덱스 중복 여부를 검토했는가?
- [ ] Aurora MySQL에서는 Reader/Writer 차이, Performance Insights 대기 이벤트, 캐시 상태를 함께 확인했는가?

## 14. 결론: EXPLAIN은 Optimizer와 대화하는 방법이다

`EXPLAIN`은 단순한 표가 아니라 MySQL Optimizer가 SQL을 어떻게 이해했는지 보여 주는 인터페이스다. `type`은 접근 방식, `key`는 선택된 인덱스, `rows`와 `filtered`는 row 흐름 추정, `Extra`는 부가 작업과 최적화 단서를 제공한다. 이 컬럼들을 따로 외우기보다 하나의 실행 경로로 연결해서 읽어야 한다.

운영자가 해야 할 일은 `EXPLAIN` 결과를 보고 즉시 결론을 내리는 것이 아니다. 계획을 통해 가설을 세우고, 실제 실행 정보로 검증하며, 통계·인덱스·SQL 구조·업무 조건 중 어느 층을 바꿀지 결정하는 것이다. 다음 글들에서는 이 기본 해석을 바탕으로 `EXPLAIN FORMAT=JSON`, `EXPLAIN ANALYZE`, 조인 순서와 인덱스 설계의 세부 판단을 더 깊게 다룰 수 있다.
