
NL 조인은 MySQL이 기본적으로 사용하는 조인 방식이며, 인덱스를 활용한 조인이라고 이해하면 가장 쉽다.
드라이빙 테이블(먼저 읽는 테이블)의 row 하나를 가져옴
이 row의 조인 조건을 이용해
드리븐 테이블(나중에 읽는 테이블)에서 해당 row를 찾음
이때 드리븐 테이블의 조인 컬럼에 인덱스가 있으면 매우 빠름
(인덱스 = 랜덤 I/O 최소화)
드리븐 테이블에서 조인 조건을 만족하는 row를 찾는 과정이 핵심이기 때문에
드리븐 테이블 인덱스 없음?
→ 드라이빙 테이블 row 수만큼 full table scan 반복
→ 최악의 경우 O(N × M) 발생 → 지옥의 성능
SELECT post.title, post.content
FROM user
JOIN post ON user.id = post.user
WHERE user.nickname = 'User920508';
-> Nested loop inner join (cost=4315.18 rows=9444) (actual time=0.099..5.375 rows=15 loops=1)
-> Filter: (`user`.nickname = 'User920508') (cost=1009.65 rows=985) (actual time=0.067..5.257 rows=1 loops=1)
-> Table scan on user (cost=1009.65 rows=9854) (actual time=0.063..3.520 rows=10000 loops=1)
-> Index lookup on post using user (user=`user`.id) (cost=2.40 rows=10) (actual time=0.031..0.116 rows=15 loops=1)
user 테이블(드라이빙)을 먼저 full scan하여 nickname 조건 만족 row 찾음post 테이블(드리븐)에서 post.user 인덱스를 이용한 index lookup 수행→ 인덱스 덕분에 드리븐 테이블 접근 비용이 최소화됨
| 역할 | 의미 |
|---|---|
| 드라이빙 테이블 | 조인에서 먼저 읽는 테이블 |
| 드리븐 테이블 | 드라이빙 테이블의 row로 조건 검색되는 테이블 |
MySQL 옵티마이저는
→ Outer Join은 NL 조인의 구조는 동일하지만
드리븐에서 매칭 실패해도 드라이빙 row를 버리지 않음
그중 하나가 바로 BNL 조인이다.
MySQL의 예전 버전(8.0.18 이전)에서 사용됨.
→ 메모리 버퍼를 사용해 full scan 비용을 줄이는 조인 방식
BNL 조인은 더 이상 기본 사용 X
→ 더 빠르고 효율적인 Hash Join으로 대체
따라서 BNL 조인은
옛날 MySQL이 인덱스 없는 조인을 처리하던 방식 정도로 기억하면 됨.
NL 조인의 성능은 드리븐 테이블 접근 비용에 의해 전부 결정
NL 조인의 핵심 규칙
드리븐 테이블(나중에 읽는 테이블)의 조인 조건 컬럼에 인덱스가 없으면 지옥
SELECT *
FROM A
JOIN B ON A.id = B.a_id
여기서 B.a_id에 인덱스 없으면
→ A의 row 수만큼 B 테이블 full scan 반복
→ 최악의 경우 O(N×M) 발생 = 최악
가장 중요한 튜닝 포인트
드라이빙 테이블에서 많이 나올수록
→ 드리븐 테이블 접근 횟수도 증가
즉, WHERE 조건 절로 많이 걸러지는 테이블이 드라이빙이 되어야 한다.
옵티마이저가 자동으로 결정하지만 잘못 결정될 때도 있음.
드라이빙 테이블 row 수 줄이는 방법
STRAIGHT_JOIN 힌트)SELECT /*+ STRAIGHT_JOIN */ ...
FROM small_table s
JOIN big_table b ON s.id = b.s_id
MySQL은 조인 조건에서 컬럼에 함수가 적용되면
절대 인덱스를 못 탐
JOIN post p ON DATE(p.created_at) = u.created_date
→ p.created_at 인덱스 못 사용 → full scan 발생
해결
조인 조건 양쪽 컬럼의 타입이 다르면 인덱스 못 탐
user.id (INT)
post.user_id (VARCHAR)
→ 비교 전 타입 변환 → 인덱스 완전 무효화 → full scan 발생
반드시 데이터 타입을 통일하자.
카디널리티 낮으면 조인 후 row 수가 크게 늘어나므로
드리븐 테이블 접근 횟수도 커짐
예) 성별(gender), 상태(status) 같은 컬럼으로 조인 하면 안됨
조인 키는 가능한 고유성이 높은 값이 유리함
(예: id, email, unique key 등)
NL 조인에서는 드리븐 테이블 접근이 비용의 대부분
만약 커버링 인덱스가 가능하면 PK lookup을 하지 않아도 됨.
SELECT id, name
FROM user
JOIN post ON user.id = post.user_id;
→ post (user_id, id, name) 형태로 인덱스를 구성하면
→ post 테이블 데이터 페이지 읽을 필요 없음
→ 인덱스에서 바로 끝남 → 압도적으로 빠름
커버링 인덱스 활용 시
WHERE created_at >= NOW() - INTERVAL 1 DAY
→ 최근 데이터만 읽게 하여 드리븐 접근 횟수 감소
조인하기 전에 먼저 row 수를 줄이는 것
= NL 조인 튜닝의 핵심
다중 인덱스
INDEX idx (dept_no, emp_no)
WHERE 절이
WHERE emp_no = 10
만 쓰면 인덱스 못 탐
→ NL 조인에서도 동일
반드시 인덱스 선행 컬럼을 조인 조건 또는 WHERE에서 사용해야 한다.
옵티마이저가 잘못된 순서를 선택해 NL 조인이 폭발할 때 사용.
SELECT /*+ STRAIGHT_JOIN */
FROM A
JOIN B ON A.id = B.a_id
→ A를 강제로 드라이빙 테이블로 만든다.
ORDER BY, GROUP BY가 조인보다 먼저 적용되면
→ 정렬을 위한 임시 테이블 발생
→ 조인 순서가 뒤틀릴 수 있음
가능하면 조인 후 정렬하도록 쿼리 구조 조정
NL 조인의 단점을 해결하려고 만들어진 조인 방식
MySQL에서는 지원 안함
→ MySQL은 대신 Hash Join으로 대체했다.
NL 조인의 대량 탐색 시 랜덤 I/O 폭증 문제와
Sort-Merge Join의 정렬 오버헤드 문제를 동시에 개선하는 조인 방식
Build 단계 (해시 테이블 생성)
Probe 단계
| 문제점 | Hash Join의 해결 방식 |
|---|---|
| NL 조인: 랜덤 I/O 폭탄 | 해시 테이블은 메모리 기반 → 랜덤 I/O 제거 |
| Sort-Merge 조인: 정렬 비용 | 정렬 필요 없음 |
| 인덱스 필수 조건 | 인덱스 없이도 빠른 조인 가능 |
대량 데이터 + 인덱스 없는 조인 상황에서 최고의 성능
SELECT * FROM t1
JOIN t2 ON t1.c1 = t2.c1;
단, 조인 컬럼에 인덱스가 없을 때 Hash Join 자동 사용
→ 인덱스가 있으면 대부분 NL Join 선택
t1.c1 > t2.c1
등호가 없어도 Hash Join 가능하지만
인덱스가 있으면 MySQL은 여전히 NL 조인을 더 선호
SELECT * FROM post JOIN comment;
조건 없이 조인하면 Hash Join 사용.
/*+ BNL(t2) */
BNL은 이전 블록 NL Join이었지만 이제 Hash Join 힌트 역할도 가능
/*+ NO_BNL(t2) */
post.createdAt = comment.created_at
인덱스 없으면 Hash Join 자동 선택
MySQL은 인덱스가 있는 조인에서는 무조건 NL Join을 우선 선택
이게 더 빠르기 때문
하지만 조인 건수가 매우 많으면 Hash Join 성능이 안 좋을 수 있음
| 조인 방식 | 장점 | 단점 | MySQL 지원 |
|---|---|---|---|
| NL Join | 인덱스 기반 소량 조인 최강 | 인덱스 없으면 성능 폭망 | ✔ |
| Sort-Merge Join | 대용량 조인에 강함, 정렬 기반 | 정렬 오버헤드 큼 | ❌ |
| Hash Join | 인덱스 없어도 OK, 대량 조인 강함 | 메모리 많이 씀, spill 시 느림 | ✔ (8.0.18+) |
Hash Join은 인덱스 없는 대량 조인에서 NL 조인 보다 뛰어난 성능을 보이지만
세팅이나 테이블 크기에 따라 성능이 극단적으로 좋아지거나 망가질 수 있음
핵심 개념
튜닝 방법
SET GLOBAL join_buffer_size = 128 * 1024 * 1024; -- 128MB
주의사항
세션 단위 설정이 아님 → 모든 연결에 적용됨 → 너무 크게 잡으면 메모리 터짐
Hash Join 가능성이 높은 쿼리를 파악한 후 설정
Hash Join의 핵심은 작은 테이블을 Build side로 두는 것
최적화 규칙
예시 — 나쁜 경우
SELECT *
FROM orders o
JOIN customers c ON o.customer_id = c.id;
orders가 10M rows이고, customers가 100K rows인데
orders가 build side가 되면 해시 테이블이 10M rows → 버퍼 초과 → 디스크 spill → 성능 최악
개선
SELECT /*+ JOIN_ORDER(customers, orders) */ ...
또는 WHERE 조건으로 build 쪽을 줄이는 전략
Hash Join은 기본적으로 equi-join에서 가장 빠르게 동작한다.
해시 기반은 "=" 기반으로만 해시 테이블을 직접 조회할 수 있음
“>”, “<”, “>=”, “<=” 같은 range join은 해시 테이블 전체 탐색이 필요 → 느려짐
가능하면 "=" 조건으로 rewriting 하기
다소 counter-intuitive(직관적이지 않다)
인덱스가 있으면 MySQL은 Hash Join 대신 NL 조인을 강제 선택하게 된다.
왜?
해결 방법
SELECT /*+ BNL(t2) */ ...
BNL은 원래 Block Nested Loop Join을 의미했지만
MySQL 8.0.20+에서는 사실상 Hash Join 유도 힌트 역할
Hash Join 못 쓰는 이유 분석에도 유용
특정 조인이 Hash Join이 걸리지 않을 때
등을 판단하는 데 힌트가 매우 유용
Hash Join 성능이 밀리면 아래도 함께 최적화
join_buffer_size
해시 테이블 저장 버퍼
tmp_table_size
Hash Join spill 시 임시 테이블로 사용됨
max_heap_table_size
메모리 기반 임시 테이블 크기
이 둘도 같이 늘리는 것이 좋음
SET GLOBAL tmp_table_size = 256M;
SET GLOBAL max_heap_table_size = 256M;
Hash Join은 카디널리티(Unique 값 개수)가 낮은 컬럼 조인에서 효과가 큼
이런 컬럼으로 조인하면 해시 테이블의 버킷 충돌이 적고 매우 빠르다.
반대로 카디널리티 높은 컬럼(예: UUID)은 해시 테이블이 비대해질 수 있음
해시 테이블에 들어가는 실제 데이터 크기를 줄이면
추천 전략
아래 항목을 하나씩 체크
해시 조인이 느린 이유 Top 6
| 원인 | 설명 |
|---|---|
| join_buffer_size 초과 | 해시 테이블이 메모리 대신 디스크로 spill |
| build side 선택이 잘못됨 | 큰 테이블을 build side로 설정 |
| 조인 조건이 "="이 아님 | full probe 필요 |
| 조인 키 타입이 무겁다 | VARCHAR, TEXT → 해시 충돌 증가 |
| select * 사용 | 불필요하게 큰 row가 해시 테이블에 들어감 |
| 인덱스가 있어 optimizer가 NL Join 선택 | 부적절한 실행 계획 |
DB 옵티마이저는 쿼리 블록 단위로 동작
즉, 어떤 서브쿼리든 하나의 독립적인 쿼리 블록으로 판단하고 최적화 수행
하지만 각 블록을 따로 최적화했다고 해서 전체 쿼리가 최적화되는 건 아님
서브쿼리는 메인쿼리에 종속되므로, 전체 관점에서 최적화하려면 서브쿼리 계층을 풀어내는(unnest) 작업이 필요
상관 서브쿼리는 내부적으로 Nested Loop(Filter 방식) 으로 수행
메인쿼리 row 1개 읽음
서브쿼리에 값을 전달
서브쿼리를 실행해 존재 여부(exists) 확인
TRUE이면 즉시 멈추고 다음 메인 row로 이동
(Exists 조건일 경우)
서브쿼리를 메인 쿼리와 동일 레벨로 펼쳐서
조인 형태로 재구성하는 것
UNNEST
NO_UNNEST (반대로 unnest 방지)
중첩 서브쿼리를 더 먼저 실행해서
메인쿼리로 전달되는 데이터 수를 줄이는 기법
아래의 문제를 해결
서브쿼리는 항상 메인쿼리의 드리븐 역할만 할 수 있다는 제약 극복
NO_UNNEST
PUSH_SUBQ
인라인 뷰를 메인쿼리와 합쳐서 NL 조인 형태로 변경
머징되면 메인쿼리 조건을 view 안에 pushdown할 수 있음
필요한 데이터만 읽게 됨 → 성능 개선 폭 큼
뷰에 MERGE 힌트
SELECT /*+ MERGE(v) */ ...
FROM (SELECT ... FROM ...) v
스칼라 서브쿼리는 SELECT 절에서 자주 사용되며,
실제론 NL 조인 + 캐싱으로 동작
메인쿼리 row 1개에 대해 서브쿼리 1회 실행
입력값 → 반환값 형태로 캐싱 (PGA 내부)
다음 row가 같은 값을 가진 경우 조인 생략
→ 캐시 재사용
→ 실행 횟수 급감