Skip to content

Split View: 관측 데이터를 ClickHouse에 넣는다는 것 — 스키마, 롤업, TTL, 그리고 역할 분담

✨ Learn with Quiz
|

관측 데이터를 ClickHouse에 넣는다는 것 — 스키마, 롤업, TTL, 그리고 역할 분담

들어가며 — 30일치 트레이스를 훑어야 하는 질문이 생겼을 때

"지난 30일 동안 특정 결제 대행사로 나간 요청 중 3초를 넘긴 것들의 테넌트 분포를 보고 싶다."

이 질문이 들어오면 대부분의 관측 스택이 막힙니다. Prometheus는 개별 요청을 모릅니다. 트레이스 백엔드는 보존 기간이 7일입니다. 검색 엔진에 넣어 뒀다면 30일치를 훑는 동안 클러스터가 흔들립니다.

관측 데이터가 하루 수 TB 규모가 되고 이런 질문이 반복되기 시작하면, 분석 데이터베이스가 필요해집니다. ClickHouse가 이 자리에 자주 등장하는 이유는 관측 데이터의 성질이 컬럼 저장에 잘 맞기 때문입니다.

이 글은 실제 스키마를 설계합니다. ClickHouse 26.5 stable 기준으로 확인했고, 장기 안정 버전을 쓴다면 26.3 LTS 계열이 대안입니다. 25.8 LTS는 2026년 8월 말 지원이 끝나므로 신규 구축에는 권하지 않습니다. 네이티브 JSON 타입은 25.3에서 production-ready가 되었습니다.

컬럼 저장이 관측 데이터에 맞는 이유

세 가지가 겹칩니다.

첫째, 질의가 좁습니다. 트레이스 테이블에 컬럼이 25개 있어도 "서비스별 p99"를 구하는 질의가 읽는 컬럼은 서비스 이름과 지속 시간, 두 개뿐입니다. 행 저장은 25개 컬럼을 전부 디스크에서 읽어야 하지만 컬럼 저장은 두 개만 읽습니다.

둘째, 값이 반복됩니다. 서비스 이름은 수십 종, 스팬 이름은 수백 종, 상태 코드는 열 종 남짓입니다. 같은 값이 연속으로 늘어서면 압축률이 극단적으로 좋아집니다. 타임스탬프는 델타 인코딩으로, 낮은 카디널리티 문자열은 사전 인코딩으로 줄어듭니다.

셋째, 쓰기가 append 전용입니다. 관측 데이터는 갱신되지 않습니다. MergeTree가 전제하는 워크로드와 정확히 일치합니다.

압축률을 체감하려면 직접 재 보는 것이 빠릅니다.

SELECT
    table,
    formatReadableSize(sum(data_uncompressed_bytes)) AS raw,
    formatReadableSize(sum(data_compressed_bytes))   AS compressed,
    round(sum(data_uncompressed_bytes) / sum(data_compressed_bytes), 1) AS ratio
FROM system.columns
WHERE database = 'otel'
GROUP BY table
ORDER BY sum(data_compressed_bytes) DESC;

-- 컬럼별로도 볼 수 있다. 압축이 안 되는 컬럼이 비용의 대부분을 차지한다
SELECT
    name,
    type,
    formatReadableSize(data_compressed_bytes) AS compressed,
    round(data_uncompressed_bytes / data_compressed_bytes, 1) AS ratio
FROM system.columns
WHERE database = 'otel' AND table = 'otel_traces'
ORDER BY data_compressed_bytes DESC
LIMIT 15;

압축비가 1에 가까운 컬럼이 있다면 대개 원인은 둘입니다. 무작위 문자열(트레이스 ID, UUID)이거나, 자유 형식 텍스트입니다. 트레이스 ID는 어쩔 수 없지만 자유 텍스트는 별도 컬럼으로 분리해 다른 코덱을 적용할 여지가 있습니다.

트레이스 스키마 — 정렬 키가 전부다

MergeTree에서 가장 중요한 결정은 ORDER BY 입니다. 데이터가 디스크에 이 순서로 정렬되고, 질의는 이 순서를 이용해 읽을 블록을 건너뜁니다. 정렬 키를 잘못 잡으면 다른 무엇으로도 만회할 수 없습니다.

OpenTelemetry ClickHouse 익스포터가 만드는 기본 테이블은 트레이스를 서비스 이름, 스팬 이름, 시각 순으로 정렬합니다. 이것은 "서비스와 오퍼레이션 단위로 훑는" 질의에 최적화된 선택입니다.

CREATE TABLE otel.otel_traces
(
    Timestamp          DateTime64(9) CODEC(Delta(8), ZSTD(1)),
    TraceId            String CODEC(ZSTD(1)),
    SpanId             String CODEC(ZSTD(1)),
    ParentSpanId       String CODEC(ZSTD(1)),
    TraceState         String CODEC(ZSTD(1)),
    SpanName           LowCardinality(String) CODEC(ZSTD(1)),
    SpanKind           LowCardinality(String) CODEC(ZSTD(1)),
    ServiceName        LowCardinality(String) CODEC(ZSTD(1)),
    ResourceAttributes Map(LowCardinality(String), String) CODEC(ZSTD(1)),
    ScopeName          String CODEC(ZSTD(1)),
    ScopeVersion       String CODEC(ZSTD(1)),
    SpanAttributes     Map(LowCardinality(String), String) CODEC(ZSTD(1)),
    Duration           UInt64 CODEC(ZSTD(1)),
    StatusCode         LowCardinality(String) CODEC(ZSTD(1)),
    StatusMessage      String CODEC(ZSTD(1)),

    Events Nested (
        Timestamp  DateTime64(9),
        Name       LowCardinality(String),
        Attributes Map(LowCardinality(String), String)
    ) CODEC(ZSTD(1)),

    Links Nested (
        TraceId    String,
        SpanId     String,
        TraceState String,
        Attributes Map(LowCardinality(String), String)
    ) CODEC(ZSTD(1)),

    INDEX idx_trace_id       TraceId TYPE bloom_filter(0.001) GRANULARITY 1,
    INDEX idx_res_attr_key   mapKeys(ResourceAttributes)   TYPE bloom_filter(0.01) GRANULARITY 1,
    INDEX idx_res_attr_value mapValues(ResourceAttributes) TYPE bloom_filter(0.01) GRANULARITY 1,
    INDEX idx_span_attr_key  mapKeys(SpanAttributes)       TYPE bloom_filter(0.01) GRANULARITY 1,
    INDEX idx_duration       Duration TYPE minmax GRANULARITY 1
)
ENGINE = MergeTree
PARTITION BY toDate(Timestamp)
ORDER BY (ServiceName, SpanName, toDateTime(Timestamp))
TTL toDateTime(Timestamp) + toIntervalDay(30)
SETTINGS index_granularity = 8192, ttl_only_drop_parts = 1;

