ClickHouse로 대규모 시계열 집계 파이프라인 설계하기

실시간 통계 API를 위한 멀티 테넌트 분석 아키텍처


왜 ClickHouse인가

MySQL 같은 RDB는 트랜잭션 처리에 최적화되어 있다. 행 단위로 데이터를 읽고 쓰는 구조이기 때문에, 수천만 건의 로그를 집계하는 분석 쿼리에는 태생적으로 불리하다. 테넌트 수가 늘고 데이터 규모가 커질수록 이 한계는 선명하게 드러난다.

ClickHouse는 열 지향(columnar) 스토리지를 기반으로 하며, 집계 연산에 특화된 엔진들을 제공한다. 같은 쿼리를 RDB 대비 수십 배 빠르게 처리하는 것이 가능하다. 분석용 파이프라인의 핵심 저장소로 적합한 이유다.


 

전체 파이프라인 구조

Raw Ingest
    ↓
Materialized Views (실시간 변환)
    ↓
MINUTE 집계 테이블
    ↓
HOUR / DAY / MONTH 집계 테이블 (계층적 롤업)
 

핵심은 "한 번 적재, 다단계 집계" 다. 원본 데이터를 그대로 쌓아두는 것이 아니라, Materialized View가 ingest 시점에 바로 변환하여 각 시간 단위 집계 테이블로 흘려보낸다. 쿼리 시점의 부하를 적재 시점으로 분산시키는 구조다.


테이블 엔진 선택

SummingMergeTree — 일반 집계용

SummingMergeTree는 동일한 Primary Key를 가진 row를 백그라운드에서 자동으로 합산한다. INSERT가 발생하면 일단 별도 파트로 저장되고, 이후 백그라운드 프로세스가 같은 Key의 파트들을 병합하며 값을 누적한다. 카운트, 합계 같은 단순 집계 지표를 저장하는 테이블에 적합하다.

멀티 테넌트 환경에서는 SharedSummingMergeTree를 사용한다. ClickHouse Cloud나 Shared 클러스터 구성에서 여러 노드가 스토리지를 공유하는 방식이다.

ReplacingMergeTree — Top-N 랭킹용

ReplacingMergeTree는 같은 Key에 대해 가장 최근 row만 남긴다. "최신 값으로 교체"가 필요한 순위 데이터에 적합하다.

Top-N 테이블은 SharedReplacingMergeTree와 Refreshable Materialized View를 조합해 구성한다. Refreshable MV가 주기적으로 집계 결과를 다시 계산하고, 결과를 Top-N 테이블에 덮어쓰는 방식이다.


ORDER BY 설계

ORDER BY (tenantId, code, timestamp, statValue)
 

시계열 데이터라고 해서 무조건 시간을 앞에 두는 것이 최선은 아니다. ClickHouse의 Primary Key는 ORDER BY와 동일하게 구성되며, 앞에 배치된 컬럼일수록 범위 스캔이 효율적으로 동작한다.

실제 쿼리 패턴을 보면 "어떤 지표를" → "어느 기간에" 순으로 조건이 걸리는 경우가 압도적이다. 지표 코드(code)를 시간(timestamp)보다 앞에 두는 이유다. 시간 필터는 PARTITION BY를 통한 파티션 프루닝으로 처리하면 충분하다.


SummingMergeTree의 단편화 문제

SummingMergeTree를 실제로 운용하면 반드시 마주치는 문제가 있다. 백그라운드 merge가 완료되기 전까지는 같은 Key의 row가 여러 파트에 분산되어 존재한다. 즉, 쿼리 시점에 아직 합산되지 않은 중간 상태 데이터가 함께 읽힐 수 있다.

FINAL 키워드는 왜 쓰면 안 되나

SELECT * FROM stats_table FINAL WHERE ...
 

FINAL은 쿼리 시점에 강제로 merge를 수행해 정확한 값을 반환한다. 언뜻 깔끔한 해결책처럼 보이지만, 내부적으로 전체 데이터를 재정렬하고 병합하는 작업이 발생한다. 대용량 테이블에서는 쿼리 하나가 시스템 전체에 부하를 줄 수 있다. 특히 여러 테넌트가 동시에 쿼리를 날리는 멀티 테넌트 환경에서는 사용하기 어렵다.

View + GROUP BY로 해결

CREATE VIEW v_stats AS
SELECT
    tenantId,
    code,
    timestamp,
    sum(value) AS value
FROM stats_table
GROUP BY tenantId, code, timestamp;
 

쿼리 소비자가 항상 View를 통해 조회하도록 하면, View의 GROUP BY가 단편화된 데이터를 투명하게 합산해준다. FINAL보다 훨씬 빠르고, 테이블 구현 세부사항을 API 레이어로부터 완전히 분리할 수 있다.


왜 시간 단위별로 테이블을 분리하는가

가장 먼저 드는 의문은 "그냥 하나의 테이블에 다 넣고 쿼리할 때 GROUP BY로 조절하면 안 되나?" 다.

