본문으로 건너뛰기
  1. Posts/

[RDBMS] JOIN의 드라이빙 테이블과 드리븐 테이블 정리

NineKoo9
작성자
NineKoo9
목차

이 문서는 의문을 가지고 있던 지점을 AI 에이전트와 대화하며 정리한 메모입니다. Nested Loop Join을 중심으로 드라이빙 테이블과 드리븐 테이블이 왜 중요하고, 실행 계획에서 어떻게 읽는지 정리합니다.


목차
#

  1. 핵심 개념 정리
  2. JOIN의 내부 동작 원리
  3. 왜 드라이빙 테이블 선택이 중요한가?
  4. 좋은 드라이빙/드리븐 테이블 선택 기준
  5. 인덱스와의 관계
  6. JOIN 알고리즘별 드라이빙/드리븐 역할 차이
  7. 옵티마이저가 드라이빙 테이블을 결정하는 방법
  8. 강제로 드라이빙 테이블 지정하는 방법
  9. 실무 체크리스트
  10. 한 줄 요약

1. 핵심 개념 정리
#

JOIN을 실행할 때 옵티마이저는 두 테이블 중 어느 쪽을 먼저 읽을지 결정한다.

구분다른 이름설명
드라이빙 테이블Driving Table / Outer Table먼저 읽히는 테이블. JOIN의 기준점이 됨
드리븐 테이블Driven Table / Inner Table드라이빙 테이블의 각 행마다 탐색당하는 테이블

이 선택은 단순한 순서의 문제가 아니라, 쿼리 전체 성능을 크게 좌우하는 핵심 요소다.

다만 이 용어는 특히 JOIN 순서Nested Loop Join(NLJ) 을 설명할 때 가장 직관적이다.
Hash Join이나 Merge Join에서는 outer/inner, build/probe 같은 표현이 더 정확할 때도 있다.


2. JOIN의 내부 동작 원리
#

2-1. Nested Loop Join (NLJ) - 드라이빙/드리븐 개념을 이해하기 가장 쉬운 모델
#

드라이빙/드리븐 개념은 NLJ에서 가장 선명하게 드러난다.

// 드라이빙 = A, 드리븐 = B라고 가정
for each row in A (드라이빙):             // A를 한 번 순회
    for each matching row in B (드리븐):  // B를 A의 row 수만큼 반복 탐색
        output(A.row + B.row)

이 구조를 보면 핵심 비대칭이 명확해진다.

  • 드라이빙 테이블은 한 번 읽힌다
  • 드리븐 테이블은 드라이빙 결과 row 수만큼 반복 탐색된다

따라서 NLJ에서는 드리븐 테이블의 탐색 비용이 전체 JOIN 성능을 지배하기 쉽다.

2-2. 탐색 횟수로 보는 비용
#

드라이빙 결과 = N건
드리븐 탐색 비용 = 1회당 C

전체 비용 = N × C
  • N을 줄이려면 -> 드라이빙 테이블에 필터 효과가 큰 WHERE 조건
  • C를 줄이려면 -> 드리븐 테이블 JOIN 컬럼에 인덱스

3. 왜 드라이빙 테이블 선택이 중요한가?
#

예시 시나리오
#

SELECT *
FROM orders o
JOIN customers c ON o.customer_id = c.id
WHERE o.status = 'PENDING';

테이블 현황 가정:

테이블전체 건수조건 필터 후 건수
orders1,000,000500건 (status = 'PENDING')
customers100,000조건 없음

케이스 1: orders가 드라이빙 (좋은 경우)

1. orders에서 status = 'PENDING' 필터 -> 500건 추출
2. 500건의 customer_id로 customers 테이블 인덱스 탐색 -> 500회
총 탐색 횟수 = 500회

케이스 2: customers가 드라이빙 (나쁜 경우)

1. customers 전체 읽기 -> 100,000건
2. 100,000건 각각으로 orders 테이블 탐색 -> 100,000회
총 탐색 횟수 = 100,000회