세 가지를 눈여겨봅니다.

LowCardinality(String) 의 사용처. 값 종류가 대략 1만 개 미만인 문자열에만 씁니다. 사전이 만들어져 압축과 필터링이 빨라집니다. TraceId 처럼 값이 사실상 유일한 컬럼에 붙이면 사전이 무한정 커져 오히려 나빠집니다.

블룸 필터 인덱스의 역할. 정렬 키가 서비스와 스팬 이름이므로 트레이스 ID 하나로 조회하는 질의는 정렬을 못 씁니다. 블룸 필터가 "이 블록에 이 트레이스 ID가 없다"를 빠르게 판정해 읽을 블록을 줄여 줍니다. 다만 이것은 보조 수단이고, 정렬 키를 대신하지 못합니다.

ttl_only_drop_parts = 1. TTL 만료를 행 단위가 아니라 파트 단위로 처리합니다. 파티션이 날짜 단위이므로 만료된 날짜의 파트가 통째로 사라집니다. 이 설정이 없으면 TTL 정리가 파트 재작성을 유발해 디스크 I/O가 크게 늘어납니다.

정렬 키를 바꾸고 싶다면 어떤 질의가 대다수인지부터 봅니다.

주된 질의권장 ORDER BY대가
서비스별 지연 분석(ServiceName, SpanName, Timestamp)트레이스 ID 조회는 블룸 필터 의존
트레이스 ID 단건 조회가 압도적(TraceId) 또는 별도 조회용 테이블시간 범위 스캔이 비효율
테넌트별 분석이 대부분(TenantId, ServiceName, Timestamp)테넌트 컬럼을 물리 컬럼으로 승격 필요
최근 시간대 탐색 위주(toStartOfHour(Timestamp), ServiceName)오래된 구간 필터링이 덜 효율적

두 가지 접근 패턴이 모두 중요하면 테이블을 하나 더 만듭니다. 저장 비용을 두 배 쓰는 대신 두 질의가 모두 빨라집니다. ClickHouse에서는 이것이 흔한 선택입니다.

로그 스키마 — 속성을 Map 으로 둘 것인가 JSON 으로 둘 것인가

로그 테이블의 정렬 키는 다릅니다. 로그는 시간 범위와 서비스로 좁히는 질의가 압도적이므로, 시간을 앞에 두되 너무 세밀하지 않게 자릅니다.

CREATE TABLE otel.otel_logs
(
    Timestamp          DateTime64(9) CODEC(Delta(8), ZSTD(1)),
    TraceId            String CODEC(ZSTD(1)),
    SpanId             String CODEC(ZSTD(1)),
    TraceFlags         UInt8,
    SeverityText       LowCardinality(String) CODEC(ZSTD(1)),
    SeverityNumber     UInt8,
    ServiceName        LowCardinality(String) CODEC(ZSTD(1)),
    Body               String CODEC(ZSTD(1)),
    ResourceAttributes Map(LowCardinality(String), String) CODEC(ZSTD(1)),
    LogAttributes      Map(LowCardinality(String), String) CODEC(ZSTD(1)),

    -- 자주 필터링하는 키는 물리 컬럼으로 승격한다
    HttpRoute    LowCardinality(String) MATERIALIZED LogAttributes['http.route'],
    HttpStatus   UInt16                 MATERIALIZED toUInt16OrZero(LogAttributes['http.response.status_code']),
    ErrorType    LowCardinality(String) MATERIALIZED LogAttributes['error.type'],

    INDEX idx_trace_id  TraceId    TYPE bloom_filter(0.001) GRANULARITY 1,
    INDEX idx_body      Body       TYPE tokenbf_v1(32768, 3, 0) GRANULARITY 1,
    INDEX idx_severity  SeverityNumber TYPE set(16) GRANULARITY 4
)
ENGINE = MergeTree
PARTITION BY toDate(Timestamp)
ORDER BY (ServiceName, toStartOfFiveMinutes(Timestamp), Timestamp)
TTL toDateTime(Timestamp) + toIntervalDay(30)
SETTINGS index_granularity = 8192, ttl_only_drop_parts = 1;

MATERIALIZED 컬럼이 중요한 장치입니다. 삽입 시점에 Map에서 값을 꺼내 별도 컬럼으로 저장합니다. Map 조회는 매번 키를 찾아야 하지만 물리 컬럼은 바로 읽히므로, 자주 쓰는 필터가 훨씬 빨라집니다. 저장 비용은 늘어나지만 낮은 카디널리티 컬럼은 압축이 잘 되어 실제 증가폭은 작습니다.

속성 저장 방식은 두 가지 선택지가 있습니다.

항목Map(String, String)JSON 타입
타입 보존전부 문자열로 평탄화원래 타입 유지
질의 시 읽는 양키 하나만 필요해도 Map 전체를 읽음해당 하위 컬럼만 읽음
스키마 변화자유롭다자유롭다, 하위 컬럼이 자동 생성됨
하위 키 수가 많을 때압축과 질의가 함께 나빠짐하위 컬럼 상한 설정으로 제어
도구 호환성어디서나 동작25.3 이후 필요
마이그레이션기준기존 테이블은 재작성 필요

Map의 가장 큰 약점은 부분 읽기가 안 된다는 점입니다. LogAttributes['http.route'] 하나를 읽으려 해도 그 행의 Map 전체를 디스크에서 읽고 압축을 풀어야 합니다. 속성이 40개인 로그에서는 이 비용이 큽니다. JSON 타입은 하위 키를 각각 별도 컬럼처럼 저장하므로 이 문제가 없습니다.