기술적으로는 가능하다. 하지만 분 단위 원본 데이터를 그대로 보관하면서 월 단위 집계 쿼리를 날리는 상황을 생각해보면 문제가 명확해진다. 하루 1억 건의 분 단위 데이터가 쌓이는 환경에서 "이번 달 집계"를 구하려면 수십억 건을 매번 스캔해야 한다. 멀티 테넌트 환경에서 이런 쿼리가 동시에 수십 개 날아온다면 시스템은 버티기 어렵다.

테이블을 시간 단위별로 분리하는 이유는 세 가지다.

스토리지 효율. 분 단위 데이터는 빠르게 만료시키고, 시간/일/월 단위로 올라갈수록 오래 보존한다. 하나의 테이블로 관리하면 TTL 정책을 세밀하게 적용하기 어렵다. 테이블을 분리하면 각 단위별로 독립적인 보존 정책을 가질 수 있다.

쿼리 성능. 월간 집계 쿼리는 월 테이블만 읽으면 된다. 분 단위 테이블에는 접근조차 하지 않는다. 테이블 자체의 크기가 작으니 스캔 비용이 줄고, 캐시 효율도 높아진다.

집계 정확성. 분 단위 데이터로부터 시간 단위를 실시간으로 계산하면 SummingMergeTree의 단편화 문제가 중첩된다. 각 단위별로 이미 집계된 테이블을 별도로 유지하면, 상위 단위 쿼리는 항상 이미 정리된 데이터를 읽는다.

결국 "저장은 한 번, 집계는 미리"라는 원칙이 테이블 분리로 이어진다. 적재 시점에 Materialized View가 각 집계 단위 테이블로 동시에 흘려보내기 때문에, 운영 복잡도 증가 없이 각 단위별 최적화된 테이블을 유지할 수 있다.


계층적 롤업

집계 단위마다 별도 테이블을 두고, 데이터 흐름은 아래와 같이 구성한다.

원본 단위반영 대상

MINUTE MINUTE / HOUR / DAY / MONTH
HOUR HOUR / DAY / MONTH
DAY DAY / MONTH
MONTH MONTH

분 단위 원본 데이터가 있다면 모든 집계 테이블에 반영된다. 시간 단위 데이터만 있다면 HOUR 이상에만 반영된다. 지표마다 어떤 granularity를 지원하는지는 메타데이터로 관리하며, 파이프라인은 이를 참조해 해당 테이블에만 적재한다.


고카디널리티 지표: Dual-Path 설계

고유값이 많은(High-Cardinality) 지표는 단순 SUM으로 처리할 수 없다. 기간을 나눠 집계한 값들을 다시 합산하면 중복이 발생하기 때문이다.

이 경우 파이프라인을 두 갈래로 나눈다.

Raw Ingest
    ├─→ [단기 TTL] Raw 집계 테이블  ──→  Top-N Ranking 테이블 (장기 보존)
    └─→ [일반] 집계 테이블
 

Raw 집계 테이블은 짧은 TTL로 원본에 가까운 데이터를 유지한다. Refreshable Materialized View가 이 데이터를 주기적으로 읽어 Top-N을 계산하고, 결과를 SharedReplacingMergeTree 기반의 랭킹 테이블에 저장한다. 랭킹 테이블은 긴 보존 기간을 가지며, API가 직접 서빙한다.


TTL 이중 관리

보존 기간이 다른 데이터가 한 파이프라인 안에 공존할 때, TTL 컬럼을 목적에 따라 분리하면 유연하게 관리할 수 있다.

ingestTtlDate Date,  -- 단기 만료 (ingest 원본 데이터 정리용)
ttlDate       Date,  -- 장기 보존 (집계 결과, 랭킹 데이터)
 

ingestTtlDate는 빠르게 만료시켜 스토리지를 확보하고, ttlDate는 장기 보존이 필요한 집계 결과에 적용한다. 하나의 TTL 정책으로 모든 데이터를 관리하려 하면, 어느 한쪽이 너무 짧거나 너무 길어지는 딜레마가 생긴다.


메타데이터 기반 확장

새 지표를 추가할 때마다 코드를 수정하고 배포하는 구조는 운영 부담이 크다. 지표의 속성(어떤 집계 단위를 지원하는지, 어떤 테이블을 참조하는지 등)을 메타데이터로 정의하고, 파이프라인과 API가 이를 참조해 동적으로 동작하면 코드 변경 없이 지표를 추가할 수 있다.

ClickHouse 테이블 구조 자체는 범용적으로 유지하되, 지표의 의미와 동작 방식은 메타데이터가 결정하는 구조다.


정리

문제접근

RDB 집계 성능 한계 ClickHouse 열 지향 스토리지로 전환
SummingMergeTree 단편화 View + GROUP BY 레이어
FINAL 성능 비용 View로 추상화, FINAL 미사용
고카디널리티 지표 Dual-Path + Refreshable MV
TTL 정책 충돌 컬럼 분리 (ingestTtlDate / ttlDate)
지표 확장성 메타데이터 기반 동적 구성

ClickHouse는 분석 워크로드에서 강력하지만, SummingMergeTree의 단편화나 FINAL의 숨겨진 비용처럼 직접 부딪혀봐야 알 수 있는 특성들이 있다. 설계 단계에서 이 특성들을 이해하고 구조에 반영하는 것이 안정적인 파이프라인의 출발점이다.

+ Recent posts