---
title: "Join 알고리즘 개요: nested loop, block nested loop, hash join의 적용 조건"
description: "MySQL의 nested loop, block nested loop, hash join이 선택되는 조건과 실행 경로를 운영 관점에서 정리한다."
tags: [ MySQL, 성능최적화, 인덱스, DBA ]
image: "mysql-report-bg.png"
published: "2026-07-13"
updated: "2026-07-13"
author: "MySQL 기술 노트"
source_url: ""
---

두 테이블을 `JOIN`한다고 해서 MySQL이 항상 같은 방식으로 행을 결합하는 것은 아니다. 바깥쪽 입력의 각 행마다 안쪽 테이블의 인덱스를 찾을 수도 있고, 여러 바깥쪽 행을 메모리에 모아 안쪽 입력을 한 번 읽을 수도 있으며, 한쪽 입력으로 hash table을 만든 뒤 다른 입력을 probe할 수도 있다. 논리적으로 같은 SQL이라도 선택된 알고리즘과 조인 순서에 따라 CPU, 메모리, 임시 디스크, buffer pool 접근량이 크게 달라진다.

특히 MySQL 8.0에서는 버전 경계를 주의해야 한다. 과거의 block nested loop(BNL)를 현재 버전의 일반적인 실행 방식으로 설명하면 실제 실행 계획과 맞지 않는다. MySQL 8.0.18에서 hash join이 도입되었고, 8.0.20부터 BNL은 실행 알고리즘으로 사용되지 않는다. 현재의 `block_nested_loop` optimizer switch와 `BNL`/`NO_BNL` 힌트는 이름과 달리 hash join 사용 여부에 영향을 준다.

이 글은 세 알고리즘을 단순한 용어 목록이 아니라 **어떤 입력을 반복하고, 무엇을 메모리에 보관하며, 어느 조건에서 비용이 커지는가**라는 관점으로 설명한다. 예제와 실행 계획은 MySQL 8.0 기준이다.

## 1. 조인 실행을 이해하는 세 가지 축

조인 알고리즘을 보기 전에 다음 세 가지를 분리해야 한다.

1. **조인 순서**: 어느 테이블 또는 중간 결과가 먼저 읽히는가
2. **접근 방식**: 각 입력을 `const`, `ref`, `range`, `ALL` 중 어떤 방식으로 읽는가
3. **결합 알고리즘**: 찾은 행을 nested loop 또는 hash 방식으로 어떻게 결합하는가

`EXPLAIN`에서 먼저 나온 테이블이 대체로 바깥쪽 입력이며, 뒤의 테이블이 안쪽 입력이다. 그러나 단순히 테이블 행 수가 작은 쪽이 항상 먼저 오는 것은 아니다. 필터 적용 후 예상 행 수, 사용 가능한 인덱스, 조인 key의 선택도, 정렬이나 집계 요구, 통계 오차가 조인 순서에 영향을 준다.

```mermaid
flowchart TD
    Q[조인 SQL] --> O[옵티마이저가 조인 순서와 접근 경로 탐색]
    O --> I{안쪽 입력에 효율적인 lookup 경로가 있는가}
    I -- 예 --> N[Indexed nested loop 후보]
    I -- 아니오 --> H{입력을 hash table 또는 join buffer로 처리할 수 있는가}
    H -- MySQL 8.0.20 이상 --> J[Hash join 후보]
    H -- 구버전 BNL 적용 조건 --> B[Block nested loop 후보]
    N --> C[비용 비교 후 계획 선택]
    J --> C
    B --> C
    C --> E[Executor가 iterator 계획 실행]
```

이 흐름은 개념 모델이다. “인덱스가 있으면 무조건 nested loop”, “인덱스가 없으면 무조건 hash join”이라는 고정 규칙은 아니다. 옵티마이저는 추정 비용을 비교하며, 인덱스가 존재해도 선택도가 낮거나 많은 random lookup이 필요하면 table scan과 hash join이 더 저렴할 수 있다.

