Kafka 파티셔닝과 ClickHouse part 일치 설계 테스트

ClickHouse 분류
Core Architecture
Type
Lab
작성자

Ken

같은 데이터, 같은 ORDER BY인데 포인트 룩업이 20배 차이 났다. Kafka 파티셔닝 방식과 적재 경로가 ClickHouse MergeTree의 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의 읽기 가속은 여러 층으로 일어난다. 핵심은 두 가지다.

  1. part 레벨: 각 part는 정렬 키의 min/max를 메타로 갖는다. 쿼리 조건이 그 범위에 안 걸리면 part를 열지도 않는다.
  2. 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_alignedpartition = 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_alignedrange 토픽인데도 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_aligned5/38 parts · 5/1216 granules, engine_misaligned32/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로 검증하라.

참고 문서

Happy Pruning! ⚡