Ken
같은 데이터, 같은ORDER BY인데 포인트 룩업이 20배 차이 났다. Kafka 파티셔닝 방식과 적재 경로가 ClickHouseMergeTree의 part 레벨 pruning을 어떻게 켜고 끄는지, 1,000만 행으로 직접 측정
TL;DR
- ClickHouse는 part 내부를 항상
ORDER BY로 정렬하므로 granule skipping은 늘 작동한다. 차이는 part 레벨에서 난다 — 각 part의 정렬 키 min/max로 "값이 없을 수 없는 part"를 통째로 건너뛴다. - part가 건너뛸 수 있는지는 각 part가 좁고 서로 겹치지 않는 키 범위를 담는지에 달렸고, 이는 Kafka 파티션이 어떻게 형성되어 어떤 경로로 part가 되는지가 결정한다.
- 단순
hash(key)는 키별 순서는 지키지만 한 파티션에 여러 키를 섞어 part가 전 범위를 차지한다 → pruning 불가. 좁은 part를 만드는 건 range/custom 파티셔닝이다. - 적재 경로가 파티셔너만큼 중요하다. Kafka 엔진은 기본값에서 지역성을 희석하고(
kafka_thread_per_consumer=1필요), Connect Sink는 설계상 보존하지만 part 폭증을 머지로 다스려야 한다. - 설계 의도는 증명이 아니다. 항상
EXPLAIN indexes = 1로 검증하라.
1. 왜 이 질문이 중요한가
스트리밍 분석 파이프라인을 짤 때 우리는 보통 두 결정을 따로 내린다.
- Kafka 쪽: 토픽 파티션을 몇 개로, 무슨 키로 나눌까? (처리량·순서 보장 관점)
- ClickHouse 쪽:
ORDER BY를 어떻게 잡을까? (쿼리 성능 관점)
그런데 이 둘은 사실 하나의 결정이다. Kafka 파티셔닝 전략이 곧 ClickHouse의 part 배치(layout) 전략이기 때문이다. 이걸 모르면 "분명 ORDER BY를 잘 잡았는데 포인트 룩업이 느린" 상황을 만난다.
이 글은 그 연결고리를 1,000만 행짜리 재현 가능한 랩으로 측정한 결과다.
2. 배경 — part 레벨 pruning은 어떻게 작동하나
ClickHouse MergeTree의 읽기 가속은 여러 층으로 일어난다. 핵심은 두 가지다.
- part 레벨: 각 part는 정렬 키의 min/max를 메타로 갖는다. 쿼리 조건이 그 범위에 안 걸리면 part를 열지도 않는다.
- granule 레벨: part 안은 항상 정렬되어 있고, 8,192행마다 마크를 둔 희소 인덱스로 범위 밖 granule을 건너뛴다.
여기서 결정적인 사실 — part 내부 정렬은 적재 방식과 무관하게 항상 보장된다. 그래서 granule skipping은 어떤 경우든 작동한다. 진짜 변수는 part 레벨이다. part가 전 범위 키를 담으면 어떤 필터도 그 part를 못 쳐낸다.
💡 그래서 핵심 질문은 이것이다: 어떻게 하면 각 part가 좁고 분리된 키 범위를 담게 만들까?
그 답은 ClickHouse 안이 아니라 상류에 있다. part는 결국 하나의 INSERT 배치이고, 그 배치에 어떤 행들이 모여 들어오느냐가 part의 키 범위를 결정한다.
3. 어긋남 vs 정렬 — 그림으로
ORDER BY (customer_id, ts) 테이블에 WHERE customer_id = M을 던진다고 하자.
어긋났을 때 (hash 파티셔닝)
한 파티션에 여러 customer가 섞여 들어오면, 각 INSERT 배치(= part)가 키 공간 전체에 퍼진다.
정렬했을 때 (range 파티셔닝)
각 파티션이 키의 연속 구간만 운반하면, 각 part가 좁고 분리된 범위를 담는다.
같은 ORDER BY, 같은 데이터다. 차이는 오직 들어온 순서 — 즉 Kafka 파티셔닝 전략뿐이다.
4. 실험 설계 — 변수는 하나
주장만으로는 부족하다. 직접 돌렸다.
- 토픽당 1,000만 이벤트,
customer_id ∈ [0, 9999], 파티션 8개 events_misaligned— 기본 hash 파티셔너events_aligned—partition = customer_id // 1250(range 파티셔너)- 두 토픽 모두 동일한 교차 시간 순서로 생성. 파티셔너만 다름.
- 백그라운드 머지 정지 → 스트리밍 정상상태(머지 안 된 part 다수)를 관찰. part 레벨 pruning이 실제로 중요한 구간이다.
여기에 적재 경로 3가지를 교차했다.
경로 | 파티션 → part 배치 방식 | 지역성 결과 |
A. Kafka 엔진, 기본값 | 단일 컨슈머가 모든 파티션을 한 INSERT 블록에 멀티플렉싱 | 희석 — range 토픽조차 전 범위 part |
B. Kafka 엔진, 튜닝 | kafka_num_consumers=8 + kafka_thread_per_consumer=1 → 파티션당 컨슈머 1개, 병렬 flush | 보존 — part = 한 파티션의 범위 |
C. Kafka Connect Sink | 설계상 topic-partition 단위 그룹핑, 파티션당 배치 1개 | CH 튜닝 없이 보존, 단 작은 part 다수 → 머지에 의존 |
🧰 검증 환경: ClickHouse 26.6.1 · Apache Kafka 3.9.0 (KRaft) · Kafka Connect 7.8.0 / clickhouse-kafka-connect v1.3.9 — 모두 단일 노드 Docker.
5. 결과
5.1 part당 키 범위 — "왜 그런가"의 핵심
part당 평균 customer_id 폭. 낮을수록 pruning이 잘 된다(part가 좁다는 뜻). 막대 길이 = 키 범위(0 → 10,000).
정렬된 두 타겟(engine_aligned, connect_aligned)만 ~1,250이고, 나머지는 모두 ~9,999 — 전 범위다. 특히 engine_default_aligned는 range 토픽인데도 9,999다.
5.2 룩업 비용 — WHERE customer_id = 4242
EXPLAIN ESTIMATE + system.query_log 실측.
타겟 | 경로 · 토픽 | part 폭 | 읽은 part | 읽은 행 | 시간 |
engine_aligned | B 엔진 튜닝 · range | 1,249 | 5 / 38 | 40,960 | 5 ms |
engine_default_aligned | A 엔진 기본값 · range | 9,999 ⚠ | 10 / 10 | 82,418 | 9 ms |
engine_misaligned | B 엔진 튜닝 · hash | 9,992 | 32 / 32 | 262,144 | 21 ms |
connect_aligned | C Connect · range | 1,249 | 234 / 1861 † | 996,619 | 134 ms |
connect_misaligned | C Connect · hash | 9,989 | 1836 / 1836 | 7,839,808 | 685 ms |
EXPLAIN indexes = 1(granule 레벨): engine_aligned는 5/38 parts · 5/1216 granules, engine_misaligned는 32/32 parts · 32/1220 granules를 읽는다.
정렬된 엔진은 비정렬 대비 6.4배 적은 행, 4배 빠르다.
5.3 Connect의 part 폭증 — 머지로 회복
connect_aligned는 part 폭이 좁은데(1,249) 왜 996,619행이나 읽었을까? part가 1,861개로 폭증했기 때문이다. 머지를 켜면 해결된다.
parts | 룩업이 읽는 양 | |
머지 OFF (스트리밍 정상상태) | 1,861 | 234 parts · 996,619 rows |
OPTIMIZE … FINAL 이후 | 1 | 1 part · 8,192 rows |
머지는 ORDER BY를 보존하므로, part가 합쳐져도 지역성(part pruning)은 살아남는다. Connect의 레버는 컨슈머 튜닝이 아니라 건강한 머지다.
6. 세 가지 교훈
① range 파티셔닝은 작동한다
engine_aligned는 각 part를 1,250 폭 버킷 하나로 유지한다. 포인트 룩업이 38개 중 33개 part를 건너뛴다. 설계가 그대로 성능이 됐다.
② 엔진 기본값은 함정이다
engine_default_aligned는 같은 range 토픽을 소비하지만, 단일 컨슈머가 8개 파티션을 한 INSERT 블록에 멀티플렉싱한다. 결과적으로 모든 part가 전 범위(폭 9,999)로 퍼져 pruning이 무력화된다.
⚠️ 반드시kafka_thread_per_consumer = 1+kafka_num_consumers = 파티션 수를 설정해야 파티션별로 분리 flush되어 지역성이 보존된다. 이건 추정이 아니라 위 표(span 9999 → 1249)로 실측된 사실이다.
③ Connect는 지역성을 공짜로 보존하지만 part가 폭증한다
connect_aligned는 CH 튜닝 0으로 좁은 part(폭 1,249)를 만든다. clickhouse-kafka-connect가 설계상 topic-partition 단위로 배치하기 때문이다. 대신 작은 part가 1,861개. 머지를 켜고 OPTIMIZE … FINAL을 하면 1 part로 수렴한다.
7. 경로별 정리
- Kafka 엔진:
kafka_thread_per_consumer = 1+kafka_num_consumers = 파티션 수필수. - Connect Sink: 설계상 보존. part 수 제어를 위해 머지를 건강하게 유지.
- ClickPipes: 관리형 배치(크기·시간 기준). 동일 메커니즘이 적용되지만 파티션 단위 배치는 문서화된 보장이 아니므로, 쓴다면 part별 min/max로 검증.
8. 과설계 주의
range/custom Kafka 파티셔닝은 파티션 키에 대한 point/등치 룩업에서 part 레벨 pruning을 얻지만, 공짜가 아니다.
- 핫 파티션·스큐 위험
- 파티션 수 = 준영구 결정(나중에 바꾸기 어려움)
- 순서 보장은 파티션 내에서만
쿼리가 주로 시간 범위 스캔이라면 일반 hash 파티셔닝 + ORDER BY (time, …)이 더 낫다 — 이미 시간으로 pruning되고 스큐 위험도 없다. 그리고 part가 머지된 뒤에는 within-part granule skipping이 포인트 룩업을 처리한다. 정렬의 이점은 정확히 머지 안 된 고처리량 스트리밍 정상상태에 관한 것이다.
9. 직접 검증하기
설계 의도는 증명이 아니다. 어디서나 EXPLAIN으로 확인할 수 있다.
-- part·행·마크가 얼마나 읽히는지 (추정)
EXPLAIN ESTIMATE
SELECT count() FROM events WHERE customer_id = 4242;
-- granule 레벨까지 (Parts: a/b · Granules: c/d)
EXPLAIN indexes = 1
SELECT count() FROM events WHERE customer_id = 4242;
-- part별 정렬 키 범위가 분리되어 있는지 직접 확인
SELECT _part, rows, min(customer_id) AS lo, max(customer_id) AS hi
FROM events GROUP BY _part ORDER BY lo;Kafka 없이 clickhouse-local만으로도 핵심은 재현된다. 같은 데이터를 (a) 키 전 범위에서 무작위로, (b) 연속 구간으로 나눠 20개 배치로 넣으면:
sql_misaligned → parts=20 rows=163,840 marks=20
sql_aligned → parts=1 rows=8,192 marks=1핵심 요약
- Kafka 파티션 전략은 곧 ClickHouse part 배치 전략이다.
hash(key)는 순서는 지키되 part를 전 범위로 만든다 → pruning 불가. 좁은 part는 range/custom이 만든다.- 적재 경로가 파티셔너만큼 중요하다: 엔진은
thread_per_consumer=1없이는 희석, Connect는 보존하되 머지 필요. - 항상
EXPLAIN indexes = 1로 검증하라.
참고 문서
- 희소 기본 인덱스 가이드
- Index-based pruning (블로그)
- ORDER BY 최적화 가이드
- Kafka 테이블 엔진 — thread_per_consumer
- Kafka 테이블 엔진 튜닝
- Connect Sink 설계 문서 (topic/partition 단위 배치)
- Connect Sink 문서
Happy Pruning! ⚡