같은 논리의 쿼리여도 조인 순서에 따라 비용이 크게 벌어진다.


4. 좋은 드라이빙/드리븐 테이블 선택 기준
#

4-1. 드라이빙 테이블로 적합한 것
#

✅ WHERE 조건 적용 후 결과 건수가 적은 테이블
✅ 필터 효과가 큰 조건을 가진 테이블
✅ 조인 전에 row 수를 많이 줄일 수 있는 테이블

필터 효과 계산 예시
#

orders 테이블: 1,000,000건
status = 'PENDING' 조건 후 결과: 500건

조건 일치 비율 = 500 / 1,000,000 = 0.0005 (0.05%)
-> 남는 row 비율이 매우 낮으므로 필터 효과가 큼

문헌에 따라 selectivity 용어를 “남는 비율"로 정의하기도 하고, 실무에서는 반대로 “필터가 잘 먹는다"는 뜻으로 느슨하게 쓰기도 한다.
실전에서는 용어보다 조건 적용 후 몇 건이 남는가 를 보는 편이 안전하다.

4-2. 드리븐 테이블로 적합한 것
#

✅ JOIN 컬럼(ON 절 컬럼)에 인덱스가 걸려 있는 테이블
✅ 단건 또는 소수건 lookup 역할을 하는 테이블
✅ 테이블이 커도 JOIN 컬럼 인덱스로 빠르게 찾을 수 있는 테이블

특히 NLJ에서는 드리븐 테이블의 JOIN 컬럼 인덱스가 사실상 성능의 핵심이다.

황금 법칙: 드라이빙은 “작게”, 드리븐은 “인덱스 있게”


5. 인덱스와의 관계 - 가장 중요한 부분
#

5-1. 이상적인 인덱스 구성 예시
#

-- 테이블 구조
CREATE TABLE orders (
    id          BIGINT PRIMARY KEY,
    customer_id BIGINT NOT NULL,
    status      VARCHAR(20) NOT NULL,
    created_at  DATETIME NOT NULL
);

CREATE TABLE customers (
    id    BIGINT PRIMARY KEY,
    name  VARCHAR(100),
    email VARCHAR(200)
);

-- 인덱스 추가
CREATE INDEX idx_orders_status      ON orders(status);        -- WHERE 조건용
CREATE INDEX idx_orders_customer_id ON orders(customer_id);   -- JOIN/FK 컬럼용

5-2. 이상적인 실행 흐름
#

SELECT *
FROM orders o
JOIN customers c ON o.customer_id = c.id
WHERE o.status = 'PENDING';
Step 1. orders에서 idx_orders_status 사용
        -> status = 'PENDING' 에 해당하는 row만 빠르게 추출 (드라이빙)

Step 2. 추출된 N건의 customer_id 각각으로
        customers의 PK 인덱스 탐색 (드리븐)
        -> 매 반복마다 index seek

결과: 전체 비용 ≈ N × lookup_cost

5-3. 드리븐 테이블에 인덱스가 없는 경우
#

-- 최악에 가까운 시나리오
SELECT * FROM small_ref_table s
JOIN huge_table h ON h.non_indexed_col = s.id;
-> small_ref_table 결과가 N건이라면
-> huge_table을 N번 Full Table Scan
-> 전체 비용 = N × M  (M = huge_table 전체 건수)
-> N=1,000, M=5,000,000이면 row 비교 수가 폭증

결론: NLJ에서 드리븐 테이블의 JOIN 컬럼에 인덱스가 없으면, 전체 JOIN 비용이 급격히 나빠질 수 있다.

5-4. FK 컬럼 인덱스 주의사항
#

FK 컬럼 인덱스는 DBMS마다 자동 생성 여부가 다르다.

  • MySQL InnoDB: 참조하는 쪽(referencing table)에 필요한 인덱스가 없으면 자동으로 생성될 수 있다
  • PostgreSQL: FK 선언만으로 referencing columns 인덱스를 자동 생성하지 않는다
  • SQL Server: FK 선언만으로 corresponding index를 자동 생성하지 않는다

