본문으로 건너뛰기
clickhouse검색
SQL과 쿼리

JOIN 전략 (hash, parallel_hash, grace_hash, full_sorting_merge, direct, 딕셔너리)

ClickHouse JOIN이 메모리를 많이 쓰는 이유와 알고리즘별 특징, 오른쪽 테이블을 작게 만드는 요령, ASOF JOIN과 분산 JOIN, 딕셔너리로 대체하는 방법까지 현장 기준으로 정리합니다.

JOINhash joingrace_hashASOF딕셔너리메모리2026-10 기준, 약 11분

ClickHouse JOIN의 기본 전제

ClickHouse는 오른쪽 테이블을 메모리에 해시 테이블로 올리고 왼쪽 테이블을 흘려보내며 짝을 맞춥니다. 그래서 다음 세 가지가 성능을 거의 결정합니다.

  1. 오른쪽 테이블이 작아야 합니다. 큰 테이블은 왼쪽에 둡니다.
  2. 오른쪽 테이블은 JOIN 전에 미리 걸러서(서브쿼리) 필요한 컬럼과 행만 남깁니다.
  3. 메모리가 모자라면 알고리즘을 디스크를 쓰는 쪽(grace_hash, partial_merge)으로 바꾸거나 설계를 바꿉니다(비정규화, 딕셔너리, 머티리얼라이즈드 뷰).

주의 ClickHouse는 통계 기반 조인 순서 재배치를 다른 DB처럼 공격적으로 하지 않습니다. 최근 버전에 작은 쪽을 자동으로 오른쪽에 두는 설정(query_plan_join_swap_table)이 생겼지만, 쿼리를 쓸 때 큰 테이블을 왼쪽에 두는 습관이 여전히 중요합니다.

조인 알고리즘 고르기

SETTINGS join_algorithm = '...'으로 고릅니다. 쉼표로 여러 개를 적으면 조건에 맞는 첫 번째를 씁니다. 최근 버전의 기본값은 direct, parallel_hash, hash 순으로 시도하는 조합입니다.

알고리즘동작메모리쓸 때
hash오른쪽 전체를 하나의 해시 테이블로오른쪽 크기에 비례오른쪽이 수백만 행 안쪽
parallel_hash해시 테이블을 여러 조각으로 나눠 병렬 구축hash와 비슷, 조금 더코어가 많고 오른쪽이 큼. 대부분의 기본 선택
grace_hash양쪽을 버킷으로 나눠 디스크에 쓰고 버킷 단위로 조인버킷 하나 분량오른쪽이 메모리보다 클 때
partial_merge오른쪽을 정렬해 블록 단위로 디스크에 두고 병합작음메모리는 아끼지만 느림
full_sorting_merge양쪽을 정렬해 병합정렬 버퍼양쪽이 모두 크고, 조인 키가 정렬 키라 정렬이 공짜일 때
direct오른쪽이 딕셔너리, Join 엔진, EmbeddedRocksDB면 키 조회로해시 테이블 안 만듦차원 테이블 조인의 최적 경로
autohash로 시작해 메모리 한도를 넘으면 partial_merge로안전 장치
-- 오른쪽이 메모리보다 클 때
SELECT l.order_id, r.customer_name
FROM orders AS l
INNER JOIN customers AS r ON l.customer_id = r.customer_id
SETTINGS join_algorithm = 'grace_hash', grace_hash_join_initial_buckets = 16;

-- 양쪽이 조인 키로 정렬되어 있을 때
SELECT ...
FROM big_a INNER JOIN big_b USING (user_id)
SETTINGS join_algorithm = 'full_sorting_merge';
오른쪽 테이블이어떤 모양인가? 딕셔너리, Join 엔진, EmbeddedRocksDB(차원 테이블을 미리 올려 둠) 메모리에 넉넉히 들어감(수백만 행, 필요한 컬럼만) 메모리보다 큼(해시 테이블이 한도를 넘음) 양쪽 모두 크고 조인 키가 정렬 키(정렬이 거의 공짜) direct키 조회, 해시 테이블 없음 parallel_hash (기본)코어 수만큼 병렬 구축 grace_hash버킷으로 나눠 디스크 사용 full_sorting_merge정렬 병합, 메모리 일정 큰 테이블은 왼쪽에 오른쪽은 서브쿼리로 미리 거르기
조인 알고리즘 고르기

