Skip to content
Published on

ClickHouse 실시간 OLAP 분석 엔진: MergeTree 최적화부터 프로덕션 운영까지

Authors
  • Name
    Twitter
ClickHouse OLAP

1. 들어가며: OLTP vs OLAP, 왜 ClickHouse인가

데이터베이스를 선택할 때 가장 먼저 구분해야 하는 것은 워크로드의 성격이다. OLTP(Online Transaction Processing)는 짧은 트랜잭션과 행 단위 읽기/쓰기에 최적화되어 있고, OLAP(Online Analytical Processing)는 수십억 행에 걸친 집계 쿼리와 분석에 특화되어 있다.

특성OLTPOLAP
대표 쿼리SELECT * FROM users WHERE id = 42SELECT country, COUNT(*) FROM events GROUP BY country
데이터 접근 패턴행 단위, 포인트 룩업컬럼 단위, 전체 스캔/집계
동시성수천 TPS의 짧은 트랜잭션소수의 무거운 분석 쿼리
지연시간 목표1~10ms100ms~수 초
대표 엔진PostgreSQL, MySQL, OracleClickHouse, BigQuery, Druid

PostgreSQL에 pg_analytics 확장을 붙이거나, TimescaleDB를 활용해 분석 쿼리를 처리하는 방법도 있다. 하지만 데이터가 수십억 행을 넘어가고, 실시간에 가까운 대시보드를 운영해야 하며, 서브초(sub-second) 수준의 응답이 필요하다면 전용 OLAP 엔진이 필수다.

ClickHouse는 Yandex에서 웹 분석 서비스 Metrica를 위해 2016년 오픈소스로 공개한 컬럼 지향 분석 데이터베이스다. 현재 ClickHouse Inc.가 개발을 주도하고 있으며, 2025~2026년에 걸쳐 벡터 검색, SharedCatalog, AI 통합 등 혁신적인 기능들이 추가되고 있다. 단일 서버에서도 초당 수십억 행을 스캔할 수 있으며, 클러스터로 확장하면 페타바이트 규모의 데이터를 서브초 지연시간으로 분석할 수 있다.

ClickHouse를 선택해야 하는 시나리오는 명확하다.

  • 이벤트/로그/메트릭 데이터가 일 단위로 수억 건 이상 적재되는 경우
  • 대시보드가 수백 개의 GROUP BY 집계를 서브초로 반환해야 하는 경우
  • INSERT는 배치 단위로 많이, UPDATE/DELETE는 거의 없는 append-heavy 워크로드
  • 컬럼 단위 압축으로 스토리지 비용을 극적으로 줄이고 싶은 경우

반대로 ClickHouse가 적합하지 않은 경우도 있다. 행 단위 포인트 룩업이 주 워크로드이거나, ACID 트랜잭션이 필수이거나, 빈번한 UPDATE/DELETE가 필요한 경우에는 PostgreSQL이나 MySQL이 더 적합하다.

2. ClickHouse 아키텍처: 컬럼 스토리지와 압축

컬럼 지향 스토리지의 원리

전통적인 행 지향(row-oriented) 데이터베이스는 하나의 행에 속한 모든 컬럼 값을 연속된 블록에 저장한다. SELECT country FROM events처럼 단일 컬럼만 필요한 쿼리에서도 나머지 모든 컬럼 데이터를 디스크에서 읽어야 하므로 I/O 낭비가 크다.

ClickHouse는 각 컬럼의 값을 별도 파일에 연속으로 저장한다. 분석 쿼리가 필요로 하는 컬럼만 읽으므로 디스크 I/O가 극적으로 줄어든다. 동일 타입의 값이 연속으로 배치되므로 압축률도 훨씬 높다.

지향 (PostgreSQL):
┌──────┬─────────┬─────────┬──────────┐
│ id   │ country │ browser │ duration │  ← 행 1
├──────┼─────────┼─────────┼──────────┤
│ id   │ country │ browser │ duration │  ← 행 2
└──────┴─────────┴─────────┴──────────┘

컬럼 지향 (ClickHouse):
┌──────────────────────┐
│ id: 1, 2, 3, 4, ...  │  ← 컬럼 파일 1
├──────────────────────┤
│ country: KR, US, ... │  ← 컬럼 파일 2
├──────────────────────┤
│ browser: Chrome, ... │  ← 컬럼 파일 3
├──────────────────────┤
│ duration: 42, 17, ...│  ← 컬럼 파일 4
└──────────────────────┘

압축 알고리즘: LZ4 vs ZSTD

