---
title: "MySQL Cost Model 이해: 인덱스와 조인 순서를 선택하는 기준"
description: "MySQL Cost Model이 row 추정, 인덱스 비용 비교, 조인 순서 선택에 어떤 영향을 주는지 운영 관점에서 정리한다."
tags: [ MySQL, 성능최적화, 인덱스, 운영, DBA ]
image: "mysql-report-bg.png"
published: "2026-07-02"
updated: "2026-07-02"
author: "MySQL 기술 노트"
source_url: ""
---

## 1. 왜 Cost Model을 DBA가 이해해야 하는가

운영 현장에서 자주 듣는 질문은 대개 비슷하다. “인덱스가 있는데 왜 full scan을 탔는가”, “어제까지는 빠르던 조인이 왜 오늘은 느린가”, “같은 SQL인데 어떤 값에서는 빠르고 어떤 값에서는 느린가” 같은 질문이다. 이때 많은 경우 문제의 핵심은 단순히 인덱스의 유무가 아니라, **MySQL이 어떤 경로를 더 싸다고 계산했는가**에 있다.

MySQL Optimizer는 가능한 모든 계획을 완전 탐색하는 존재가 아니다. 제한된 통계와 메타데이터를 바탕으로 각 접근 경로의 비용을 추정하고, 그중 가장 낮다고 판단한 경로를 고른다. 이 판단 체계가 Cost Model이다.

Cost Model을 이해하면 다음과 같은 운영 판단이 쉬워진다.

- 인덱스 추가가 실제로 도움이 될지, 아니면 통계 보정이 먼저인지 구분할 수 있다.
- 조인 순서가 왜 바뀌었는지 `EXPLAIN` 결과를 구조적으로 해석할 수 있다.
- `ANALYZE TABLE`, Histogram, 복합 인덱스, 쿼리 재작성 중 무엇이 더 근본적인 대응인지 우선순위를 잡을 수 있다.
- Aurora MySQL처럼 엔진 호환은 같지만 운영 환경이 다른 플랫폼에서도, “스토리지가 다르니 실행 계획도 별개”라는 오해를 피할 수 있다.

중요한 점은 Cost Model이 절대적인 진실이 아니라는 사실이다. Cost Model은 **실제 비용이 아니라 추정 비용**을 다룬다. 따라서 통계가 낡았거나 데이터 분포가 치우쳐 있거나 조건식이 비SARGable하면, 합리적으로 보이는 계산 끝에 비합리적인 계획이 선택될 수 있다.

## 2. Cost Model의 큰 그림

MySQL은 SQL을 처리할 때 단순히 “인덱스가 있으면 인덱스, 없으면 scan”처럼 판단하지 않는다. Optimizer는 각 후보 계획에 대해 대략 다음 질문을 던진다.

1. 이 조건으로 몇 row가 남을 것 같은가?
2. 그 row를 읽기 위해 어떤 access path를 사용할 수 있는가?
3. index lookup 비용과 table scan 비용 중 어느 쪽이 더 작은가?
4. 여러 테이블을 조인한다면 어느 테이블부터 읽는 편이 전체 row 폭증을 줄이는가?
5. 정렬, 임시 테이블, random I/O, 재탐색 횟수를 모두 포함하면 어떤 계획이 더 싼가?

이를 단순화하면 다음 흐름으로 이해할 수 있다.

```mermaid
flowchart LR
    A[SQL과 조건식] --> B[통계와 메타데이터]
    B --> C[row 수 추정]
    C --> D[access path 비용 비교]
    D --> E[조인 순서 비용 비교]
    E --> F[정렬/임시 테이블 비용 반영]
    F --> G[최종 실행 계획 선택]
```

운영자가 기억할 핵심은 다음과 같다.