따라서 실무에서는 다음 원칙이 안전하다.

FK가 있으니 인덱스도 있겠지 -> 위험한 가정
실제 인덱스 정의 + EXPLAIN 결과를 확인 -> 안전한 접근
-- 필요 시 명시적으로 생성
CREATE INDEX idx_orders_customer_id ON orders(customer_id);

6. JOIN 알고리즘별 드라이빙/드리븐 역할 차이
#

6-1. Nested Loop Join (NLJ)
#

드라이빙/드리븐 개념이 가장 직접적으로 적용되는 알고리즘이다.

특징:
- 드라이빙 테이블 한 번 순회
- 드리븐 테이블을 드라이빙 row 수만큼 반복 탐색

최적 조건:
- 드라이빙: 소량 결과, 필터 효과 큼
- 드리븐:   JOIN 컬럼에 인덱스가 매우 중요

적합 상황: OLTP, 비교적 작은 결과 집합, 인덱스 잘 갖춰진 환경
-- NLJ 동작 의사코드
for each row r1 in driving_table:
    lookup driven_table where join_col = r1.join_col
    if found:
        emit(r1, matched_row)

6-2. Hash Join (MySQL 8.4, PostgreSQL, SQL Server 등)
#

인덱스가 약한 상황에서도 대용량 JOIN을 처리할 수 있는 알고리즘이다.

동작 방식:
Phase 1 (Build):  보통 더 작은 입력을 해시 테이블로 적재
Phase 2 (Probe):  다른 입력을 스캔하며 해시 테이블과 매칭

특징:
- 드리븐 인덱스가 없어도 동작 가능
- 메모리 사용량이 큼
- 메모리를 초과하면 디스크 spill이 발생할 수 있음

적합 상황: 대용량 데이터, equi-join 중심, 인덱스가 약한 환경

PostgreSQL 문서는 Hash Join에서 한쪽 입력이 Hash 노드로 해시 테이블이 되고, 다른 쪽 입력이 이를 probe한다고 설명한다.
MySQL 8.4 문서도 EXPLAIN FORMAT=TREEHashInner hash join 노드가 표시된다고 설명한다.

-- MySQL 8.4에서 Hash Join 동작 확인
EXPLAIN FORMAT=TREE
SELECT * FROM orders o JOIN customers c ON o.customer_id = c.id;
-- 출력에 "Hash" 또는 "Inner hash join" 이 보이면 Hash Join 사용 중
-- MySQL: hash join 메모리와 관련된 대표 설정
SET join_buffer_size = 256 * 1024 * 1024;  -- 256MB

MySQL 8.4 문서 기준으로 hash join 메모리 사용량은 join_buffer_size 와 관련이 있고, 부족하면 디스크 파일을 사용할 수 있다.


6-3. Sort Merge Join (PostgreSQL, Oracle, SQL Server)
#

양쪽 입력이 JOIN 컬럼 기준으로 정렬되어 있을 때 유리한 알고리즘이다.

동작 방식:
1. 양쪽 입력을 JOIN 컬럼 기준으로 정렬
2. 정렬된 두 목록을 순차 스캔하며 병합

특징:
- 양쪽 입력이 정렬돼 있어야 함
- 정렬 비용이 들 수 있음
- 범위 조건이나 정렬된 입력 활용에 유리할 수 있음
- 드라이빙/드리븐 개념이 NLJ보다 덜 중요함

PostgreSQL 공식 문서도 merge join은 입력 데이터가 join key 기준으로 정렬되어 있어야 한다고 설명한다.


6-4. 알고리즘 선택 요약
#

알고리즘인덱스 필요성메모리 사용적합 환경
Nested Loop Join드리븐 인덱스가 매우 중요낮음OLTP, 소량 결과
Hash Join인덱스 의존도 낮음높음대량 데이터, equi-join
Sort Merge Join정렬된 입력이 중요중간대량 데이터, 정렬 활용