ClickHouse는 기본적으로 LZ4 압축을 사용한다. LZ4는 압축률은 중간이지만 압축/해제 속도가 매우 빠르다. 더 높은 압축률이 필요하면 ZSTD를 선택할 수 있다.

알고리즘압축률압축 속도해제 속도권장 시나리오
LZ4보통 (3~5x)매우 빠름매우 빠름실시간 쿼리 위주, 핫 데이터
ZSTD높음 (5~10x)빠름빠름콜드 데이터, 스토리지 절약 우선
Delta + ZSTD매우 높음보통보통타임스탬프, 연속 증가 정수
DoubleDelta + LZ4높음빠름빠름메트릭 데이터(거의 일정한 간격)

컬럼별로 코덱을 지정할 수 있다는 점이 ClickHouse의 강력한 장점이다.

CREATE TABLE events
(
    event_time DateTime CODEC(DoubleDelta, LZ4),
    user_id    UInt64   CODEC(Delta, ZSTD(3)),
    country    LowCardinality(String) CODEC(ZSTD(1)),
    duration   Float32  CODEC(Gorilla, LZ4),
    raw_json   String   CODEC(ZSTD(5))
)
ENGINE = MergeTree()
ORDER BY (event_time, user_id);

event_time처럼 단조 증가하는 타임스탬프에는 DoubleDelta 코덱이 효과적이고, duration처럼 부동소수점 메트릭에는 Gorilla 코덱이 적합하다. 자주 접근하지 않는 큰 JSON 필드에는 ZSTD 레벨을 높여 스토리지를 절약한다.

벡터화 쿼리 실행

ClickHouse는 한 번에 하나의 행이 아니라 수천 개의 값을 묶은 컬럼 벡터 단위로 연산을 수행한다. 이를 통해 CPU 캐시 효율이 극대화되고, SIMD 명령어(SSE4.2, AVX2, AVX-512)를 활용한 병렬 처리가 가능하다. 단일 코어에서도 초당 수억 행을 처리할 수 있는 이유가 바로 이 벡터화 실행 엔진에 있다.

3. MergeTree 엔진 패밀리

MergeTree는 ClickHouse의 핵심 테이블 엔진이다. 이름 그대로 데이터를 파트(part) 단위로 저장하고, 백그라운드에서 파트를 병합(merge)하는 LSM-Tree 유사 구조를 따른다.

MergeTree 기본 동작

  1. INSERT: 데이터가 메모리 버퍼에 쌓이다가 임계값에 도달하면 정렬된 파트로 디스크에 기록된다.
  2. Merge: 백그라운드 스레드가 작은 파트들을 주기적으로 병합하여 큰 파트를 만든다. 병합 과정에서 중복 제거, 집계 등의 특수 로직이 실행될 수 있다.
  3. SELECT: 쿼리 시 Primary Key(Sparse Index)를 활용해 필요한 그래뉼(granule)만 읽는다. 기본 그래뉼 크기는 8,192행이다.

엔진 패밀리 비교

엔진핵심 기능사용 사례
MergeTree기본 엔진, ORDER BY 기반 정렬 저장범용 분석 테이블
ReplacingMergeTree동일 ORDER BY 키의 중복 행을 최신 버전으로 대체CDC 파이프라인, 상태 스냅샷
SummingMergeTree병합 시 수치 컬럼을 자동 합산카운터, 누적 메트릭
AggregatingMergeTree병합 시 AggregateFunction 상태를 자동 병합Materialized View 사전 집계
CollapsingMergeTreesign 컬럼(+1/-1)으로 행 삽입/취소변경 로그 기반 실시간 집계
VersionedCollapsingMergeTreeCollapsing + 버전 관리순서가 보장되지 않는 CDC 스트림

ReplacingMergeTree 예제

CDC(Change Data Capture) 파이프라인에서 같은 키에 대한 여러 버전의 행이 들어올 때 최신 상태만 유지하고 싶다면 ReplacingMergeTree를 사용한다.

CREATE TABLE user_profiles
(
    user_id    UInt64,
    name       String,
    email      String,
    updated_at DateTime
)
ENGINE = ReplacingMergeTree(updated_at)
ORDER BY user_id;

-- 동일 user_id로 여러 번 INSERT 가능
INSERT INTO user_profiles VALUES (1, '김영주', 'yj@example.com', '2026-03-01 10:00:00');
INSERT INTO user_profiles VALUES (1, '김영주', 'yj_new@example.com', '2026-03-06 09:00:00');