- **row estimate가 틀리면 뒤 단계 전체가 흔들린다.**
- **인덱스 존재만으로는 충분하지 않다.** 예상 row 수가 많으면 scan이 더 싸다고 계산될 수 있다.
- **조인 순서는 독립 결정이 아니다.** 앞 단계에서 얼마나 row를 줄일 수 있느냐가 뒤 테이블의 lookup 비용을 바꾼다.
- **정렬과 임시 테이블 비용도 계획 선택에 개입한다.** 필터링에 좋은 인덱스와 정렬 회피에 좋은 인덱스가 경쟁할 수 있다.

## 3. Cost Model의 입력: Optimizer는 무엇을 보고 계산하는가

### 3.1 기본 입력 요소

MySQL Cost Model은 대략 다음 입력을 사용한다.

- 테이블 row 수와 page 수에 대한 통계
- 인덱스 cardinality
- 조건식 selectivity 추정치
- 히스토그램 같은 분포 보조 통계
- join predicate와 available index
- 정렬, group by, temporary table 필요 여부
- 엔진별 I/O 및 CPU 계열 비용 상수

즉, Cost Model은 단일 숫자 하나가 아니라 **통계 + 후보 경로 + 비용 상수 + 변환 규칙**의 조합이다.

### 3.2 왜 잘못된 추정이 자주 생기는가

실제 운영 데이터는 평균적이지 않다. 특정 tenant가 대부분의 row를 차지하고, 특정 상태값이 95%를 차지하며, 최근 데이터만 폭증하는 경우가 흔하다. 그런데 옵티마이저가 이 분포를 충분히 반영하지 못하면 다음 문제가 생긴다.

- 희소 값은 너무 비싸게 추정되어 index lookup이 버려진다.
- 흔한 값은 너무 싸게 추정되어 비효율적인 nested loop가 유지된다.
- outer table row 수를 과소추정해 잘못된 join order가 선택된다.
- covering index보다 정렬 회피 인덱스를 택하거나, 반대로 정렬 비용을 과소평가한다.

따라서 실행 계획 문제는 종종 “옵티마이저 버그”보다 **입력 통계와 데이터 분포의 불일치**로 설명된다.

## 4. 인덱스를 선택할 때 MySQL이 보는 것

인덱스가 있다고 해서 무조건 좋은 것은 아니다. 인덱스를 사용하면 보통 다음 이점이 있다.

- 필요한 row만 좁게 읽을 수 있다.
- 정렬 순서를 인덱스로 해결할 수 있다.
- covering index라면 base table 접근을 줄일 수 있다.

반대로 다음 비용도 따라온다.

- index traversal 자체의 비용
- secondary index에서 PK lookup으로 다시 table row를 찾아가는 비용
- 조건이 넓으면 많은 random access가 발생하는 비용
- 인덱스를 탔지만 결국 읽는 row 수가 너무 많은 경우의 비효율

결국 MySQL은 “인덱스 유무”가 아니라 **이 인덱스를 타면 얼마나 적은 row를 얼마나 적은 재탐색으로 읽을 수 있는가**를 계산한다. 그래서 다음 현상이 자연스럽게 발생한다.

- 조건이 너무 넓으면 full scan이 선택될 수 있다.
- 선행 컬럼이 맞지 않는 복합 인덱스는 후보 가치가 낮다.
- 함수가 걸린 조건은 range access 후보를 약하게 만들 수 있다.
- 정렬 회피가 가능한 인덱스가 filtering이 좋은 인덱스를 이길 수 있다.

실무에서는 “인덱스를 추가했는데 왜 여전히 scan인가”를 불평하기 전에, **해당 인덱스가 실제로 row 수를 충분히 줄이고 있는가**를 먼저 봐야 한다.

## 5. 조인 순서를 선택할 때 MySQL이 보는 것

MySQL 8.0의 일반적인 조인 실행은 여전히 nested-loop 계열 사고방식에 강하게 기대고 있다. 따라서 첫 번째로 읽는 테이블의 선택이 매우 중요하다. 바깥쪽 테이블이 너무 많은 row를 내보내면, 안쪽 테이블 lookup이 그만큼 반복되기 때문이다.