-- JSON 타입 사용 예 — 하위 컬럼 수에 상한을 둔다
CREATE TABLE otel.otel_logs_json
(
    Timestamp    DateTime64(9) CODEC(Delta(8), ZSTD(1)),
    ServiceName  LowCardinality(String) CODEC(ZSTD(1)),
    SeverityText LowCardinality(String) CODEC(ZSTD(1)),
    TraceId      String CODEC(ZSTD(1)),
    Body         String CODEC(ZSTD(1)),
    Attributes   JSON(max_dynamic_paths = 512) CODEC(ZSTD(1))
)
ENGINE = MergeTree
PARTITION BY toDate(Timestamp)
ORDER BY (ServiceName, toStartOfFiveMinutes(Timestamp), Timestamp)
TTL toDateTime(Timestamp) + toIntervalDay(30)
SETTINGS ttl_only_drop_parts = 1;

-- 하위 경로에 타입을 명시해서 접근하면 인덱스와 통계가 활용된다
SELECT
    ServiceName,
    Attributes.http.route::LowCardinality(String) AS route,
    count() AS c,
    quantile(0.99)(Attributes.duration_ms::Float64) AS p99
FROM otel.otel_logs_json
WHERE Timestamp >= now() - INTERVAL 1 HOUR
  AND SeverityText = 'ERROR'
GROUP BY ServiceName, route
ORDER BY c DESC
LIMIT 20;

max_dynamic_paths 가 검색 엔진의 필드 수 상한과 같은 역할을 합니다. 상한을 넘는 경로는 별도의 공유 저장소로 밀려나 질의가 느려지되, 클러스터가 무너지지는 않습니다. 매핑 폭발과 같은 사고를 구조적으로 완화합니다.

판단 기준은 단순합니다. 속성 키가 대체로 예측 가능하고 개수가 적으면 Map으로 충분합니다. 키 집합이 서비스마다 다르고 계속 늘어나면 JSON 타입이 낫습니다.

파티셔닝, TTL, 그리고 계층 저장

파티션은 날짜 단위가 기본입니다. 더 잘게 쪼개고 싶은 유혹이 있지만 참는 편이 좋습니다. 파티션이 많아지면 파트 수가 늘고, 파트가 많으면 병합 부하와 메타데이터 부담이 커집니다. 하루 데이터가 수 TB라면 시간 단위 파티션을 고려하되, 그 전에 TTL과 정렬 키로 해결되는지부터 봅니다.

TTL은 삭제만이 아니라 이동에도 씁니다. 최근 데이터는 빠른 디스크에, 오래된 데이터는 느리고 싼 저장소에 둡니다.

-- 스토리지 정책 정의 (config.xml 또는 별도 설정 파일)
-- hot: 로컬 NVMe, cold: 객체 스토리지

ALTER TABLE otel.otel_traces
MODIFY TTL
    toDateTime(Timestamp) + INTERVAL 3 DAY  TO VOLUME 'hot',
    toDateTime(Timestamp) + INTERVAL 14 DAY TO VOLUME 'cold',
    toDateTime(Timestamp) + INTERVAL 90 DAY DELETE;

이동 TTL을 쓸 때 반드시 확인할 것이 있습니다. 객체 스토리지로 옮긴 데이터에 대한 질의는 네트워크 왕복이 생겨 훨씬 느립니다. "90일 보존"이 "90일 동안 같은 속도로 조회 가능"을 뜻하지 않는다는 점을 사용자에게 미리 알려야 합니다. 그러지 않으면 어느 날 누군가 60일 전 데이터를 전체 스캔해서 클러스터를 마비시킵니다.

TTL이 실제로 도는지도 확인합니다.

-- 파티션별 크기와 가장 오래된 데이터
SELECT
    table,
    partition,
    formatReadableSize(sum(bytes_on_disk)) AS size,
    sum(rows) AS rows,
    min(min_time) AS oldest
FROM system.parts
WHERE database = 'otel' AND active
GROUP BY table, partition
ORDER BY partition
LIMIT 10;

-- 병합 대기와 진행 중인 병합
SELECT table, elapsed, progress, num_parts, formatReadableSize(memory_usage) AS mem
FROM system.merges
WHERE database = 'otel';

-- 파트 수가 많으면 삽입이 거부되기 시작한다
SELECT table, count() AS parts
FROM system.parts
WHERE database = 'otel' AND active
GROUP BY table
ORDER BY parts DESC;

파트 수는 주시할 가치가 있습니다. 작은 삽입이 잦으면 파트가 폭증하고, 병합이 따라가지 못하면 삽입 자체가 거부됩니다. 익스포터의 배치 크기를 키우고 비동기 삽입을 켜는 것이 기본 대응입니다.

머티리얼라이즈드 뷰로 롤업

원본 트레이스를 30일 보존하는 것은 비쌉니다. 그런데 대부분의 대시보드 질의는 원본이 아니라 집계값을 필요로 합니다. 머티리얼라이즈드 뷰가 삽입 시점에 집계를 만들어 주면, 원본은 짧게 두고 집계는 길게 둘 수 있습니다.

-- 1) 집계 결과를 담을 테이블
CREATE TABLE otel.trace_rollup_1m
(
    Bucket      DateTime,
    ServiceName LowCardinality(String),
    SpanName    LowCardinality(String),
    SpanKind    LowCardinality(String),
    Calls       AggregateFunction(count),
    Errors      AggregateFunction(countIf, UInt8),
    DurationQ   AggregateFunction(quantiles(0.5, 0.9, 0.99), Float64),
    DurationSum AggregateFunction(sum, Float64)
)
ENGINE = AggregatingMergeTree
PARTITION BY toYYYYMM(Bucket)
ORDER BY (ServiceName, SpanName, SpanKind, Bucket)
TTL Bucket + toIntervalDay(400);

-- 2) 원본에 삽입될 때 자동으로 집계한다
CREATE MATERIALIZED VIEW otel.trace_rollup_1m_mv TO otel.trace_rollup_1m AS
SELECT
    toStartOfMinute(Timestamp) AS Bucket,
    ServiceName,
    SpanName,
    SpanKind,
    countState()                                   AS Calls,
    countIfState(StatusCode = 'Error')             AS Errors,
    quantilesState(0.5, 0.9, 0.99)(Duration / 1e6) AS DurationQ,
    sumState(Duration / 1e6)                       AS DurationSum