## 2. Nested loop join: 바깥쪽 행마다 안쪽 입력을 찾는다

Nested loop join(NLJ)의 기본 실행 경로는 다음과 같다.

1. 바깥쪽 입력에서 조건을 만족하는 행 하나를 읽는다.
2. 그 행의 join key로 안쪽 입력에서 일치하는 행을 찾는다.
3. 결합된 행에 나머지 조건을 적용하고 결과를 반환한다.
4. 바깥쪽 입력이 끝날 때까지 반복한다.

안쪽 테이블의 join key에 적절한 인덱스가 있으면 두 번째 단계는 짧은 B-tree lookup이 된다. 이를 흔히 indexed nested loop join이라고 부른다. 바깥쪽 결과가 `N`행이고 각 lookup이 충분히 선택적이라면 비용을 대략 `N × lookup 비용`으로 볼 수 있다.

예를 들어 고객 100명을 먼저 고른 뒤 `orders(customer_id)` 인덱스로 고객별 주문을 찾는다면 NLJ는 자연스럽다. 반대로 바깥쪽 입력이 수백만 행이고 각 행마다 buffer pool에 없는 서로 다른 페이지를 찾아야 한다면, 인덱스를 사용하더라도 random I/O와 CPU가 누적될 수 있다.

### 2.1 NLJ가 유리한 조건

- 바깥쪽 입력이 필터링되어 작다.
- 안쪽 join key에 선택적인 인덱스가 있다.
- 안쪽 lookup 한 번이 반환하는 행 수가 적다.
- `LIMIT` 때문에 일부 결과만 찾고 조기에 종료할 수 있다.
- 두 입력 전체를 읽는 hash build/probe 비용보다 반복 lookup이 저렴하다.

### 2.2 NLJ가 불리해지는 조건

- 바깥쪽 실제 행 수가 추정치보다 훨씬 많다.
- 안쪽 인덱스의 선택도가 낮아 lookup마다 많은 행을 읽는다.
- 안쪽 row가 넓고 non-covering index lookup이 대량으로 발생한다.
- lookup 대상 페이지가 흩어져 buffer pool miss가 반복된다.
- 다중 조인에서 잘못된 선두 테이블 선택이 반복 횟수를 증폭한다.

Batched Key Access(BKA)는 NLJ 자체를 없애는 알고리즘이 아니다. 바깥쪽의 여러 join key를 batch로 모아 안쪽 lookup을 효율화하는 NLJ 변형이다. 따라서 `Using join buffer (Batched Key Access)`가 보이면 “hash join으로 전환되었다”가 아니라 “nested loop의 안쪽 접근을 묶어서 처리한다”고 해석해야 한다.

## 3. Block nested loop: 현재 MySQL 8.0에서는 역사적 경계를 먼저 본다

Block nested loop는 바깥쪽 행을 하나씩 즉시 처리하지 않고 join buffer에 여러 행을 모은 다음, 안쪽 입력을 한 번 scan하면서 buffer의 여러 행과 비교한다. 단순 nested loop가 바깥쪽 행마다 안쪽 테이블 전체를 다시 읽는 최악의 상황을 완화하려는 방식이다.

개념적인 흐름은 다음과 같다.

1. join buffer가 찰 때까지 바깥쪽 행과 필요한 컬럼을 적재한다.
2. 안쪽 테이블을 한 번 scan한다.
3. 안쪽 행을 buffer에 있는 여러 바깥쪽 행과 비교한다.
4. buffer를 비우고 다음 batch에 대해 반복한다.

이 방식은 안쪽 full scan 횟수를 줄이지만, 비교 연산량 자체가 작아지는 것은 아니다. buffer가 작으면 안쪽 입력을 여러 번 scan해야 하고, 조인 조건의 선택도가 낮으면 후보 비교가 많아진다. 또한 결과 순서가 보장되는 것도 아니다.

운영자가 반드시 기억해야 할 버전 차이는 다음과 같다.