조인 순서 선택에서 Cost Model이 중요하게 보는 질문은 다음과 같다.

1. 어느 테이블이 가장 먼저 row 수를 크게 줄일 수 있는가?
2. 그 테이블을 먼저 읽었을 때 다음 테이블 조인 조건이 인덱스를 타는가?
3. 조인 후 중간 결과가 얼마나 불어날 것 같은가?
4. 정렬과 집계까지 고려하면 어느 순서가 전체 비용을 줄이는가?

여기서 자주 생기는 오해는 “작은 테이블부터 읽는다”는 단순화다. 실제로는 단순 크기보다 **필터 후 남는 row 수와 다음 단계 lookup 비용**이 더 중요하다. 따라서 절대 row 수가 큰 테이블이라도, 아주 selective한 조건과 좋은 인덱스가 있으면 먼저 읽는 편이 더 쌀 수 있다.

## 6. 운영 관점에서 중요한 Cost Model 관련 객체와 변수

Cost Model은 내부 알고리즘처럼 느껴지지만, 실제로 운영자가 관찰할 수 있는 표면도 존재한다. 대표적으로 다음 요소를 기억할 가치가 있다.

- `mysql.server_cost`
- `mysql.engine_cost`
- `optimizer_switch`
- 인덱스 cardinality와 histogram 정보
- `EXPLAIN FORMAT=TREE`, `EXPLAIN ANALYZE`

특히 `mysql.server_cost`, `mysql.engine_cost`는 MySQL이 비용 상수를 어떤 구조로 관리하는지 보여 주는 대표적인 진입점이다. 대부분의 운영 환경에서는 기본값을 직접 바꾸지 않지만, **옵티마이저가 추상적인 감이 아니라 비용 테이블과 통계를 바탕으로 행동한다**는 사실을 이해하는 데 도움이 된다.

아래 SQL은 현재 인스턴스의 버전과 비용 테이블의 기본 상태를 확인하는 예제다.

```sql
SELECT VERSION() AS mysql_version;

SELECT cost_name, default_value, cost_value
FROM mysql.server_cost
ORDER BY cost_name;

SELECT engine_name, device_type, cost_name, default_value, cost_value
FROM mysql.engine_cost
ORDER BY engine_name, device_type, cost_name;
```

실행 결과(MySQL 8.0.x):

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

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

mysql> SELECT cost_name, default_value, cost_value
    -> FROM mysql.server_cost
    -> ORDER BY cost_name;

+------------------------------+---------------+------------+
| cost_name                    | default_value | cost_value |
+------------------------------+---------------+------------+
| disk_temptable_create_cost   |            20 |       NULL |
| disk_temptable_row_cost      |           0.5 |       NULL |
| key_compare_cost             |          0.05 |       NULL |
| memory_temptable_create_cost |             1 |       NULL |
| memory_temptable_row_cost    |           0.1 |       NULL |
| row_evaluate_cost            |           0.1 |       NULL |
+------------------------------+---------------+------------+
6 rows in set (0.00 sec)

mysql> SELECT engine_name, device_type, cost_name, default_value, cost_value
    -> FROM mysql.engine_cost
    -> ORDER BY engine_name, device_type, cost_name;

+-------------+-------------+------------------------+---------------+------------+
| engine_name | device_type | cost_name              | default_value | cost_value |
+-------------+-------------+------------------------+---------------+------------+
| default     |           0 | io_block_read_cost     |             1 |       NULL |
| default     |           0 | memory_block_read_cost |          0.25 |       NULL |
+-------------+-------------+------------------------+---------------+------------+
2 rows in set (0.00 sec)
```

이 조회는 “당장 값을 조정하라”는 의미가 아니다. 실무에서는 오히려 비용 상수 직접 튜닝보다, 통계 갱신과 인덱스 설계와 SQL 재작성의 효과가 더 크다. 다만 이 테이블을 확인해 두면 실행 계획 논의를 할 때 “MySQL은 내부적으로 비용 테이블 없이 감으로 고른다”는 식의 오해를 줄일 수 있다.

## 7. 예제 1: 선택도가 높을 때와 낮을 때 인덱스 비용이 어떻게 달라지는가

아래 예제는 하나의 복합 인덱스가 있어도, 조건의 선택도가 달라지면 옵티마이저가 같은 방식으로 보지 않는다는 점을 보여 주기 위한 축소 재현이다. `category_code='D'`는 매우 희소하고, `category_code='A'`는 대부분의 row를 차지하도록 데이터를 만든다.

```sql
DROP TABLE IF EXISTS cost_access_demo;