FROM otel.otel_traces
GROUP BY Bucket, ServiceName, SpanName, SpanKind;

-- 3) 조회할 때 상태를 합친다
SELECT
    ServiceName,
    SpanName,
    countMerge(Calls)                          AS calls,
    countIfMerge(Errors)                       AS errors,
    round(countIfMerge(Errors) / countMerge(Calls), 4) AS error_ratio,
    arrayElement(quantilesMerge(0.5, 0.9, 0.99)(DurationQ), 3) AS p99_ms
FROM otel.trace_rollup_1m
WHERE Bucket >= now() - INTERVAL 30 DAY
GROUP BY ServiceName, SpanName
ORDER BY calls DESC
LIMIT 30;

세 가지 함정이 있습니다.

첫째, 머티리얼라이즈드 뷰는 삽입 트리거입니다. 원본에 이미 들어 있는 데이터는 처리하지 않습니다. 뷰를 만든 뒤 과거 데이터를 채우려면 별도로 INSERT SELECT를 돌려야 합니다.

둘째, 원본과 뷰의 TTL이 독립적입니다. 원본을 7일로 줄이면서 뷰를 400일로 두는 것이 목적이었다면 정확히 그렇게 동작합니다. 다만 뷰의 정의를 바꾸면 그 시점 이전과 이후의 집계 의미가 달라지므로, 정의 변경은 새 뷰와 새 테이블을 만드는 편이 안전합니다.

셋째, 분위수는 상태로 저장해야 합니다. 1분 단위 p99를 그냥 숫자로 저장한 뒤 나중에 평균 내면 그것은 p99가 아닙니다. quantilesState 로 중간 상태를 저장하고 조회 시 quantilesMerge 로 합쳐야 여러 구간에 걸친 분위수가 근사적으로 성립합니다.

원본과 롤업의 보존 조합이 비용을 결정합니다.

데이터보존상대 크기답할 수 있는 질문
원본 스팬7~14일1.0개별 요청의 전체 경로, 임의 속성 필터
1분 롤업90~400일0.005 이하서비스별 추세, 배포 전후 비교, SLO 계산
오류 스팬만 별도 보존90일0.02드문 오류의 장기 패턴

컬렉터에서 ClickHouse 로 넣을 때

익스포터가 스키마를 자동 생성하게 두는 것은 개발 환경에서만 권합니다. 운영에서는 DDL을 직접 관리하고 자동 생성을 끕니다. 정렬 키와 TTL은 조직마다 달라야 하는데, 자동 생성된 스키마를 나중에 바꾸려면 테이블을 다시 만들어야 하기 때문입니다.

# otel-collector.yaml
exporters:
  clickhouse:
    endpoint: tcp://clickhouse.observability.svc:9000?dial_timeout=10s
    database: otel
    username: otel_writer
    password: ${env:CLICKHOUSE_PASSWORD}
    # 운영에서는 DDL 을 직접 관리한다
    create_schema: false
    logs_table_name: otel_logs
    traces_table_name: otel_traces
    compress: lz4
    async_insert: true
    timeout: 10s
    sending_queue:
      enabled: true
      num_consumers: 10
      queue_size: 10000
    retry_on_failure:
      enabled: true
      initial_interval: 5s
      max_elapsed_time: 300s

processors:
  # ClickHouse 는 큰 배치를 좋아한다. 작은 삽입이 잦으면 파트가 폭증한다
  batch:
    timeout: 10s
    send_batch_size: 20000
    send_batch_max_size: 50000

service:
  pipelines:
    traces:
      receivers: [otlp]
      processors: [memory_limiter, batch]
      exporters: [clickhouse]
    logs:
      receivers: [otlp]
      processors: [memory_limiter, batch]
      exporters: [clickhouse]

배치 크기가 핵심 설정입니다. ClickHouse는 한 번에 수만 행을 받는 것을 전제로 설계되었습니다. 초당 수백 번의 작은 삽입은 파트를 양산하고 병합 부하로 되돌아옵니다.

실제로 겪는 실패들을 모아 둡니다.

증상원인확인대응
삽입이 거부됨활성 파트 수 초과system.parts 의 파트 수배치 크기 확대, 비동기 삽입, 파티션 단위 재검토
특정 질의만 극단적으로 느림정렬 키를 타지 못하는 필터EXPLAIN 의 읽은 마크 수정렬 키 재설계 또는 보조 테이블
디스크가 예상보다 빨리 참TTL 이 파트 단위로 처리되지 않음파티션별 크기와 최저 시각ttl_only_drop_parts 확인, 파티션 단위 점검
메모리 초과로 질의 실패GROUP BY 카디널리티 과다질의 로그의 메모리 사용량사전 집계 뷰 사용, 질의별 메모리 상한
오래된 데이터 조회가 매우 느림객체 스토리지 계층으로 이동됨스토리지 정책과 파트 위치사용자에게 계층 구조 안내, 롤업 테이블 유도
로그와 트레이스 상관이 안 됨TraceId 포맷 불일치양쪽 샘플 비교컬렉터에서 표기 통일

질의가 정렬 키를 타는지는 EXPLAIN 으로 확인합니다.

EXPLAIN indexes = 1
SELECT count()
FROM otel.otel_traces
WHERE ServiceName = 'checkout-api'
  AND Timestamp >= now() - INTERVAL 1 HOUR;
-- 읽은 마크 수가 전체의 극히 일부여야 한다

Prometheus, OpenSearch, ClickHouse 의 역할 나누기

셋을 다 쓰는 것이 낭비처럼 보이지만, 각자 다른 질문에 답합니다. 하나로 통합하려는 시도는 대개 그중 하나의 강점을 잃는 것으로 끝납니다.

