이 가이드는 ClickStack에서 가장 일반적이면서도 효과적인 성능 최적화 방법에 중점을 두며, 일반적으로 하루 수십 테라바이트 규모까지의 실제 관측성 워크로드 대부분을 최적화하는 데 충분합니다.
이러한 최적화는 의도된 순서에 따라, 가장 간단하면서 효과가 큰 기법부터 시작해 더 고급스럽고 특화된 튜닝으로 나아가도록 구성되어 있습니다. 초기 최적화는 먼저 적용해야 하며, 이들만으로도 상당한 성능 향상을 얻는 경우가 많습니다. 데이터 규모가 커지고 워크로드 요구 사항이 높아질수록, 뒤에 나오는 기법을 검토할 가치도 점점 커집니다.
이 가이드에서 설명하는 최적화를 적용하기 전에 몇 가지 핵심 ClickHouse 개념을 이해하는 것이 중요합니다.
ClickStack에서는 각 데이터 소스가 하나 이상의 ClickHouse 테이블에 직접 매핑됩니다. OpenTelemetry를 사용하는 경우 ClickStack은 로그, 트레이스, 메트릭 데이터를 저장하는 기본 테이블 집합을 생성하고 관리합니다. 사용자 지정 스키마를 사용하거나 직접 테이블을 관리하고 있다면 이러한 개념에 이미 익숙할 수 있습니다. 하지만 OpenTelemetry Collector를 통해 데이터를 전송하기만 하는 경우 이러한 테이블은 자동으로 생성되며, 아래에서 설명하는 모든 최적화는 이 테이블에 적용됩니다.
ClickHouse에서 테이블은 데이터베이스에 속합니다. 기본적으로 default 데이터베이스가 사용되며, 이는 OpenTelemetry collector에서 변경할 수 있습니다.
로그와 트레이스에 집중대부분의 경우 성능 튜닝은 로그 및 트레이스 테이블에 집중합니다. 메트릭 테이블도 필터링에 맞게 최적화할 수 있지만, 해당 스키마는 Prometheus 스타일 워크로드에 맞춰 의도적으로 설계되어 있으므로 일반적인 차트 작성에서는 보통 수정할 필요가 없습니다. 반면 로그와 트레이스는 더 다양한 액세스 패턴을 지원하므로 튜닝 효과가 가장 큽니다. 세션 데이터는 사용자 경험이 고정되어 있어 스키마를 수정해야 하는 경우가 드뭅니다.
최소한 다음 ClickHouse 기본 개념은 이해해야 합니다.
| 개념 | 설명 |
|---|
| 테이블 | ClickStack의 데이터 소스가 기반이 되는 ClickHouse 테이블과 어떻게 대응되는지 설명합니다. ClickHouse의 테이블은 주로 MergeTree 엔진을 사용합니다. |
| 파트 | 데이터가 변경 불가능한 파트로 기록되고 시간이 지나면서 머지되는 방식입니다. |
| 파티션 | 파티션은 테이블의 데이터 파트를 정리된 논리 단위로 그룹화합니다. 이러한 단위는 관리, 쿼리, 최적화가 더 쉽습니다. |
| 머지 | 쿼리해야 하는 파트 수를 줄이기 위해 파트들을 함께 머지하는 내부 프로세스입니다. 쿼리 성능을 유지하는 데 필수적입니다. |
| 그래뉼 | 쿼리 실행 중 ClickHouse가 읽고 가지치기하는 가장 작은 데이터 단위입니다. |
| 프라이머리(정렬) 키 | ORDER BY 키가 디스크상의 데이터 레이아웃, 압축, 쿼리 가지치기를 어떻게 결정하는지 설명합니다. |
이러한 개념은 ClickHouse 성능의 핵심입니다. 데이터가 어떻게 기록되는지, 디스크에 어떻게 구조화되는지, 그리고 ClickHouse가 쿼리 시점에 데이터를 얼마나 효율적으로 건너뛸 수 있는지를 결정합니다. 이 가이드의 모든 최적화—materialized 컬럼, 스킵 인덱스, 프라이머리 키, 프로젝션, materialized view—는 이러한 핵심 메커니즘을 기반으로 합니다.
튜닝을 수행하기 전에 다음 ClickHouse 문서를 검토하는 것을 권장합니다.
아래에 설명된 모든 최적화는 표준 ClickHouse SQL을 사용해 기본 테이블에 직접 적용할 수 있으며, ClickHouse Cloud SQL 콘솔 또는 ClickHouse client를 통해 적용할 수 있습니다.
ClickStack 사용자를 위한 첫 번째이자 가장 간단한 최적화는 LogAttributes, ScopeAttributes, ResourceAttributes에서 자주 쿼리되는 속성을 식별하고, materialized 컬럼을 사용해 이를 최상위 컬럼으로 승격하는 것입니다.
이 최적화만으로도 ClickStack 배포를 하루 수십 테라바이트 규모까지 확장하기에 충분한 경우가 많으며, 더 고급 튜닝 기법을 검토하기 전에 먼저 적용해야 합니다.
ClickStack는 Kubernetes 레이블, 서비스 메타데이터, 사용자 정의 속성과 같은 메타데이터를 Map(String, String) 컬럼에 저장합니다. 이는 유연성을 제공하지만, 맵의 하위 키를 쿼리할 때 성능에 중요한 영향을 미칩니다.
맵 컬럼에서 단일 키를 쿼리할 때 ClickHouse는 디스크에서 맵 컬럼 전체를 읽어야 합니다. 맵에 키가 많이 포함되어 있으면 별도의 전용 컬럼을 읽는 경우와 비교해 불필요한 IO가 발생하고 쿼리도 더 느려집니다.
자주 조회하는 속성을 구체화하면 삽입 시점에 값을 추출해 별도의 컬럼으로 저장하므로 이러한 오버헤드를 피할 수 있습니다.
materialized 컬럼:
- 삽입 시 자동으로 계산됩니다
- INSERT 문에서 명시적으로 설정할 수 없습니다
- 모든 ClickHouse 표현식을 지원합니다
- String에서 더 효율적인 숫자 또는 날짜 타입으로 변환할 수 있습니다
- 스킵 인덱스와 프라이머리 키를 활용할 수 있습니다
- 맵 전체를 읽지 않아도 되므로 디스크 읽기를 줄입니다
ClickStack는 맵에서 추출한 materialized 컬럼을 자동으로 감지하며, 사용자가 계속 원래 속성 경로로 쿼리하더라도 쿼리 실행 중 이를 투명하게 사용합니다.
Kubernetes 메타데이터가 ResourceAttributes에 저장되는 기본 ClickStack 트레이스 스키마를 살펴보겠습니다:
CREATE TABLE IF NOT EXISTS 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.Timestamp` Array(DateTime64(9)) CODEC(ZSTD(1)),
`Events.Name` Array(LowCardinality(String)) CODEC(ZSTD(1)),
`Events.Attributes` Array(Map(LowCardinality(String), String)) CODEC(ZSTD(1)),
`Links.TraceId` Array(String) CODEC(ZSTD(1)),
`Links.SpanId` Array(String) CODEC(ZSTD(1)),
`Links.TraceState` Array(String) CODEC(ZSTD(1)),
`Links.Attributes` Array(Map(LowCardinality(String), String)) CODEC(ZSTD(1)),
`__hdx_materialized_rum.sessionId` String MATERIALIZED ResourceAttributes['rum.sessionId'] CODEC(ZSTD(1)),
INDEX idx_trace_id TraceId TYPE bloom_filter(0.001) GRANULARITY 1,
INDEX idx_rum_session_id __hdx_materialized_rum.sessionId 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_span_attr_value mapValues(SpanAttributes) TYPE bloom_filter(0.01) GRANULARITY 1,
INDEX idx_duration Duration TYPE minmax GRANULARITY 1,
INDEX idx_lower_span_name lower(SpanName) TYPE tokenbf_v1(32768, 3, 0) GRANULARITY 8
)
ENGINE = MergeTree
PARTITION BY toDate(Timestamp)
ORDER BY (ServiceName, SpanName, toDateTime(Timestamp))
TTL toDate(Timestamp) + toIntervalDay(30)
SETTINGS index_granularity = 8192, ttl_only_drop_parts = 1;
Lucene 구문을 사용해 트레이스를 필터링할 수 있습니다. 예: ResourceAttributes.k8s.pod.name:"checkout-675775c4cc-f2p9c":
그러면 다음과 유사한 SQL 프레디케이트가 생성됩니다:
ResourceAttributes['k8s.pod.name'] = 'checkout-675775c4cc-f2p9c'
맵 키에 접근하므로 ClickHouse는 일치하는 각 행에 대해 ResourceAttributes 컬럼 전체를 읽어야 합니다. 맵에 키가 많으면 이 컬럼의 크기가 매우 커질 수 있습니다.
이 속성을 자주 조회한다면 최상위 컬럼으로 구체화하는 것이 좋습니다.
삽입 시점에 파드 이름을 추출하려면 구체화된 컬럼을 추가하십시오:
ALTER TABLE otel_v2.otel_traces
ADD COLUMN PodName String
MATERIALIZED ResourceAttributes['k8s.pod.name']
이제부터 새로운 데이터는 파드 이름을 전용 컬럼인 PodName에 저장합니다.
이제 Lucene 구문을 사용해, 예를 들어 PodName:"checkout-675775c4cc-f2p9c"와 같이 파드 이름을 효율적으로 조회할 수 있습니다.
새로 삽입되는 데이터에서는 이렇게 하면 맵에 전혀 접근하지 않아도 되므로 I/O가 크게 줄어듭니다.
하지만 사용자가 원래 속성 경로(예: ResourceAttributes.k8s.pod.name:"checkout-675775c4cc-f2p9c")로 계속 쿼리하더라도, ClickStack은 내부적으로 쿼리를 자동으로 재작성하여 구체화된 PodName 컬럼을 사용합니다. 즉, 다음 프레디케이트를 사용합니다:
PodName = 'checkout-675775c4cc-f2p9c'
이렇게 하면 대시보드, 알림, 저장된 쿼리를 변경하지 않아도 사용자가 이 최적화의 이점을 누릴 수 있습니다.
기본적으로 materialized 컬럼은 SELECT * 쿼리에서 제외됩니다. 이렇게 하면 쿼리 결과를 항상 테이블에 다시 삽입할 수 있다는 원칙이 유지됩니다.
materialized 컬럼은 해당 컬럼이 생성된 후 삽입된 데이터에만 자동으로 적용됩니다. 기존 데이터의 경우 materialized 컬럼을 조회하는 쿼리는 자동으로 원본 맵을 읽도록 처리됩니다.
과거 데이터에 대한 성능이 중요하다면 mutation을 사용해 해당 컬럼을 백필할 수 있습니다. 예:
ALTER TABLE otel_v2.otel_traces
MATERIALIZE COLUMN PodName
이 작업은 컬럼을 채우기 위해 기존 파트를 재작성합니다. 뮤테이션은 각 파트에서 단일 스레드로 수행되므로, 대용량 데이터셋에서는 시간이 걸릴 수 있습니다. 영향을 줄이려면 뮤테이션 범위를 특정 파티션으로 한정할 수 있습니다:
ALTER TABLE otel_v2.otel_traces
MATERIALIZE COLUMN PodName
IN PARTITION '2026-01-02'
뮤테이션 진행 상황은 system.mutations 테이블을 사용해 모니터링할 수 있습니다. 예:
SELECT *
FROM system.mutations
WHERE database = 'otel'
AND table = 'otel_traces'
ORDER BY create_time DESC;
해당 뮤테이션의 is_done = 1이 될 때까지 기다리십시오.
뮤테이션은 추가적인 IO 및 CPU 오버헤드를 발생시키므로 꼭 필요한 경우에만 사용하는 것이 좋습니다. 많은 경우에는 오래된 데이터가 자연스럽게 만료되도록 두고, 새로 수집된 데이터에 대한 성능 개선만 활용해도 충분합니다.
자주 쿼리되는 속성을 구체화한 다음 단계의 최적화는, 쿼리 실행 중 ClickHouse가 읽어야 하는 데이터 양을 더 줄이기 위해 데이터 스키핑 인덱스를 추가하는 것입니다.
스킵 인덱스를 사용하면 일치하는 값이 없다고 판단할 수 있을 때 ClickHouse가 데이터 블록 전체를 스캔하지 않아도 됩니다. 기존의 보조 인덱스와 달리 스킵 인덱스는 granule 단위로 동작하며, 쿼리 필터가 데이터셋의 큰 부분을 제외할 때 가장 효과적입니다. 올바르게 사용하면 쿼리 의미를 바꾸지 않고도 카디널리티가 높은 속성에 대한 필터링 성능을 크게 높일 수 있습니다.
스킵 인덱스를 포함하는 ClickStack의 기본 traces 스키마를 살펴보겠습니다:
CREATE TABLE IF NOT EXISTS 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.Timestamp` Array(DateTime64(9)) CODEC(ZSTD(1)),
`Events.Name` Array(LowCardinality(String)) CODEC(ZSTD(1)),
`Events.Attributes` Array(Map(LowCardinality(String), String)) CODEC(ZSTD(1)),
`Links.TraceId` Array(String) CODEC(ZSTD(1)),
`Links.SpanId` Array(String) CODEC(ZSTD(1)),
`Links.TraceState` Array(String) CODEC(ZSTD(1)),
`Links.Attributes` Array(Map(LowCardinality(String), String)) CODEC(ZSTD(1)),
`__hdx_materialized_rum.sessionId` String MATERIALIZED ResourceAttributes['rum.sessionId'] CODEC(ZSTD(1)),
INDEX idx_trace_id TraceId TYPE bloom_filter(0.001) GRANULARITY 1,
INDEX idx_rum_session_id __hdx_materialized_rum.sessionId 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_span_attr_value mapValues(SpanAttributes) TYPE bloom_filter(0.01) GRANULARITY 1,
INDEX idx_duration Duration TYPE minmax GRANULARITY 1,
INDEX idx_lower_span_name lower(SpanName) TYPE tokenbf_v1(32768, 3, 0) GRANULARITY 8
)
ENGINE = MergeTree
PARTITION BY toDate(Timestamp)
ORDER BY (ServiceName, SpanName, toDateTime(Timestamp))
TTL toDate(Timestamp) + toIntervalDay(30)
SETTINGS index_granularity = 8192, ttl_only_drop_parts = 1;
이러한 인덱스는 다음과 같은 두 가지 일반적인 패턴에 중점을 둡니다:
- TraceId, 세션 식별자, 속성 키 또는 값과 같은 카디널리티가 높은 문자열 필터링
- 스팬 소요 시간과 같은 숫자 범위 필터링
블룸 필터 인덱스는 ClickStack에서 가장 널리 사용되는 스킵 인덱스 유형입니다. 블룸 필터 인덱스는 카디널리티가 높은 문자열 컬럼, 즉 일반적으로 고유값이 수만 개 이상인 경우에 특히 적합합니다. 거짓 양성 비율 0.01에 세분화 수준 1을 사용하는 설정은 시작점으로 적절한 기본값이며, 스토리지 오버헤드와 효과적인 프루닝 간의 균형을 잘 맞춰 줍니다.
Optimization 1의 예시를 이어서, Kubernetes 파드 이름이 ResourceAttributes에서 구체화되었다고 가정합니다:
ALTER TABLE otel_traces
ADD COLUMN PodName String
MATERIALIZED ResourceAttributes['k8s.pod.name']
그런 다음 이 컬럼에 대한 필터링을 가속하기 위해 블룸 필터 스킵 인덱스를 추가할 수 있습니다:
ALTER TABLE otel_traces
ADD INDEX idx_pod_name PodName
TYPE bloom_filter(0.01)
GRANULARITY 1
추가한 후에는 스킵 인덱스를 구체화해야 합니다. “스킵 인덱스 구체화”를 참조하십시오.
생성 및 구체화가 완료되면 ClickHouse는 요청된 파드 이름을 포함하지 않는 것이 확실한 전체 그래뉼을 건너뛸 수 있으므로, PodName:"checkout-675775c4cc-f2p9c"와 같은 쿼리에서 읽어야 하는 데이터 양을 줄일 수 있습니다.
블룸 필터는 특정 값이 비교적 적은 수의 파트에만 나타나는 값 분포에서 가장 효과적입니다. 이는 파드 이름, 트레이스 ID, 세션 식별자와 같은 메타데이터가 시간과 연관되어 있고, 그 결과 테이블의 정렬 키에 따라 함께 묶이는 관측성 워크로드에서 자연스럽게 자주 발생합니다.
모든 스킵 인덱스와 마찬가지로, 블룸 필터도 선택적으로 추가하고 실제 쿼리 패턴을 기준으로 검증하여 측정 가능한 효과를 제공하는지 확인해야 합니다. “스킵 인덱스 효과 평가”를 참조하십시오.
MinMax 인덱스는 각 granule의 최소값과 최대값을 저장하며, 매우 가볍습니다. 특히 숫자 컬럼과 범위 쿼리에서 효과적입니다. 모든 쿼리의 속도를 높여 주지는 않을 수 있지만, 비용이 낮기 때문에 숫자 필드에는 거의 항상 추가할 만한 가치가 있습니다.
MinMax 인덱스는 숫자 값이 자연스럽게 정렬되어 있거나 각 파트 내에서 좁은 범위에 모여 있을 때 가장 효과적입니다.
예를 들어, SpanAttributes에서 Kafka 오프셋을 자주 쿼리한다고 가정해 보겠습니다:
SpanAttributes['messaging.kafka.offset']
이 값은 구체화한 후 숫자형으로 CAST할 수 있습니다:
ALTER TABLE otel_traces
ADD COLUMN KafkaOffset UInt64
MATERIALIZED toUInt64(SpanAttributes['messaging.kafka.offset'])
그런 다음 minmax 인덱스를 추가할 수 있습니다.
ALTER TABLE otel_traces
ADD INDEX idx_kafka_offset KafkaOffset TYPE minmax GRANULARITY 1
이렇게 하면 예를 들어 consumer lag을 디버깅하거나 replay 동작을 확인할 때 Kafka offset 범위로 필터링하는 경우, ClickHouse가 파트를 효율적으로 건너뛸 수 있습니다.
다시 한번, 인덱스를 사용 가능하게 하려면 먼저 구체화해야 합니다.
스킵 인덱스를 추가한 후에는 새로 수집된 데이터에만 적용됩니다. 기존 데이터는 인덱스를 명시적으로 구체화하기 전까지는 그 이점을 활용할 수 없습니다.
예를 들어, 이미 스킵 인덱스를 추가한 경우:
ALTER TABLE otel_traces ADD INDEX idx_kafka_offset KafkaOffset TYPE minmax GRANULARITY 1;
기존 데이터에 대해서는 인덱스를 명시적으로 생성해야 합니다:
ALTER TABLE otel_traces MATERIALIZE INDEX idx_kafka_offset;
스킵 인덱스 구체화스킵 인덱스를 구체화하는 작업은 일반적으로 가볍고 안전하며, 특히 MinMax 인덱스에서 그렇습니다. 대규모 데이터셋의 블룸 필터 인덱스는 리소스 사용량을 더 잘 제어하기 위해 파티션별로 구체화하는 편이 나을 수 있습니다. 예를 들면 다음과 같습니다.ALTER TABLE otel_v2.otel_traces
MATERIALIZE INDEX idx_kafka_offset
IN PARTITION '2026-01-02';
스킵 인덱스 구체화는 mutation으로 실행됩니다. 진행 상황은 시스템 테이블을 사용해 모니터링할 수 있습니다.
SELECT *
FROM system.mutations
WHERE database = 'otel'
AND table = 'otel_traces'
ORDER BY create_time DESC;
해당 mutation의 is_done 값이 1이 될 때까지 기다리십시오.
완료되면 인덱스 데이터가 생성되었는지 확인하십시오:
SELECT database, table, name,
data_compressed_bytes,
data_uncompressed_bytes,
marks_bytes
FROM system.data_skipping_indices
WHERE database = 'otel'
AND table = 'otel_traces'
AND name = 'idx_kafka_offset';
0이 아닌 값은 인덱스가 성공적으로 구체화되었음을 나타냅니다.
스킵 인덱스의 크기는 쿼리 성능에 직접적인 영향을 미치므로 중요합니다. 수십~수백 기가바이트에 이르는 매우 큰 스킵 인덱스는 쿼리 실행 중 평가하는 데 상당한 시간이 걸릴 수 있으며, 이로 인해 효과가 줄어들거나 심지어 없어질 수도 있습니다.
실제로 MinMax 인덱스는 일반적으로 매우 작고 평가 비용도 낮으므로 거의 항상 안전하게 구체화할 수 있습니다. 반면 블룸 필터 인덱스는 카디널리티, 세분화 수준, 거짓 양성 확률에 따라 크기가 크게 증가할 수 있습니다.
허용되는 거짓 양성 비율을 높이면 블룸 필터 크기를 줄일 수 있습니다. 예를 들어 확률 매개변수를 0.01에서 0.05로 높이면 더 작고 더 빠르게 평가되는 인덱스가 생성되지만, 그만큼 프루닝 강도는 낮아집니다. 스키핑되는 그래뉼 수는 줄어들 수 있지만, 인덱스 평가가 더 빨라지므로 전체 쿼리 지연 시간은 오히려 개선될 수 있습니다.
따라서 블룸 필터 매개변수 튜닝은 워크로드에 따라 달라지는 최적화 작업이며, 실제 쿼리 패턴과 프로덕션에 가까운 데이터 규모를 사용해 검증해야 합니다.
스킵 인덱스에 대한 자세한 내용은 가이드 “Understanding ClickHouse data skipping indexes.”를 참조하십시오.
스킵 인덱스 프루닝을 평가하는 가장 신뢰할 수 있는 방법은 EXPLAIN indexes = 1을 사용하는 것입니다. 이 명령은 쿼리 계획의 각 단계에서 얼마나 많은 파트와 그래뉼이 제외되는지를 보여줍니다. 대부분의 경우 Skip 단계에서 그래뉼 수가 크게 줄어드는 것이 바람직하며, 이상적으로는 기본 키(primary key)가 이미 검색 범위를 줄인 다음이어야 합니다. 스킵 인덱스는 파티션 프루닝과 기본 키 프루닝 이후에 평가되므로, 그 효과는 남아 있는 파트와 그래뉼을 기준으로 측정하는 것이 가장 적절합니다.
EXPLAIN은 프루닝이 실제로 발생하는지 확인해 주지만, 전체적인 속도 향상을 보장하지는 않습니다. 스킵 인덱스를 평가하는 데에도 비용이 들며, 특히 인덱스가 큰 경우 그 비용이 더 커질 수 있습니다. 실제 성능 개선을 확인하려면 인덱스를 추가하고 구체화하기 전후에 항상 쿼리를 벤치마크하십시오.
예를 들어, 기본 Traces schema에 포함된 TraceId용 기본 블룸 필터 스킵 인덱스를 살펴보겠습니다:
INDEX idx_trace_id TraceId TYPE bloom_filter(0.001) GRANULARITY 1
선택도가 높은 쿼리에서 얼마나 효과적인지 확인하려면 EXPLAIN indexes = 1을 사용할 수 있습니다:
EXPLAIN indexes = 1
SELECT *
FROM otel_v2.otel_traces
WHERE (ServiceName = 'accounting')
AND (TraceId = 'aeea7f401feb75fc5af8eb25ebc8e974');
ReadFromMergeTree (otel_v2.otel_traces)
Indexes:
PrimaryKey
Keys:
ServiceName
Parts: 6/18
Granules: 255/35898
Skip
Name: idx_trace_id
Description: bloom_filter GRANULARITY 1
Parts: 1/6
Granules: 1/255
이 경우 먼저 프라이머리 키 필터가 데이터 범위를 크게 줄이고(35898개 그래뉼에서 255개까지), 이어서 블룸 필터가 이를 단 1개의 그래뉼(1/255)로 더 줄입니다. 이것이 스킵 인덱스에 가장 이상적인 패턴입니다. 프라이머리 키 프루닝으로 검색 범위를 좁힌 다음, 스킵 인덱스로 남아 있는 대부분을 제거합니다.
실제 영향을 검증하려면 안정적인 설정으로 쿼리를 벤치마크하고 실행 시간을 비교하십시오. 결과 직렬화 오버헤드를 피하려면 FORMAT Null을 사용하고, 실행을 반복 가능하게 유지하려면 쿼리 조건 캐시를 비활성화하십시오:
SELECT *
FROM otel_traces
WHERE (ServiceName = 'accountingservice') AND (TraceId = '4512e822ca3c0c68bbf5d4a263f9943d')
SETTINGS use_query_condition_cache = 0
2 rows in set. Elapsed: 0.025 sec. Processed 8.52 thousand rows, 299.78 KB (341.22 thousand rows/s., 12.00 MB/s.)
Peak memory usage: 41.97 MiB.
이제 스킵 인덱스를 비활성화한 상태로 동일한 쿼리를 실행하세요:
SELECT *
FROM otel_traces
WHERE (ServiceName = 'accountingservice') AND (TraceId = '4512e822ca3c0c68bbf5d4a263f9943d')
FORMAT Null
SETTINGS use_query_condition_cache = 0, use_skip_indexes = 0;
0 rows in set. Elapsed: 0.702 sec. Processed 1.62 million rows, 56.62 MB (2.31 million rows/s., 80.71 MB/s.)
Peak memory usage: 198.39 MiB.
use_query_condition_cache를 비활성화하면 캐시된 필터링 판단이 결과에 영향을 주지 않게 되며, use_skip_indexes = 0을 설정하면 비교를 위한 명확한 기준선을 마련할 수 있습니다. 프루닝이 효과적이고 인덱스 평가 비용이 낮다면, 위 예시처럼 인덱스가 적용된 쿼리가 상당히 더 빨라야 합니다.
EXPLAIN에 granule 프루닝이 거의 표시되지 않거나 스킵 인덱스가 매우 큰 경우에는 인덱스 평가 비용이 이점을 상쇄할 수 있습니다. EXPLAIN indexes = 1로 프루닝을 확인한 다음, 벤치마크를 수행해 엔드투엔드 성능 개선을 검증하십시오.
스킵 인덱스는 사용자가 가장 자주 사용하는 필터 유형과 파트 및 그래뉼 내 데이터 분포를 기준으로 선별적으로 추가해야 합니다. 목표는 인덱스 자체를 평가하는 비용을 상쇄할 만큼 충분한 그래뉼을 걸러내는 것이므로, 운영 환경과 유사한 데이터로 벤치마크하는 것이 필수적입니다.
필터에 사용되는 숫자형 컬럼에는 minmax 스킵 인덱스가 거의 항상 좋은 선택입니다. minmax는 가볍고 평가 비용이 낮으며, 범위 프레디케이트에 효과적인 경우가 많습니다. 특히 값이 대체로 정렬되어 있지 않더라도, 파트 내부에서는 좁은 범위에 모여 있을 때 더욱 유용합니다. minmax가 특정 쿼리 패턴에는 도움이 되지 않더라도, 일반적으로 오버헤드가 매우 낮아 그대로 유지해도 충분히 합리적입니다.
String 컬럼. 카디널리티가 높고 값이 희소할 때는 블룸 필터를 사용하십시오.
블룸 필터는 각 값의 출현 빈도가 비교적 낮은 고카디널리티 String 컬럼에서 가장 효과적입니다. 즉, 대부분의 파트와 그래뉼에 검색 대상 값이 포함되지 않은 경우입니다. 경험적으로 블룸 필터는 해당 컬럼에 고유한 값이 최소 10,000개 이상 있을 때 가장 유망하며, 100,000개 이상의 고유 값을 가질 때 가장 좋은 성능을 보이는 경우가 많습니다. 또한 일치하는 값이 소수의 연속된 파트에 모여 있을 때 더 효과적인데, 이는 일반적으로 해당 컬럼이 순서 지정 키와 연관되어 있을 때 발생합니다. 다시 말해, 실제 효과는 환경에 따라 달라질 수 있으므로 실제 환경에 가까운 테스트를 대체할 수 있는 방법은 없습니다.
프라이머리 키(primary key)는 대부분의 워크로드에서 ClickHouse 성능 튜닝의 핵심 요소 중 하나입니다. 이를 효과적으로 튜닝하려면 작동 방식과 쿼리 패턴과의 상호작용을 이해해야 합니다. 궁극적으로 프라이머리 키는 사용자의 데이터 접근 방식, 특히 가장 자주 필터링하는 컬럼에 맞아야 합니다.
프라이머리 키는 압축과 저장 레이아웃에도 영향을 주지만, 주된 목적은 쿼리 성능입니다. ClickStack에서는 기본 제공 프라이머리 키가 가장 일반적인 관측성 액세스 패턴과 높은 압축 효율에 맞게 이미 최적화되어 있습니다. 로그, 트레이스, 메트릭 테이블의 기본 키는 일반적인 워크플로에서 좋은 성능을 내도록 설계되어 있습니다.
프라이머리 키에서 앞쪽에 있는 컬럼으로 필터링하는 것이 뒤쪽에 있는 컬럼으로 필터링하는 것보다 더 효율적입니다. 기본 구성은 대부분의 사용자에게 충분하지만, 특정 워크로드에서는 프라이머리 키를 수정하면 성능이 향상될 수 있습니다.
용어 관련 참고 사항이 문서 전체에서 “순서 지정 키”라는 용어는 “프라이머리 키”와 같은 의미로 사용됩니다. 엄밀히 말하면 ClickHouse에서는 둘이 다르지만, ClickStack에서는 일반적으로 테이블 ORDER BY 절에 지정된 동일한 컬럼을 가리킵니다. 자세한 내용은 정렬 키와 다른 프라이머리 키를 선택하는 방법에 관한 ClickHouse 문서를 참조하십시오.
프라이머리 키를 수정하기 전에 ClickHouse에서 프라이머리 인덱스가 작동하는 방식을 이해하기 위한 가이드를 먼저 읽어볼 것을 강력히 권장합니다:
프라이머리 키 튜닝은 테이블과 데이터 타입에 따라 달라집니다. 한 테이블과 데이터 타입에 도움이 되는 변경이 다른 경우에는 적용되지 않을 수 있습니다. 목표는 항상 특정 데이터 타입(예: 로그)에 맞게 최적화하는 것입니다.
일반적으로는 로그 및 트레이스용 테이블을 최적화하게 됩니다. 다른 데이터 타입에서 프라이머리 키를 변경해야 하는 경우는 드뭅니다.
아래는 로그와 메트릭에 대한 ClickStack 테이블의 기본 프라이머리 키입니다.
- Logs (
otel_logs) - (ServiceName, TimestampTime, Timestamp)
- Traces (‘otel_traces) -
(ServiceName, SpanName, toDateTime(Timestamp))
다른 데이터 타입용 테이블에 사용되는 프라이머리 키는 “ClickStack에서 사용하는 테이블과 스키마”를 참조하십시오. 예를 들어 트레이스 테이블은 서비스 이름과 스팬 이름으로 필터링한 다음 timestamp와 트레이스 ID로 필터링할 때 최적화되어 있습니다. 반대로 로그 테이블은 서비스 이름, 그다음 날짜, 그리고 timestamp로 필터링할 때 최적화되어 있습니다. 최적의 방식은 프라이머리 키 순서대로 필터를 적용하는 것이지만, 이러한 컬럼 중 어느 것이든 어떤 순서로든 필터링하면 쿼리는 여전히 큰 이점을 얻으며, ClickHouse는 읽기 전에 데이터를 프루닝합니다.
프라이머리 키를 선택할 때는 컬럼의 최적 순서를 정하기 위해 고려해야 할 다른 사항도 있습니다. “프라이머리 키 선택하기.”를 참조하십시오.
프라이머리 키는 각 테이블별로 분리해서 변경해야 합니다. 로그에 적합한 것이 트레이스나 메트릭에는 적합하지 않을 수 있습니다.
먼저 특정 테이블의 액세스 패턴이 기본값과 크게 다른지 확인합니다. 예를 들어, 일반적으로 서비스 이름보다 Kubernetes 노드로 로그를 먼저 필터링하고, 이것이 주요 워크플로라면 프라이머리 키를 변경하는 것이 타당할 수 있습니다.
기본 프라이머리 키 수정기본 프라이머리 키는 대부분의 경우 충분합니다. 변경은 신중하게 수행해야 하며, 쿼리 패턴을 명확히 이해한 경우에만 적용해야 합니다. 프라이머리 키를 수정하면 다른 워크플로의 성능이 저하될 수 있으므로 테스트가 필수적입니다.
원하는 컬럼을 추출했다면 이제 정렬 키(ordering key)/프라이머리 키 최적화를 시작할 수 있습니다.
정렬 키를 선택할 때 도움이 되는 몇 가지 간단한 규칙이 있습니다. 아래 규칙은 서로 충돌할 수도 있으므로, 제시된 순서대로 검토하십시오. 이 과정을 통해 최대 4~5개의 키를 선택하는 것을 목표로 하십시오:
- 일반적으로 사용하는 필터와 액세스 패턴에 맞는 컬럼을 선택합니다. 예를 들어 관측성 조사 시 보통 특정 컬럼(예: 파드 이름)으로 먼저 필터링한다면, 이 컬럼은
WHERE 절에서 자주 사용됩니다. 사용 빈도가 낮은 컬럼보다 이러한 컬럼을 키에 포함하는 것을 우선하십시오.
- 필터링 시 전체 행의 큰 비율을 제외할 수 있는 컬럼을 우선적으로 선택합니다. 그러면 읽어야 하는 데이터 양을 줄일 수 있습니다. 서비스 이름과 상태 코드는 좋은 후보인 경우가 많습니다. 다만 상태 코드의 경우, 대부분의 행을 제외할 수 있는 값으로 필터링할 때만 해당합니다. 예를 들어 대부분의 시스템에서 200 코드로 필터링하면 대다수의 행과 일치하지만, 500 오류는 작은 부분 집합에만 해당합니다.
- 테이블의 다른 컬럼과 높은 상관관계를 가질 가능성이 큰 컬럼을 우선합니다. 이렇게 하면 이러한 값도 연속적으로 저장되어 압축이 개선됩니다.
- 정렬 키에 포함된 컬럼에 대한
GROUP BY(차트용 집계) 및 ORDER BY(정렬) 작업은 메모리를 더 효율적으로 사용할 수 있습니다.
정렬 키에 사용할 컬럼의 부분 집합을 식별한 후에는 이를 특정한 순서로 선언해야 합니다. 이 순서는 쿼리에서 보조 키 컬럼에 대한 필터링 효율성과 테이블 데이터 파일의 압축률 모두에 큰 영향을 줄 수 있습니다. 일반적으로는 카디널리티가 낮은 것부터 높은 것 순으로 키를 배치하는 것이 가장 좋습니다. 다만 정렬 키에서 뒤쪽에 오는 컬럼에 대한 필터링은 튜플 앞쪽에 오는 컬럼보다 효율이 떨어진다는 점도 함께 고려해야 합니다. 이러한 특성의 균형을 맞추고 액세스 패턴을 고려하십시오. 무엇보다도 다양한 변형을 테스트하십시오. 정렬 키와 최적화 방법을 더 잘 이해하려면 “Choosing a Primary Key.”를 읽어 보시기 바랍니다. 프라이머리 키 튜닝과 내부 데이터 구조를 더 깊이 이해하려면 “A practical introduction to primary indexes in ClickHouse.”도 참고하는 것이 좋습니다.
데이터 수집 전에 액세스 패턴이 확실하다면, 해당 데이터 타입의 테이블을 삭제한 뒤 다시 생성하면 됩니다.
아래 예시는 기존 스키마를 유지하면서 ServiceName보다 앞에 SeverityText 컬럼이 포함된 새 기본 키(primary key)로 로그 테이블을 새로 만드는 간단한 방법을 보여줍니다.
새 테이블 생성
CREATE TABLE otel_logs_temp AS otel_logs
PRIMARY KEY (SeverityText, ServiceName, TimestampTime)
ORDER BY (SeverityText, ServiceName, TimestampTime)
정렬 키와 기본 키위 예시에서는 PRIMARY KEY와 ORDER BY를 모두 지정해야 합니다.
ClickStack에서는 이 둘이 거의 항상 동일합니다.
ORDER BY는 물리적 데이터 배치를 제어하고, PRIMARY KEY는 희소 인덱스(sparse index)를 정의합니다.
아주 드물게 매우 큰 workload에서는 둘이 다를 수 있지만, 대부분은 둘을 일치시키는 것이 좋습니다.
테이블 교환 및 삭제
EXCHANGE statement는 테이블 이름을 원자적으로 스왑하는 데 사용됩니다. 임시 테이블(이제 이전 기본 테이블이 된 테이블)은 삭제할 수 있습니다.EXCHANGE TABLES otel_logs_temp AND otel_logs
DROP TABLE otel_logs_temp
하지만 기존 테이블의 기본 키는 수정할 수 없습니다. 기본 키를 변경하려면 새 테이블을 만들어야 합니다.
다음 절차를 따르면 기존 데이터를 유지하면서 계속 투명하게 쿼리할 수 있습니다(필요한 경우 HyperDX에서 기존 키를 그대로 사용). 동시에 새 데이터는 사용자의 액세스 패턴에 맞게 최적화된 새 테이블을 통해 노출됩니다. 이 방식은 수집 파이프라인을 수정할 필요가 없고, 데이터는 계속 기본 테이블 이름으로 전송되며, 모든 변경 사항이 사용자에게 투명하게 적용됩니다.
기존 데이터를 새 테이블로 백필하는 작업은 대규모 환경에서는 대체로 효율적이지 않습니다. 일반적으로 컴퓨트와 IO 비용이 높아, 그에 비해 성능상 이점이 크지 않습니다. 대신 오래된 데이터는 TTL을 통해 만료되도록 두고, 새로운 데이터가 개선된 키의 이점을 누리게 하는 편이 좋습니다.
아래에서도 기본 키의 첫 번째 컬럼으로 SeverityText를 추가하는 동일한 예시를 사용합니다. 이 경우 새 데이터용 테이블을 만들고, 과거 데이터 분석을 위해 기존 테이블은 유지합니다.
새 테이블 생성
원하는 기본 키로 새 테이블을 생성합니다. _23_01_2025 접미사는 현재 날짜에 맞게 조정하십시오. 예:CREATE TABLE otel_logs_23_01_2025 AS otel_logs
PRIMARY KEY (SeverityText, ServiceName, TimestampTime)
ORDER BY (SeverityText, ServiceName, TimestampTime)
Merge 테이블 생성
Merge 엔진은 MergeTree와 혼동하면 안 되며, 자체적으로 데이터를 저장하지는 않지만 여러 다른 테이블을 동시에 읽을 수 있게 해줍니다.CREATE TABLE otel_logs_merge
AS otel_logs
ENGINE = Merge(currentDatabase(), 'otel_logs*')
currentDatabase()는 명령이 올바른 데이터베이스에서 실행된다고 가정합니다. 그렇지 않다면 데이터베이스 이름을 명시적으로 지정하십시오.
이제 이 테이블을 쿼리해 otel_logs의 데이터를 반환하는지 확인할 수 있습니다.HyperDX가 merge 테이블을 읽도록 업데이트
로그 data source의 테이블로 otel_logs_merge를 사용하도록 HyperDX를 구성합니다.이 시점에도 쓰기는 기존 기본 키를 사용하는 otel_logs로 계속 들어가고, 읽기는 merge 테이블을 사용합니다. 사용자에게 보이는 변경은 없으며 수집에도 영향이 없습니다.테이블 교환
이제 EXCHANGE statement를 사용해 otel_logs와 otel_logs_23_01_2025 테이블의 이름을 원자적으로 스왑합니다.EXCHANGE TABLES otel_logs AND otel_logs_23_01_2025
이제 쓰기는 업데이트된 기본 키를 사용하는 새 otel_logs 테이블로 들어갑니다. 기존 데이터는 otel_logs_23_01_2025에 남아 있으며, merge 테이블을 통해 계속 액세스할 수 있습니다. 이 접미사는 변경이 적용된 날짜를 나타내며, 해당 테이블에 포함된 최신 timestamp도 의미합니다.이 과정을 따르면 수집 중단이나 사용자에게 보이는 영향 없이 기본 키를 변경할 수 있습니다.
이 프로세스는 프라이머리 키(primary key)에 추가 변경이 필요할 때마다 맞게 조정해 적용할 수 있습니다. 예를 들어, 일주일 후 SeverityText가 아니라 SeverityNumber를 프라이머리 키의 일부로 포함해야 한다고 판단할 수 있습니다. 아래 프로세스는 프라이머리 키 변경이 필요할 때마다 반복해서 적용할 수 있습니다.
새 테이블 생성
원하는 프라이머리 키로 새 테이블을 생성합니다.
아래 예시에서는 테이블 날짜를 나타내는 접미사(suffix)로 30_01_2025를 사용합니다. 예:CREATE TABLE otel_logs_30_01_2025 AS otel_logs
PRIMARY KEY (SeverityNumber, ServiceName, TimestampTime)
ORDER BY (SeverityNumber, ServiceName, TimestampTime)
테이블 교환
이제 EXCHANGE 문을 사용하여 otel_logs 테이블과 otel_logs_30_01_2025 테이블의 이름을 원자적으로 스왑합니다.EXCHANGE TABLES otel_logs AND otel_logs_30_01_2025
이제 쓰기 작업은 업데이트된 프라이머리 키를 사용하는 새 otel_logs 테이블로 전달됩니다. 기존 데이터는 otel_logs_30_01_2025에 그대로 남아 있으며, 머지 테이블을 통해 액세스할 수 있습니다.
불필요한 테이블권장되는 대로 TTL 정책이 적용되어 있다면, 더 이상 쓰기를 받지 않는 이전 프라이머리 키의 테이블은 데이터가 만료됨에 따라 점차 비게 됩니다. 이러한 테이블은 지속적으로 모니터링하고, 데이터가 없어지면 주기적으로 정리해야 합니다. 현재 이 정리 프로세스는 수동입니다.
최적화 4. materialized view 활용
ClickStack은 시간에 따른 분당 평균 요청 Duration 계산처럼 집계가 많은 쿼리에 의존하는 시각화를 가속하기 위해 증분형 materialized view를 활용할 수 있습니다. 이 기능은 쿼리 성능을 크게 향상시킬 수 있으며, 일반적으로 하루 10 TB 이상을 처리하는 대규모 배포에서 가장 큰 효과를 발휘합니다. 또한 일일 페타바이트 규모까지 스케일링할 수 있습니다. Incremental Materialized Views는 베타 단계이므로 주의해서 사용해야 합니다.
ClickStack에서 이 기능을 사용하는 방법에 대한 자세한 내용은 전용 가이드 “ClickStack - Materialized Views.”를 참조하십시오.
프로젝션은 materialized 컬럼, 스킵 인덱스, 프라이머리 키, materialized view를 검토한 뒤에 고려할 수 있는 마지막 고급 최적화 기법입니다. 프로젝션과 materialized view는 비슷해 보일 수 있지만, ClickStack에서는 용도가 다르며 각각 적합한 사용 시나리오도 다릅니다.
실제로 프로젝션은 동일한 행을 다른 물리적 순서로 저장하는 테이블의 추가적인 숨은 복사본으로 볼 수 있습니다. 따라서 프로젝션은 기본 테이블의 ORDER BY 키와는 별도의 자체 프라이머리 인덱스를 가지며, ClickHouse는 원래 정렬 순서와 맞지 않는 액세스 패턴에서도 데이터를 더 효과적으로 가지치기할 수 있습니다.
materialized view도 다른 정렬 키를 가진 별도의 대상 테이블에 행을 명시적으로 기록함으로써 비슷한 효과를 낼 수 있습니다. 핵심 차이점은 프로젝션은 ClickHouse가 자동으로 투명하게 유지 관리하는 반면, materialized view는 ClickStack이 의도적으로 등록하고 선택해야 하는 명시적 테이블이라는 점입니다.
쿼리가 기본 테이블을 대상으로 하면 ClickHouse는 기본 레이아웃과 사용 가능한 프로젝션을 평가하고, 각 프라이머리 인덱스를 샘플링한 뒤, 가장 적은 그래뉼을 읽으면서도 올바른 결과를 생성할 수 있는 레이아웃을 선택합니다. 이 결정은 쿼리 분석기가 자동으로 내립니다.
따라서 ClickStack에서 프로젝션은 다음과 같은 경우의 순수한 데이터 재정렬에 가장 적합합니다.
- 액세스 패턴이 기본 프라이머리 키와 근본적으로 다릅니다
- 단일 정렬 키로 모든 워크플로를 포괄하기 어렵습니다
- ClickHouse가 최적의 물리적 레이아웃을 투명하게 선택하도록 하고 싶습니다
사전 집계와 메트릭 가속에는 ClickStack이 명시적인 materialized view를 강력히 권장합니다. 이렇게 하면 애플리케이션 계층이 뷰 선택과 사용을 완전히 제어할 수 있습니다.
추가 배경 정보는 다음 문서를 참조하십시오.
트레이스 테이블이 ClickStack의 기본 액세스 패턴에 맞게 최적화되어 있다고 가정하겠습니다.
ORDER BY (ServiceName, SpanName, toDateTime(Timestamp))
TraceId를 기준으로 필터링하는(또는 이를 중심으로 자주 그룹화하고 필터링하는) 주된 워크플로가 있다면, TraceId와 시간 순으로 정렬된 행을 저장하는 프로젝션을 추가할 수 있습니다:
ALTER TABLE otel_v2.otel_traces
ADD PROJECTION prj_traceid_time
(
SELECT *
ORDER BY (TraceId, toDateTime(Timestamp))
);
와일드카드 사용위의 예시 프로젝션에서는 와일드카드(SELECT *)를 사용합니다. 컬럼의 부분 집합만 선택하면 쓰기 오버헤드를 줄일 수 있지만, 그만큼 프로젝션을 사용할 수 있는 경우도 제한됩니다. 해당 컬럼만으로 완전히 처리할 수 있는 쿼리에만 적용할 수 있기 때문입니다. ClickStack에서는 이 때문에 프로젝션 사용이 매우 제한적인 경우로 좁혀지는 일이 많습니다. 따라서 일반적으로는 적용 범위를 최대화하기 위해 와일드카드를 사용하는 것이 권장됩니다.
다른 데이터 레이아웃 변경과 마찬가지로, 프로젝션은 새로 기록되는 파트에만 영향을 줍니다. 기존 데이터에 이를 적용하려면 구체화하십시오:
ALTER TABLE otel_v2.otel_traces
MATERIALIZE PROJECTION prj_traceid_time;
프로젝션을 구체화하는 데는 시간이 오래 걸리고 상당한 리소스를 소모할 수 있습니다. 관측성 데이터는 일반적으로 TTL에 따라 만료되므로, 이는 꼭 필요한 경우에만 수행해야 합니다. 대부분의 경우 프로젝션을 새로 수집된 데이터에만 적용해도 충분하며, 이렇게 하면 지난 24시간처럼 가장 자주 쿼리되는 시간 범위를 최적화할 수 있습니다.
ClickHouse는 프로젝션이 기본 레이아웃보다 더 적은 그래뉼을 스캔할 것으로 판단하면 프로젝션을 자동으로 선택할 수 있습니다. 프로젝션은 전체 행 집합(SELECT *)을 단순히 재정렬한 형태이고, 쿼리 필터가 프로젝션의 ORDER BY와 밀접하게 맞아떨어질 때 가장 안정적으로 동작합니다.
TraceId로 필터링하고(특히 등가 조건) 시간 범위를 포함하는 쿼리는 위 프로젝션의 이점을 얻을 수 있습니다. 예를 들면 다음과 같습니다:
-- 특정 트레이스를 빠르게 조회
SELECT *
FROM otel_traces
WHERE TraceId = 'aeea7f401feb75fc5af8eb25ebc8e974'
AND Timestamp >= now() - INTERVAL 1 DAY
ORDER BY Timestamp;
-- 트레이스 범위 집계
SELECT
toStartOfMinute(Timestamp) AS t,
count() AS spans
FROM otel_traces
WHERE TraceId = 'aeea7f401feb75fc5af8eb25ebc8e974'
AND Timestamp >= now() - INTERVAL 1 DAY
GROUP BY t
ORDER BY t;
TraceId를 제한하지 않거나, 프로젝션의 순서 지정 키에서 앞부분이 아닌 다른 차원을 주로 필터링하는 쿼리는 일반적으로 큰 효과를 보지 못하며(대신 기본 레이아웃을 통해 읽을 수 있음), 경우에 따라서는 기본 레이아웃으로 읽게 됩니다.
프로젝션은 집계도 저장할 수 있습니다(materialized view와 유사). ClickStack에서는 프로젝션 기반 집계를 일반적으로 권장하지 않습니다. 선택 여부가 ClickHouse 분석기에 따라 달라지고, 사용 방식을 제어하거나 동작을 예측하기가 더 어렵기 때문입니다. 대신 ClickStack이 애플리케이션 계층에서 등록하고 의도적으로 선택할 수 있는 명시적인 materialized view를 사용하는 것이 좋습니다.
실제 운영에서는 프로젝션이, 더 넓은 범위의 검색에서 트레이스 중심 드릴다운으로 자주 피벗하는 워크플로에 가장 적합합니다(예: 특정 TraceId의 모든 스팬을 가져오는 경우).
- 삽입 오버헤드: 서로 다른 정렬 키를 사용하는
SELECT * 프로젝션은 사실상 데이터를 두 번 쓰는 것과 같으므로, 쓰기 I/O가 증가하며 수집을 지속하려면 추가 CPU와 디스크 처리량이 필요할 수 있습니다.
- 필요한 경우에만 사용: 프로젝션은 서로 다른 액세스 패턴이 실제로 존재하고, 두 번째 물리적 정렬이 상당수 쿼리에서 의미 있는 프루닝을 가능하게 하는 경우에 사용하는 것이 가장 좋습니다. 예를 들어, 두 팀이 동일한 데이터셋을 근본적으로 다른 방식으로 쿼리하는 경우가 이에 해당합니다.
- 벤치마크로 검증: 다른 모든 튜닝과 마찬가지로, 프로젝션을 추가하고 구체화하기 전후의 실제 쿼리 지연 시간과 리소스 사용량을 비교하십시오.
더 자세한 배경은 다음을 참조하십시오:
_part_offset를 사용하는 경량 프로젝션
경량 프로젝션은 ClickStack에서 베타 기능입니다_part_offset-based 경량 프로젝션은 ClickStack 워크로드에는 권장되지 않습니다. 스토리지와 쓰기 I/O를 줄일 수는 있지만, 쿼리 시점에 더 많은 랜덤 액세스를 유발할 수 있으며, 관측성 규모의 프로덕션 환경에서의 동작은 아직 평가 중입니다. 이 권장 사항은 기능이 성숙해지고 운영 데이터가 더 축적되면 변경될 수 있습니다.
최신 ClickHouse 버전은 전체 행을 복제하는 대신, 프로젝션 정렬 키와 기본 테이블(base table)을 가리키는 _part_offset 포인터만 저장하는 더 경량의 프로젝션도 지원합니다. 이렇게 하면 스토리지 오버헤드를 크게 줄일 수 있으며, 최근 개선으로 granule 단위 프루닝이 가능해져 실질적인 보조 인덱스와 더 유사하게 동작합니다. 자세한 내용은 다음을 참조하십시오.
여러 개의 정렬 키가 필요하더라도 프로젝션만이 유일한 선택지는 아닙니다. 운영상의 제약과 ClickStack에서 쿼리를 라우팅하려는 방식에 따라 다음 옵션을 고려하십시오.
- OpenTelemetry collector가 서로 다른
ORDER BY 키를 가진 두 테이블에 쓰도록 구성하고, 각 테이블에 대해 별도의 ClickStack 소스를 생성하십시오.
- 복사 pipeline으로 materialized view를 생성하십시오. 즉, 기본 테이블에 materialized view를 연결해 원시 행을 다른 정렬 키를 가진 보조 테이블로 선택하도록 합니다(비정규화 또는 라우팅 패턴). 이 대상 테이블에 대한 소스를 생성하십시오. 예시는 여기에서 확인할 수 있습니다.