- MySQL 5.7 및 초기 8.0 문서에서 BNL과 `join_buffer_size` 설명을 자주 볼 수 있다.
- MySQL 8.0.18에서 equi-join을 위한 hash join이 도입되었다.
- MySQL 8.0.20부터 executor는 BNL을 사용하지 않고, 과거 BNL이 고려되던 경로를 hash join이 담당한다.
- MySQL 8.0.20 이상에서 `block_nested_loop=on/off`와 `BNL`/`NO_BNL` 힌트는 hash join 허용을 제어하는 역사적 이름으로 남아 있다.

따라서 최신 `EXPLAIN FORMAT=TREE`에 `Inner hash join`이 나타났는데 `block_nested_loop` 설정이 관련되어 있다고 해서 이를 BNL로 읽으면 안 된다. 설정 이름과 실제 알고리즘을 분리해서 해석해야 한다.

## 4. Hash join: 한쪽 입력을 build하고 다른 입력으로 probe한다

Hash join은 일반적으로 한쪽 입력의 join key와 필요한 payload로 hash table을 만드는 **build 단계**, 다른 쪽 입력의 join key로 hash table을 찾는 **probe 단계**로 구성된다. 일치 후보를 찾은 뒤에는 전체 join 조건과 잔여 조건을 확인한다.

```mermaid
sequenceDiagram
    participant B as Build 입력
    participant M as 메모리 hash table
    participant P as Probe 입력
    participant R as 결과
    B->>M: join key와 payload 적재
    Note over M: hash bucket 구성
    P->>M: 각 probe key로 bucket 조회
    M-->>P: 일치 후보 반환
    P->>R: 조건 재확인 후 결과 출력
```

Hash join의 장점은 큰 입력을 처리할 때 안쪽 테이블을 바깥쪽 행마다 반복 scan하지 않는다는 점이다. 두 입력을 각각 한 번에 가깝게 읽고 hash lookup으로 결합할 수 있어, 사용 가능한 인덱스가 없는 큰 equi-join에서 특히 유리하다.

그러나 hash join이 공짜는 아니다.

- build 입력을 보관할 메모리가 필요하다.
- hash 계산과 bucket 탐색에 CPU를 사용한다.
- 메모리 한도를 넘으면 파티셔닝과 디스크 spill이 발생할 수 있다.
- 입력이 매우 비대칭인데 큰 쪽을 build하면 메모리와 spill 비용이 증가한다.
- 결과가 거의 없고 선택적인 인덱스 lookup이 가능한 경우 NLJ가 더 빨리 끝날 수 있다.

MySQL은 hash join에 사용할 수 있는 메모리를 join별로 `join_buffer_size` 범위에서 관리하고, 필요한 메모리가 커지면 디스크를 사용할 수 있다. 이 변수를 전역으로 크게 올리는 것은 안전한 만능 해법이 아니다. 복잡한 쿼리에는 여러 join buffer가 필요할 수 있고 동시 세션 수까지 곱해져 메모리 압박을 만들 수 있기 때문이다. 우선 세션 단위 실험과 실제 동시성을 기준으로 판단해야 한다.

MySQL 8.0.20 이상에서는 hash join 적용 범위가 equi-join뿐 아니라 non-equi 조건, semijoin, antijoin, Cartesian product까지 확대되었다. 다만 “지원한다”와 “항상 선택한다”는 다른 말이다. 비용 모델과 다른 접근 경로에 따라 nested loop가 선택될 수 있다.

## 5. MySQL 8.0에서 두 실행 경로 재현하기

다음 축소 예제는 같은 고객·주문 데이터에 인덱스 lookup 경로와 hash join 경로를 각각 만든다. 작은 테이블의 처리 시간을 비교하는 벤치마크가 아니라, `EXPLAIN FORMAT=TREE`에서 iterator 구조를 확인하기 위한 예제다.

