JOIN 전략 (hash, parallel_hash, grace_hash, full_sorting_merge, direct, 딕셔너리)
ClickHouse JOIN이 메모리를 많이 쓰는 이유와 알고리즘별 특징, 오른쪽 테이블을 작게 만드는 요령, ASOF JOIN과 분산 JOIN, 딕셔너리로 대체하는 방법까지 현장 기준으로 정리합니다.
ClickHouse JOIN의 기본 전제
ClickHouse는 오른쪽 테이블을 메모리에 해시 테이블로 올리고 왼쪽 테이블을 흘려보내며 짝을 맞춥니다. 그래서 다음 세 가지가 성능을 거의 결정합니다.
- 오른쪽 테이블이 작아야 합니다. 큰 테이블은 왼쪽에 둡니다.
- 오른쪽 테이블은 JOIN 전에 미리 걸러서(서브쿼리) 필요한 컬럼과 행만 남깁니다.
- 메모리가 모자라면 알고리즘을 디스크를 쓰는 쪽(
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면 키 조회로 | 해시 테이블 안 만듦 | 차원 테이블 조인의 최적 경로 |
auto | hash로 시작해 메모리 한도를 넘으면 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';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_buckets | grace_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으로 알고리즘을 확인했나.