Skip to content

Database ‐ Join Optimization

woojin edited this page Aug 12, 2026 · 2 revisions

조인의 동작 원리 - NLJ

for each row in 바깥쪽_테이블 { -- 외부 루프
    for each row in 안쪽_테이블 { -- 내부 루프
        if (조인 조건 일치) {
            결과 추가
        }
    }
}
  • MySQL은 인덱스를 사용할 수 있는 조인을 기본적으로 이 중첩 루프 방식으로 실행한다. 테이블이 N개가 될 때마다 루프가 중첩되는 깊이가 달라질뿐 기본 구조는 같다.

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

  • 드라이빙 테이블(Driving Table) : 외부 루프를 담당하는 테이블이다. 조인의 시작점이 된다.
  • 드리븐 테이블(Driven Table) : 내부 루프를 담당하는 테이블이다. 드라이빙 테이블의 각 행마다 반복적으로 접근되는 테이블이다.
  • 성능 최적화를 위해서라면 트라이빙 테이블의 행 수를 줄이는 것이 매우 중요하다.

NLJ와 인덱스 - Driven Table

  • 드리븐 테이블 탐색은 시작할 때마다 비용이 든다. 왜냐하면 인덱스 탐색 한 번은 루트에서 리프까지 내려가는 작업이기 때문이다.
  • 결국 같은 양을 읽어도 루프 횟수가 큰 쪽이 더 느리다. 드리븐에서 가져오는 행이 늘어나는 것은 상대적으로 싸다. 그래서 조인 튜닝의 1순위는 항상 드라이빙 테이블 결과를 줄이는 것이다.
  • 조인 쿼리가 느릴 때 가장 먼저 확인할 것은 드라이빙 테이블의 EXPLAIN 실행 계획이다. 드리븐 테이블의 인덱스를 아무리 정비해도 드라이빙 결과를 줄이지 못하면 루프 횟수 자체는 줄지 않는다. 드라이빙 테이블의 숫자를 줄이는 것이 튜닝의 출발점이 된다.
  • 일반적으로 사용하는 NLJ의 성능 공식은 드라이빙 결과 건수(루프 횟수) x 드리븐 테이블 1회 탐색 비용이다.
    • 드라이빙 테이블의 WHERE 조건과 인덱스 루프 횟수를 줄인다.
    • 드리븐 테이블의 조인 조건 인덱스가 1회 탐색 비용을 줄인다.

해시 조인 - 인덱스가 없을 때

  • 해시 탐색은 키를 해시 함수에 넣으면 위치가 한 번에 계산된다. 따라서 행 수가 아무리 많아도 탐색 비용은 거의 일정하다.
  • 해시 테이블은 키-값 쌍을 저장하는 자료구조다. 키를 해시 함수에 넣으면 숫자가 하나 계산되는데, 그 숫자가 곧 데이터가 저장될 위치다. 저장할 때도 찾을 때도 같은 계산을 하므로 데이터가 5건이든 500만 건이든 키만 있다면 위치를 O(1) 시간 복잡도로 한 번에 찾아간다.
  • 대신 해시 테이블은 데이터가 정렬되어 보관되지 않는다. 따라서 데이터가 계산된 위치에 흩어져 저장되므로, 키가 정확히 일치하는 등호 탐색만 가능하다. 범위 검색과 정렬까지 처리해야 하는 인덱스가 해시가 아니라 B+Tree를 사용하는 이유다.

해시 조인의 조건과 한계

  • 인덱스가 있으면 NLJ가 우선이다. 드리븐 테이블에 인덱스가 있으면 NLJ는 필요한 소수 행만 정확히 탐색한다. 옵티마이저는 사용할 인덱스가 있으면 NLJ를, 없으면 해시 조인을 자동으로 선택한다.
  • 해시 테이블은 메모리에 만든다. 사용할 수 있는 메모리 최대 크기가 join_buffer_size다. 기본값 262,144바이트(256KB)다. 빌드 입력이 이 크기를 초과하면 MySQL은 디스크의 임시 파일로 나눠서 처리하고, 성능이 상당히 저하된다. 빌드 입력이 작을수록 해시 조인이 유리한 이유이기도 하다.