-- 병합 전에는 두 행이 모두 보일 수 있음
-- FINAL 키워드로 최신 버전만 조회
SELECT * FROM user_profiles FINAL WHERE user_id = 1;

주의: FINAL 키워드는 쿼리 시점에 중복 제거를 수행하므로 성능 오버헤드가 있다. 프로덕션에서는 OPTIMIZE TABLE ... FINAL을 정기적으로 실행하거나, 쿼리 레벨에서 argMax 함수를 활용하는 것이 더 효율적인 경우가 많다.

4. 스키마 설계 베스트 프랙티스

ORDER BY 설계 원칙

ORDER BY는 ClickHouse에서 가장 중요한 설계 결정이다. Primary Key(Sparse Index)로도 기능하며, 데이터의 물리적 정렬 순서를 결정한다.

핵심 원칙:

  • 가장 자주 필터링하는 컬럼을 앞에 배치한다.
  • 카디널리티가 낮은 컬럼을 앞에, 높은 컬럼을 뒤에 배치한다.
  • 시계열 데이터는 (low_cardinality_dim, timestamp) 순서가 일반적이다.
-- 잘못된 설계: 고카디널리티 컬럼이 앞에
CREATE TABLE events_bad
(
    event_id   UUID,
    user_id    UInt64,
    event_type LowCardinality(String),
    event_time DateTime
)
ENGINE = MergeTree()
ORDER BY (event_id);  -- UUID가 첫 번째: sparse index 효율 극히 낮음

-- 올바른 설계: 필터 패턴에 맞춘 정렬
CREATE TABLE events_good
(
    event_id   UUID,
    user_id    UInt64,
    event_type LowCardinality(String),
    event_time DateTime
)
ENGINE = MergeTree()
PARTITION BY toYYYYMM(event_time)
ORDER BY (event_type, user_id, event_time);

파티셔닝 전략

PARTITION BY는 데이터를 물리적으로 분리하여 오래된 데이터의 삭제(TTL 또는 DROP PARTITION)를 효율적으로 만들고, 쿼리 시 파티션 프루닝을 가능하게 한다.

파티션 키파티션 수(1년 기준)권장 시나리오
toYYYYMM(event_time)12개월 단위 보존 정책, 대부분의 시계열
toYYYYMMDD(event_time)365개일 단위 TTL이 필요한 로그
toMonday(event_time)52개주 단위 분석이 주 패턴
intDiv(user_id, 1000000)가변사용자 ID 범위 기반 조회

경고: 파티션 수가 지나치게 많아지면 (수천 개 이상) ZooKeeper/ClickHouse Keeper의 메타데이터 부하가 급증하고, 파트 관리 오버헤드가 커진다. 월 단위 파티셔닝이 가장 무난하다.

LowCardinality 타입

카디널리티가 수천 이하인 문자열 컬럼에는 반드시 LowCardinality(String)을 사용한다. 내부적으로 딕셔너리 인코딩을 적용하여 저장 공간을 줄이고 쿼리 성능을 높인다.

-- LowCardinality 적용 전후 비교
CREATE TABLE logs_v1 (level String) ENGINE = MergeTree() ORDER BY tuple();
CREATE TABLE logs_v2 (level LowCardinality(String)) ENGINE = MergeTree() ORDER BY tuple();

-- 데이터 삽입 후 크기 비교
SELECT
    table,
    formatReadableSize(sum(data_compressed_bytes)) AS compressed,
    formatReadableSize(sum(data_uncompressed_bytes)) AS uncompressed
FROM system.columns
WHERE database = currentDatabase() AND table IN ('logs_v1', 'logs_v2')
GROUP BY table;

카디널리티가 10,000을 넘어가면 LowCardinality의 이점이 줄어들고 오히려 오버헤드가 될 수 있으므로, 적용 전에 SELECT uniq(column) FROM table로 확인한다.

5. Materialized View와 사전 집계

Materialized View(MV)는 ClickHouse에서 실시간 사전 집계를 구현하는 핵심 메커니즘이다. PostgreSQL의 Materialized View와는 근본적으로 다르다. ClickHouse의 MV는 INSERT 트리거처럼 동작하여, 원본 테이블에 데이터가 삽입될 때마다 자동으로 변환/집계된 결과를 대상 테이블에 기록한다.

AggregatingMergeTree + State/Merge 패턴

가장 강력한 사전 집계 패턴은 AggregatingMergeTree 엔진과 -State/-Merge 콤비네이터를 결합하는 것이다.