7. 옵티마이저가 드라이빙 테이블을 결정하는 방법
#

옵티마이저는 통계 정보(테이블 건수, 인덱스 분포, 컬럼 분포 등)를 기반으로 비용을 추정해 조인 순서를 결정한다.

7-1. 실행계획(EXPLAIN) 읽는 법
#

-- MySQL
EXPLAIN SELECT *
FROM orders o
JOIN customers c ON o.customer_id = c.id
WHERE o.status = 'PENDING';
+----+-------------+-------+--------+-------------------------+-------------------+---------+-------------------+------+
| id | select_type | table | type   | possible_keys           | key               | key_len | ref               | rows |
+----+-------------+-------+--------+-------------------------+-------------------+---------+-------------------+------+
|  1 | SIMPLE      | o     | ref    | idx_orders_status       | idx_orders_status | 22      | const             |  500 |
|  1 | SIMPLE      | c     | eq_ref | PRIMARY                 | PRIMARY           | 8       | mydb.o.customer_id|    1 |
+----+-------------+-------+--------+-------------------------+-------------------+---------+-------------------+------+

MySQL 공식 문서는 EXPLAIN테이블이 어떤 순서로 조인되는지 보여 준다고 설명한다.
따라서 tabular EXPLAIN 에서는 보통 위에서 아래 순서가 조인 순서를 반영한다.

다만 다음 예외는 기억해야 한다.

  • const 또는 system 테이블은 최적화 단계에서 먼저 읽혀 계획 맨 앞에 나타날 수 있다
  • Hash Join 같은 경우는 FORMAT=TREE 로 보는 편이 실제 build/probe 구조를 더 잘 보여 준다

7-2. type 컬럼 - MySQL에서 보는 주요 접근 방식
#

type 값의미
system테이블에 행이 1건뿐인 경우
constPK/Unique = 상수값, 1건 접근
eq_refPK/Unique를 이용한 조인 lookup
ref일반 인덱스 lookup
range인덱스 범위 탐색
index인덱스 전체 스캔
ALL테이블 전체 스캔

대체로 위에서 아래로 갈수록 비용이 커지는 경향이 있다.

드리븐 테이블의 typeALL 이면 인덱스 추가나 조인 순서 재검토를 먼저 의심하는 편이 좋다.

7-3. 추가로 봐야 할 컬럼들
#

컬럼의미
rows옵티마이저가 예측한 탐색 건수
filteredWHERE 조건으로 걸러질 비율 추정치
ExtraUsing index, Using filesort, Using join buffer (hash join) 등 부가 정보

7-4. 통계 정보 갱신
#

옵티마이저 판단이 이상할 때는 통계가 오래됐을 가능성도 확인해야 한다.

-- MySQL
ANALYZE TABLE orders;
ANALYZE TABLE customers;

-- PostgreSQL
ANALYZE orders;
ANALYZE customers;

-- Oracle
EXEC DBMS_STATS.GATHER_TABLE_STATS('schema_name', 'orders');

8. 강제로 드라이빙 테이블 지정하는 방법
#

옵티마이저가 잘못된 판단을 내릴 때는 힌트나 planner 설정으로 영향을 줄 수 있다.
다만 힌트는 어디까지나 예외적 수단 이고, 먼저 인덱스와 통계부터 바로잡는 편이 안전하다.

8-1. MySQL
#

-- STRAIGHT_JOIN: FROM 절 순서를 고정
SELECT STRAIGHT_JOIN *
FROM orders o
JOIN customers c ON o.customer_id = c.id;

-- JOIN_ORDER: 더 세밀한 조인 순서 지정
SELECT /*+ JOIN_ORDER(o, c) */ *
FROM orders o
JOIN customers c ON o.customer_id = c.id;

MySQL 8.4 공식 문서 기준으로:

  • STRAIGHT_JOIN 은 FROM 절의 순서를 따르게 한다
  • JOIN_FIXED_ORDERSTRAIGHT_JOIN 과 같은 효과다
  • JOIN_ORDER 는 특정 테이블들의 조인 순서를 지정한다

