---
title: "MySQL EXPLAIN FORMAT=JSON 읽기: nested_loop, cost_info, attached_condition"
description: "MySQL EXPLAIN FORMAT=JSON의 nested_loop, cost_info, attached_condition을 실행 계획 해석과 운영 진단 관점에서 정리한다."
tags: [ MySQL, 성능최적화, 인덱스, 운영, DBA ]
image: "mysql-report-bg.png"
published: "2026-07-04"
updated: "2026-07-04"
author: "MySQL 기술 노트"
source_url: ""
---

## 1. 왜 JSON 실행 계획을 읽어야 하는가

MySQL에서 `EXPLAIN`은 쿼리가 어떤 테이블을 어떤 순서로 읽고, 어떤 인덱스를 사용할 가능성이 있는지 보여 주는 기본 진단 도구다. 전통적인 표 형태의 `EXPLAIN`도 여전히 유용하지만, 조인 순서·비용 추정·조건 적용 위치를 세밀하게 읽기에는 정보가 압축되어 있다. 운영 환경에서 “인덱스가 있는데 왜 느린가”, “조인 순서가 왜 바뀌었는가”, “조건이 인덱스 탐색에 쓰였는가 아니면 읽은 뒤 필터링되었는가”를 판단해야 할 때는 `EXPLAIN FORMAT=JSON`이 더 직접적인 단서를 제공한다.

이 글은 `EXPLAIN FORMAT=JSON` 전체 문법을 나열하기보다, 실제 운영 진단에서 자주 마주치는 세 가지 필드에 집중한다.

- `nested_loop`: MySQL이 조인 입력을 어떤 순서로 중첩 반복하는지 보여 준다.
- `cost_info`: 옵티마이저가 각 접근 경로와 전체 계획을 어느 정도 비용으로 평가했는지 보여 준다.
- `attached_condition`: 테이블에서 행을 읽은 뒤 해당 테이블 단계에 붙어서 평가되는 조건을 보여 준다.

이 세 필드를 함께 읽으면 “실행 계획이 무엇을 하는가”에서 한 단계 더 나아가 “왜 이 계획이 선택되었고, 어느 지점에서 행 수가 줄어드는가”를 설명할 수 있다. 단, `EXPLAIN`은 실제 실행 시간이 아니라 옵티마이저의 사전 추정이다. 따라서 운영 판단에서는 실제 실행 통계, `EXPLAIN ANALYZE`, Performance Schema, 슬로우 쿼리 로그와 함께 해석해야 한다.

## 2. FORMAT=JSON의 기본 구조

`EXPLAIN FORMAT=JSON` 결과는 하나의 JSON 문서다. 최상위에는 보통 `query_block`이 있고, 그 안에 단일 테이블 접근이면 `table`, 조인이면 `nested_loop` 배열이 나타난다. 각 `table` 객체에는 접근 방식, 후보 키, 선택된 키, 예상 행 수, 필터링 비율, 비용 정보, 붙은 조건 등이 들어간다.

```mermaid
flowchart TD
    A[SQL 문장] --> B[Parser와 Resolver]
    B --> C[Optimizer]
    C --> D[접근 경로 후보 생성]
    D --> E[비용 추정]
    E --> F[조인 순서 선택]
    F --> G[EXPLAIN FORMAT=JSON]
    G --> H[nested_loop]
    G --> I[cost_info]
    G --> J[attached_condition]
    H --> K[테이블 읽기 순서]
    I --> L[계획 선택 근거]
    J --> M[조건 적용 위치]
```

표 형태의 `EXPLAIN`에서 `type`, `key`, `rows`, `filtered`, `Extra`를 읽던 경험은 JSON 형식에서도 그대로 이어진다. 다만 JSON 형식은 계층 구조를 보존하므로, 특히 조인 계획에서 “어느 테이블의 어떤 단계에 어떤 조건이 붙었는가”를 더 안정적으로 추적할 수 있다.

## 3. 재현용 스키마와 데이터

아래 예제는 고객과 주문 테이블을 조인하는 단순한 조회를 사용한다. 운영 쿼리와 비교하면 작고 단순하지만, `nested_loop`, `cost_info`, `attached_condition`을 읽는 데 필요한 구조를 모두 포함한다. 예제는 MySQL 8.0 검증 컨테이너에서 실행 가능한 형태로 작성했다.