PrometheusOpenSearchClickHouse
데이터 모델시계열, 레이블 집합역인덱스 문서컬럼 테이블
가장 잘하는 것초 단위 집계, 알림 평가전문 검색, 임의 필드 조회대량 스캔, 임의 집계, 조인
카디널리티취약, 예산 관리 필수필드 수에 취약상대적으로 관대
보존수개월 (다운샘플링 필요)수주 (비용 제약)수개월에서 수년
지연초 단위초 단위초에서 분 단위
적합한 질문지금 나쁜가, 알림을 울릴까이 요청은 왜 실패했나30일 동안 어떤 패턴이 있었나

현실적인 배치는 이렇습니다.

  • 알림과 SLO 평가는 Prometheus입니다. 초 단위 평가와 저비용 질의가 필요하고, 이 영역에서 다른 도구로 대체할 이유가 거의 없습니다.
  • 최근 로그의 전문 검색은 OpenSearch입니다. "이 오류 메시지가 포함된 로그"처럼 텍스트를 찾는 질의는 역인덱스가 압도적으로 유리합니다. 대신 보존을 짧게 갑니다.
  • 장기 보존과 임의 분석은 ClickHouse입니다. 트레이스 원본, 로그 원본, 롤업이 모두 여기에 있고, 조인해서 답하는 질문을 담당합니다.

셋을 함께 쓸 때 반드시 지킬 것은 식별자의 일관성입니다. service.name, trace_id, deployment.environment.name 이 세 시스템에서 같은 값이어야 도구 사이를 건너다닐 수 있습니다. 컬렉터에서 한 번 정규화하고, 각 익스포터가 그 값을 그대로 쓰게 합니다.

processors:
  transform/normalize:
    error_mode: ignore
    trace_statements:
      - context: resource
        statements:
          # 예전 이름으로 들어온 것을 현재 규약으로 통일한다
          - set(attributes["deployment.environment.name"], attributes["deployment.environment"])
            where attributes["deployment.environment.name"] == nil
                  and attributes["deployment.environment"] != nil
          - delete_key(attributes, "deployment.environment")

마치며 — 스키마 결정은 나중에 되돌리기 어렵다

ClickHouse에서 되돌리기 쉬운 것과 어려운 것이 명확히 갈립니다. 인덱스 추가, TTL 변경, 머티리얼라이즈드 뷰 추가는 운영 중에 할 수 있습니다. 정렬 키 변경과 파티션 키 변경은 사실상 테이블 재생성입니다.

그래서 도입 순서는 이렇게 됩니다. 먼저 어떤 질의가 하루에 수천 번 실행될지를 적습니다. 그 질의의 필터 조건이 정렬 키의 앞부분이 되어야 합니다. 그다음 보존 기간을 원본과 롤업으로 나눠 정합니다. 마지막으로 속성 저장 방식을 고릅니다. 이 세 가지만 초기에 제대로 정해 두면 나머지는 나중에 고칠 수 있습니다.

지금 할 수 있는 점검은 컬럼별 압축비를 뽑아 보는 것입니다. 압축이 안 되는 컬럼이 저장 비용의 절반을 차지하고 있다면, 그 컬럼이 정말 필요한지부터 다시 묻습니다.

더 파고들 자료입니다.

Putting Observability Data Into ClickHouse — Schema, Rollups, TTL, and Splitting the Work

Introduction — When You Need to Scan 30 Days of Traces to Answer a Question

"I want to see the tenant distribution of requests to a specific payment processor over the last 30 days that took longer than 3 seconds."

When a question like this comes in, most observability stacks choke. Prometheus doesn't know about individual requests. The trace backend only retains 7 days. If you stored it in a search engine, the cluster wobbles while it scans 30 days' worth.

Once observability data reaches multiple terabytes a day and questions like this start recurring, you need an analytical database. ClickHouse shows up in this spot often because the nature of observability data suits columnar storage so well.

This post designs a real schema. Verified against ClickHouse 26.5 stable; if you want a long-term-stable line, the 26.3 LTS series is the alternative. 25.8 LTS support ends in late August 2026, so it isn't recommended for new builds. The native JSON type became production-ready in 25.3.

Why Columnar Storage Fits Observability Data

Three things line up.

First, queries are narrow. Even if a trace table has 25 columns, a query for "p99 by service" only reads two of them: service name and duration. Row storage has to read all 25 columns off disk; columnar storage reads only two.

Second, values repeat. There are dozens of service names, hundreds of span names, and around ten status codes. When the same value runs in a row, compression gets extremely good. Timestamps shrink via delta encoding, and low-cardinality strings shrink via dictionary encoding.

Third, writes are append-only. Observability data is never updated. This matches exactly the workload MergeTree assumes.

The fastest way to feel the compression ratio is to measure it yourself.

SELECT
    table,
    formatReadableSize(sum(data_uncompressed_bytes)) AS raw,
    formatReadableSize(sum(data_compressed_bytes))   AS compressed,
    round(sum(data_uncompressed_bytes) / sum(data_compressed_bytes), 1) AS ratio
FROM system.columns
WHERE database = 'otel'
GROUP BY table
ORDER BY sum(data_compressed_bytes) DESC;

-- You can also look at it column by column. Columns that don't compress well make up most of the cost
SELECT
    name,
    type,
    formatReadableSize(data_compressed_bytes) AS compressed,
    round(data_uncompressed_bytes / data_compressed_bytes, 1) AS ratio
FROM system.columns
WHERE database = 'otel' AND table = 'otel_traces'
ORDER BY data_compressed_bytes DESC
LIMIT 15;

If a column has a compression ratio close to 1, there are usually two causes: random strings (trace IDs, UUIDs), or free-form text. Trace IDs can't be helped, but free text can be split into a separate column where you have room to apply a different codec.

Trace Schema — The Sort Key Is Everything

The single most important decision in MergeTree is ORDER BY. Data is sorted on disk in this order, and queries use that order to skip blocks they don't need to read. Get the sort key wrong, and nothing else can make up for it.

The default table the OpenTelemetry ClickHouse exporter creates sorts traces by service name, span name, then timestamp. That's a choice optimized for queries that "scan by service and operation."