-- 1단계: 원본 이벤트 테이블
CREATE TABLE raw_events
(
    event_time DateTime,
    event_type LowCardinality(String),
    user_id    UInt64,
    revenue    Float64
)
ENGINE = MergeTree()
PARTITION BY toYYYYMM(event_time)
ORDER BY (event_type, event_time);

-- 2단계: 사전 집계 대상 테이블 (AggregatingMergeTree)
CREATE TABLE hourly_stats
(
    hour       DateTime,
    event_type LowCardinality(String),
    user_count AggregateFunction(uniq, UInt64),
    total_rev  AggregateFunction(sum, Float64),
    p99_rev    AggregateFunction(quantile(0.99), Float64)
)
ENGINE = AggregatingMergeTree()
PARTITION BY toYYYYMM(hour)
ORDER BY (event_type, hour);

-- 3단계: Materialized View 생성
CREATE MATERIALIZED VIEW hourly_stats_mv
TO hourly_stats
AS SELECT
    toStartOfHour(event_time) AS hour,
    event_type,
    uniqState(user_id)    AS user_count,
    sumState(revenue)     AS total_rev,
    quantileState(0.99)(revenue) AS p99_rev
FROM raw_events
GROUP BY hour, event_type;

-- 4단계: 조회 시 -Merge 콤비네이터 사용
SELECT
    hour,
    event_type,
    uniqMerge(user_count)    AS unique_users,
    sumMerge(total_rev)      AS total_revenue,
    quantileMerge(0.99)(p99_rev) AS revenue_p99
FROM hourly_stats
WHERE hour >= '2026-03-01'
GROUP BY hour, event_type
ORDER BY hour;

이 패턴의 핵심 이점은 다음과 같다.

  • 실시간성: INSERT가 발생할 때마다 즉시 집계가 업데이트된다.
  • 정확성: -State/-Merge 함수는 중간 상태를 바이너리로 저장하므로, 파트 병합 후에도 수학적으로 정확한 결과를 보장한다.
  • 쿼리 속도: 수십억 행의 원본 대신 집계 테이블을 조회하므로 응답이 밀리초 단위로 떨어진다.
  • 근사값 지원: uniq(HyperLogLog 기반), quantile(t-digest 기반) 등 근사 집계 함수도 State/Merge 패턴과 완벽히 호환된다.

경고: Materialized View는 INSERT 시점에만 트리거된다. 기존 데이터에 대해 MV를 소급 적용하려면 INSERT INTO hourly_stats SELECT ... FROM raw_events로 수동 백필이 필요하다.

6. 쿼리 최적화 기법

PREWHERE: WHERE보다 먼저 필터링

ClickHouse 고유의 PREWHERE 절은 WHERE 조건 중 일부를 컬럼 읽기 전에 먼저 평가하여, 불필요한 컬럼 데이터의 디스크 I/O를 줄인다. 최신 버전에서는 옵티마이저가 자동으로 WHERE를 PREWHERE로 변환하지만, 명시적으로 지정할 수도 있다.

-- PREWHERE를 명시적으로 사용
SELECT user_id, raw_json
FROM raw_events
PREWHERE event_type = 'purchase'
WHERE revenue > 100.0;

위 쿼리에서 event_type 컬럼만 먼저 읽어 필터링한 후, 통과한 행에 대해서만 raw_json(큰 String 컬럼)을 읽는다. 큰 컬럼이 포함된 테이블에서 PREWHERE의 효과는 극적이다.

샘플링으로 대략적 결과 빠르게 얻기

데이터 탐색 단계에서는 정확한 집계보다 빠른 피드백이 더 중요할 수 있다. SAMPLE 절을 사용하면 데이터의 일부만 읽어 근사 결과를 반환한다.

-- 테이블 생성 시 SAMPLE BY 지정 필요
CREATE TABLE events_sampled
(
    event_time DateTime,
    user_id    UInt64,
    event_type LowCardinality(String),
    revenue    Float64
)
ENGINE = MergeTree()
ORDER BY (event_type, sipHash64(user_id))
SAMPLE BY sipHash64(user_id);

-- 전체 데이터의 10%만 샘플링하여 조회
SELECT
    event_type,
    count() * 10 AS estimated_count,  -- 10배 보정
    avg(revenue) AS avg_revenue
FROM events_sampled
SAMPLE 0.1
GROUP BY event_type;

병렬 실행과 리소스 제어

ClickHouse는 기본적으로 가용한 모든 CPU 코어를 사용하여 쿼리를 병렬 실행한다. 프로덕션 환경에서는 쿼리 간 리소스 경합을 방지하기 위해 적절한 제한이 필요하다.