CREATE TABLE cost_access_demo (
  id INT PRIMARY KEY,
  category_code CHAR(1) NOT NULL,
  created_at DATE NOT NULL,
  amount INT NOT NULL,
  KEY idx_category_created (category_code, created_at)
);

INSERT INTO cost_access_demo (id, category_code, created_at, amount)
WITH RECURSIVE seq AS (
  SELECT 1 AS n
  UNION ALL
  SELECT n + 1 FROM seq WHERE n < 1000
)
SELECT n,
       CASE
         WHEN n <= 900 THEN 'A'
         WHEN n <= 970 THEN 'B'
         WHEN n <= 995 THEN 'C'
         ELSE 'D'
       END AS category_code,
       DATE_ADD('2026-01-01', INTERVAL MOD(n, 30) DAY) AS created_at,
       n * 10 AS amount
FROM seq;

ANALYZE TABLE cost_access_demo;

EXPLAIN FORMAT=TREE
SELECT id, created_at, amount
FROM cost_access_demo
WHERE category_code = 'D'
  AND created_at >= '2026-01-20'
ORDER BY created_at;

EXPLAIN FORMAT=TREE
SELECT id, created_at, amount
FROM cost_access_demo
WHERE category_code = 'A'
  AND created_at >= '2026-01-20'
ORDER BY created_at;

DROP TABLE IF EXISTS cost_access_demo;
```

실행 결과(MySQL 8.0.x):

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

Query OK, 0 rows affected (0.00 sec)

mysql> CREATE TABLE cost_access_demo (...);

Query OK, 0 rows affected (0.01 sec)

mysql> INSERT INTO cost_access_demo (...)
    -> WITH RECURSIVE seq AS (...)
    -> SELECT ...
    -> FROM seq;

Query OK, 1000 rows affected (0.00 sec)
Records: 1000  Duplicates: 0  Warnings: 0

mysql> ANALYZE TABLE cost_access_demo;

+----------------------------------+---------+----------+----------+
| Table                            | Op      | Msg_type | Msg_text |
+----------------------------------+---------+----------+----------+
| mysql_tech_note.cost_access_demo | analyze | status   | OK       |
+----------------------------------+---------+----------+----------+
1 row in set (0.01 sec)

mysql> EXPLAIN FORMAT=TREE
    -> SELECT id, created_at, amount
    -> FROM cost_access_demo
    -> WHERE category_code = 'D'
    ->   AND created_at >= '2026-01-20'
    -> ORDER BY created_at;

+-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+
| EXPLAIN                                                                                                                                                                                                                                                                 |
+-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+
| -> Index range scan on cost_access_demo using idx_category_created over (category_code = 'D' AND '2026-01-20' <= created_at), with index condition: ((cost_access_demo.category_code = 'D') and (cost_access_demo.created_at >= DATE'2026-01-20'))  (cost=0.71 rows=1) |
+-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+
1 row in set (0.00 sec)

mysql> EXPLAIN FORMAT=TREE
    -> SELECT id, created_at, amount
    -> FROM cost_access_demo
    -> WHERE category_code = 'A'
    ->   AND created_at >= '2026-01-20'
    -> ORDER BY created_at;

+-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+
| EXPLAIN                                                                                                                                                                                                                                                         |
+-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+
| -> Sort: cost_access_demo.created_at  (cost=101 rows=1000)                                                                                                                                                                                                   |
|     -> Filter: ((cost_access_demo.category_code = 'A') and (cost_access_demo.created_at >= DATE'2026-01-20'))  (cost=101 rows=1000)                                                                                                                        |
|         -> Table scan on cost_access_demo  (cost=101 rows=1000)                                                                                                                                                                                             |
+-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+
1 row in set (0.00 sec)

mysql> DROP TABLE IF EXISTS cost_access_demo;

Query OK, 0 rows affected (0.00 sec)
```