```sql
DROP TABLE IF EXISTS explain_json_orders;
DROP TABLE IF EXISTS explain_json_customers;

CREATE TABLE explain_json_customers (
  customer_id BIGINT PRIMARY KEY,
  region VARCHAR(10) NOT NULL,
  tier VARCHAR(10) NOT NULL,
  created_at DATETIME NOT NULL,
  KEY idx_region_tier (region, tier)
) ENGINE=InnoDB;

CREATE TABLE explain_json_orders (
  order_id BIGINT PRIMARY KEY,
  customer_id BIGINT NOT NULL,
  status VARCHAR(12) NOT NULL,
  amount DECIMAL(10,2) NOT NULL,
  created_at DATETIME NOT NULL,
  KEY idx_customer_status_created (customer_id, status, created_at),
  KEY idx_status_amount (status, amount)
) ENGINE=InnoDB;

INSERT INTO explain_json_customers VALUES
  (1, 'KR', 'GOLD', '2026-01-01 00:00:00'),
  (2, 'KR', 'SILVER', '2026-01-02 00:00:00'),
  (3, 'US', 'GOLD', '2026-01-03 00:00:00'),
  (4, 'JP', 'BRONZE', '2026-01-04 00:00:00'),
  (5, 'KR', 'GOLD', '2026-01-05 00:00:00');

INSERT INTO explain_json_orders VALUES
  (101, 1, 'PAID', 120.00, '2026-02-01 10:00:00'),
  (102, 1, 'CANCELLED', 40.00, '2026-02-02 10:00:00'),
  (103, 2, 'PAID', 70.00, '2026-02-03 10:00:00'),
  (104, 3, 'PAID', 300.00, '2026-02-04 10:00:00'),
  (105, 5, 'PAID', 220.00, '2026-02-05 10:00:00'),
  (106, 5, 'REFUND', 220.00, '2026-02-06 10:00:00');

ANALYZE TABLE explain_json_customers, explain_json_orders;
```

실행 결과(MySQL 8.0.x):

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

mysql> CREATE TABLE explain_json_orders (...);
Query OK, 0 rows affected (0.01 sec)

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

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

mysql> ANALYZE TABLE explain_json_customers, explain_json_orders;
+----------------------------------------+---------+----------+----------+
| Table                                  | Op      | Msg_type | Msg_text |
+----------------------------------------+---------+----------+----------+
| mysql_tech_note.explain_json_customers | analyze | status   | OK       |
| mysql_tech_note.explain_json_orders    | analyze | status   | OK       |
+----------------------------------------+---------+----------+----------+
2 rows in set (0.01 sec)
```

이 예제에서는 `explain_json_customers.region` 조건과 `explain_json_orders.status`, `amount` 조건이 함께 등장한다. 옵티마이저는 고객 테이블에서 지역으로 먼저 좁힐 수도 있고, 주문 테이블에서 상태와 금액으로 먼저 좁힌 뒤 고객을 붙일 수도 있다. 작은 예제에서는 비용 차이가 크지 않지만, 같은 읽기 방식이 대용량 테이블에서는 수 초 이상의 차이로 확대될 수 있다.

## 4. nested_loop: 조인 순서를 계층으로 읽기

다음 쿼리는 한국 고객 중 결제 완료 주문을 조회한다.

```sql
EXPLAIN FORMAT=JSON
SELECT c.customer_id, c.region, o.order_id, o.amount
FROM explain_json_customers AS c
JOIN explain_json_orders AS o
  ON o.customer_id = c.customer_id
WHERE c.region = 'KR'
  AND o.status = 'PAID'
ORDER BY o.created_at DESC;
```

실행 결과(MySQL 8.0.x, 핵심 필드 발췌):

```json
{
  "query_cost": "2.30",
  "nested_loop": [
    {
      "table_name": "o",
      "access_type": "ref",
      "key": "idx_status_amount",
      "used_key_parts": ["status"],
      "rows_examined_per_scan": 4,
      "rows_produced_per_join": 4,
      "filtered": "100.00",
      "cost_info": {
        "read_cost": "0.50",
        "eval_cost": "0.40",
        "prefix_cost": "0.90",
        "data_read_per_join": "320"
      }
    },
    {
      "table_name": "c",
      "access_type": "eq_ref",
      "key": "PRIMARY",
      "used_key_parts": ["customer_id"],
      "rows_examined_per_scan": 1,
      "rows_produced_per_join": 2,
      "filtered": "60.00",
      "attached_condition": "(`mysql_tech_note`.`c`.`region` = 'KR')",
      "cost_info": {
        "read_cost": "1.00",
        "eval_cost": "0.24",
        "prefix_cost": "2.30",
        "data_read_per_join": "230"
      }
    }
  ]
}
```

JSON 결과에서 가장 먼저 볼 부분은 `query_block.nested_loop`다. 배열의 앞쪽에 있는 테이블이 바깥쪽 입력, 뒤쪽에 있는 테이블이 안쪽 입력이다. Nested Loop Join은 바깥쪽 테이블에서 후보 행을 읽고, 그 행마다 안쪽 테이블을 탐색하는 방식이다.

개념적으로는 다음과 같이 해석할 수 있다.

```text
for each row from first table in nested_loop:
    for each matching row from second table in nested_loop:
        apply remaining conditions
        return joined row