```sql
DROP TABLE IF EXISTS join_orders;
DROP TABLE IF EXISTS join_customers;

CREATE TABLE join_customers (
  customer_id BIGINT NOT NULL PRIMARY KEY,
  segment CHAR(1) NOT NULL,
  customer_name VARCHAR(40) NOT NULL,
  KEY ix_segment (segment)
) ENGINE=InnoDB;

CREATE TABLE join_orders (
  order_id BIGINT NOT NULL PRIMARY KEY,
  customer_id BIGINT NOT NULL,
  amount DECIMAL(10,2) NOT NULL,
  KEY ix_customer (customer_id)
) ENGINE=InnoDB;

INSERT INTO join_customers VALUES
  (1, 'A', '고객-1'), (2, 'A', '고객-2'), (3, 'B', '고객-3'),
  (4, 'B', '고객-4'), (5, 'C', '고객-5'), (6, 'C', '고객-6');

INSERT INTO join_orders VALUES
  (101, 1, 110.00), (102, 1, 120.00), (103, 2, 130.00),
  (104, 3, 140.00), (105, 4, 150.00), (106, 6, 160.00);

ANALYZE TABLE join_customers, join_orders;
SELECT COUNT(*) AS customer_count FROM join_customers;
SELECT COUNT(*) AS order_count FROM join_orders;
```

실행 결과(MySQL 8.0.x):

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

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

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

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

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

mysql> SELECT COUNT(*) AS customer_count FROM join_customers;
+----------------+
| customer_count |
+----------------+
|              6 |
+----------------+
1 row in set (0.00 sec)

mysql> SELECT COUNT(*) AS order_count FROM join_orders;
+-------------+
| order_count |
+-------------+
|           6 |
+-------------+
1 row in set (0.00 sec)
```

첫 계획은 `join_orders.ix_customer`를 사용할 수 있게 둔다. `JOIN_ORDER(c, o)` 힌트는 교육용 예제에서 순서를 고정하여 바깥쪽 고객 행마다 주문 인덱스를 lookup하는 구조를 명확하게 만든다.

```sql
EXPLAIN FORMAT=TREE
SELECT /*+ JOIN_ORDER(c, o) */
       c.customer_id, c.customer_name, o.order_id, o.amount
FROM join_customers c
JOIN join_orders o ON o.customer_id = c.customer_id
WHERE c.segment = 'A';
```

실행 결과(MySQL 8.0.x):

```text
mysql> EXPLAIN FORMAT=TREE
    -> SELECT /*+ JOIN_ORDER(c, o) */ ...
    -> FROM join_customers c
    -> JOIN join_orders o ON o.customer_id = c.customer_id
    -> WHERE c.segment = 'A';

-> Nested loop inner join  (cost=1.54 rows=2.4)
    -> Index lookup on c using ix_segment (segment='A'),
       with index condition: (c.segment = 'A')  (cost=0.7 rows=2)
    -> Index lookup on o using ix_customer (customer_id=c.customer_id)
       (cost=0.36 rows=1.2)
1 row in set (0.00 sec)
```

`Single-row index lookup` 또는 `Index lookup`이 안쪽 iterator에 나타나면 indexed nested loop 경로다. 작은 테이블에서는 optimizer가 table scan을 더 싸다고 판단할 수 있으므로, 운영 계획에서는 힌트 없이 선택된 실제 `key`, iterator, 실제 행 수를 다시 확인해야 한다.

두 번째 계획은 안쪽 테이블의 조인 인덱스를 예제에서만 제외한다. `IGNORE INDEX (ix_customer)`는 인덱스 삭제 후 배포하라는 권고가 아니라, 사용 가능한 ref lookup이 없는 상황에서 hash join 구조를 관찰하기 위한 장치다.

```sql
EXPLAIN FORMAT=TREE
SELECT /*+ JOIN_ORDER(c, o) BNL(o) */
       c.customer_id, c.customer_name, o.order_id, o.amount
FROM join_customers c
JOIN join_orders o IGNORE INDEX (ix_customer)
  ON o.customer_id = c.customer_id