CREATE TABLE otel.otel_traces
(
    Timestamp          DateTime64(9) CODEC(Delta(8), ZSTD(1)),
    TraceId            String CODEC(ZSTD(1)),
    SpanId             String CODEC(ZSTD(1)),
    ParentSpanId       String CODEC(ZSTD(1)),
    TraceState         String CODEC(ZSTD(1)),
    SpanName           LowCardinality(String) CODEC(ZSTD(1)),
    SpanKind           LowCardinality(String) CODEC(ZSTD(1)),
    ServiceName        LowCardinality(String) CODEC(ZSTD(1)),
    ResourceAttributes Map(LowCardinality(String), String) CODEC(ZSTD(1)),
    ScopeName          String CODEC(ZSTD(1)),
    ScopeVersion       String CODEC(ZSTD(1)),
    SpanAttributes     Map(LowCardinality(String), String) CODEC(ZSTD(1)),
    Duration           UInt64 CODEC(ZSTD(1)),
    StatusCode         LowCardinality(String) CODEC(ZSTD(1)),
    StatusMessage      String CODEC(ZSTD(1)),

    Events Nested (
        Timestamp  DateTime64(9),
        Name       LowCardinality(String),
        Attributes Map(LowCardinality(String), String)
    ) CODEC(ZSTD(1)),

    Links Nested (
        TraceId    String,
        SpanId     String,
        TraceState String,
        Attributes Map(LowCardinality(String), String)
    ) CODEC(ZSTD(1)),

    INDEX idx_trace_id       TraceId TYPE bloom_filter(0.001) GRANULARITY 1,
    INDEX idx_res_attr_key   mapKeys(ResourceAttributes)   TYPE bloom_filter(0.01) GRANULARITY 1,
    INDEX idx_res_attr_value mapValues(ResourceAttributes) TYPE bloom_filter(0.01) GRANULARITY 1,
    INDEX idx_span_attr_key  mapKeys(SpanAttributes)       TYPE bloom_filter(0.01) GRANULARITY 1,
    INDEX idx_duration       Duration TYPE minmax GRANULARITY 1
)
ENGINE = MergeTree
PARTITION BY toDate(Timestamp)
ORDER BY (ServiceName, SpanName, toDateTime(Timestamp))
TTL toDateTime(Timestamp) + toIntervalDay(30)
SETTINGS index_granularity = 8192, ttl_only_drop_parts = 1;

Three things are worth watching closely.

Where to use LowCardinality(String). Use it only on strings with roughly fewer than 10,000 distinct values. A dictionary gets built, which speeds up both compression and filtering. Attach it to a column like TraceId, where values are essentially unique, and the dictionary grows without bound and makes things worse instead.

The role of the bloom filter index. Since the sort key is service and span name, a query that looks up a single trace ID can't use the sort order. The bloom filter quickly rules out "this trace ID isn't in this block," cutting down the blocks that need to be read. But this is a secondary aid — it can't substitute for the sort key.

ttl_only_drop_parts = 1. This processes TTL expiry at the part level, not the row level. Since partitions are per-day, an entire part for an expired day disappears at once. Without this setting, TTL cleanup triggers part rewrites, which drives disk I/O way up.

If you want to change the sort key, start by looking at which queries dominate.

Dominant queryRecommended ORDER BYTrade-off
Per-service latency analysis(ServiceName, SpanName, Timestamp)Single trace-ID lookups depend on the bloom filter
Single trace-ID lookups dominate(TraceId) or a separate lookup tableTime-range scans become inefficient
Mostly per-tenant analysis(TenantId, ServiceName, Timestamp)The tenant column must be promoted to a physical column
Mostly recent-window exploration(toStartOfHour(Timestamp), ServiceName)Filtering older ranges is less efficient

If both access patterns matter, create a second table. You pay double the storage cost, but both queries get fast. This is a common choice in ClickHouse.

Log Schema — Should Attributes Be a Map or a JSON Type?

The sort key for a log table is different. Since queries that narrow by time range and service dominate for logs, put time first, but don't slice it too finely.

CREATE TABLE otel.otel_logs
(
    Timestamp          DateTime64(9) CODEC(Delta(8), ZSTD(1)),
    TraceId            String CODEC(ZSTD(1)),
    SpanId             String CODEC(ZSTD(1)),
    TraceFlags         UInt8,
    SeverityText       LowCardinality(String) CODEC(ZSTD(1)),
    SeverityNumber     UInt8,
    ServiceName        LowCardinality(String) CODEC(ZSTD(1)),
    Body               String CODEC(ZSTD(1)),
    ResourceAttributes Map(LowCardinality(String), String) CODEC(ZSTD(1)),
    LogAttributes      Map(LowCardinality(String), String) CODEC(ZSTD(1)),

    -- Promote frequently filtered keys to physical columns
    HttpRoute    LowCardinality(String) MATERIALIZED LogAttributes['http.route'],
    HttpStatus   UInt16                 MATERIALIZED toUInt16OrZero(LogAttributes['http.response.status_code']),
    ErrorType    LowCardinality(String) MATERIALIZED LogAttributes['error.type'],

    INDEX idx_trace_id  TraceId    TYPE bloom_filter(0.001) GRANULARITY 1,
    INDEX idx_body      Body       TYPE tokenbf_v1(32768, 3, 0) GRANULARITY 1,
    INDEX idx_severity  SeverityNumber TYPE set(16) GRANULARITY 4
)
ENGINE = MergeTree
PARTITION BY toDate(Timestamp)
ORDER BY (ServiceName, toStartOfFiveMinutes(Timestamp), Timestamp)
TTL toDateTime(Timestamp) + toIntervalDay(30)
SETTINGS index_granularity = 8192, ttl_only_drop_parts = 1;

The MATERIALIZED column is an important device here. At insert time, it pulls a value out of the Map and stores it as a separate column. A Map lookup has to search for the key every time, but a physical column reads directly, so frequently used filters get much faster. Storage cost goes up, but low-cardinality columns compress well, so the actual increase is small.

There are two options for how to store attributes.

ItemMap(String, String)JSON type
Type preservationEverything flattened to stringsOriginal types preserved
Amount read per queryReads the whole Map even for one keyReads only the relevant sub-column
Schema changeFree-formFree-form, sub-columns auto-created
Many sub-keysCompression and queries both degradeControlled via a sub-column cap
Tooling compatibilityWorks everywhereRequires 25.3+
MigrationBaselineExisting tables need rewriting