```

운영 진단에서 중요한 질문은 세 가지다.

1. 바깥쪽 테이블의 예상 행 수가 충분히 작게 줄었는가?
2. 안쪽 테이블 접근이 조인 키 또는 적절한 복합 인덱스를 사용하는가?
3. 조인 뒤에야 적용되는 조건 때문에 불필요하게 많은 행을 읽고 있지는 않은가?

`nested_loop` 배열의 순서가 기대와 다르다고 해서 즉시 나쁜 계획은 아니다. MySQL 옵티마이저는 통계와 비용 모델에 따라 더 싸다고 판단한 순서를 고른다. 그러나 통계가 오래되었거나, 조건의 선택도가 실제와 다르거나, 복합 인덱스 선두 컬럼이 쿼리 조건과 맞지 않으면 바깥쪽 입력이 과도하게 커질 수 있다. 이때는 `ANALYZE TABLE`, 인덱스 재설계, 조건식 단순화, Histogram 검토가 필요하다.

## 5. cost_info: 절대값보다 상대 비교와 변화 추세를 본다

`cost_info`는 JSON 실행 계획에서 다음과 같은 위치에 나타난다.

- `query_block.cost_info.query_cost`: 전체 쿼리 블록 비용 추정값
- 각 `table.cost_info.read_cost`: 행을 읽는 데 드는 비용 추정값
- 각 `table.cost_info.eval_cost`: 조건 평가 비용 추정값
- 각 `table.cost_info.prefix_cost`: 현재 단계까지 누적된 비용 추정값
- 각 `table.cost_info.data_read_per_join`: 조인 단계에서 읽을 것으로 예상되는 데이터량

비용 값은 실제 밀리초나 I/O 횟수가 아니다. MySQL 내부 비용 모델이 접근 경로를 비교하기 위해 사용하는 상대적 수치다. 따라서 “`query_cost`가 10이면 10ms”처럼 해석하면 안 된다. 대신 같은 쿼리에 대해 인덱스 추가 전후, 조건 변경 전후, 통계 갱신 전후의 비용과 예상 행 수가 어떤 방향으로 변하는지 보는 것이 안전하다.

운영에서 `cost_info`를 읽을 때는 다음 순서가 실용적이다.

1. `query_cost`가 갑자기 커진 배포 또는 통계 갱신 시점이 있는지 확인한다.
2. 조인 각 단계의 `rows_examined_per_scan`, `rows_produced_per_join`, `filtered`를 함께 본다.
3. `read_cost`가 큰 테이블이 실제 병목 테이블인지 슬로우 로그와 Performance Schema로 대조한다.
4. 비용은 낮아 보이지만 실제 시간이 긴 경우, 잠금 대기·디스크 I/O·버퍼 풀 미스·임시 테이블·정렬 비용을 별도로 확인한다.

특히 `data_read_per_join`은 실행 계획이 읽을 것으로 보는 데이터량의 감각을 제공한다. 대형 테이블에서 이 값이 비정상적으로 크면 “인덱스를 썼다”는 사실만으로 안심해서는 안 된다. 인덱스 범위가 너무 넓거나, 커버링되지 않아 테이블 행을 많이 되찾아오는 상황일 수 있다.

## 6. attached_condition: 조건이 어디에 붙었는가

`attached_condition`은 특정 테이블 접근 단계에 붙어서 평가되는 조건을 표시한다. 이 이름 때문에 “인덱스 조건으로 모두 밀어 넣어진 조건”이라고 오해하기 쉽지만, 의미는 더 넓다. 해당 테이블에서 행을 읽은 뒤 그 단계에서 평가되는 조건이라는 뜻에 가깝다.

다음 예제는 주문 금액 조건을 추가해 조건 적용 위치를 더 분명히 보이게 한다.

```sql
EXPLAIN FORMAT=JSON
SELECT c.customer_id, c.region, c.tier, o.order_id, o.amount
FROM explain_json_customers AS c
JOIN explain_json_orders AS o
  ON o.customer_id = c.customer_id