이 예제에서 기대하는 해석 포인트는 단순하다.

- 희소한 `D` 조건은 인덱스를 통해 매우 적은 row만 읽는 편이 싸게 계산되기 쉽다.
- 대부분을 차지하는 `A` 조건은 같은 인덱스라도 훨씬 많은 row를 읽게 되므로 비용상 이점이 줄어든다.
- 즉, **같은 인덱스도 조건 값의 분포에 따라 옵티마이저의 평가가 달라진다.**

실제 운영에서는 이 차이가 더 극단적으로 나타난다. 특정 tenant, 특정 status, 특정 날짜 구간이 전체 데이터 대부분을 차지한다면, “인덱스를 탔는가”보다 “인덱스를 타서 얼마나 많이 다시 읽어야 하는가”가 더 중요해진다.

## 8. 예제 2: Cost Model이 조인 순서를 고르는 방식

다음 예제는 고객과 주문 테이블을 만들어, 조건의 선택도 차이에 따라 조인 순서 해석이 어떻게 달라지는지 보는 흐름이다. 핵심은 “작은 테이블부터”가 아니라 **먼저 읽었을 때 전체 row 폭증을 줄일 수 있는 쪽**이 유리하다는 점이다.

```sql
DROP TABLE IF EXISTS cost_orders;
DROP TABLE IF EXISTS cost_customers;

CREATE TABLE cost_customers (
  customer_id INT PRIMARY KEY,
  customer_tier VARCHAR(10) NOT NULL,
  region_code CHAR(2) NOT NULL,
  KEY idx_tier_region (customer_tier, region_code)
);

CREATE TABLE cost_orders (
  order_id INT PRIMARY KEY,
  customer_id INT NOT NULL,
  status VARCHAR(10) NOT NULL,
  created_at DATE NOT NULL,
  amount INT NOT NULL,
  KEY idx_customer_status_created (customer_id, status, created_at),
  KEY idx_status_created_customer (status, created_at, customer_id)
);

INSERT INTO cost_customers (customer_id, customer_tier, region_code)
WITH RECURSIVE seq AS (
  SELECT 1 AS n
  UNION ALL
  SELECT n + 1 FROM seq WHERE n < 1000
)
SELECT n,
       CASE
         WHEN n <= 20 THEN 'VIP'
         WHEN n <= 220 THEN 'GOLD'
         ELSE 'STANDARD'
       END AS customer_tier,
       CASE MOD(n, 4)
         WHEN 0 THEN 'KR'
         WHEN 1 THEN 'US'
         WHEN 2 THEN 'JP'
         ELSE 'DE'
       END AS region_code
FROM seq;

INSERT INTO cost_orders (order_id, customer_id, status, created_at, amount)
WITH RECURSIVE seq1 AS (
  SELECT 0 AS n
  UNION ALL
  SELECT n + 1 FROM seq1 WHERE n < 99
),
seq2 AS (
  SELECT 1 AS n
  UNION ALL
  SELECT n + 1 FROM seq2 WHERE n < 100
)
SELECT (seq1.n * 100) + seq2.n AS order_id,
       (((seq1.n * 100) + seq2.n - 1) MOD 1000) + 1 AS customer_id,
       CASE
         WHEN MOD((seq1.n * 100) + seq2.n, 20) = 0 THEN 'FAILED'
         WHEN MOD((seq1.n * 100) + seq2.n, 3) = 0 THEN 'PENDING'
         ELSE 'PAID'
       END AS status,
       DATE_ADD('2026-02-01', INTERVAL MOD((seq1.n * 100) + seq2.n, 28) DAY) AS created_at,
       50 + MOD((seq1.n * 100) + seq2.n, 500) AS amount
FROM seq1
CROSS JOIN seq2;

ANALYZE TABLE cost_customers;
ANALYZE TABLE cost_orders;

EXPLAIN FORMAT=TREE
SELECT c.customer_id, o.order_id, o.amount
FROM cost_customers c
JOIN cost_orders o
  ON o.customer_id = c.customer_id
WHERE c.customer_tier = 'VIP'
  AND c.region_code = 'KR'
  AND o.status = 'PAID'
  AND o.created_at >= '2026-02-20';

EXPLAIN FORMAT=TREE
SELECT c.customer_id, o.order_id, o.amount
FROM cost_customers c
JOIN cost_orders o
  ON o.customer_id = c.customer_id
WHERE c.customer_tier = 'STANDARD'
  AND o.status = 'FAILED'
  AND o.created_at >= '2026-02-20';

DROP TABLE IF EXISTS cost_orders;
DROP TABLE IF EXISTS cost_customers;
```