Map's biggest weakness is that it can't be partially read. Even to read a single LogAttributes['http.route'], you have to read the entire Map for that row off disk and decompress it. For a log with 40 attributes, that cost is significant. The JSON type stores each sub-key like its own separate column, so it doesn't have this problem.

-- Example JSON type usage — cap the number of sub-columns
CREATE TABLE otel.otel_logs_json
(
    Timestamp    DateTime64(9) CODEC(Delta(8), ZSTD(1)),
    ServiceName  LowCardinality(String) CODEC(ZSTD(1)),
    SeverityText LowCardinality(String) CODEC(ZSTD(1)),
    TraceId      String CODEC(ZSTD(1)),
    Body         String CODEC(ZSTD(1)),
    Attributes   JSON(max_dynamic_paths = 512) CODEC(ZSTD(1))
)
ENGINE = MergeTree
PARTITION BY toDate(Timestamp)
ORDER BY (ServiceName, toStartOfFiveMinutes(Timestamp), Timestamp)
TTL toDateTime(Timestamp) + toIntervalDay(30)
SETTINGS ttl_only_drop_parts = 1;

-- Accessing a sub-path with an explicit type lets indexes and statistics kick in
SELECT
    ServiceName,
    Attributes.http.route::LowCardinality(String) AS route,
    count() AS c,
    quantile(0.99)(Attributes.duration_ms::Float64) AS p99
FROM otel.otel_logs_json
WHERE Timestamp >= now() - INTERVAL 1 HOUR
  AND SeverityText = 'ERROR'
GROUP BY ServiceName, route
ORDER BY c DESC
LIMIT 20;

max_dynamic_paths plays the same role as a field-count cap in a search engine. Paths beyond the cap get pushed into a separate shared store — queries get slower, but the cluster doesn't collapse. It structurally mitigates incidents like mapping explosions.

The decision rule is simple. If attribute keys are mostly predictable and few in number, a Map is enough. If the key set differs by service and keeps growing, the JSON type is better.

Partitioning, TTL, and Tiered Storage

A daily partition is the default. There's a temptation to slice it finer, but it's better to resist. More partitions means more parts, and more parts means heavier merge load and metadata overhead. If daily data runs into multiple terabytes, consider hourly partitions, but check first whether TTL and the sort key already solve the problem.

TTL is used for moving data, not just deleting it. Keep recent data on fast disks, and older data on slower, cheaper storage.

-- Define a storage policy (in config.xml or a separate config file)
-- hot: local NVMe, cold: object storage

ALTER TABLE otel.otel_traces
MODIFY TTL
    toDateTime(Timestamp) + INTERVAL 3 DAY  TO VOLUME 'hot',
    toDateTime(Timestamp) + INTERVAL 14 DAY TO VOLUME 'cold',
    toDateTime(Timestamp) + INTERVAL 90 DAY DELETE;

There's one thing you must confirm when using move TTL. Queries against data moved to object storage incur a network round trip and get much slower. You need to tell users up front that "90-day retention" doesn't mean "queryable at the same speed for 90 days." Otherwise, someday someone runs a full scan over data from 60 days ago and paralyzes the cluster.

Also verify that TTL is actually running.

-- Size per partition and the oldest data
SELECT
    table,
    partition,
    formatReadableSize(sum(bytes_on_disk)) AS size,
    sum(rows) AS rows,
    min(min_time) AS oldest
FROM system.parts
WHERE database = 'otel' AND active
GROUP BY table, partition
ORDER BY partition
LIMIT 10;

-- Merges pending and in progress
SELECT table, elapsed, progress, num_parts, formatReadableSize(memory_usage) AS mem
FROM system.merges
WHERE database = 'otel';

-- If the part count is high, inserts start getting rejected
SELECT table, count() AS parts
FROM system.parts
WHERE database = 'otel' AND active
GROUP BY table
ORDER BY parts DESC;

Part count is worth watching. Frequent small inserts make parts explode, and if merges can't keep up, inserts themselves get rejected. The standard response is to increase the exporter's batch size and turn on async inserts.

Rollups With Materialized Views

Retaining raw traces for 30 days is expensive. But most dashboard queries need aggregates, not raw data. If a materialized view builds the aggregate at insert time, you can keep the raw data short-lived and the aggregate long-lived.

-- 1) The table that will hold the aggregated results
CREATE TABLE otel.trace_rollup_1m
(
    Bucket      DateTime,
    ServiceName LowCardinality(String),
    SpanName    LowCardinality(String),
    SpanKind    LowCardinality(String),
    Calls       AggregateFunction(count),
    Errors      AggregateFunction(countIf, UInt8),
    DurationQ   AggregateFunction(quantiles(0.5, 0.9, 0.99), Float64),
    DurationSum AggregateFunction(sum, Float64)
)
ENGINE = AggregatingMergeTree
PARTITION BY toYYYYMM(Bucket)
ORDER BY (ServiceName, SpanName, SpanKind, Bucket)
TTL Bucket + toIntervalDay(400);

-- 2) Automatically aggregate as data is inserted into the source table
CREATE MATERIALIZED VIEW otel.trace_rollup_1m_mv TO otel.trace_rollup_1m AS
SELECT
    toStartOfMinute(Timestamp) AS Bucket,
    ServiceName,
    SpanName,
    SpanKind,
    countState()                                   AS Calls,
    countIfState(StatusCode = 'Error')             AS Errors,
    quantilesState(0.5, 0.9, 0.99)(Duration / 1e6) AS DurationQ,
    sumState(Duration / 1e6)                       AS DurationSum
FROM otel.otel_traces
GROUP BY Bucket, ServiceName, SpanName, SpanKind;

-- 3) Merge the states together at query time
SELECT
    ServiceName,
    SpanName,
    countMerge(Calls)                          AS calls,
    countIfMerge(Errors)                       AS errors,
    round(countIfMerge(Errors) / countMerge(Calls), 4) AS error_ratio,
    arrayElement(quantilesMerge(0.5, 0.9, 0.99)(DurationQ), 3) AS p99_ms
FROM otel.trace_rollup_1m
WHERE Bucket >= now() - INTERVAL 30 DAY
GROUP BY ServiceName, SpanName
ORDER BY calls DESC
LIMIT 30;