WHERE c.region = 'KR'
  AND c.tier = 'GOLD'
  AND o.status = 'PAID'
  AND o.amount >= 100.00;
```

실행 결과(MySQL 8.0.x, 핵심 필드 발췌):

```json
{
  "query_cost": "1.15",
  "nested_loop": [
    {
      "table_name": "c",
      "access_type": "ref",
      "key": "idx_region_tier",
      "used_key_parts": ["region", "tier"],
      "rows_examined_per_scan": 2,
      "rows_produced_per_join": 2,
      "filtered": "100.00",
      "using_index": true,
      "cost_info": {
        "read_cost": "0.25",
        "eval_cost": "0.20",
        "prefix_cost": "0.45",
        "data_read_per_join": "192"
      }
    },
    {
      "table_name": "o",
      "access_type": "ref",
      "key": "idx_customer_status_created",
      "used_key_parts": ["customer_id", "status"],
      "rows_examined_per_scan": 1,
      "rows_produced_per_join": 0,
      "filtered": "33.33",
      "attached_condition": "(`mysql_tech_note`.`o`.`amount` >= 100.00)",
      "cost_info": {
        "read_cost": "0.50",
        "eval_cost": "0.07",
        "prefix_cost": "1.15",
        "data_read_per_join": "53"
      }
    }
  ]
}
```

이 결과에서 고객 테이블 쪽에는 `c.region = 'KR'`, `c.tier = 'GOLD'`와 관련된 조건이 붙고, 주문 테이블 쪽에는 `o.status = 'PAID'`, `o.amount >= 100.00`, `o.customer_id = c.customer_id`와 관련된 조건이 나타날 수 있다. 실제 표현은 MySQL minor version, 선택된 조인 순서, 조건 단순화 결과에 따라 다소 달라진다.

여기서 주의할 점은 `attached_condition`만 보고 인덱스 사용 여부를 단정하지 않는 것이다. 인덱스 탐색에 실제로 사용된 컬럼은 `key`, `used_key_parts`, `ref`, `access_type`, `index_condition` 등을 함께 봐야 한다. 예를 들어 복합 인덱스 `(customer_id, status, created_at)`에서 `customer_id`와 `status`가 탐색에 쓰이고, `amount >= 100.00`은 인덱스에 없으므로 읽은 뒤 필터링될 수 있다. 반대로 `(status, amount)` 인덱스를 쓰면 상태와 금액 범위 탐색에는 유리하지만 고객 조인에는 다른 비용이 생긴다.

정리하면 다음과 같다.

| 필드 | 운영 해석 |
|---|---|
| `key` | 선택된 인덱스다. `NULL`이면 테이블 스캔 또는 다른 접근 방식 가능성을 의심한다. |
| `used_key_parts` | 복합 인덱스 중 실제 탐색 조건으로 사용된 선두 컬럼 범위다. |
| `attached_condition` | 해당 테이블 단계에서 평가되는 조건이다. 인덱스 탐색 조건과 사후 필터 조건이 함께 보일 수 있다. |
| `index_condition` | Index Condition Pushdown이 적용되어 스토리지 엔진의 인덱스 레벨에서 평가되는 조건을 나타낼 수 있다. |
| `rows_examined_per_scan` | 한 번의 스캔에서 읽을 것으로 예상되는 행 수다. |
| `rows_produced_per_join` | 현재 조인 단계까지 생산될 것으로 예상되는 행 수다. |

## 7. 표 형태 EXPLAIN과 함께 대조하기

JSON 계획은 상세하지만 길다. 현장에서 빠르게 볼 때는 표 형태 `EXPLAIN`으로 접근 방식을 먼저 확인하고, 의심 지점만 JSON으로 내려가는 방식이 효율적이다.

```sql
EXPLAIN
SELECT c.customer_id, c.region, c.tier, o.order_id, o.amount
FROM explain_json_customers AS c
JOIN explain_json_orders AS o
  ON o.customer_id = c.customer_id
WHERE c.region = 'KR'
  AND c.tier = 'GOLD'
  AND o.status = 'PAID'
  AND o.amount >= 100.00;