비교 항목 중첩 루프 조인 (NLJ) 해시 조인 (Hash Join)
기본 동작 드라이빙 행마다 드리븐 탐색 빌드(해시 테이블) + 프로브(1회 스캔)
드리븐 인덱스 필수 (생명줄) 불필요
인덱스 있을 때 매우 효율적 (소수 행만) 한쪽 전체를 읽으므로 불리
인덱스 없을 때 매 루프 풀 스캔 (재앙) 효율적
메모리 거의 안 씀 join_buffer_size 의존, 초과 시 디스크
옵티마이저 선택 인덱스 있으면 우선 인덱스 없는 조인에서 자동
  • 드라이빙 테이블은 SQL에 쓴 순서대로 정해지는 것이 아니다. 옵티마이저가 비용을 계산해서 직접 고르는 것이다.
  • WHERE 조건으로 결과가 가장 적게 나오는 테이블이 드라이빙 테이블이 되어야 한다. 옵티마이저는 이 통계를 기반으로 판단을 자동으로 해주고 있었던 것이다.

옵티마이저를 맹목적으로 신뢰하라는 말이 아니다.

  • 대부분 조인 순서를 옵티마이저에게 맡기면 되지만 옵티마이저의 판단 근거는 통계 정보다. 통계는 추정치고, 데이터가 대량으로 변경된 직후나 분포가 심하게 편중된 상황에서는 추정이 빗나갈 수 있다.
  • 추정이 빗나가면 옵티마이저가 드라이빙 테이블을 잘못 선택할 수 있기에 만약 옵티마이저가 이상한 순서를 골랐다고 의심이 될 경우 대응 순서를 다음과 같이 정리할 수 있다.
    • ANALYZE TABLE로 통계를 갱신한다. 가장 흔한 원인이 낡은 통계다.
    • 히스토그램을 검토한다. 편중된 컬럼이 원인이라면 히스토그램이 해결책이다.
    • 드라이빙 후보 테이블의 WHERE 조건에 인덱스를 보강한다. 옵티마이저가 좋은 순서를 고를 수 있는 재료를 준다.
    • 힌트는 최후의 수단이다. JOIN_FIXED_ORDER()과 같은 순서 강제 힌트는 그때그때의 데이터 분포에 대한 판단을 코드에 고정시켜 버린다. 지금은 옳은 순서가 될 수 있지만 1년 뒤에는 틀린 순서가 될 수 있다.

📖 Java🔥

📖 Kotlin⭐

📖 Coroutine📎

📖 Spring🔥

📖 Spring Security⭐

📖 Spring Security OAuth2⭐

📖 Spring Batch📎

📖 Database🔥

📖 MySQL🔥

📖 Redis⭐

📖 JPA⭐

📖 QueryDsl📎

📖 MSA⭐

📖 Kafka⭐

📖 Apache Flink📎

  • [Apache Flink - Apache Flink Architecture]
  • [Apache Flink - Stream Processing]
  • [Apache Flink - Data Stream API & Window]
  • [Apache Flink - State Management]

📖 HTTP🔥

📖 AWS⭐

📖 Docker⭐

📖 Kubernetes⭐

📖 Github Actions📎

📖 Jenkins📎

📖 Nginx⭐

📖 Monitoring📎

📖 Test(feat. Load Testing)📎

📖 Test(feat. Java)⭐

📖 Spring AI📎

📖 gRPC📎

  • [gRPC - Writing .proto Files with Protocol Buffers]
  • [gRPC - Various Communication Patterns in gRPC]
  • [gRPC - gRPC Optimization Techniques and Advanced Features]

📖 TDD(Test-Driven-Development)⭐