실행 결과(MySQL 8.0.x):

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

Query OK, 0 rows affected (0.00 sec)

mysql> DROP TABLE IF EXISTS cost_customers;

Query OK, 0 rows affected (0.00 sec)

mysql> CREATE TABLE cost_customers (...);

Query OK, 0 rows affected (0.00 sec)

mysql> CREATE TABLE cost_orders (...);

Query OK, 0 rows affected (0.01 sec)

mysql> INSERT INTO cost_customers (...)
    -> WITH RECURSIVE seq AS (...)
    -> SELECT ...
    -> FROM seq;

Query OK, 1000 rows affected (0.00 sec)
Records: 1000  Duplicates: 0  Warnings: 0

mysql> INSERT INTO cost_orders (...)
    -> WITH RECURSIVE seq1 AS (...), seq2 AS (...)
    -> SELECT ...
    -> FROM seq1
    -> CROSS JOIN seq2;

Query OK, 10000 rows affected (0.09 sec)
Records: 10000  Duplicates: 0  Warnings: 0

mysql> ANALYZE TABLE cost_customers;

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

mysql> ANALYZE TABLE cost_orders;

+-----------------------------+---------+----------+----------+
| Table                       | Op      | Msg_type | Msg_text |
+-----------------------------+---------+----------+----------+
| mysql_tech_note.cost_orders | analyze | status   | OK       |
+-----------------------------+---------+----------+----------+
1 row in set (0.01 sec)

mysql> EXPLAIN FORMAT=TREE
    -> SELECT c.customer_id, o.order_id, o.amount
    -> FROM cost_customers c
    -> JOIN cost_orders o
    ->   ON o.customer_id = c.customer_id
    -> WHERE c.customer_tier = 'VIP'
    ->   AND c.region_code = 'KR'
    ->   AND o.status = 'PAID'
    ->   AND o.created_at >= '2026-02-20';

+-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+
| EXPLAIN                                                                                                                                                                                                                                                                                                                                                                                                                     |
+-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+
| -> Nested loop inner join  (cost=14.6 rows=25.3)                                                                                                                                                                                                                                                                                                                                                                            |
|     -> Filter: (c.region_code = 'KR')  (cost=0.757 rows=5)                                                                                                                                                                                                                                                                                                                                                                  |
|         -> Covering index lookup on c using idx_tier_region (customer_tier='VIP', region_code='KR')  (cost=0.757 rows=5)                                                                                                                                                                                                                                                                                                 |
|     -> Index lookup on o using idx_customer_status_created (customer_id=c.customer_id, status='PAID'), with index condition: (o.created_at >= DATE'2026-02-20')  (cost=1.3 rows=5.05)                                                                                                                                                                                                                                   |
+-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+
1 row in set (0.00 sec)