Hash Join 제어도 주의가 필요하다.

-- MySQL 8.4에서는 HASH_JOIN/NO_HASH_JOIN 대신 BNL/NO_BNL 사용
SELECT /*+ NO_BNL(c) */ *
FROM orders o
JOIN customers c ON o.customer_id = c.id;

MySQL 8.4 문서는 HASH_JOIN / NO_HASH_JOIN 힌트가 효과가 없고, hash join 제어에는 BNL / NO_BNL 을 쓰라고 설명한다.

8-2. PostgreSQL
#

-- 조인 재정렬을 강하게 줄이기
SET join_collapse_limit = 1;

-- 특정 알고리즘 비선호
SET enable_hashjoin = off;
SET enable_mergejoin = off;

PostgreSQL 공식 문서 기준으로:

  • join_collapse_limit 은 planner가 JOIN 순서를 얼마나 재배치할지에 영향을 준다
  • enable_hashjoin, enable_mergejoin, enable_nestloop 는 해당 조인 방식을 비활성화라기보다 비선호 하게 만든다
  • 특히 nested loop는 다른 대안이 없으면 완전히 배제되지는 않는다

8-3. Oracle
#

-- LEADING 힌트: 조인 순서 시작점 지정
SELECT /*+ LEADING(o) */ *
FROM orders o
JOIN customers c ON o.customer_id = c.id;

-- USE_NL: 지정한 테이블을 inner table로 nested loop 사용
SELECT /*+ LEADING(o) USE_NL(c) */ *
FROM orders o
JOIN customers c ON o.customer_id = c.id;

Oracle 공식 문서는:

  • LEADING 을 조인 순서를 잡는 multitable hint로
  • USE_NL 을 지정한 테이블을 inner table로 nested loops join 하도록 지시하는 hint로 설명한다

8-4. SQL Server
#

SELECT *
FROM orders o
INNER LOOP JOIN customers c ON o.customer_id = c.id
OPTION (FORCE ORDER);

SQL Server 공식 문서는:

  • LOOP, HASH, MERGE 를 join hint로
  • FORCE ORDER 를 query hint로 제공한다
  • 하나의 join hint를 주면 쿼리 전체 조인 순서에도 영향이 생길 수 있다고 설명한다

⚠️ 힌트 사용 시 주의사항
데이터 분포, 통계, 인덱스가 바뀌면 힌트가 오히려 성능을 악화시킬 수 있다.
가능하면 인덱스 설계와 통계 정보 갱신으로 먼저 해결하자.


9. 실무 체크리스트
#

9-1. 쿼리 작성 시
#

✅ WHERE 조건으로 결과가 많이 줄어드는 테이블을 드라이빙 후보로 본다
✅ 드리븐 테이블 JOIN 컬럼(ON 절)에 인덱스가 있는지 확인한다
✅ FK 컬럼은 "자동 인덱스가 있을 것"이라고 가정하지 말고 실제 인덱스를 확인한다
✅ 3개 이상 JOIN 시 EXPLAIN으로 실행 순서를 확인한다
✅ SELECT * 를 피하고 필요한 컬럼만 선택해 커버링 인덱스 가능성을 높인다

9-2. 성능 문제 의심 시 진단 절차
#

Step 1. EXPLAIN 실행
        -> JOIN 순서와 access type 확인
        -> 드리븐 쪽이 ALL인지 먼저 본다

Step 2. rows / filtered 확인
        -> 드라이빙 후보의 결과 건수가 비정상적으로 큰지 점검

Step 3. 통계 정보 갱신
        -> ANALYZE 또는 DBMS_STATS 후 실행계획 재확인

Step 4. 인덱스 추가
        -> 드리븐 JOIN 컬럼 인덱스
        -> 필요 시 드라이빙 필터 컬럼 인덱스

Step 5. 그래도 느리면
        -> DBMS별 힌트 또는 planner 설정으로 예외적으로 보정