📖 PostgreSQL📎

  • [PostgreSQL - Docker만을 사용하는 경량화된 환경 구성 방법]
  • [PostgreSQL - PostgreSQL에서 제공하는 데이터 타입]
  • [PostgreSQL - PostgreSQI의 JSONB, 역인덱싱과 활용 방법]
  • [PostgreSQL - 데이터베이스 성능을 위한 최적화 패턴 및 전략]
  • [PostgreSQL - 트랜잭션과 ACID, Isolation 수준별 차이]
  • [PostgreSQL - Database Lock 교착상태와 읽기/쓰기 성능을 보장하는 MVCC 모델]
  • [PostgreSQL - pgvector와 벡터 저장, 유사도 검색 패턴 개념]
  • [PostgreSQL - 벡터 인덱스 최적화와 벡터 검색과 전문 검색 결합 패턴]
  • [PostgreSQL - PostgreSQL 플러그인]
  • [PostgreSQL - PostGIS - 공간 쿼리와 GIST 인덱스, 지리 타입과 공간 쿼리를 위한 타입과 기본 함수]
  • [PostgreSQL - pg_search - 검색 엔진 없이 텍스트 검색 구현과 주의사항]
  • [PostgreSQL - 단일 인스턴스 한계를 극복하는 분산 패턴과 스케줄링, 분산 환경 구축 방법]
  • [PostgreSQL - Citus - 분산 테이블과 분산 쿼리를 위한 Extension과 데이터 분산 처리]
  • [PostgreSQL - pg_cron - PostgreSQL로 구성하는 CronJob]
  • [PostgreSQL - 스케줄러 + 분산 처리를 동시에 도입하는 주기적 집계 쿼리 패턴]

📖 Workflow-Driven Techniques for Large-Scale Traffic Processing📎

  • [Workflow-Driven Techniques for Large-Scale Traffic Processing - Kafka + Debezium을 활용한 CDC 패턴 설계]
  • [Workflow-Driven Techniques for Large-Scale Traffic Processing - Temporal을 활용한 워크플로우 패턴]
  • [Workflow-Driven Techniques for Large-Scale Traffic Processing - Docker와 경량 이미지를 활용한 환경 구축 방법]
  • [Workflow-Driven Techniques for Large-Scale Traffic Processing - Kafka에서의 메시지 Delivery Guarantee]
  • [Workflow-Driven Techniques for Large-Scale Traffic Processing - 실시간 동기화의 핵심 CDC]
  • [Workflow-Driven Techniques for Large-Scale Traffic Processing - MySQL Binary Log 기반의 CDC]
  • [Workflow-Driven Techniques for Large-Scale Traffic Processing - Binary Log 기반의 CDC 구현 플랫폼 Debezium이란?]
  • [Workflow-Driven Techniques for Large-Scale Traffic Processing - Debezium Architecture]
  • [Workflow-Driven Techniques for Large-Scale Traffic Processing - Debezium Architecture Best Practice와 주의사항]

📖 Reactive Programming📎

📖 ElasticSearch📎

📖 Design Pattern📎

📖 Clean Spring📎

  • [Clean Spring - Domain-Driven Development]
  • [Clean Spring - Domain-Driven Development with Design Patterns]
  • [Clean Spring - Developing Membership Application with Hexagonal Architecture]
  • [Clean Spring - JPA and Domain Model Patterns]
  • [Clean Spring - Designing a Consistent Domain Model with Aggregates]
  • [Clean Spring - Web API Adapter]
  • [Clean Spring - Hexagonal Architecture: Ports]
  • [Clean Spring - Hexagonal Architecture: Application Components]
  • [Clean Spring - Test Improvement & Architecture Validation]
  • [Clean Spring - Developing Application Components]
  • [Real MySQL 8.0 - 인덱스]
  • [Real MySQL 8.0 - 실행 계획]
  • [Real MySQL 8.0 - 아키텍처]
  • [Real MySQL 8.0 - 트랜잭션과 잠금]

Clone this wiki locally