```

실행 결과(MySQL 8.0.x):

```text
mysql> EXPLAIN
    -> SELECT c.customer_id, c.region, c.tier, o.order_id, o.amount
    -> FROM explain_json_customers AS c
    -> JOIN explain_json_orders AS o
    ->   ON o.customer_id = c.customer_id
    -> WHERE c.region = 'KR'
    ->   AND c.tier = 'GOLD'
    ->   AND o.status = 'PAID'
    ->   AND o.amount >= 100.00;

+----+-------------+-------+------------+------+-----------------------------------------------+-----------------------------+---------+-------------------------------------+------+----------+-------------+
| id | select_type | table | partitions | type | possible_keys                                 | key                         | key_len | ref                                 | rows | filtered | Extra       |
+----+-------------+-------+------------+------+-----------------------------------------------+-----------------------------+---------+-------------------------------------+------+----------+-------------+
|  1 | SIMPLE      | c     | NULL       | ref  | PRIMARY,idx_region_tier                       | idx_region_tier             | 84      | const,const                         |    2 |   100.00 | Using index |
|  1 | SIMPLE      | o     | NULL       | ref  | idx_customer_status_created,idx_status_amount | idx_customer_status_created | 58      | mysql_tech_note.c.customer_id,const |    1 |    33.33 | Using where |
+----+-------------+-------+------------+------+-----------------------------------------------+-----------------------------+---------+-------------------------------------+------+----------+-------------+
2 rows in set (0.00 sec)
```

표 형태 결과에서는 `type`, `possible_keys`, `key`, `rows`, `filtered`, `Extra`가 핵심이다. `type`이 `ALL`이면 무조건 나쁘다고 볼 수는 없지만, 대형 테이블에서 선택도가 높은 조건이 있는데도 `ALL`이면 통계·인덱스·조건 표현을 점검해야 한다. `rows`와 `filtered`는 JSON의 `rows_examined_per_scan`, `filtered`와 연결해서 읽는다.

운영에서는 다음과 같은 순서로 보면 좋다.

1. 표 형태 `EXPLAIN`으로 전체 테이블 순서와 인덱스 선택을 빠르게 확인한다.
2. `rows`가 큰 테이블 또는 `key`가 기대와 다른 테이블을 찾는다.
3. 같은 쿼리를 `EXPLAIN FORMAT=JSON`으로 실행한다.
4. 해당 테이블 객체의 `used_key_parts`, `attached_condition`, `cost_info`를 자세히 읽는다.
5. 실제 실행 시간이 문제라면 `EXPLAIN ANALYZE` 또는 운영 부하가 낮은 환경의 재현 테스트로 추정과 실제 차이를 확인한다.

## 8. MySQL과 Aurora MySQL에서의 운영 해석 차이

Aurora MySQL도 MySQL 호환 옵티마이저와 실행 계획 형식을 제공하므로 `EXPLAIN FORMAT=JSON`의 기본 해석법은 동일하다. 그러나 운영 판단에서는 몇 가지 차이를 고려해야 한다.

첫째, Aurora의 스토리지 계층은 MySQL Community Server의 로컬 InnoDB 파일 배치와 다르다. 실행 계획의 비용 추정은 여전히 옵티마이저 모델에 기반하지만, 실제 지연 시간은 Aurora 스토리지, 캐시 상태, reader 인스턴스의 워밍업 상태, 네트워크 경로의 영향을 받을 수 있다. 따라서 JSON 계획에서 비용이 낮아 보여도 reader 전환 직후나 버퍼 캐시가 차가운 인스턴스에서는 실제 시간이 길어질 수 있다.

둘째, Aurora에서는 Performance Insights, CloudWatch 지표, wait event가 실행 계획 해석을 보완한다. `EXPLAIN FORMAT=JSON`이 “많은 행을 읽을 가능성”을 보여 준다면, Performance Insights는 실제로 CPU, I/O, lock, buffer 관련 대기 중 무엇이 시간을 소비했는지 보여 준다.

셋째, Aurora reader endpoint를 사용하는 애플리케이션은 동일 SQL이라도 reader별 통계·캐시 상태·버전 패치 수준에 따라 체감 성능이 달라질 수 있다. 계획이 불안정한 쿼리는 단일 인스턴스에서만 보지 말고 writer와 주요 reader에서 함께 확인해야 한다.

## 9. 흔한 오해와 장애 패턴

### 9.1 `attached_condition`이 있으면 인덱스를 못 쓴 것이다?

그렇지 않다. `attached_condition`은 해당 테이블 단계에 붙은 조건을 뜻한다. 인덱스 탐색에 쓰인 조건도 표현에 포함될 수 있고, 인덱스로 행을 좁힌 뒤 추가로 평가되는 조건도 포함될 수 있다. 반드시 `key`, `used_key_parts`, `access_type`, `rows_examined_per_scan`과 함께 판단해야 한다.

### 9.2 `cost_info`가 낮으면 실제 쿼리도 빠르다?

항상 그렇지 않다. 비용은 옵티마이저의 선택용 추정값이다. 실제 시간은 캐시 적중률, 동시성, 잠금 대기, 디스크 상태, 정렬과 임시 테이블, 네트워크 전송량에 영향을 받는다. 비용이 낮지만 느리다면 실행 대기 이벤트와 실제 row count를 확인해야 한다.

### 9.3 `nested_loop`라서 무조건 비효율적이다?

MySQL의 일반적인 조인 실행은 Nested Loop 기반이며, 적절한 인덱스가 있으면 매우 효율적이다. 문제는 바깥쪽 입력이 너무 크거나, 안쪽 테이블 탐색이 인덱스를 제대로 사용하지 못할 때다. `nested_loop` 자체보다 배열 순서, 각 단계의 예상 행 수, 인덱스 접근 방식을 봐야 한다.

### 9.4 JSON 결과가 길어서 핵심을 놓친다

JSON 결과 전체를 한 번에 읽으려 하면 피로도가 높다. 먼저 `nested_loop` 배열의 테이블 순서를 확인하고, 각 테이블에서 `access_type`, `key`, `used_key_parts`, `rows_examined_per_scan`, `rows_produced_per_join`, `filtered`, `cost_info`, `attached_condition`만 추려 읽는 습관이 필요하다.

## 10. 운영 점검 체크리스트

`EXPLAIN FORMAT=JSON`을 운영 쿼리에 적용할 때는 다음 항목을 순서대로 점검한다.

- [ ] 문제 SQL의 바인드 값 또는 대표 리터럴이 실제 운영 분포를 반영하는가?
- [ ] `nested_loop` 배열에서 바깥쪽 테이블이 과도하게 큰 입력이 아닌가?
- [ ] 각 테이블의 `access_type`이 기대한 접근 방식인가?
- [ ] `key`와 `used_key_parts`가 복합 인덱스 설계 의도와 일치하는가?
- [ ] `rows_examined_per_scan`과 `rows_produced_per_join`이 실제 데이터 규모에 비해 과도하지 않은가?
- [ ] `attached_condition`에 남은 조건이 인덱스에 포함되어야 할 고선택도 조건은 아닌가?
- [ ] `cost_info.query_cost`와 주요 테이블의 `prefix_cost`가 변경 전후 어떤 방향으로 움직였는가?
- [ ] 통계가 오래되었거나 데이터 분포가 크게 바뀐 테이블에 `ANALYZE TABLE`이 필요한가?
- [ ] MySQL Community와 Aurora MySQL에서 동일 계획이 나오는지, reader별 차이가 있는지 확인했는가?
- [ ] 실제 지연 시간은 `EXPLAIN ANALYZE`, 슬로우 로그, Performance Schema, Performance Insights로 대조했는가?

## 11. 결론

`EXPLAIN FORMAT=JSON`은 표 형태 `EXPLAIN`의 대체물이 아니라 확장판에 가깝다. 표 형태가 빠른 진단용 요약이라면, JSON 형식은 조인 순서와 비용 추정, 조건 적용 위치를 계층적으로 설명하는 문서다. `nested_loop`는 쿼리가 어떤 순서로 테이블을 중첩 탐색하는지 보여 주고, `cost_info`는 옵티마이저가 그 계획을 선택한 상대적 근거를 제공하며, `attached_condition`은 조건이 어느 테이블 단계에서 평가되는지 알려 준다.

다음 단계에서는 JSON 실행 계획을 실제 실행 결과와 연결해야 한다. `EXPLAIN ANALYZE`, Performance Schema, 슬로우 쿼리 로그를 함께 사용하면 옵티마이저 추정과 실제 실행 사이의 차이를 발견할 수 있다. 실행 계획을 읽는 목적은 단순히 “어떤 인덱스를 썼다”를 확인하는 데 있지 않다. 데이터 분포, 조인 순서, 조건 적용 위치, 실제 대기 원인을 연결해 더 안정적인 스키마와 쿼리 설계를 만드는 데 있다.
