Theme:

두 테이블을 JOIN할 때, MySQL은 내부적으로 어떤 방식으로 행을 매칭시키고 있을까요?

JOIN은 SQL에서 가장 빈번하게 사용되는 연산이지만, 내부 동작은 생각보다 복잡합니다. MySQL은 조인 조건, 인덱스 유무, 데이터 크기에 따라 다양한 알고리즘을 선택합니다. 이 글에서는 각 알고리즘의 동작 원리와 성능 차이를 살펴보겠습니다.

개념 정의

JOIN 알고리즘은 두 테이블의 행을 어떻게 매칭시킬지 결정하는 방법입니다. MySQL에서 사용하는 주요 알고리즘은 다음과 같습니다.

  • Simple Nested Loop Join (NLJ): 이중 for문과 동일
  • Block Nested Loop Join (BNL): 메모리 버퍼를 활용한 NLJ 개선
  • Index Nested Loop Join: 드리븐 테이블에 인덱스 활용
  • Hash Join: 해시 테이블을 사용한 매칭 (8.0.18+)

드라이빙 테이블과 드리븐 테이블

JOIN에서 가장 먼저 이해해야 할 개념입니다.

PLAINTEXT
드라이빙 테이블 (Driving Table): 먼저 읽히는 테이블 (외부 루프)
드리븐 테이블 (Driven Table):  나중에 읽히는 테이블 (내부 루프)
SQL
-- 옵티마이저가 드라이빙 테이블을 결정합니다
SELECT u.name, o.amount
FROM users u            -- 드라이빙? 드리븐?
JOIN orders o ON u.id = o.user_id  -- 옵티마이저가 결정
WHERE u.age > 20;

옵티마이저의 선택 기준:

  • WHERE 조건 적용 후 예상 행 수가 적은 테이블을 드라이빙으로 선택합니다
  • 드리븐 테이블에 인덱스가 있는 경우 더 유리합니다
  • STRAIGHT_JOIN으로 순서를 강제할 수 있습니다
SQL
-- 드라이빙 테이블 순서 강제 (옵티마이저가 잘못 선택할 때만 사용)
SELECT STRAIGHT_JOIN u.name, o.amount
FROM users u
JOIN orders o ON u.id = o.user_id;

Simple Nested Loop Join (NLJ)

가장 기본적인 알고리즘으로, 이중 for문과 동일합니다.

PLAINTEXT
for each row in 드라이빙_테이블:
    for each row in 드리븐_테이블:
        if 조인_조건_일치:
            결과에 추가
SQL
-- 예시: users(100행) JOIN orders(10,000행)
-- 비교 횟수: 100 × 10,000 = 1,000,000
SELECT u.name, o.amount
FROM users u
JOIN orders o ON u.id = o.user_id;

특징:

  • 드리븐 테이블을 반복적으로 풀 스캔합니다
  • 인덱스가 없을 때 사용됩니다
  • 데이터가 많으면 극도로 느립니다
  • 실제로 이 방식이 그대로 사용되는 경우는 거의 없습니다

Block Nested Loop Join (BNL)

Simple NLJ의 개선 버전으로, join_buffer를 활용합니다.

PLAINTEXT
드라이빙 테이블의 행을 join_buffer에 가득 채움
for each row in 드리븐_테이블:
    for each row in join_buffer:
        if 조인_조건_일치:
            결과에 추가
SQL
-- join_buffer_size로 버퍼 크기 조절 (기본 256KB)
SHOW VARIABLES LIKE 'join_buffer_size';

NLJ와 BNL의 디스크 I/O 차이:

PLAINTEXT
NLJ: 드리븐 테이블 풀 스캔 × 드라이빙 행 수
     = 10,000 × 100 = 1,000,000 행 읽기

BNL: join_buffer에 50행이 담긴다면
     드리븐 테이블 풀 스캔 × (100/50) = 2번
     = 10,000 × 2 = 20,000 행 읽기

MySQL 8.0.20부터 BNL은 Hash Join으로 대체되었습니다.

Index Nested Loop Join

드리븐 테이블에 인덱스가 있을 때 사용되며, 실무에서 가장 많이 사용되는 방식입니다.

PLAINTEXT
for each row in 드라이빙_테이블:
    인덱스를 사용하여 드리븐_테이블에서 매칭 행 검색  -- O(log n)
    if 조인_조건_일치:
        결과에 추가
SQL
-- orders.user_id에 인덱스가 있는 경우
CREATE INDEX idx_user_id ON orders(user_id);

-- users(100행) JOIN orders(10,000행)
-- 비교 횟수: 100 × log(10,000) ≈ 100 × 13 = 1,300
SELECT u.name, o.amount
FROM users u
JOIN orders o ON u.id = o.user_id
WHERE u.age > 20;

EXPLAIN에서 확인:

SQL
EXPLAIN SELECT u.name, o.amount
FROM users u
JOIN orders o ON u.id = o.user_id
WHERE u.age > 20;

-- type: ref (인덱스 사용)
-- key: idx_user_id
-- Extra: Using index condition (ICP 적용 시)