mysql> EXPLAIN FORMAT=TREE
    -> SELECT c.customer_id, o.order_id, o.amount
    -> FROM cost_customers c
    -> JOIN cost_orders o
    ->   ON o.customer_id = c.customer_id
    -> WHERE c.customer_tier = 'STANDARD'
    ->   AND o.status = 'FAILED'
    ->   AND o.created_at >= '2026-02-20';

+-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+
| EXPLAIN                                                                                                                                                                                                                                                                                                                                                                                                                                                           |
+-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+
| -> Nested loop inner join  (cost=115 rows=112)                                                                                                                                                                                                                                                                                                                                                                                                                    |
|     -> Index range scan on o using idx_status_created_customer over (status = 'FAILED' AND '2026-02-20' <= created_at), with index condition: ((o.`status` = 'FAILED') and (o.created_at >= DATE'2026-02-20'))  (cost=64.6 rows=143)                                                                                                                                                                                    |
|     -> Filter: (c.customer_tier = 'STANDARD')  (cost=0.251 rows=0.78)                                                                                                                                                                                                                                                                                                                                                                                             |
|         -> Single-row index lookup on c using PRIMARY (customer_id=o.customer_id)  (cost=0.251 rows=1)                                                                                                                                                                                                                                                                                                                                                            |
+-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+
1 row in set (0.00 sec)

mysql> DROP TABLE IF EXISTS cost_orders;

Query OK, 0 rows affected (0.00 sec)

mysql> DROP TABLE IF EXISTS cost_customers;