WHERE c.segment = 'A';
```

실행 결과(MySQL 8.0.x):

```text
mysql> EXPLAIN FORMAT=TREE
    -> SELECT /*+ JOIN_ORDER(c, o) BNL(o) */ ...
    -> FROM join_customers c
    -> JOIN join_orders o IGNORE INDEX (ix_customer)
    ->   ON o.customer_id = c.customer_id
    -> WHERE c.segment = 'A';

-> Inner hash join (o.customer_id = c.customer_id)  (cost=2.15 rows=2.4)
    -> Table scan on o  (cost=0.185 rows=6)
    -> Hash
        -> Index lookup on c using ix_segment (segment='A'),
           with index condition: (c.segment = 'A')  (cost=0.7 rows=2)
1 row in set (0.00 sec)
```

`Inner hash join`과 `Hash` 노드가 나타나면 한쪽 입력으로 hash table을 만들고 다른 입력으로 probe하는 계획이다. `BNL(o)`라는 힌트 이름이 출력의 hash join과 다르게 보이는 이유는 앞서 설명한 버전 호환 역사 때문이다.

현재 세션에서 관련 optimizer switch와 buffer 크기를 확인할 수 있다.

```sql
SELECT VERSION() AS mysql_version;
SELECT @@session.join_buffer_size AS session_join_buffer_size;
SELECT
  @@session.optimizer_switch LIKE '%block_nested_loop=on%' AS block_nested_loop_enabled,
  @@session.optimizer_switch LIKE '%batched_key_access=on%' AS batched_key_access_enabled;