핵심 포인트:

  • 드리븐 테이블의 조인 컬럼에 인덱스가 있어야 합니다
  • 드라이빙 테이블의 각 행에 대해 인덱스 탐색만 하므로 매우 빠릅니다
  • 가장 이상적인 JOIN 방식입니다

Hash Join (MySQL 8.0.18+)

MySQL 8.0.18에서 도입된 알고리즘으로, 인덱스가 없는 등가 조인에서 BNL을 대체합니다.

동작 방식

PLAINTEXT
Build Phase (빌드 단계):
    작은 테이블로 해시 테이블 생성
    for each row in 작은_테이블:
        hash(조인_키) → 해시 테이블에 저장

Probe Phase (탐색 단계):
    for each row in 큰_테이블:
        hash(조인_키) → 해시 테이블에서 매칭 검색
SQL
-- 인덱스가 없는 등가 조인에서 자동 사용
SELECT u.name, o.amount
FROM users u
JOIN orders o ON u.id = o.user_id;  -- user_id에 인덱스 없으면 Hash Join

-- EXPLAIN으로 확인
EXPLAIN FORMAT=TREE
SELECT u.name, o.amount
FROM users u
JOIN orders o ON u.id = o.user_id;
-- → Hash join 이 표시됩니다

성능 비교

알고리즘시간 복잡도인덱스 필요
Simple NLJO(n × m)X
Block NLJO(n × m / buffer)X
Index NLJO(n × log m)O
Hash JoinO(n + m)X

Hash Join은 인덱스 없이도 O(n + m) 성능을 제공하므로, 대규모 테이블의 인덱스 없는 조인에서 매우 효과적입니다.

Hash Join의 메모리 제한

SQL
-- join_buffer_size가 해시 테이블 메모리로 사용됩니다
-- 메모리가 부족하면 디스크를 사용하는 Grace Hash Join으로 전환
SET join_buffer_size = 262144;  -- 256KB (기본값)

메모리 초과 시:

  1. 빌드 테이블을 파티션으로 분할
  2. 각 파티션을 디스크에 저장
  3. 프로브 테이블도 같은 해시 함수로 파티셔닝
  4. 파티션별로 메모리에서 조인 수행

다중 테이블 JOIN

MySQL은 다중 테이블 JOIN도 Nested Loop 방식으로 처리합니다.

SQL
SELECT a.*, b.*, c.*
FROM A
JOIN B ON A.id = B.a_id
JOIN C ON B.id = C.b_id;

-- 실행 순서 (옵티마이저가 결정):
-- for each row in A:
--     for each row in B (using index on B.a_id):
--         for each row in C (using index on C.b_id):
--             결과에 추가

옵티마이저는 가능한 모든 테이블 순서를 고려합니다. 3개 테이블이면 3! = 6가지, 10개면 10! = 362만 가지입니다. 테이블이 많아지면 모든 경우를 확인할 수 없으므로 휴리스틱을 사용합니다.

SQL
-- optimizer_search_depth로 탐색 깊이 제한
SHOW VARIABLES LIKE 'optimizer_search_depth';

JOIN 성능 최적화 팁

1. 드리븐 테이블의 조인 컬럼에 인덱스 생성

SQL
-- 가장 중요한 최적화
CREATE INDEX idx_user_id ON orders(user_id);

2. WHERE 조건으로 드라이빙 테이블 축소

SQL
-- 드라이빙 테이블의 행 수를 줄이면 전체 성능 향상
SELECT u.name, o.amount
FROM users u
JOIN orders o ON u.id = o.user_id
WHERE u.created_at >= '2026-01-01';  -- users 테이블 필터링

3. 불필요한 컬럼 제거

SQL
-- 나쁜 예: SELECT *
SELECT * FROM users u JOIN orders o ON u.id = o.user_id;

-- 좋은 예: 필요한 컬럼만
SELECT u.name, o.amount FROM users u JOIN orders o ON u.id = o.user_id;

4. EXPLAIN으로 실행 계획 확인

SQL
EXPLAIN FORMAT=TREE
SELECT u.name, COUNT(o.id)
FROM users u
LEFT JOIN orders o ON u.id = o.user_id
GROUP BY u.name;

확인할 것:

  • type: ALL → 인덱스 없이 풀 스캔 (개선 필요)
  • type: ref → 인덱스 사용 (양호)
  • Extra: Using join buffer → BNL 또는 Hash Join 사용 중

정리

  • MySQL의 기본 JOIN 전략은 Nested Loop 기반입니다
  • Index Nested Loop Join이 가장 효율적이므로, 드리븐 테이블의 조인 컬럼에 인덱스를 생성하는 것이 핵심입니다
  • Hash Join(8.0.18+)은 인덱스가 없는 등가 조인에서 BNL을 대체합니다
  • 드라이빙 테이블의 행 수를 WHERE로 줄이면 전체 JOIN 성능이 향상됩니다
  • EXPLAIN으로 실행 계획을 확인하고, typeExtra 컬럼을 주의 깊게 봐야 합니다
댓글 로딩 중...