ClickHouse로 실시간 사용자 행동 분석 파이프라인 구축하기 — Kafka 수집부터 Materialized View·Grafana 대시보드까지
DAU 150만 시점, 이벤트 집계 쿼리의 p99 레이턴시가 18초를 넘기기 시작했습니다. GROUP BY event_type, DATE(event_time) 형태의 쿼리 하나가 RDS r5.2xlarge 인스턴스 CPU를 90% 이상 점유했고, 리포트 페이지 새로고침이 데이터베이스 부하 스파이크로 직결되는 구조였습니다. 파티셔닝과 인덱스 튜닝으로 버텼지만, 데이터가 두 배로 불어나면 응답이 두 배가 아니라 네 배로 느려지는 현실이 명확했습니다.
그때 제대로 들여다본 것이 ClickHouse였습니다. 컬럼 지향 저장, 벡터화 실행, Materialized View 기반 인크리멘탈 집계가 이런 OLAP 워크로드에 구조적으로 맞는 선택이었습니다.
이 글은 Kafka로 이벤트를 수집하고, ClickHouse Materialized View로 실시간 집계하고, Grafana에서 대시보드로 연결하는 전 과정을 다룹니다. 엔진 선택 이유, MV 설계 패턴, 운영 중에 마주치는 함정까지 솔직하게 풀어볼 예정입니다. Kafka를 어느 정도 써봤고 ClickHouse 도입을 검토하거나 이제 막 시작한 백엔드·데이터 엔지니어라면 도움이 될 내용입니다.
ClickHouse가 이 파이프라인에 어울리는 이유
코드를 보기 전에 "왜 ClickHouse인가"를 짚고 넘어가는 게 순서라고 생각합니다.
| 항목 | 내용 |
|---|---|
| OLAP 쿼리 속도 | 동일 데이터 기준 PostgreSQL 대비 최대 1,000배. 수십억 행을 밀리초 단위로 스캔 |
| 압축률 | 컬럼 저장 + LZ4/ZSTD로 원본 대비 10~20배 압축, 스토리지 비용 절감 |
| 수집·조회 동시 성능 | Kafka 수집 중에도 집계 쿼리 응답이 끊기지 않음 |
| MV 인크리멘탈 집계 | INSERT 시점 집계로 SELECT 부하를 대폭 줄임 |
| Kafka 네이티브 통합 | 별도 ETL 없이 ClickHouse 내에서 직접 Kafka 소비 가능 |
| 수평 확장 | 샤딩·복제 내장, 클러스터 확장 용이 |
실제 도입 사례도 이를 뒷받침합니다. Wingify(VWO)는 PostgreSQL에서 ClickHouse Materialized View로 전환한 후 실시간 A/B 테스트 집계 비용을 80% 절감했고, Braze는 수억 건의 사용자 행동 이벤트를 Kafka → ClickHouse Cloud 파이프라인으로 전면 재구축했습니다.
반면 ClickHouse가 어울리지 않는 경우도 있습니다. 빈번한 UPDATE/DELETE, 복잡한 JOIN 중심의 OLTP 워크로드, 행 단위 Exactly-Once가 반드시 필요한 경우라면 설계 단계에서 재검토가 필요합니다. 이 부분은 뒤의 단점 섹션에서 자세히 다룹니다.
전체 아키텍처
파이프라인은 수집, 저장·집계, 시각화의 세 레이어로 나뉩니다.
Kafka는 초당 수십만 건 이상의 이벤트를 내구성 있게 버퍼링합니다. ClickHouse는 그 이벤트를 컬럼 지향 구조로 저장하면서, INSERT가 들어올 때마다 Materialized View가 집계 결과를 별도 테이블에 씁니다. Grafana는 집계 테이블만 조회하기 때문에 응답이 극적으로 빨라집니다.
여기서 Kafka Engine 방식과 Connect Sink 방식의 경로가 다르다는 점을 다이어그램에서 확인할 수 있습니다. Kafka Engine은 ClickHouse 내부의 Kafka 엔진 테이블을 거쳐 원시 테이블로 넘어가고, Connect Sink는 중간 엔진 테이블 없이 원시 이벤트 테이블에 직접 씁니다.
핵심 개념
수집 경로 선택: Kafka Engine vs Kafka Connect Sink
Kafka Engine은 ClickHouse 내부에서 직접 Kafka 토픽을 소비하는 방식입니다. 별도 ETL 레이어가 필요 없어 인프라가 단순합니다. JSON, Protobuf, CSV 등 다양한 포맷을 지원하지만, Schema Registry 자동 연동은 Avro(format_avro_schema_registry_url)에만 제공됩니다. Protobuf는 로컬 스키마 파일을 통해 소비할 수 있지만, Schema Registry에서 스키마를 자동으로 가져오는 기능은 없습니다. At-Least-Once 보장이라 드물게 중복 삽입이 발생할 수 있다는 점도 감안해야 합니다.
Kafka Connect Sink는 Exactly-Once를 보장하며, Avro·JSON Schema·Protobuf·JSON 등을 Schema Registry와 함께 폭넓게 지원합니다. 2025년부터는 Confluent Cloud에서 완전 관리형 커넥터도 제공되어 UI 설정만으로 연결이 가능합니다.
소규모 팀이나 빠른 프로토타이핑이 목적이라면 Kafka Engine이 편리합니다. Exactly-Once나 Schema Registry 기반 비Avro 포맷 관리가 필요하다면 Connect Sink가 더 적합합니다.
저장 엔진: MergeTree 계열 선택
ClickHouse의 핵심은 MergeTree 엔진 계열입니다.
| 엔진 | 적합한 용도 |
|---|---|
MergeTree |
원시 이벤트 저장. 순수 Append-Only 로그 |
SummingMergeTree |
카운터·합계처럼 단순 숫자 합산 집계 |
AggregatingMergeTree |
uniq, avg, quantile 등 복합 집계 함수 상태 저장 |
ReplacingMergeTree |
Kafka at-least-once 보완용 중복 제거 |
단순 합산에는 SummingMergeTree로 충분하지만, 고유 사용자 수(DAU)나 백분위처럼 부분 결과를 단순히 합쳐도 올바른 값이 나오지 않는 집계는 AggregatingMergeTree가 필요합니다. 여기서 State/Merge 패턴이 등장하는데, 이 개념은 다음 섹션에서 한 번에 설명합니다.
Materialized View의 동작 원리
MV는 INSERT 시점에 동기적으로 실행되는 트리거 + 집계 쿼리라고 이해하면 됩니다.
핵심 포인트 두 가지입니다.
첫째, MV는 소스 테이블의 전체 데이터가 아닌, 방금 INSERT된 배치에 대해서만 실행됩니다. 인크리멘탈 집계가 가능한 이유입니다.
둘째, AggregatingMergeTree 집계 테이블에서 데이터를 읽을 때는 반드시 …Merge 함수를 써야 합니다. 저장할 때는 uniqHLL12State(user_id)처럼 …State 함수로 중간 상태를 쌓고, 읽을 때는 uniqHLL12Merge(unique_users)로 최종값을 뽑습니다. State로 저장된 바이너리 값을 SUM으로 읽으면 의미 없는 숫자가 나오고, SELECT *로 조회하면 Grafana 대시보드에 깨진 값이 그대로 표시됩니다. 이 패턴이 낯설게 느껴지더라도, 시간대별 집계를 나중에 재집계(rollup)할 때 중복 카운팅 없이 정확한 값을 뽑아낼 수 있는 이유가 바로 여기에 있습니다.
실전 적용
이커머스 서비스에서 클릭·페이지뷰·구매 이벤트를 실시간으로 집계하는 시나리오를 기준으로 설명합니다.
1단계: 원시 이벤트 테이블 생성
CREATE TABLE user_events
(
event_time DateTime,
user_id UInt64,
session_id String,
event_type LowCardinality(String),
page_url String,
product_id Nullable(UInt32),
amount Nullable(Decimal(10, 2))
)
ENGINE = MergeTree()
PARTITION BY toYYYYMM(event_time)
ORDER BY (event_type, event_time, user_id)
TTL event_time + INTERVAL 90 DAY DELETE;LowCardinality(String)은 카디널리티가 낮은 문자열 컬럼에 딕셔너리 인코딩을 적용해 압축률과 쿼리 속도를 동시에 높여줍니다.
ORDER BY (event_type, event_time, user_id)는 "이벤트 타입별 시간 범위 조회"가 가장 빈번하다는 가정에서 나온 순서입니다. "특정 사용자의 행동 타임라인 조회"가 주된 쿼리 패턴이라면 (user_id, event_time)이 더 유리합니다. ORDER BY는 운영 중 변경하려면 테이블을 새로 만들고 전체 데이터를 재삽입해야 하기 때문에, 실제 쿼리 패턴을 기준으로 초반에 결정하는 것이 중요합니다.
2단계: Kafka Engine 테이블 생성
CREATE TABLE user_events_kafka
(
event_time DateTime,
user_id UInt64,
session_id String,
event_type String,
page_url String,
product_id Nullable(UInt32),
amount Nullable(Decimal(10, 2))
)
ENGINE = Kafka()
SETTINGS
kafka_broker_list = 'kafka-broker:9092',
kafka_topic_list = 'user-events',
kafka_group_name = 'clickhouse-consumer',
kafka_format = 'JSONEachRow',
kafka_num_consumers = 4,
kafka_max_block_size = 65536,
kafka_handle_error_mode = 'stream';kafka_max_block_size는 Kafka에서 한 번에 가져올 최대 메시지 수입니다. 기본값(65536)은 처리량과 레이턴시의 균형점이지만, 메시지 크기가 크거나 수집 지연이 허용된다면 늘려볼 수 있습니다. INSERT 배치 크기와 MV 실행 빈도에 직접 영향을 주니 환경에 맞게 벤치마킹해보는 것을 권장합니다.
kafka_handle_error_mode = 'stream'은 파싱에 실패한 메시지를 만났을 때 컨슈머를 멈추지 않고, _error와 _raw_message 가상 컬럼으로 오류 내용을 노출하게 합니다. 이 컬럼을 별도 MV로 라우팅하면 DLQ(Dead Letter Queue) 패턴을 구현할 수 있습니다.
Kafka Engine 테이블 자체는 영구 저장 역할을 하지 않습니다. 다음 단계의 MV를 통해 원시 이벤트 테이블로 데이터를 넘겨야 합니다.
3단계: Kafka → 원시 테이블 연결 MV와 DLQ MV
정상 메시지를 원시 테이블로 라우팅하는 MV입니다.
CREATE MATERIALIZED VIEW user_events_ingest_mv
TO user_events
AS
SELECT
event_time,
user_id,
session_id,
event_type,
page_url,
product_id,
amount
FROM user_events_kafka
WHERE _error = '';파싱 오류 메시지를 별도로 보관하는 DLQ 테이블과 MV입니다.
CREATE TABLE user_events_dlq
(
raw_message String,
error String,
received_at DateTime DEFAULT now()
)
ENGINE = MergeTree()
ORDER BY received_at
TTL received_at + INTERVAL 7 DAY DELETE;
CREATE MATERIALIZED VIEW user_events_dlq_mv
TO user_events_dlq
AS
SELECT
_raw_message AS raw_message,
_error AS error
FROM user_events_kafka
WHERE _error != '';이 구조를 갖추면 malformed 메시지가 들어와도 컨슈머가 멈추지 않습니다. 오류 메시지는 user_events_dlq 테이블에서 확인하고 재처리할 수 있습니다. 오프셋을 수동으로 건너뛰는 응급처치 없이도 파이프라인이 계속 흘러갑니다.
4단계: AggregatingMergeTree로 시간대별 집계
시간대별·이벤트 타입별 세션 수와 고유 사용자 수를 실시간으로 집계하는 예시입니다.
CREATE TABLE user_events_hourly_agg
(
hour DateTime,
event_type LowCardinality(String),
session_count AggregateFunction(uniq, String),
unique_users AggregateFunction(uniqHLL12, UInt64)
)
ENGINE = AggregatingMergeTree()
ORDER BY (hour, event_type);
CREATE MATERIALIZED VIEW user_events_hourly_mv
TO user_events_hourly_agg
AS
SELECT
toStartOfHour(event_time) AS hour,
event_type,
uniqState(session_id) AS session_count,
uniqHLL12State(user_id) AS unique_users
FROM user_events
GROUP BY hour, event_type;session_count에 countState() 대신 uniqState(session_id)를 쓰는 이유를 짚어둘 필요가 있습니다. countState()는 INSERT 배치의 행 수, 즉 이벤트 수를 셉니다. 동일 세션에서 여러 이벤트가 들어오면 각각 카운트되어 실제 세션 수와 전혀 다른 값이 나옵니다. uniqState(session_id)는 고유한 session_id 값의 수를 추적합니다. 컬럼 타입도 AggregateFunction(uniq, String)으로 맞춰야 합니다.
읽을 때는 …Merge 함수를 사용합니다.
SELECT
hour,
event_type,
uniqMerge(session_count) AS total_sessions,
uniqHLL12Merge(unique_users) AS dau
FROM user_events_hourly_agg
WHERE hour >= now() - INTERVAL 24 HOUR
GROUP BY hour, event_type
ORDER BY hour DESC;5단계: SummingMergeTree로 구매 합계 집계
단순 합산이라면 SummingMergeTree가 훨씬 간단합니다.
CREATE TABLE purchase_daily_sum
(
day Date,
product_id UInt32,
total_amount Decimal(18, 2),
purchase_count UInt64
)
ENGINE = SummingMergeTree((total_amount, purchase_count))
PARTITION BY toYYYYMM(day)
ORDER BY (day, product_id);
CREATE MATERIALIZED VIEW purchase_daily_mv
TO purchase_daily_sum
AS
SELECT
toDate(event_time) AS day,
product_id,
sum(amount) AS total_amount,
count() AS purchase_count
FROM user_events
WHERE event_type = 'purchase' AND product_id IS NOT NULL
GROUP BY day, product_id;total_amount를 Decimal(18, 2)로 선언한 이유는 합산 오버플로우 방지입니다. 원본 amount는 Decimal(10, 2)로 단건 최대값이 약 9,999만 원 수준이지만, 수백만 건의 구매 금액을 일별로 누적하면 이 범위를 쉽게 초과합니다. 집계 테이블에서는 정밀도를 넉넉하게 잡아두는 것이 안전합니다.
SummingMergeTree는 동일 ORDER BY 키를 가진 행들이 머지될 때 수치 컬럼을 자동으로 합산합니다. 아직 머지되지 않은 파트까지 정확히 집계하려면 읽을 때 GROUP BY + sum()을 함께 써야 합니다.
SELECT
day,
product_id,
sum(total_amount) AS total_revenue,
sum(purchase_count) AS orders
FROM purchase_daily_sum
WHERE day >= today() - 30
GROUP BY day, product_id
ORDER BY total_revenue DESC;Grafana 연결 설정
apiVersion: 1
datasources:
- name: ClickHouse
type: grafana-clickhouse-datasource
jsonData:
defaultDatabase: default
protocol: native
server: clickhouse-server
port: 9000
username: default
secureJsonData:
password: ""username은 jsonData에 위치합니다. secureJsonData는 평문 저장이 안 되어야 하는 값(password)만 들어가는 섹션이라, username을 여기에 넣으면 의도대로 동작하지 않습니다.
Native 프로토콜(포트 9000) 연결이 HTTP(포트 8123)보다 약간 빠릅니다. 플러그인 v4부터는 로그·트레이스 쿼리 빌더가 내장되어 있어 SRE나 데이터 분석가가 SQL 없이도 대시보드를 구성할 수 있습니다.
대시보드 패널에서 집계 테이블을 조회하는 시계열 쿼리 예시입니다.
SELECT
hour AS time,
event_type,
uniqHLL12Merge(unique_users) AS dau
FROM user_events_hourly_agg
WHERE hour >= $__fromTime AND hour <= $__toTime
GROUP BY time, event_type
ORDER BY time ASC;hour 컬럼은 이미 toStartOfHour()로 집계된 값입니다. 여기에 toStartOfMinute()를 적용하면 같은 시각이 반복될 뿐 분 단위 세분화가 되지 않습니다. 분 단위 집계가 필요하다면 원시 이벤트 테이블을 직접 조회하거나 별도의 분 단위 집계 테이블을 만드는 것이 맞습니다. Grafana의 $__fromTime, $__toTime 매크로는 대시보드 시간 범위 필터를 자동으로 바인딩합니다.
운영에서 마주치는 단점과 함정
알아두면 좋은 한계
| 항목 | 내용 |
|---|---|
| At-Least-Once 보장 | Kafka Engine은 드물게 중복 삽입 발생. ReplacingMergeTree 또는 멱등성 설계 필요 |
| MV INSERT 블로킹 | MV가 INSERT와 동기 실행되므로 복잡한 MV가 많으면 수집 지연 발생 가능 |
| 높은 카디널리티 한계 | GROUP BY 키의 유니크 값이 매우 많으면 타깃 테이블이 비대해져 성능 이점 소멸 |
| 스키마 변경 어려움 | MV 타깃 테이블 스키마 변경은 새 테이블과 MV를 만들고 트래픽을 전환하는 블루-그린 방식 필요 |
| Schema Registry 지원 제한 | Kafka Engine의 Schema Registry 자동 연동은 Avro만 지원. 비Avro 포맷에 Schema Registry가 필요하면 Connect Sink 사용 |
| JOIN 및 업데이트 비효율 | Append-Only에 최적화. 빈번한 UPDATE/DELETE나 복잡한 JOIN에는 부적합 |
| 학습 곡선 | MergeTree 엔진 선택, 파티션 설계, TTL 설정이 성능에 직접 영향. 초기 설계 비용이 높음 |
실무에서 자주 나오는 함정 두 가지
① 스키마 변경 비용 과소평가
ORDER BY, PARTITION BY, 데이터 타입은 운영 중 ALTER로 바꿀 수 없거나 전체 테이블 재구성을 요구합니다. 초기 설계 때 데이터 규모와 쿼리 패턴을 충분히 검토하지 않으면, 이후에 수십억 행의 데이터를 새 테이블에 재삽입하는 작업을 감당해야 합니다. Wingify 사례에서도 초기 파티션 설계 실수로 리파티셔닝에 상당한 공수가 소요됐다고 밝혔습니다. 초반에 투자하는 설계 시간이 나중의 재구성 비용보다 훨씬 저렴합니다.
② 집계 테이블을 SELECT *로 읽기
AggregatingMergeTree 테이블에서 SELECT *를 하면 바이너리 상태 값이 그대로 반환됩니다. 반드시 …Merge 함수를 써야 사람이 읽을 수 있는 값이 나옵니다. Grafana에서 이상한 숫자나 NaN이 나온다면 이 이유일 가능성이 가장 높습니다.
마치며
이 글에서 다룬 핵심을 정리하면 이렇습니다.
- Kafka → ClickHouse 연결은 Exactly-Once 필요 여부와 Schema Registry 연동 요구사항에 따라 Kafka Engine 또는 Connect Sink를 선택합니다. Kafka Engine은 인프라가 단순하고 JSON 등 다양한 포맷을 지원하지만, Exactly-Once나 비Avro Schema Registry 연동이 필요하면 Connect Sink가 맞습니다.
kafka_handle_error_mode = 'stream'으로 DLQ 패턴을 구성하면 malformed 메시지가 들어와도 컨슈머가 멈추지 않고 오류를 별도 테이블로 라우팅할 수 있습니다.- MergeTree 엔진은 단순 합산엔 SummingMergeTree, 고유 수·백분위 등 복합 집계엔 AggregatingMergeTree를 씁니다.
…State로 저장하고…Merge로 읽는 패턴이 핵심이며, 집계 컬럼명이 실제 계산 내용을 정확히 반영하는지 확인하는 것도 중요합니다. - Materialized View는 INSERT 트리거이므로 SELECT 부하가 거의 없는 집계 테이블을 실시간으로 유지할 수 있습니다. MV 복잡도가 높아지면 수집 지연으로 돌아오니 설계 단계에서 균형을 잡아야 합니다.
- Grafana 플러그인 v4부터는 쿼리 빌더가 내장되어 분석가도 SQL 없이 대시보드를 구성할 수 있습니다.
지금 바로 시작해보고 싶다면 아래 순서로 접근해보시면 됩니다.
- ClickHouse Cloud 무료 티어로 클러스터를 띄우고, 샘플 이벤트 CSV를 INSERT해서 MergeTree와 집계 쿼리 속도를 직접 체감해보시면 됩니다.
- 로컬에 Kafka + ClickHouse를 Docker Compose로 구성하고, Kafka Engine 테이블과 간단한 Materialized View를 연결해 이벤트 흐름을 눈으로 확인해볼 수 있습니다.
- Grafana를 붙이고
$__fromTime매크로 쿼리로 시계열 패널을 하나 만들어보시면 전체 파이프라인의 감을 잡을 수 있습니다.
ClickHouse는 처음 설계가 이후 운영 비용을 크게 결정합니다. ORDER BY, PARTITION BY, TTL 설정을 운영 중에 바꾸는 작업은 수십억 행의 데이터를 다시 써야 하는 작업으로 이어질 수 있습니다. 초반에 데이터 규모와 쿼리 패턴을 충분히 검토하고 시작하면 나중의 재설계 비용을 크게 줄일 수 있습니다.
참고 자료
- ClickHouse 공식 Kafka 통합 문서
- ClickHouse Materialized View 베스트 프랙티스 공식 문서
- Grafana ClickHouse 옵저버빌리티 공식 가이드
- Grafana Labs 공식 ClickHouse 플러그인
- ClickHouse 2025 연간 업데이트 정리
- Braze의 ClickHouse Cloud 기반 실시간 분석 파이프라인 재구축 사례
- Wingify: Materialized View로 집계 비용 80% 절감 사례
- Kafka → ClickHouse 3가지 수집 방식 비교
- ClickHouse Materialized View 완전 가이드 (GlassFlow)
- Confluent Cloud 완전 관리형 ClickHouse 커넥터 발표
- AggregatingMergeTree 상세 해설
- ClickHouse Kafka Engine vs Tinybird 커넥터 비교
- ClickHouse로 Flink를 대체한 실시간 파이프라인 단순화 사례
- Real-Time Click Stream Analytics GitHub 레포