There are three traps here.

First, a materialized view is an insert trigger. It doesn't process data already sitting in the source table. If you want to backfill historical data after creating the view, you have to run a separate INSERT SELECT.

Second, the source's and the view's TTLs are independent. If your goal was to shrink the source to 7 days while keeping the view at 400, that's exactly what happens. But if you change the view's definition, the meaning of the aggregate before and after that point diverges, so it's safer to create a new view and a new table when changing the definition.

Third, quantiles must be stored as state. If you store a per-minute p99 as a plain number and then average it later, that isn't a p99 anymore. You have to store the intermediate state with quantilesState and merge it at query time with quantilesMerge for the quantile across multiple buckets to hold approximately.

The combination of source and rollup retention determines cost.

DataRetentionRelative sizeQuestions it can answer
Raw spans7–14 days1.0Full path of an individual request, arbitrary attribute filters
1-minute rollup90–400 days0.005 or lessPer-service trends, before/after deploy comparisons, SLO computation
Error spans retained separately90 days0.02Long-term patterns of rare errors

Sending From the Collector Into ClickHouse

Letting the exporter auto-create the schema is recommended only for development. In production, manage the DDL yourself and turn off auto-creation. The sort key and TTL need to differ by organization, and changing an auto-created schema later means recreating the table.

# otel-collector.yaml
exporters:
  clickhouse:
    endpoint: tcp://clickhouse.observability.svc:9000?dial_timeout=10s
    database: otel
    username: otel_writer
    password: ${env:CLICKHOUSE_PASSWORD}
    # Manage the DDL yourself in production
    create_schema: false
    logs_table_name: otel_logs
    traces_table_name: otel_traces
    compress: lz4
    async_insert: true
    timeout: 10s
    sending_queue:
      enabled: true
      num_consumers: 10
      queue_size: 10000
    retry_on_failure:
      enabled: true
      initial_interval: 5s
      max_elapsed_time: 300s

processors:
  # ClickHouse likes large batches. Frequent small inserts make parts explode
  batch:
    timeout: 10s
    send_batch_size: 20000
    send_batch_max_size: 50000

service:
  pipelines:
    traces:
      receivers: [otlp]
      processors: [memory_limiter, batch]
      exporters: [clickhouse]
    logs:
      receivers: [otlp]
      processors: [memory_limiter, batch]
      exporters: [clickhouse]

Batch size is the key setting. ClickHouse is designed on the assumption that it receives tens of thousands of rows at a time. Hundreds of small inserts per second mass-produce parts and come back as merge load.

Here's a collection of failures you'll actually run into.

SymptomCauseCheckResponse
Inserts get rejectedActive part count exceededPart count in system.partsIncrease batch size, enable async insert, revisit partitioning
One specific query is extremely slowA filter that doesn't hit the sort keyMarks read in EXPLAINRedesign the sort key or add an auxiliary table
Disk fills up faster than expectedTTL isn't being processed at the part levelPer-partition size and earliest timestampCheck ttl_only_drop_parts, review partitioning
Query fails from memory overrunExcessive GROUP BY cardinalityMemory usage in the query logUse a pre-aggregated view, cap memory per query
Querying old data is very slowMoved to the object-storage tierStorage policy and part locationExplain the tiering to users, steer them to the rollup table
Logs and traces don't correlateTraceId format mismatchCompare samples from both sidesNormalize the representation in the collector

Check whether a query hits the sort key with EXPLAIN.

EXPLAIN indexes = 1
SELECT count()
FROM otel.otel_traces
WHERE ServiceName = 'checkout-api'
  AND Timestamp >= now() - INTERVAL 1 HOUR;
-- The number of marks read should be a tiny fraction of the total

Dividing Roles Among Prometheus, OpenSearch, and ClickHouse

Running all three looks wasteful, but each answers a different question. Attempts to consolidate into one usually end up losing the strength of one of them.

AxisPrometheusOpenSearchClickHouse
Data modelTime series, label setsInverted-index documentsColumnar tables
Best atSecond-level aggregation, alert evaluationFull-text search, arbitrary field lookupsLarge scans, arbitrary aggregation, joins
CardinalityFragile, budgeting requiredFragile to field countRelatively forgiving
RetentionMonths (needs downsampling)Weeks (cost-constrained)Months to years
LatencySecondsSecondsSeconds to minutes
Right question for itIs it bad right now, should we pageWhy did this request failWhat patterns showed up over 30 days

A realistic deployment looks like this.

  • Alerting and SLO evaluation belong to Prometheus. It needs second-level evaluation and low-cost queries, and there's little reason to swap in another tool for this.
  • Full-text search over recent logs belongs to OpenSearch. For queries that search text, like "logs containing this error message," an inverted index is overwhelmingly favorable. In exchange, keep retention short.
  • Long-term retention and arbitrary analysis belong to ClickHouse. Raw traces, raw logs, and rollups all live here, and it handles questions answered by joining them.

When you run all three together, identifier consistency is non-negotiable. service.name, trace_id, and deployment.environment.name must hold the same value across all three systems, or you can't hop between tools. Normalize once in the collector, and have each exporter use that value as-is.

processors:
  transform/normalize:
    error_mode: ignore
    trace_statements:
      - context: resource
        statements:
          # Unify anything that came in under the old name to the current convention
          - set(attributes["deployment.environment.name"], attributes["deployment.environment"])
            where attributes["deployment.environment.name"] == nil
                  and attributes["deployment.environment"] != nil
          - delete_key(attributes, "deployment.environment")

Closing — Schema Decisions Are Hard to Reverse Later

In ClickHouse, what's easy to reverse and what's hard splits cleanly. Adding an index, changing TTL, adding a materialized view — you can do these while running. Changing the sort key or the partition key is effectively recreating the table.

So the adoption order looks like this. First, write down which queries will run thousands of times a day. Those queries' filter conditions should become the front of the sort key. Next, split retention between raw data and rollups. Finally, choose how to store attributes. Get just these three right early on, and everything else can be fixed later.

A check you can run right now is pulling the per-column compression ratio. If a column that doesn't compress is eating up half your storage cost, start by asking again whether you really need that column.

Further reading.