Query OK, 0 rows affected (0.00 sec)
```

이 예제에서 볼 핵심은 다음과 같다.

- 첫 번째 쿼리는 `VIP`와 `KR` 조건이 매우 좁기 때문에, 고객 테이블을 먼저 읽고 주문 테이블을 lookup하는 계획이 유리하게 계산될 가능성이 높다.
- 두 번째 쿼리는 `STANDARD` 고객은 넓지만 `FAILED` 주문은 상대적으로 희소하므로, 주문 조건을 먼저 좁히는 쪽이 더 합리적으로 보일 수 있다.
- 즉 조인 순서는 절대 규칙이 아니라 **각 단계의 row estimate와 인덱스 lookup 비용의 합**으로 결정된다.

운영 현장에서는 이 원리가 매우 중요하다. 어떤 배포 이후 조인 순서가 뒤집혔다면, 무조건 힌트를 넣기 전에 먼저 다음을 확인해야 한다.

- 통계가 오래되지 않았는가?
- 데이터 분포가 최근 크게 바뀌지 않았는가?
- 새 인덱스가 생기면서 후보 경로가 달라지지 않았는가?
- 조건식 구조가 바뀌어 row estimate가 흔들리지 않았는가?

## 9. Aurora MySQL에서의 해석 포인트

Aurora MySQL은 스토리지 아키텍처와 운영 모델이 Community MySQL과 다르지만, Optimizer와 Cost Model을 해석하는 기본 출발점은 여전히 MySQL 호환 계층이다. 즉, 다음 원칙은 그대로 유효하다.

- 실행 계획 문제의 1차 원인은 대개 통계, 인덱스, SQL 구조, 데이터 분포다.
- Aurora 스토리지가 빠르다고 해서 나쁜 계획이 좋은 계획으로 바뀌지는 않는다.
- reader endpoint, failover, autoscaling 같은 운영 특성은 지연 체감과 부하 분산에는 영향을 주지만, cost-based plan 선택의 논리를 대체하지 않는다.

다만 Aurora에서는 다음 점을 추가로 염두에 둘 필요가 있다.

1. **운영자가 보는 병목 위치가 다를 수 있다.** 스토리지 계층이 분리되어 있어도, 잘못된 row estimate로 인한 비효율적 join order는 여전히 CPU와 row access 폭증으로 나타난다.
2. **통계 관리 절차를 writer 기준으로 명확히 해야 한다.** `ANALYZE TABLE` 실행 주체와 유지보수 윈도우를 분명히 해야 한다.
3. **Performance Insights, CloudWatch와 EXPLAIN을 분리해서 보지 말아야 한다.** 상위 지표에서 CPU 또는 DB load가 튄다고 해서 바로 인프라 이슈로만 보면, 실제 계획 악화를 놓치기 쉽다.

요약하면 Aurora MySQL의 차이는 “Cost Model이 무의미하다”가 아니라, **동일한 Cost Model 문제를 어떤 운영 계층에서 더 빨리 감지하고 어떻게 대응할 것인가**에 가깝다.

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

### 10.1 인덱스가 있는데 scan이면 옵티마이저가 틀린 것이다

항상 그렇지는 않다. 조건이 넓고 lookup 재탐색이 많으면 scan이 실제로 더 쌀 수 있다. 먼저 row estimate와 filtered 비율을 확인해야 한다.

### 10.2 조인 순서는 작은 테이블부터 읽는 것이 정답이다

작은 테이블보다 **먼저 읽었을 때 중간 결과를 많이 줄일 수 있는 테이블**이 더 중요하다. row 수 자체보다 필터 후 cardinality가 핵심이다.

### 10.3 Cost Model 문제는 힌트로 고정하는 것이 가장 빠르다

힌트는 응급 처치일 수는 있어도, 데이터 분포와 통계가 변하면 다시 어긋날 수 있다. 가능한 한 통계, 인덱스, SQL 구조를 먼저 바로잡는 편이 장기적으로 안정적이다.

### 10.4 비용 상수를 직접 만지면 계획이 좋아진다

일반 운영 환경에서는 비용 상수 직접 조정보다 통계 보정과 인덱스 재설계가 훨씬 효과적이다. 비용 테이블 변경은 충분한 검증 없이 넓게 적용하면 예기치 않은 plan shift를 만들 수 있다.

## 11. 실무 점검 체크리스트

- [ ] 문제가 된 SQL의 `EXPLAIN FORMAT=TREE` 또는 `EXPLAIN ANALYZE`를 확보했는가?
- [ ] 예상보다 많은 row가 남는 구간이 어느 단계인지 확인했는가?
- [ ] 인덱스가 없는 것이 문제인지, 인덱스가 있어도 선택도가 낮은 것이 문제인지 구분했는가?
- [ ] `ANALYZE TABLE` 이후 계획이 달라지는지 확인했는가?
- [ ] 데이터 분포 편향이 심한 컬럼에 Histogram 검토가 필요한가?
- [ ] 조인 순서가 바뀐 시점에 배포, 데이터 급증, 인덱스 추가, 통계 갱신이 있었는가?
- [ ] Aurora MySQL이라면 writer 기준 통계 관리와 관측 지표를 함께 확인했는가?
- [ ] 힌트 적용 전에 SQL 구조 단순화와 복합 인덱스 재설계를 검토했는가?

## 12. 결론

MySQL Cost Model은 “왜 이 계획이 선택되었는가”를 설명하는 가장 중요한 배경이다. 인덱스 사용 여부와 조인 순서는 감으로 정해지지 않는다. MySQL은 통계와 분포와 후보 경로를 바탕으로, 전체 비용이 가장 낮다고 추정한 계획을 고른다.

따라서 실행 계획 문제를 안정적으로 다루려면 단순히 `possible_keys`와 `key`만 보는 수준에서 멈추면 안 된다. **row estimate가 어디서 틀어졌는지, 그 오차가 access path와 join order를 어떻게 왜곡했는지**를 읽어야 한다. 이 관점이 있어야 `ANALYZE TABLE`, Histogram, 복합 인덱스, SQL 재작성, 힌트 적용 중 무엇이 본질적 해법인지 판단할 수 있다.

다음 주제들인 Optimizer trace, join buffer, semijoin transformation, sort/group cost를 이어서 보면 Cost Model이 실제 실행 계획 선택에 어떻게 연결되는지 더 입체적으로 이해할 수 있다.