-- 쿼리 레벨 리소스 제한
SET max_threads = 8;                    -- 쿼리당 최대 스레드 수
SET max_memory_usage = 10000000000;     -- 쿼리당 최대 메모리 (10GB)
SET max_execution_time = 30;            -- 최대 실행 시간 (초)
SET max_rows_to_read = 1000000000;      -- 최대 읽기 행 수

-- 사용자/프로필 단위 제한 (config.xml 또는 SQL)
CREATE SETTINGS PROFILE 'analyst' SETTINGS
    max_threads = 4,
    max_memory_usage = 5000000000,
    max_execution_time = 60
TO analyst_role;

쿼리 성능 분석

느린 쿼리를 진단할 때는 clickhouse-client의 프로파일링 옵션을 활용한다.

# 쿼리 실행 통계 포함 실행
clickhouse-client --query "
    SELECT event_type, count()
    FROM raw_events
    WHERE event_time >= '2026-03-01'
    GROUP BY event_type
    FORMAT PrettyCompactMonoBlock
    SETTINGS send_logs_level = 'trace'
"

# system.query_log에서 느린 쿼리 분석
clickhouse-client --query "
    SELECT
        query_duration_ms,
        read_rows,
        formatReadableSize(read_bytes) AS read_size,
        formatReadableSize(memory_usage) AS peak_memory,
        query
    FROM system.query_log
    WHERE type = 'QueryFinish'
      AND query_duration_ms > 1000
    ORDER BY query_duration_ms DESC
    LIMIT 10
"

7. ClickHouse vs PostgreSQL vs BigQuery vs Druid

OLAP 워크로드에서 자주 비교되는 네 가지 엔진의 특성을 정리한다.

항목ClickHousePostgreSQL (+ pg_analytics)BigQueryApache Druid
스토리지 모델컬럼 지향행 지향 (확장으로 컬럼 지원)컬럼 지향 (Capacitor)컬럼 지향 (세그먼트)
배포 모델셀프호스팅 / ClickHouse Cloud셀프호스팅 / 매니지드완전 관리형 SaaS셀프호스팅 / Imply Cloud
실시간 수집INSERT 즉시 조회 가능INSERT 즉시 조회 가능스트리밍 버퍼 (수 초 지연)실시간 수집 노드 (수 초 지연)
쿼리 지연시간밀리초~초초~분 (대규모 집계)초~십 초 (콜드 스타트)밀리초~초
압축률매우 높음 (10~40x)보통 (2~4x)높음 (관리형)높음 (5~10x)
SQL 호환성ClickHouse SQL (표준 호환 높음)표준 SQL 완전 지원표준 SQL (GoogleSQL)Druid SQL (제한적)
JOIN 성능제한적 (대형 JOIN 주의)우수 (해시, 머지, 네스티드 루프)우수 (분산 셔플)미지원 (룩업 JOIN만)
UPDATE/DELETE비동기 Mutation (무거움)즉시 처리 (MVCC)DML 지원 (비용 발생)미지원
비용 모델인프라 비용 (예측 가능)인프라 비용 (예측 가능)스캔 바이트 과금 (예측 어려움)인프라 비용 (예측 가능)
학습 곡선중간낮음낮음 (SQL 친숙)높음 (세그먼트 개념)

핵심 선택 기준:

  • 이미 PostgreSQL을 쓰고 있고 데이터가 수십 GB 이하: PostgreSQL에 머물러라. pg_analytics 확장으로 충분할 수 있다.
  • 수십 TB 이상, 자체 인프라, 서브초 응답 필요: ClickHouse가 최적이다.
  • 인프라 관리를 원하지 않고 비용에 유연: BigQuery가 적합하다.
  • 고성능 실시간 대시보드가 핵심이고 JOIN이 불필요: Druid도 선택지다.

8. 2025~2026년 주요 신기능

ClickHouse는 2025년 한 해 동안 50개 이상의 릴리스를 통해 대규모 기능 업데이트를 진행했다. 2026년 초까지의 주요 변화를 정리한다.

ClickHouse 25.1부터 usearch 인덱스를 활용한 ANN(Approximate Nearest Neighbor) 검색이 정식 지원된다. 별도의 벡터 DB 없이도 분석 데이터와 임베딩 벡터를 같은 테이블에서 관리할 수 있다.

CREATE TABLE embeddings
(
    doc_id   UInt64,
    content  String,
    vector   Array(Float32),
    INDEX vec_idx vector TYPE usearch(256) GRANULARITY 1
)
ENGINE = MergeTree()
ORDER BY doc_id;