EXPLAIN PIPELINE으로 어떤 알고리즘이 실제로 쓰였는지 확인할 수 있습니다. 자세한 읽는 법은 쿼리 최적화에 있습니다.

오른쪽 테이블을 작게 만드는 요령

-- 나쁜 예: 전체 사용자 테이블을 오른쪽에
SELECT e.*, u.plan
FROM events e
LEFT JOIN users u ON e.user_id = u.user_id
WHERE e.event_date = today();

-- 좋은 예: 오늘 등장한 사용자만, 필요한 컬럼만
SELECT e.*, u.plan
FROM events e
LEFT JOIN
(
    SELECT user_id, plan FROM users
    WHERE user_id IN (SELECT user_id FROM events WHERE event_date = today())
) AS u ON e.user_id = u.user_id
WHERE e.event_date = today();
  • 새 분석기는 조건을 어느 정도 밀어내리지만, 오른쪽 테이블에 거는 조건은 서브쿼리에 직접 적는 편이 확실합니다.
  • SELECT * 대신 필요한 컬럼만. 해시 테이블에는 오른쪽 컬럼이 모두 들어갑니다.
  • 조인 키 타입을 양쪽에서 맞춥니다. UInt64와 String을 맞추려면 변환 비용이 들고 인덱스를 못 씁니다.
  • 같은 차원 테이블을 매번 조인한다면 딕셔너리로 바꾸는 쪽이 보통 열 배 이상 빠릅니다.

조인 종류

종류뜻
INNER, LEFT, RIGHT, FULL표준과 같음. OUTER는 생략 가능
CROSS모든 조합. 조건 없는 , 조인도 CROSS
ANY왼쪽 행마다 짝을 하나만 (중복 폭발 방지)
ALL짝이 여러 개면 모두 (기본값)
SEMI, ANTI짝이 있는 행만, 없는 행만 (오른쪽 컬럼은 안 가져옴)
ASOF시간처럼 정렬되는 키에서 가장 가까운 과거 행과 짝
PASTE양쪽을 행 번호 순서대로 나란히 붙임 (키 없음)

짝이 없는 쪽은 기본값(0, 빈 문자열)으로 채워집니다. NULL이 필요하면 SETTINGS join_use_nulls = 1을 켭니다. 집계와 섞을 때 0이 섞이면 평균이 틀어지므로 분석 쿼리에서는 join_use_nulls를 켜는 팀이 많습니다.

ASOF JOIN: 가장 가까운 과거 값 붙이기

주문 시점의 환율, 이벤트 시점의 가격, 센서 값에 가장 가까운 설정값처럼 "정확히 같은 시각이 없고 그 직전 값"이 필요할 때 씁니다.

SELECT
    o.order_id, o.amount_usd, o.ordered_at,
    fx.rate AS krw_rate,
    o.amount_usd * fx.rate AS amount_krw
FROM orders AS o
ASOF LEFT JOIN fx_rates AS fx
    ON o.currency = fx.currency AND o.ordered_at >= fx.valid_from;
  • 등호 조건이 하나 이상 있어야 하고, 부등호 조건은 하나만 씁니다.
  • 오른쪽 테이블이 메모리에 올라가므로 기간을 좁혀 둡니다.
  • 환율처럼 기간이 명확한 데이터는 range_hashed 딕셔너리가 더 가볍습니다. 딕셔너리

부등호 조인과 IEJoin

ON a.x < b.y처럼 등호가 없는 조인은 예전에는 CROSS JOIN 뒤에 필터로 처리되어 매우 느렸습니다. 26.x에서 구간 조인에 특화된 IEJoin 알고리즘이 들어와 "시간 구간이 겹치는 세션 찾기" 같은 쿼리가 현실적인 시간에 끝납니다. 그래도 양쪽 행 수가 수천만 단위면 먼저 등호 조건(같은 사용자, 같은 날)으로 범위를 좁히는 설계가 필요합니다.

-- 같은 사용자 안에서 겹치는 세션 쌍
SELECT a.session_id, b.session_id
FROM sessions AS a
INNER JOIN sessions AS b
    ON a.user_id = b.user_id
   AND a.start_at < b.end_at
   AND b.start_at < a.end_at
   AND a.session_id < b.session_id;