DROP TABLE join_orders;
DROP TABLE join_customers;
```

실행 결과(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 @@session.join_buffer_size AS session_join_buffer_size;

+--------------------------+
| session_join_buffer_size |
+--------------------------+
|                   262144 |
+--------------------------+
1 row in set (0.00 sec)

mysql> SELECT
    ->   @@session.optimizer_switch LIKE '%block_nested_loop=on%' AS block_nested_loop_enabled,
    ->   @@session.optimizer_switch LIKE '%batched_key_access=on%' AS batched_key_access_enabled;

+---------------------------+----------------------------+
| block_nested_loop_enabled | batched_key_access_enabled |
+---------------------------+----------------------------+
|                         1 |                          0 |
+---------------------------+----------------------------+
1 row in set (0.00 sec)

mysql> DROP TABLE join_orders;

Query OK, 0 rows affected (0.00 sec)

mysql> DROP TABLE join_customers;

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

`batched_key_access`는 기본 설정에서 꺼져 있을 수 있다. 또한 `block_nested_loop_enabled=1`은 최신 버전에서 BNL executor가 살아 있다는 의미가 아니라 hash join이 허용된 상태로 해석해야 한다.

## 6. EXPLAIN에서 무엇을 확인할 것인가

전통적인 표 형식 `EXPLAIN`은 테이블 순서와 `type`, `key`, `rows`, `Extra`를 빠르게 확인하기 좋다. 그러나 hash join을 포함한 iterator 관계는 `EXPLAIN FORMAT=TREE`가 더 직접적이다. 운영 진단에서는 다음 순서가 유용하다.

1. `EXPLAIN FORMAT=TREE`로 `Nested loop`, `Inner hash join`, `Hash`, index lookup 노드를 확인한다.
2. 표 형식 `EXPLAIN`으로 각 테이블의 `type`, `key`, `rows`, `filtered`를 확인한다.
3. 안전한 읽기 쿼리라면 `EXPLAIN ANALYZE`로 추정 행과 실제 행, 반복 횟수, iterator 시간을 비교한다.
4. hash join이 의심되면 build/probe 입력의 실제 크기와 `join_buffer_size`, 임시 디스크 사용 가능성을 함께 본다.
5. 계획이 예상과 다르면 통계 갱신 여부, histogram, 데이터 편향, 암시적 형 변환과 collation 차이를 점검한다.

`EXPLAIN ANALYZE`는 쿼리를 실제로 실행한다. 큰 조인, 잠금 영향을 줄 수 있는 읽기, Aurora reader의 높은 부하 상황에서는 운영 트래픽에 바로 적용하지 말고 축소 조건이나 staging 환경에서 먼저 확인한다.

### 6.1 NLJ 계획에서 볼 신호

- 바깥쪽 iterator의 실제 행 수가 지나치게 크지 않은가
- 안쪽 `Index lookup`의 `loops`가 얼마나 반복되는가
- lookup 1회당 실제 반환 행이 예상보다 많지 않은가
- covering 여부와 clustered index 추가 접근 비용은 어떤가
- `LIMIT`가 조기 종료에 실제로 기여하는가

### 6.2 Hash join 계획에서 볼 신호

- 어느 입력이 hash build 대상인가
- build 전 필터가 충분히 적용되는가
- build 입력의 row width가 불필요하게 넓지 않은가
- 추정치 오류로 큰 입력을 메모리에 올리고 있지 않은가
- 응답 시간 증가와 동시에 임시 디스크 I/O 또는 메모리 압박이 보이는가

## 7. 흔한 오해와 실패 모드

### 7.1 인덱스가 있으면 hash join은 절대 선택되지 않는다

인덱스 존재만으로 결정되지 않는다. 낮은 선택도, 큰 결과 집합, random lookup 비용 때문에 scan과 hash join이 더 저렴할 수 있다. 반대로 인덱스가 없어도 매우 작은 입력이나 다른 조건 때문에 nested loop 계열 경로가 충분할 수 있다.

### 7.2 `join_buffer_size`를 크게 하면 모든 join이 빨라진다

큰 buffer는 hash join이나 일부 buffered join에 도움이 될 수 있지만, 세션과 join 수에 따라 메모리 사용이 증가한다. 작은 OLTP lookup join은 이 값을 키워도 이득이 없을 수 있다. 전역 변경보다 문제 쿼리의 계획, 동시 실행 수, spill 여부를 먼저 확인한다.

### 7.3 `Using join buffer`는 모두 같은 알고리즘이다

아니다. 출력 문맥에 따라 BKA, hash join 관련 동작, 구버전 BNL을 구분해야 한다. 버전과 `FORMAT=TREE` iterator를 함께 확인하지 않으면 설정 이름만 보고 잘못 판단하기 쉽다.

### 7.4 hash join은 입력 순서와 무관하다

논리 결과는 같아도 build 입력의 크기는 메모리와 spill 비용에 직접 영향을 준다. 필터가 먼저 적용되는 작은 입력을 build할 수 있는 계획이 일반적으로 유리하지만, 통계 오차는 잘못된 선택을 만들 수 있다.

### 7.5 힌트로 계획을 고정하면 문제가 해결된다

힌트는 진단 실험이나 긴급 완화에 유용하지만 데이터 분포와 버전이 바뀌면 오래된 강제가 될 수 있다. 먼저 통계, 인덱스, predicate, join key 자료형을 바로잡고, 힌트를 사용한다면 적용 이유와 제거 조건을 함께 관리해야 한다.

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

Aurora MySQL도 MySQL 호환 optimizer와 executor를 사용하므로 NLJ와 hash join의 기본 해석은 유사하다. 다만 운영 판단에는 분산 스토리지와 관리형 환경의 특성을 추가해야 한다.

- Aurora 버전은 MySQL 호환 major/minor 계열과 기능 제공 시점이 다를 수 있으므로, 클러스터 엔진 버전에서 `EXPLAIN FORMAT=TREE`와 optimizer switch를 직접 확인한다.
- indexed NLJ의 대량 random page 접근은 buffer cache hit ratio와 스토리지 읽기 지연에 영향을 받는다. writer와 reader 인스턴스의 cache 상태가 달라 같은 SQL의 체감 시간이 다를 수 있다.
- hash join의 큰 build 입력과 spill은 인스턴스 메모리 압박 및 임시 로컬 스토리지 사용과 연결될 수 있다. DB load, memory, temporary table 및 I/O 관련 지표를 같은 시간축에서 본다.
- reader로 쿼리를 분산해도 비효율적인 대형 join 자체가 사라지는 것은 아니다. replica lag만 보지 말고 reader별 CPU, 메모리, 쿼리 동시성을 확인한다.
- DB parameter group에서 `join_buffer_size`를 전역 확대하기 전에, 세션 실험과 인스턴스 class의 가용 메모리, 최대 동시 연결 수를 기준으로 상한을 계산한다.

Aurora 전용 모니터링 화면이나 Performance Insights의 대기 이벤트 이름은 엔진 버전과 설정에 따라 달라질 수 있다. 특정 이벤트 하나만으로 알고리즘을 단정하지 말고 실행 계획, row 수, CPU·I/O·메모리 지표를 함께 해석해야 한다.

## 9. 조인 알고리즘 점검 체크리스트

### 설계 단계

- [ ] join key의 자료형, 길이, 문자셋과 collation이 양쪽에서 일치하는가
- [ ] 선택적인 lookup이 필요한 안쪽 join key에 적절한 인덱스가 있는가
- [ ] 바깥쪽 입력을 먼저 줄일 수 있는 predicate와 인덱스가 있는가
- [ ] 결과에 필요하지 않은 넓은 컬럼을 build 또는 중간 결과에 포함하지 않는가
- [ ] BKA, BNL, hash join의 버전별 의미를 구분했는가

### 실행 계획 점검

- [ ] `EXPLAIN FORMAT=TREE`에서 실제 결합 iterator를 확인했는가
- [ ] NLJ의 안쪽 lookup 반복 횟수와 반환 행 수가 합리적인가
- [ ] hash join의 build 입력이 필터 후에도 과도하게 크지 않은가
- [ ] 추정 `rows`와 실제 데이터 분포가 크게 어긋나지 않는가
- [ ] 힌트 없이 선택된 기본 계획과 힌트 적용 계획을 모두 비교했는가

### 운영 변경 전후

- [ ] `EXPLAIN ANALYZE`가 실제 실행임을 고려해 안전한 환경에서 검증했는가
- [ ] p50뿐 아니라 p95/p99 지연, CPU, 읽기 I/O, 메모리, 임시 디스크를 비교했는가
- [ ] `join_buffer_size` 변경에 동시 세션 수와 쿼리당 다중 join 수를 반영했는가
- [ ] 통계 갱신과 데이터 편향 변화 이후 계획이 유지되는지 확인했는가
- [ ] Aurora에서는 writer와 reader의 인스턴스 class·cache 상태 차이를 고려했는가
- [ ] 강제 힌트에 적용 이유, 대상 버전, 제거 조건을 기록했는가

## 10. 결론

Nested loop join은 작은 바깥쪽 입력과 선택적인 안쪽 인덱스 lookup에서 강하다. 과거의 block nested loop는 여러 바깥쪽 행을 buffer에 모아 안쪽 full scan 반복을 줄였지만, MySQL 8.0.20 이상에서는 실제 실행 알고리즘으로 사용되지 않고 hash join이 그 역할을 대체한다. Hash join은 인덱스가 없거나 큰 입력을 결합할 때 반복 scan을 피할 수 있지만, build 메모리와 CPU, disk spill 위험을 함께 가진다.

따라서 운영 판단의 핵심은 알고리즘 이름을 외우는 것이 아니라 바깥쪽 실제 행 수, 안쪽 lookup 비용, build 입력 크기, 반복 횟수와 메모리 한도를 실행 계획에서 확인하는 데 있다. 다음 조인 최적화 글에서는 조인 순서 탐색과 통계 추정이 왜 같은 인덱스 구성에서도 서로 다른 알고리즘을 선택하게 만드는지 더 구체적으로 다룬다.