-- 코사인 유사도 기반 검색
SELECT doc_id, content,
       cosineDistance(vector, [0.1, 0.2, ...]) AS distance
FROM embeddings
ORDER BY distance ASC
LIMIT 10;

SharedCatalog와 오브젝트 스토리지 분리

ClickHouse Cloud에서 도입된 SharedCatalog는 컴퓨팅과 스토리지를 완전히 분리하여, 여러 컴퓨팅 노드가 S3/GCS의 동일 데이터에 접근할 수 있게 한다. 이를 통해 읽기 전용 레플리카를 즉시 스케일아웃할 수 있다.

AI 통합과 쿼리 생성

ClickHouse는 LLM 기반 자연어-to-SQL 변환, AI 기반 쿼리 최적화 제안 등의 기능을 ClickHouse Cloud 콘솔에 통합하고 있다. 또한 UDF(User Defined Function)를 통해 외부 ML 모델을 쿼리 파이프라인에 직접 연결할 수 있다.

기타 주요 개선

  • Parallel Replicas: 단일 쿼리를 여러 레플리카에 분산 실행하여 지연시간을 줄이는 기능이 GA 되었다.
  • Lightweight DELETE/UPDATE: Mutation 대신 행 마스킹 기반의 경량 삭제가 안정화되었다.
  • Refreshable Materialized View: 주기적으로 전체 갱신되는 MV 지원이 추가되었다 (기존의 INSERT 트리거 방식과 별도).

9. 운영 시 주의사항

디스크 관리

ClickHouse는 백그라운드 머지 과정에서 일시적으로 추가 디스크 공간을 사용한다. 프로덕션에서는 최소 30%의 여유 디스크 공간을 확보해야 하며, 디스크 사용률 모니터링은 필수다.

# /etc/clickhouse-server/config.d/storage.yaml
# 멀티 디스크(핫/콜드) 스토리지 정책
storage_configuration:
  disks:
    hot:
      type: local
      path: /data/clickhouse/hot/
    cold:
      type: s3
      endpoint: https://s3.ap-northeast-2.amazonaws.com/my-bucket/clickhouse/
      access_key_id: '${S3_ACCESS_KEY}'
      secret_access_key: '${S3_SECRET_KEY}'
  policies:
    tiered:
      volumes:
        hot_volume:
          disk: hot
          max_data_part_size_bytes: 10737418240 # 10GB
        cold_volume:
          disk: cold
      move_factor: 0.8 # 핫 디스크 80% 차면 콜드로 이동

TTL(Time to Live)을 활용하면 오래된 데이터를 자동으로 콜드 스토리지로 이동하거나 삭제할 수 있다.

ALTER TABLE raw_events
    MODIFY TTL
        event_time + INTERVAL 30 DAY TO VOLUME 'cold_volume',
        event_time + INTERVAL 365 DAY DELETE;

복제와 고가용성

프로덕션에서는 반드시 ReplicatedMergeTree 엔진을 사용한다. ClickHouse Keeper(ZooKeeper 호환 합의 프로토콜)가 레플리카 간 메타데이터 동기화를 담당한다.

-- Replicated 테이블 생성
CREATE TABLE raw_events ON CLUSTER 'production'
(
    event_time DateTime,
    event_type LowCardinality(String),
    user_id    UInt64,
    revenue    Float64
)
ENGINE = ReplicatedMergeTree('/clickhouse/tables/{shard}/raw_events', '{replica}')
PARTITION BY toYYYYMM(event_time)
ORDER BY (event_type, event_time);

ClickHouse Keeper 운영 주의사항:

  • 반드시 홀수 노드(3 또는 5)로 구성한다. 3노드 구성에서 1노드 장애까지 허용한다.
  • Keeper 노드에는 전용 SSD를 할당한다. 디스크 지연이 합의 프로토콜 성능에 직접 영향을 준다.
  • raft_logs_level을 적절히 설정하여 디버깅이 가능하도록 한다.
  • Keeper 로그와 스냅샷의 보관 기간을 설정하여 디스크 폭발을 방지한다.

백업 전략

ClickHouse 내장 백업 기능을 활용하거나, clickhouse-backup 도구를 사용한다.

# 내장 BACKUP 명령어 (ClickHouse 22.8+)
clickhouse-client --query "
    BACKUP TABLE raw_events
    TO S3('https://s3.ap-northeast-2.amazonaws.com/my-backup-bucket/raw_events_20260306/',
           '${S3_ACCESS_KEY}', '${S3_SECRET_KEY}')
    SETTINGS compression_method = 'lz4'