분산 환경의 JOIN

여러 샤드에 데이터가 있을 때 조인은 각 샤드에서 따로 실행되고 결과가 모입니다. 오른쪽 테이블이 샤드마다 전체가 있어야 맞는 결과가 나옵니다.

  • 오른쪽이 분산 테이블이면 GLOBAL JOIN을 써서 오른쪽 결과를 먼저 모아 모든 샤드에 보냅니다. 안 쓰면 샤드마다 오른쪽을 다시 분산 조회하는 폭발이 일어납니다.
  • 차원 테이블은 모든 노드에 복제해 두고(ReplicatedMergeTree, 샤드 키 없이) 로컬 조인하는 설계가 가장 단순합니다.
  • distributed_product_mode로 서브쿼리 안 분산 테이블의 동작을 정합니다. local은 서브쿼리를 샤드 로컬 테이블로 바꿉니다.
  • ClickHouse Cloud처럼 공유 스토리지 위의 복제본만 있는 구조에서는 샤드가 없어서 이 걱정이 없습니다. 대신 병렬 복제본 설정이 조인에도 영향을 줍니다. 복제와 샤딩

메모리 한도와 실패 처리

설정뜻
max_bytes_in_join, max_rows_in_join해시 테이블 상한
join_overflow_mode상한을 넘으면 throw(기본) 또는 break(지금까지 결과로 계속)
max_memory_usage쿼리 전체 메모리 상한
join_algorithm = 'auto'hash에서 상한을 넘으면 partial_merge로 자동 전환
grace_hash_join_initial_bucketsgrace_hash의 버킷 수. 모자라면 자동으로 늘어남

"Memory limit (for query) exceeded"가 JOIN에서 나면 순서는 이렇습니다. 오른쪽을 서브쿼리로 줄이기, 컬럼 줄이기, parallel_hash에서 grace_hash로, 그래도 안 되면 딕셔너리나 미리 조인한 테이블(머티리얼라이즈드 뷰)로 설계 변경. 원인 진단 방법은 메모리 초과 트러블슈팅에 있습니다.

조인을 안 하는 설계

ClickHouse는 조인을 잘 해 가는 중이지만, 가장 빠른 조인은 하지 않는 조인입니다.

  • 비정규화: 수집 시점에 차원 컬럼(국가 이름, 상품 카테고리)을 이벤트 행에 함께 저장합니다. 저장 공간은 압축이 해결합니다. 압축과 코덱
  • 딕셔너리: 차원 테이블을 메모리 키-값 구조로 올려 dictGet으로 조회합니다. join_algorithm = 'direct'면 딕셔너리와의 JOIN 문법도 자동으로 조회로 바뀝니다.
  • 머티리얼라이즈드 뷰에서 조인: INSERT 시점에 조인해 결과 테이블에 저장합니다. 단, MV 안의 JOIN은 왼쪽 소스 테이블에 새 행이 들어올 때만 실행되고 오른쪽 테이블 변화는 반영되지 않습니다. 머티리얼라이즈드 뷰
  • Join 엔진 테이블: 오른쪽 테이블을 미리 해시 테이블 형태로 메모리에 유지합니다. joinGet으로 조회도 됩니다.
-- 딕셔너리로 조인 대체
SELECT
    country_id,
    dictGet('dim_country', 'name', country_id) AS country_name,
    count() AS c
FROM web_logs
WHERE event_date = today()
GROUP BY country_id
ORDER BY c DESC;

체크리스트

  • 큰 테이블은 왼쪽, 작은 테이블은 오른쪽에 두었나.
  • 오른쪽을 서브쿼리로 미리 걸렀고 컬럼을 줄였나.
  • 조인 키 타입이 양쪽에서 같은가.
  • 짝 없는 행의 0과 빈 문자열이 집계를 망치지 않나 (join_use_nulls).
  • 메모리 초과면 grace_hash, 그 전에 딕셔너리나 비정규화를 검토했나.
  • 분산 테이블 조인에 GLOBAL을 붙였나.
  • EXPLAIN PIPELINE으로 알고리즘을 확인했나.

공식 문서

이 문서에서