9-3. 자주 하는 실수 & 올바른 해결책
#

실수 1: 드리븐 테이블 JOIN 컬럼에 인덱스 없음
#

-- 나쁜 예
SELECT * FROM small_table s
JOIN huge_table h ON h.non_indexed_col = s.id;

-- 해결
CREATE INDEX idx_huge_non_indexed ON huge_table(non_indexed_col);

실수 2: 대형 테이블이 드라이빙으로 선택됨
#

-- 나쁜 예
SELECT * FROM customers c
JOIN orders o ON o.customer_id = c.id
WHERE o.status = 'PENDING';

-- 확인 후 조인 순서 보정
SELECT STRAIGHT_JOIN *
FROM orders o
JOIN customers c ON o.customer_id = c.id
WHERE o.status = 'PENDING';

다만 이런 경우도 힌트 전에 인덱스와 통계부터 확인 하는 것이 우선이다.

실수 3: FK라서 인덱스가 자동으로 있을 거라고 가정
#

ALTER TABLE orders
  ADD CONSTRAINT fk_orders_customer
  FOREIGN KEY (customer_id) REFERENCES customers(id);

이 선언만으로 모든 DBMS가 자동 인덱스를 만드는 것은 아니다.

  • PostgreSQL, SQL Server는 referencing columns 인덱스를 자동 생성하지 않는다
  • MySQL InnoDB는 필요한 인덱스를 자동 생성할 수 있지만, 실제로 어떤 인덱스가 있는지는 확인해야 한다
-- 실제 스키마 확인 후 필요 시 명시적으로 생성
CREATE INDEX idx_orders_customer_id ON orders(customer_id);

실수 4: 3개 이상 테이블 JOIN 시 순서 방치
#

SELECT *
FROM orders o
JOIN customers c ON o.customer_id = c.id
JOIN products p ON o.product_id = p.id
JOIN categories cat ON p.category_id = cat.id
WHERE o.created_at BETWEEN '2024-01-01' AND '2024-03-31';

이런 쿼리는 반드시 실행계획을 보고:

  • 어떤 테이블이 먼저 읽히는지
  • 중간 단계에서 row 수가 얼마나 줄어드는지
  • 어느 지점에서 Full Scan이나 Hash/Merge가 붙는지

를 확인해야 한다.

9-4. 인덱스 설계 가이드
#

-- 드라이빙 후보용: WHERE 조건 컬럼
CREATE INDEX idx_orders_status_created ON orders(status, created_at);

-- 드리븐 후보용: JOIN 컬럼
CREATE INDEX idx_orders_customer_id ON orders(customer_id);

-- 커버링 인덱스 예시
CREATE INDEX idx_orders_covering ON orders(customer_id, status, created_at);

MySQL의 경우 EXPLAINExtraUsing index 가 보이면 커버링 인덱스 활용 여부를 판단하는 데 도움이 된다.


10. 한 줄 요약
#

특히 NLJ에서는, 드라이빙 테이블은 “필터 후 결과가 적은 것"으로, 드리븐 테이블은 “JOIN 컬럼에 인덱스가 있는 것"으로 잡혀야 JOIN이 빨라진다.

작은 드라이빙 × 인덱스 있는 드리븐 = 빠른 JOIN
큰 드라이빙   × 인덱스 없는 드리븐 = 느린 JOIN

참고 - DB별 빠른 레퍼런스
#

목적MySQLPostgreSQLOracle
실행계획 확인EXPLAIN, EXPLAIN ANALYZE, FORMAT=TREEEXPLAIN, EXPLAIN ANALYZEEXPLAIN PLAN FOR
통계 갱신ANALYZE TABLE tANALYZE tDBMS_STATS.GATHER_TABLE_STATS
조인 순서 영향 주기STRAIGHT_JOIN, JOIN_ORDER, JOIN_FIXED_ORDERjoin_collapse_limitLEADING
Hash Join 제어BNL, NO_BNLenable_hashjoinUSE_HASH

참고 자료
#