"

# 복원
clickhouse-client --query "
    RESTORE TABLE raw_events
    FROM S3('https://s3.ap-northeast-2.amazonaws.com/my-backup-bucket/raw_events_20260306/',
            '${S3_ACCESS_KEY}', '${S3_SECRET_KEY}')
"

# clickhouse-backup 도구 사용 (더 세밀한 제어)
clickhouse-backup create --tables="default.raw_events" daily_20260306
clickhouse-backup upload daily_20260306
clickhouse-backup list remote

INSERT 배치 최적화

ClickHouse에 데이터를 삽입할 때 가장 흔한 실수는 행 단위로 INSERT를 보내는 것이다. 매 INSERT마다 새로운 파트가 생성되고, 과도한 파트 수는 "Too many parts" 오류를 유발한다.

권장사항:

  • 최소 1,000행 이상을 한 번에 배치 INSERT한다. 이상적으로는 10,000~100,000행이다.
  • Kafka, RabbitMQ 등에서 소비할 때는 ClickHouse의 테이블 엔진(Kafka engine)을 활용하여 자동 배치를 구성한다.
  • async_insert=1 설정을 활용하면 ClickHouse가 클라이언트의 작은 INSERT를 서버 측에서 모아 배치로 처리한다.
-- async_insert 활성화
SET async_insert = 1;
SET wait_for_async_insert = 1;  -- INSERT 응답 전 실제 기록 대기
SET async_insert_max_data_size = 10485760;  -- 10MB마다 플러시
SET async_insert_busy_timeout_ms = 1000;    -- 최대 1초 대기

10. 실패 사례와 복구 절차

사례 1: "Too many parts" 오류

증상: INSERT가 DB::Exception: Too many parts (N). Merges are processing significantly slower than inserts 오류와 함께 실패한다.

원인: 행 단위 INSERT를 초당 수천 번 실행하여 파트 수가 폭증했다. 기본 임계값은 파티션당 300개이다.

복구 절차:

  1. INSERT 트래픽을 즉시 중단하거나 배치 크기를 늘린다.
  2. 백그라운드 머지가 따라잡을 때까지 대기한다. system.merges 테이블에서 진행 상황을 모니터링한다.
  3. 필요시 OPTIMIZE TABLE events FINAL을 수동 실행한다. (주의: 이 명령은 디스크 I/O가 매우 크므로 피크 시간을 피해야 한다.)
  4. 근본 원인을 해결한다: async_insert 활성화, 배치 크기 증가, Kafka 엔진 도입 등.
-- 파티션별 파트 수 확인
SELECT
    partition,
    count() AS part_count,
    formatReadableSize(sum(bytes_on_disk)) AS total_size
FROM system.parts
WHERE table = 'raw_events' AND active
GROUP BY partition
ORDER BY part_count DESC;

-- 머지 진행 상황 모니터링
SELECT
    table, partition_id,
    progress, elapsed,
    formatReadableSize(total_size_bytes_compressed) AS size
FROM system.merges
WHERE table = 'raw_events';

사례 2: 레플리카 불일치 (Replica Divergence)

증상: 두 레플리카의 데이터가 서로 달라 SELECT count()결과가 다르다. system.replicas에서 is_session_expired 또는 queue_size가 비정상적으로 높다.

원인: ClickHouse Keeper 연결 끊김, 네트워크 파티션, 또는 레플리카 중 하나가 장기간 다운된 후 복구되면서 발생할 수 있다.

복구 절차:

  1. system.replicas에서 상태를 확인한다.
  2. 복제 큐가 정상적으로 처리되고 있는지 확인한다.
  3. 큐가 막혀 있다면 SYSTEM RESTART REPLICA table_name을 시도한다.
  4. 최악의 경우 문제 레플리카의 데이터를 제거하고 정상 레플리카에서 다시 복제한다.
-- 레플리카 상태 확인
SELECT
    database, table,
    is_leader, is_readonly, is_session_expired,
    future_parts, parts_to_check,
    queue_size, inserts_in_queue, merges_in_queue,
    log_pointer, total_replicas, active_replicas
FROM system.replicas
WHERE table = 'raw_events';

-- 복제 재시작 시도
SYSTEM RESTART REPLICA raw_events;

-- 그래도 안 되면 레플리카를 정상 복제본에서 재동기화
-- (주의: 해당 레플리카의 로컬 데이터가 삭제됨)
SYSTEM RESTORE REPLICA raw_events;

사례 3: 디스크 풀로 인한 서비스 중단

