Skip to content
Published on

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

공유하기
Authors

들어가며 — 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 변경, 머티리얼라이즈드 뷰 추가는 운영 중에 할 수 있습니다. 정렬 키 변경과 파티션 키 변경은 사실상 테이블 재생성입니다.

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

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

더 파고들 자료입니다.