소개
ClickHouse 개념
ClickHouse에서 테이블은 데이터베이스에 속합니다. 기본적으로
default 데이터베이스가 사용되며, 이는 OpenTelemetry collector에서 변경할 수 있습니다.
최소한 다음 ClickHouse 기본 개념은 이해해야 합니다.
이러한 개념은 ClickHouse 성능의 핵심입니다. 데이터가 어떻게 기록되는지, 디스크에 어떻게 구조화되는지, 그리고 ClickHouse가 쿼리 시점에 데이터를 얼마나 효율적으로 건너뛸 수 있는지를 결정합니다. 이 가이드의 모든 최적화—materialized 컬럼, 스킵 인덱스, 프라이머리 키, 프로젝션, materialized view—는 이러한 핵심 메커니즘을 기반으로 합니다.
튜닝을 수행하기 전에 다음 ClickHouse 문서를 검토하는 것을 권장합니다.
- ClickHouse에서 테이블 생성하기 - 테이블에 대한 간단한 소개입니다.
- Parts
- Partitions
- Merges
- Primary keys/indexes
- ClickHouse가 데이터를 저장하는 방식: 파트와 그래뉼 - 그래뉼과 프라이머리 키를 자세히 다루며, ClickHouse에서 데이터가 어떻게 구조화되고 쿼리되는지 설명하는 고급 가이드입니다.
- MergeTree- 명령과 내부 세부 사항을 이해하는 데 유용한 고급 MergeTree 참고 가이드입니다.
최적화 1. 자주 쿼리되는 속성 구체화하기
LogAttributes, ScopeAttributes, ResourceAttributes에서 자주 쿼리되는 속성을 식별하고, materialized 컬럼을 사용해 이를 최상위 컬럼으로 승격하는 것입니다.
이 최적화만으로도 ClickStack 배포를 하루 수십 테라바이트 규모까지 확장하기에 충분한 경우가 많으며, 더 고급 튜닝 기법을 검토하기 전에 먼저 적용해야 합니다.
왜 속성을 구체화하나요
Map(String, String) 컬럼에 저장합니다. 이는 유연성을 제공하지만, 맵의 하위 키를 쿼리할 때 성능에 중요한 영향을 미칩니다.
맵 컬럼에서 단일 키를 쿼리할 때 ClickHouse는 디스크에서 맵 컬럼 전체를 읽어야 합니다. 맵에 키가 많이 포함되어 있으면 별도의 전용 컬럼을 읽는 경우와 비교해 불필요한 IO가 발생하고 쿼리도 더 느려집니다.
자주 조회하는 속성을 구체화하면 삽입 시점에 값을 추출해 별도의 컬럼으로 저장하므로 이러한 오버헤드를 피할 수 있습니다.
materialized 컬럼:
- 삽입 시 자동으로 계산됩니다
- INSERT 문에서 명시적으로 설정할 수 없습니다
- 모든 ClickHouse 표현식을 지원합니다
- String에서 더 효율적인 숫자 또는 날짜 타입으로 변환할 수 있습니다
- 스킵 인덱스와 프라이머리 키를 활용할 수 있습니다
- 맵 전체를 읽지 않아도 되므로 디스크 읽기를 줄입니다
ClickStack는 맵에서 추출한 materialized 컬럼을 자동으로 감지하며, 사용자가 계속 원래 속성 경로로 쿼리하더라도 쿼리 실행 중 이를 투명하게 사용합니다.
예시
ResourceAttributes에 저장되는 기본 ClickStack 트레이스 스키마를 살펴보겠습니다:
ResourceAttributes.k8s.pod.name:"checkout-675775c4cc-f2p9c":
그러면 다음과 유사한 SQL 프레디케이트가 생성됩니다:
ResourceAttributes 컬럼 전체를 읽어야 합니다. 맵에 키가 많으면 이 컬럼의 크기가 매우 커질 수 있습니다.
이 속성을 자주 조회한다면 최상위 컬럼으로 구체화하는 것이 좋습니다.
삽입 시점에 파드 이름을 추출하려면 구체화된 컬럼을 추가하십시오:
PodName에 저장합니다.
이제 Lucene 구문을 사용해, 예를 들어 PodName:"checkout-675775c4cc-f2p9c"와 같이 파드 이름을 효율적으로 조회할 수 있습니다.
새로 삽입되는 데이터에서는 이렇게 하면 맵에 전혀 접근하지 않아도 되므로 I/O가 크게 줄어듭니다.
하지만 사용자가 원래 속성 경로(예: ResourceAttributes.k8s.pod.name:"checkout-675775c4cc-f2p9c")로 계속 쿼리하더라도, ClickStack은 내부적으로 쿼리를 자동으로 재작성하여 구체화된 PodName 컬럼을 사용합니다. 즉, 다음 프레디케이트를 사용합니다:
기본적으로 materialized 컬럼은
SELECT * 쿼리에서 제외됩니다. 이렇게 하면 쿼리 결과를 항상 테이블에 다시 삽입할 수 있다는 원칙이 유지됩니다.과거 데이터 구체화하기
system.mutations 테이블을 사용해 모니터링할 수 있습니다. 예:
is_done = 1이 될 때까지 기다리십시오.
최적화 2. 스킵 인덱스 추가
- TraceId, 세션 식별자, 속성 키 또는 값과 같은 카디널리티가 높은 문자열 필터링
- 스팬 소요 시간과 같은 숫자 범위 필터링
블룸 필터
PodName:"checkout-675775c4cc-f2p9c"와 같은 쿼리에서 읽어야 하는 데이터 양을 줄일 수 있습니다.
블룸 필터는 특정 값이 비교적 적은 수의 파트에만 나타나는 값 분포에서 가장 효과적입니다. 이는 파드 이름, 트레이스 ID, 세션 식별자와 같은 메타데이터가 시간과 연관되어 있고, 그 결과 테이블의 정렬 키에 따라 함께 묶이는 관측성 워크로드에서 자연스럽게 자주 발생합니다.
모든 스킵 인덱스와 마찬가지로, 블룸 필터도 선택적으로 추가하고 실제 쿼리 패턴을 기준으로 검증하여 측정 가능한 효과를 제공하는지 확인해야 합니다. “스킵 인덱스 효과 평가”를 참조하십시오.
MinMax 인덱스
SpanAttributes에서 Kafka 오프셋을 자주 쿼리한다고 가정해 보겠습니다:
스킵 인덱스 구체화
스킵 인덱스 구체화스킵 인덱스를 구체화하는 작업은 일반적으로 가볍고 안전하며, 특히 MinMax 인덱스에서 그렇습니다. 대규모 데이터셋의 블룸 필터 인덱스는 리소스 사용량을 더 잘 제어하기 위해 파티션별로 구체화하는 편이 나을 수 있습니다. 예를 들면 다음과 같습니다.
is_done 값이 1이 될 때까지 기다리십시오.
완료되면 인덱스 데이터가 생성되었는지 확인하십시오:
0.01에서 0.05로 높이면 더 작고 더 빠르게 평가되는 인덱스가 생성되지만, 그만큼 프루닝 강도는 낮아집니다. 스키핑되는 그래뉼 수는 줄어들 수 있지만, 인덱스 평가가 더 빨라지므로 전체 쿼리 지연 시간은 오히려 개선될 수 있습니다.
따라서 블룸 필터 매개변수 튜닝은 워크로드에 따라 달라지는 최적화 작업이며, 실제 쿼리 패턴과 프로덕션에 가까운 데이터 규모를 사용해 검증해야 합니다.
스킵 인덱스에 대한 자세한 내용은 가이드 “Understanding ClickHouse data skipping indexes.”를 참조하십시오.
스킵 인덱스 효과 평가
EXPLAIN indexes = 1을 사용하는 것입니다. 이 명령은 쿼리 계획의 각 단계에서 얼마나 많은 파트와 그래뉼이 제외되는지를 보여줍니다. 대부분의 경우 Skip 단계에서 그래뉼 수가 크게 줄어드는 것이 바람직하며, 이상적으로는 기본 키(primary key)가 이미 검색 범위를 줄인 다음이어야 합니다. 스킵 인덱스는 파티션 프루닝과 기본 키 프루닝 이후에 평가되므로, 그 효과는 남아 있는 파트와 그래뉼을 기준으로 측정하는 것이 가장 적절합니다.
EXPLAIN은 프루닝이 실제로 발생하는지 확인해 주지만, 전체적인 속도 향상을 보장하지는 않습니다. 스킵 인덱스를 평가하는 데에도 비용이 들며, 특히 인덱스가 큰 경우 그 비용이 더 커질 수 있습니다. 실제 성능 개선을 확인하려면 인덱스를 추가하고 구체화하기 전후에 항상 쿼리를 벤치마크하십시오.
예를 들어, 기본 Traces schema에 포함된 TraceId용 기본 블룸 필터 스킵 인덱스를 살펴보겠습니다:
EXPLAIN indexes = 1을 사용할 수 있습니다:
FORMAT Null을 사용하고, 실행을 반복 가능하게 유지하려면 쿼리 조건 캐시를 비활성화하십시오:
use_query_condition_cache를 비활성화하면 캐시된 필터링 판단이 결과에 영향을 주지 않게 되며, use_skip_indexes = 0을 설정하면 비교를 위한 명확한 기준선을 마련할 수 있습니다. 프루닝이 효과적이고 인덱스 평가 비용이 낮다면, 위 예시처럼 인덱스가 적용된 쿼리가 상당히 더 빨라야 합니다.
스킵 인덱스를 추가해야 하는 경우
최적화 3. 프라이머리 키 수정
용어 관련 참고 사항이 문서 전체에서 “순서 지정 키”라는 용어는 “프라이머리 키”와 같은 의미로 사용됩니다. 엄밀히 말하면 ClickHouse에서는 둘이 다르지만, ClickStack에서는 일반적으로 테이블
ORDER BY 절에 지정된 동일한 컬럼을 가리킵니다. 자세한 내용은 정렬 키와 다른 프라이머리 키를 선택하는 방법에 관한 ClickHouse 문서를 참조하십시오.- Logs (
otel_logs) -(ServiceName, TimestampTime, Timestamp) - Traces (‘otel_traces) -
(ServiceName, SpanName, toDateTime(Timestamp))
프라이머리 키 선택하기
기본 프라이머리 키 수정기본 프라이머리 키는 대부분의 경우 충분합니다. 변경은 신중하게 수행해야 하며, 쿼리 패턴을 명확히 이해한 경우에만 적용해야 합니다. 프라이머리 키를 수정하면 다른 워크플로의 성능이 저하될 수 있으므로 테스트가 필수적입니다.
- 일반적으로 사용하는 필터와 액세스 패턴에 맞는 컬럼을 선택합니다. 예를 들어 관측성 조사 시 보통 특정 컬럼(예: 파드 이름)으로 먼저 필터링한다면, 이 컬럼은
WHERE절에서 자주 사용됩니다. 사용 빈도가 낮은 컬럼보다 이러한 컬럼을 키에 포함하는 것을 우선하십시오. - 필터링 시 전체 행의 큰 비율을 제외할 수 있는 컬럼을 우선적으로 선택합니다. 그러면 읽어야 하는 데이터 양을 줄일 수 있습니다. 서비스 이름과 상태 코드는 좋은 후보인 경우가 많습니다. 다만 상태 코드의 경우, 대부분의 행을 제외할 수 있는 값으로 필터링할 때만 해당합니다. 예를 들어 대부분의 시스템에서 200 코드로 필터링하면 대다수의 행과 일치하지만, 500 오류는 작은 부분 집합에만 해당합니다.
- 테이블의 다른 컬럼과 높은 상관관계를 가질 가능성이 큰 컬럼을 우선합니다. 이렇게 하면 이러한 값도 연속적으로 저장되어 압축이 개선됩니다.
- 정렬 키에 포함된 컬럼에 대한
GROUP BY(차트용 집계) 및ORDER BY(정렬) 작업은 메모리를 더 효율적으로 사용할 수 있습니다.
기본 키 변경
ServiceName보다 앞에 SeverityText 컬럼이 포함된 새 기본 키(primary key)로 로그 테이블을 새로 만드는 간단한 방법을 보여줍니다.
1
새 테이블 생성
정렬 키와 기본 키위 예시에서는
PRIMARY KEY와 ORDER BY를 모두 지정해야 합니다.
ClickStack에서는 이 둘이 거의 항상 동일합니다.
ORDER BY는 물리적 데이터 배치를 제어하고, PRIMARY KEY는 희소 인덱스(sparse index)를 정의합니다.
아주 드물게 매우 큰 workload에서는 둘이 다를 수 있지만, 대부분은 둘을 일치시키는 것이 좋습니다.기존 데이터를 새 테이블로 백필하는 작업은 대규모 환경에서는 대체로 효율적이지 않습니다. 일반적으로 컴퓨트와 IO 비용이 높아, 그에 비해 성능상 이점이 크지 않습니다. 대신 오래된 데이터는 TTL을 통해 만료되도록 두고, 새로운 데이터가 개선된 키의 이점을 누리게 하는 편이 좋습니다.
SeverityText를 추가하는 동일한 예시를 사용합니다. 이 경우 새 데이터용 테이블을 만들고, 과거 데이터 분석을 위해 기존 테이블은 유지합니다.
1
2
3
HyperDX가 merge 테이블을 읽도록 업데이트
로그 data source의 테이블로otel_logs_merge를 사용하도록 HyperDX를 구성합니다.이 시점에도 쓰기는 기존 기본 키를 사용하는 otel_logs로 계속 들어가고, 읽기는 merge 테이블을 사용합니다. 사용자에게 보이는 변경은 없으며 수집에도 영향이 없습니다.4
테이블 교환
이제EXCHANGE statement를 사용해 otel_logs와 otel_logs_23_01_2025 테이블의 이름을 원자적으로 스왑합니다.otel_logs 테이블로 들어갑니다. 기존 데이터는 otel_logs_23_01_2025에 남아 있으며, merge 테이블을 통해 계속 액세스할 수 있습니다. 이 접미사는 변경이 적용된 날짜를 나타내며, 해당 테이블에 포함된 최신 timestamp도 의미합니다.이 과정을 따르면 수집 중단이나 사용자에게 보이는 영향 없이 기본 키를 변경할 수 있습니다.SeverityText가 아니라 SeverityNumber를 프라이머리 키의 일부로 포함해야 한다고 판단할 수 있습니다. 아래 프로세스는 프라이머리 키 변경이 필요할 때마다 반복해서 적용할 수 있습니다.
1
2
테이블 교환
이제EXCHANGE 문을 사용하여 otel_logs 테이블과 otel_logs_30_01_2025 테이블의 이름을 원자적으로 스왑합니다.otel_logs 테이블로 전달됩니다. 기존 데이터는 otel_logs_30_01_2025에 그대로 남아 있으며, 머지 테이블을 통해 액세스할 수 있습니다.불필요한 테이블권장되는 대로 TTL 정책이 적용되어 있다면, 더 이상 쓰기를 받지 않는 이전 프라이머리 키의 테이블은 데이터가 만료됨에 따라 점차 비게 됩니다. 이러한 테이블은 지속적으로 모니터링하고, 데이터가 없어지면 주기적으로 정리해야 합니다. 현재 이 정리 프로세스는 수동입니다.
최적화 4. materialized view 활용
최적화 5. 프로젝션 활용
ORDER BY 키와는 별도의 자체 프라이머리 인덱스를 가지며, ClickHouse는 원래 정렬 순서와 맞지 않는 액세스 패턴에서도 데이터를 더 효과적으로 가지치기할 수 있습니다.
materialized view도 다른 정렬 키를 가진 별도의 대상 테이블에 행을 명시적으로 기록함으로써 비슷한 효과를 낼 수 있습니다. 핵심 차이점은 프로젝션은 ClickHouse가 자동으로 투명하게 유지 관리하는 반면, materialized view는 ClickStack이 의도적으로 등록하고 선택해야 하는 명시적 테이블이라는 점입니다.
쿼리가 기본 테이블을 대상으로 하면 ClickHouse는 기본 레이아웃과 사용 가능한 프로젝션을 평가하고, 각 프라이머리 인덱스를 샘플링한 뒤, 가장 적은 그래뉼을 읽으면서도 올바른 결과를 생성할 수 있는 레이아웃을 선택합니다. 이 결정은 쿼리 분석기가 자동으로 내립니다.
따라서 ClickStack에서 프로젝션은 다음과 같은 경우의 순수한 데이터 재정렬에 가장 적합합니다.
- 액세스 패턴이 기본 프라이머리 키와 근본적으로 다릅니다
- 단일 정렬 키로 모든 워크플로를 포괄하기 어렵습니다
- ClickHouse가 최적의 물리적 레이아웃을 투명하게 선택하도록 하고 싶습니다
프로젝션 예시
와일드카드 사용위의 예시 프로젝션에서는 와일드카드(
SELECT *)를 사용합니다. 컬럼의 부분 집합만 선택하면 쓰기 오버헤드를 줄일 수 있지만, 그만큼 프로젝션을 사용할 수 있는 경우도 제한됩니다. 해당 컬럼만으로 완전히 처리할 수 있는 쿼리에만 적용할 수 있기 때문입니다. ClickStack에서는 이 때문에 프로젝션 사용이 매우 제한적인 경우로 좁혀지는 일이 많습니다. 따라서 일반적으로는 적용 범위를 최대화하기 위해 와일드카드를 사용하는 것이 권장됩니다.프로젝션을 구체화하는 데는 시간이 오래 걸리고 상당한 리소스를 소모할 수 있습니다. 관측성 데이터는 일반적으로 TTL에 따라 만료되므로, 이는 꼭 필요한 경우에만 수행해야 합니다. 대부분의 경우 프로젝션을 새로 수집된 데이터에만 적용해도 충분하며, 이렇게 하면 지난 24시간처럼 가장 자주 쿼리되는 시간 범위를 최적화할 수 있습니다.
SELECT *)을 단순히 재정렬한 형태이고, 쿼리 필터가 프로젝션의 ORDER BY와 밀접하게 맞아떨어질 때 가장 안정적으로 동작합니다.
TraceId로 필터링하고(특히 등가 조건) 시간 범위를 포함하는 쿼리는 위 프로젝션의 이점을 얻을 수 있습니다. 예를 들면 다음과 같습니다:
TraceId를 제한하지 않거나, 프로젝션의 순서 지정 키에서 앞부분이 아닌 다른 차원을 주로 필터링하는 쿼리는 일반적으로 큰 효과를 보지 못하며(대신 기본 레이아웃을 통해 읽을 수 있음), 경우에 따라서는 기본 레이아웃으로 읽게 됩니다.
프로젝션은 집계도 저장할 수 있습니다(
materialized view와 유사). ClickStack에서는 프로젝션 기반 집계를 일반적으로 권장하지 않습니다. 선택 여부가 ClickHouse 분석기에 따라 달라지고, 사용 방식을 제어하거나 동작을 예측하기가 더 어렵기 때문입니다. 대신 ClickStack이 애플리케이션 계층에서 등록하고 의도적으로 선택할 수 있는 명시적인 materialized view를 사용하는 것이 좋습니다.비용과 지침
- 삽입 오버헤드: 서로 다른 정렬 키를 사용하는
SELECT *프로젝션은 사실상 데이터를 두 번 쓰는 것과 같으므로, 쓰기 I/O가 증가하며 수집을 지속하려면 추가 CPU와 디스크 처리량이 필요할 수 있습니다. - 필요한 경우에만 사용: 프로젝션은 서로 다른 액세스 패턴이 실제로 존재하고, 두 번째 물리적 정렬이 상당수 쿼리에서 의미 있는 프루닝을 가능하게 하는 경우에 사용하는 것이 가장 좋습니다. 예를 들어, 두 팀이 동일한 데이터셋을 근본적으로 다른 방식으로 쿼리하는 경우가 이에 해당합니다.
- 벤치마크로 검증: 다른 모든 튜닝과 마찬가지로, 프로젝션을 추가하고 구체화하기 전후의 실제 쿼리 지연 시간과 리소스 사용량을 비교하십시오.
_part_offset를 사용하는 경량 프로젝션
경량 프로젝션은 ClickStack에서 베타 기능입니다
_part_offset-based 경량 프로젝션은 ClickStack 워크로드에는 권장되지 않습니다. 스토리지와 쓰기 I/O를 줄일 수는 있지만, 쿼리 시점에 더 많은 랜덤 액세스를 유발할 수 있으며, 관측성 규모의 프로덕션 환경에서의 동작은 아직 평가 중입니다. 이 권장 사항은 기능이 성숙해지고 운영 데이터가 더 축적되면 변경될 수 있습니다._part_offset 포인터만 저장하는 더 경량의 프로젝션도 지원합니다. 이렇게 하면 스토리지 오버헤드를 크게 줄일 수 있으며, 최근 개선으로 granule 단위 프루닝이 가능해져 실질적인 보조 인덱스와 더 유사하게 동작합니다. 자세한 내용은 다음을 참조하십시오.
대안
- OpenTelemetry collector가 서로 다른
ORDER BY키를 가진 두 테이블에 쓰도록 구성하고, 각 테이블에 대해 별도의 ClickStack 소스를 생성하십시오. - 복사 pipeline으로 materialized view를 생성하십시오. 즉, 기본 테이블에 materialized view를 연결해 원시 행을 다른 정렬 키를 가진 보조 테이블로 선택하도록 합니다(비정규화 또는 라우팅 패턴). 이 대상 테이블에 대한 소스를 생성하십시오. 예시는 여기에서 확인할 수 있습니다.