증상: 백그라운드 머지 실패, INSERT 실패, 극단적인 경우 서버 시작 불가.

원인: TTL 미설정, 압축 코덱 미적용, 또는 예상보다 빠른 데이터 증가.

복구 절차:

  1. 가장 큰 파티션을 식별하여 불필요한 데이터를 DROP PARTITION으로 즉시 제거한다.
  2. 임시 디스크 공간을 확보한다(시스템 로그, 임시 파일 삭제).
  3. TTL 정책을 추가하여 자동 삭제/이동을 설정한다.
  4. 멀티 디스크 정책으로 콜드 스토리지(S3)를 추가한다.
-- 파티션별 디스크 사용량 확인
SELECT
    partition,
    formatReadableSize(sum(bytes_on_disk)) AS disk_usage,
    min(min_time) AS oldest_data,
    max(max_time) AS newest_data,
    count() AS part_count
FROM system.parts
WHERE table = 'raw_events' AND active
GROUP BY partition
ORDER BY sum(bytes_on_disk) DESC;

-- 오래된 파티션 즉시 삭제 (비가역적!)
ALTER TABLE raw_events DROP PARTITION '202501';

사례 4: Materialized View 데이터 불일치

증상: 원본 테이블과 MV의 집계 결과가 맞지 않는다.

원인: MV 생성 전에 이미 존재하던 데이터는 MV에 반영되지 않는다. 또한 INSERT 중 서버 장애가 발생하면 원본에는 기록되었으나 MV에는 반영되지 않을 수 있다.

복구 절차:

  1. MV 대상 테이블을 TRUNCATE한다.
  2. 원본 테이블에서 수동으로 백필한다.
  3. 대규모 백필 시에는 날짜 범위별로 나누어 실행하여 메모리 사용을 제어한다.
-- MV 대상 테이블 초기화 후 백필
TRUNCATE TABLE hourly_stats;

INSERT INTO hourly_stats
SELECT
    toStartOfHour(event_time) AS hour,
    event_type,
    uniqState(user_id)    AS user_count,
    sumState(revenue)     AS total_rev,
    quantileState(0.99)(revenue) AS p99_rev
FROM raw_events
WHERE event_time >= '2026-01-01' AND event_time < '2026-02-01'
GROUP BY hour, event_type;
-- 월별로 반복 실행

11. 프로덕션 체크리스트

ClickHouse를 프로덕션에 배포하기 전에 확인해야 할 항목들을 정리한다.

스키마 설계

  • ORDER BY 키가 주요 쿼리 패턴과 일치하는가
  • PARTITION BY가 적절한 단위(월/주)로 설정되었는가 (파티션 수 1,000개 미만)
  • 저카디널리티 문자열에 LowCardinality 타입이 적용되었는가
  • 컬럼별 최적 압축 코덱이 지정되었는가
  • Nullable 사용을 최소화했는가 (Nullable은 추가 컬럼 파일 생성)

데이터 수집

  • INSERT 배치 크기가 최소 1,000행 이상인가
  • async_insert가 적절히 설정되었는가 (소규모 INSERT가 빈번한 경우)
  • Kafka/RabbitMQ 연동 시 테이블 엔진을 활용하고 있는가
  • "Too many parts" 알림이 설정되어 있는가

쿼리 성능

  • 대시보드 쿼리에 Materialized View 사전 집계가 적용되었는가
  • 사용자/역할별 리소스 제한(max_threads, max_memory_usage)이 설정되었는가
  • system.query_log 기반 슬로우 쿼리 모니터링이 구축되었는가
  • 대형 JOIN 대신 딕셔너리 또는 IN 서브쿼리를 사용하고 있는가

운영/안정성

  • ReplicatedMergeTree로 최소 2 레플리카를 구성했는가
  • ClickHouse Keeper가 3+ 홀수 노드로 운영되는가
  • 디스크 사용률 80% 알림이 설정되어 있는가
  • TTL 정책으로 데이터 보존 기간이 관리되고 있는가
  • 백업이 자동화되어 있고 복원 테스트를 정기적으로 수행하는가
  • 핫/콜드 스토리지 티어링이 구성되어 있는가 (대용량 데이터의 경우)

모니터링 지표

  • system.metrics에서 BackgroundMergesAndMutationsPoolTask 모니터링
  • system.asynchronous_metrics에서 MaxPartCountForPartition 모니터링 (300 초과 시 경고)
  • system.replicas에서 queue_size, is_readonly 모니터링
  • Prometheus + Grafana 대시보드로 주요 지표 시각화

12. 참고자료