OpenTelemetry + ClickHouse 로 APM 스캐터 차트 만들기 [1편]— 수집과 저장
2026년 9월 10일
원문에서 보기 ↗OpenTelemetry + ClickHouse 로 APM 스캐터 차트 만들기 (1) --- 수집과 저장
글. 박준희(Kuiper) / 숙박플랫폼개선TF팀

1. 들어가며
안녕하세요.
여기어때 숙박플랫폼개선TF팀 박준희(카이퍼)입니다.
APM 도구를 써보신 분이라면 이런 화면을 보신 적이 있으실 겁니다. 가로축은 시간, 세로축은 응답시간, 그리고 요청 하나하나가 점으로 흩뿌려지는 산점도. 이 시리즈가 끝나면 아래 화면을 직접 만들게 됩니다.

이 화면이 유용한 이유는 단순합니다. 평균과 백분위는 개별 요청을 지웁니다. 평균 응답시간 그래프가 평탄해도 그 안에서 일부 요청만 10초씩 걸리고 있을 수 있고, P99 가 정상이어도 그 바깥에서 무슨 일이 벌어지는지는 보이지 않습니다. 스캐터 차트는 개별 요청을 지우지 않기 때문에, 집계 지표가 감추는 패턴이 눈에 그대로 남습니다.
문제는 이 화면이 대개 상용 APM 제품에 묶여 있다는 점입니다. 여기어때는 이미 OpenTelemetry 로 트레이스를 수집하고 Grafana 로 관측하고 있습니다. 그렇다면 같은 데이터로, 집계 지표가 감추는 패턴을 요청 하나하나 단위로 보는 화면을 직접 만들 수 있지 않을까요?
이 글은 그 질문에 답해본 기록입니다. 두 편으로 나눠 싣습니다.
- (1편) 수집과 저장 --- OpenTelemetry Collector 로 span 을 받아 ClickHouse 에 쌓기까지. 이 글입니다
- (2편) 화면 만들기 --- Grafana 커스텀 패널 플러그인으로 스캐터 차트를 그리기
전체 구성
애플리케이션 (OTel Agent/SDK)
|
v
OpenTelemetry Collector <- 수집 대상 선정 (6장)
|
v
ClickHouse <- 스키마 설계 (7장), Materialized View 변환 (8장)
otel_traces (raw) -- Materialized View --> transactions (조회용)
|
v
Grafana 커스텀 패널 <- 2편
검증 범위를 먼저 밝힙니다
이 글의 내용은 로컬 단일 노드 환경(및 3-Keeper / 2-Shard x 2-Replica 클러스터 구성)에서 파이프라인 전 구간을 구성하고 합성 트래픽으로 검증한 기록입니다. 운영 트래픽 적용은 별도 단계이며, 본문의 용량·성능 수치는 각각 로컬 실측 / 추정치 / 공식 문서 로 출처를 구분해 표기했습니다.
1편은 다음을 다룹니다.
- 스캐터 차트가 집계 지표 대비 무엇을 더 보여주는가
- ClickHouse 는 무엇이고 왜 이런 조회에 빠른가
- 기존 트레이싱 파이프라인을 건드리지 않고 분기하는 방법
- 어떤 span 을 남기고 어떤 span 을 버릴 것인가
- 컬럼 타입·정렬 키·파티션·TTL·인덱스를 어떻게 고를 것인가
2. 스캐터 차트가 보여주는 것
2.1 집계 지표의 사각지대
- 평균 응답시간 --- 이상치가 평균에 묻혀 감지되지 않습니다
- P95 / P99 --- 백분위 바깥의 극단적 지연은 표현되지 않습니다
- 에러율 --- 성공했지만 느린 응답은 잡히지 않습니다
세 지표 모두 "개별 요청을 하나의 숫자로 접는" 연산입니다. 접는 순간 분포의 모양이 사라집니다. 스캐터 차트는 접지 않기 때문에 모양이 남습니다.

2.2 점 하나가 무엇인지부터 정하기
용어를 하나 짚고 가겠습니다. span 은 트레이스를 구성하는 작업 단위 하나입니다. HTTP 요청 처리, DB 쿼리, 외부 API 호출 같은 작업 각각이 시작 시각·소요 시간(duration)·성격(SpanKind)·속성을 가진 기록 하나로 남는데, 그 기록이 span 입니다. 요청 하나의 전체 여정(트레이스)은 이런 span 여러 개가 부모-자식으로 엮인 트리입니다.
여기서 첫 설계 결정이 나옵니다. 점 하나를 무엇으로 볼 것인가.
- 요청(트랜잭션) 하나 = 점 하나로 하면 화면은 깔끔하지만, 느려진 원인이 내부 DB 호출인지 외부 API 호출인지 구분되지 않습니다
- span 하나 = 점 하나로 하면 요청 하나가 여러 점으로 찍힙니다. 대신 진입 요청과 하위 호출을 나눠 볼 수 있습니다
저는 후자를 택했습니다. 다만 그대로 두면 화면이 점으로 뒤덮이므로, span 을 진입점 과 하위 호출로 분류해 필터로 켜고 끌 수 있게 했습니다.
OpenTelemetry 명세는 span 의 성격을 SpanKind 로 정의합니다. Trace API 명세의 정의를 그대로 옮기면 다음과 같습니다.
- SERVER --- "indicates that the span covers server-side handling of a remote request while the client awaits a response."
- CLIENT --- "indicates that the span describes a request to a remote service where the client awaits a response."
- PRODUCER --- "indicates that the span describes the initiation or scheduling of a local or remote operation."
- CONSUMER --- "indicates that the span represents the processing of an operation initiated by a producer, where the producer does not wait for the outcome."
- INTERNAL --- "Default value. Indicates that the span represents an internal operation within an application."
이 다섯 가지를 화면 어휘로 매핑했습니다.
- 진입점 (기본 켜짐) --- SERVER → HTTP In · CONSUMER → Messaging Consumer · INTERNAL(스케줄러) → Scheduled
- 하위 호출 (기본 꺼짐) --- CLIENT(HTTP 속성 보유) → HTTP Out · PRODUCER → Messaging Producer
기본값은 진입점만 켜둡니다. "HTTP In 은 그대로인데 HTTP Out 만 올라간다" 같은 판독은 하위 호출을 켰을 때 가능해집니다.
2.3 X축은 시작이 아니라 종료 시각
두 번째 결정입니다. 응답시간이 500ms 인 요청을 언제 찍을 것인가.
시작 시각에 찍으면 "지금 이 순간 시스템이 얼마나 느린가"를 볼 수 없습니다. 방금 시작된 요청은 아직 duration 이 없기 때문입니다. 그래서 종료 시각(timestamp + duration)을 X 좌표로 씁니다.
이 선택은 2편에서 조회 성능 문제로 되돌아옵니다. 저장은 시작 시각으로 정렬해 두고 화면은 종료 시각으로 그리기 때문입니다.

3. ClickHouse 는 무엇이고, 왜 이런 조회에 빠른가
저장소를 고르기 전에 ClickHouse 가 어떤 물건인지부터 정리하겠습니다. "빠르다"는 말만으로는 어떤 조회에 빠른지를 알 수 없고, 그걸 모르면 스키마를 잘못 설계하게 됩니다.
3.1 행 지향 vs 컬럼 지향
관계형 DB 대부분은 행 단위로 저장합니다. 한 행의 모든 컬럼이 디스크에 붙어 있습니다.
[행 지향]
row1: | ts | trace_id | service | uri | method | status | duration | error_msg |
row2: | ts | trace_id | service | uri | method | status | duration | error_msg |
row3: | ts | trace_id | service | uri | method | status | duration | error_msg |
이 구조는 "특정 행 하나를 통째로 읽는" 조회에 최적입니다. 주문 하나를 조회하는 화면 같은 것이죠.
컬럼 지향은 반대로 컬럼 단위로 모아 저장합니다.
[컬럼 지향]
ts: | ts | ts | ts | ts | ts | ...
trace_id: | id | id | id | id | id | ...
duration: | d | d | d | d | d | ...
error_msg: | .. | .. | .. | .. | .. | ...
스캐터 차트가 필요한 것은
종료시각·duration·status·uri·method·span_category 여섯 개뿐입니다. error_message 같은 큰 컬럼은 화면에 쓰이지 않습니다. 컬럼 지향에서는 읽지 않는 컬럼의 디스크 블록을 아예 건드리지 않습니다. 행 지향이었다면 error_message 까지 통째로 읽어야 합니다.
여기가 첫 번째 이유입니다. 조회 대상 컬럼이 전체의 일부이고, 대신 행 수가 아주 많은 워크로드에 유리합니다.

3.2 컬럼 지향은 압축이 잘 된다
두 번째 이유는 압축입니다. 같은 컬럼의 값은 같은 타입이고 값의 분포도 비슷합니다.
service_name 컬럼에는 서비스명 수십 개가 반복해서 들어갑니다. status 에는 200 이 대부분입니다. timestamp 는 계속 증가합니다. 이런 데이터는 압축 알고리즘이 아주 잘 다룹니다. 반면 행 지향에서는 서로 다른 타입의 값이 섞여 있어 압축률이 떨어집니다.
ClickHouse 는 여기서 한 걸음 더 나갑니다. 컬럼마다 압축 코덱을 따로 지정할 수 있습니다.
- ZSTD(1) --- 압축률·속도의 균형 (level 1~22, 기본 1). 대부분의 문자열에 적합
- LZ4 --- ZSTD 보다 압축률은 낮지만 빠릅니다. 자주 읽는 컬럼용
- Delta(N) --- 연속된 값의 차이만 저장합니다. 시계열(timestamp, duration)용
Delta 가 특히 재미있습니다. [1000, 1001, 1002] 를 [1000, 1, 1] 로 바꿔 저장합니다. 값 자체가 작아지니 뒤이은 압축이 훨씬 잘 먹습니다. 그래서 보통 조합해서 씁니다.
timestamp DateTime64(9) CODEC(Delta, ZSTD(1)) -- 차이로 바꾼 뒤 압축
duration UInt32 CODEC(Delta, LZ4) -- 자주 읽으니 빠른 쪽으로
uri String CODEC(ZSTD(1)) -- 일반 문자열
로컬 실측 기준 ZSTD 적용 시 원본의 약 1/10 크기(90% 감소)가 되었습니다 (로컬 실측, system.parts 의 uncompressed 대비 compressed bytes). 트레이스 데이터는 반복이 많아 압축 효율이 특히 좋은 편입니다. 다만 Delta 는 인접 값이 비슷할 때 유리한 코덱입니다 --- timestamp 처럼 단조로운 컬럼에는 잘 맞지만, 정렬 순서상 비단조인 duration 은 이득이 불확실하므로 실측 압축률로 확인하고 결정하는 편이 안전합니다.
3.3 MergeTree --- 파트와 머지
ClickHouse 의 주력 테이블 엔진은 MergeTree 계열입니다. 이름 그대로 "머지하는 트리"인데, 동작은 이렇습니다.
- INSERT 가 들어오면 새 파트(part) 를 만들어 디스크에 씁니다. 기존 파트는 건드리지 않습니다
- 파트는 불변(immutable) 입니다. 수정하지 않습니다
- 백그라운드에서 작은 파트들을 머지해 큰 파트로 합칩니다
이 구조 덕분에 INSERT 가 아주 빠릅니다. 기존 파트는 전혀 건드리지 않고, 새로 들어온 블록만 정렬·압축·인덱싱해서 새 파트로 쓰기 때문입니다.
대신 작은 INSERT 를 자주 날리면 파트가 폭증 합니다. 머지가 따라가지 못하면 조회 시 읽어야 할 파트 수가 늘고 성능이 떨어집니다. 그래서 ClickHouse 는 큰 배치를 드물게 받는 것을 선호합니다. 5장의 collector 배치 설정이 여기서 나옵니다.
3.4 정렬 키가 곧 인덱스다 (Sparse Index)
관계형 DB 의 인덱스는 보통 모든 행에 대해 만들어집니다. B-tree 리프가 행을 가리키죠.
ClickHouse 는 다릅니다. 공식 문서의 표현을 그대로 옮기면,
"The primary key also does not reference individual rows but blocks of 8192 rows called granules. This makes primary keys of huge data sets small enough to remain loaded in main memory."
즉 8192행을 하나의 granule 로 묶고, granule 단위로만 인덱스 마크를 만듭니다.
granule 0: rows 0 ~ 8191 -> index mark 0
granule 1: rows 8192 ~ 16383 -> index mark 1
granule 2: rows 16384 ~ 24575 -> index mark 2
...
조회가 들어오면 인덱스 마크로 대략적인 위치를 찾은 뒤, 해당 granule 만 실제로 읽어 그 안에서 걸러냅니다. 인덱스가 희소(sparse)하니 크기가 작아 메모리에 상주할 수 있고, 수십억 행이어도 인덱스 자체는 부담이 되지 않습니다.
여기서 중요한 결론이 나옵니다. ClickHouse 에서 ORDER BY는 정렬 지시가 아니라 인덱스 정의입니다. 데이터가 그 순서로 물리 정렬되어 저장되고, 그 순서에 대해서만 sparse index 가 만들어집니다.
그래서 스키마 설계의 핵심은 이 질문 하나로 수렴합니다.
"이 테이블에 들어올 조회는 어떤 컬럼으로 먼저 좁히는가?"
그 컬럼을 ORDER BY 앞쪽에 둬야 인덱스가 먹습니다. 7장에서 이 질문에 답합니다.
3.5 그 밖에 알아둘 것
PARTITION BY --- 파티션은 조회를 빠르게 만드는 장치가 아닙니다. 문서는 명시적으로 "Partitioning does not speed up queries (in contrast to the ORDER BY expression)" 라고 적습니다. 실익은 파티션 단위로 통째로 건너뛰거나(partition pruning) 통째로 지울 수 있다는 데 있습니다.
TTL --- 만료된 데이터를 자동 삭제합니다. 기본 동작은 백그라운드 머지 과정에서 만료된 행 단위 로 지워지는 것이고, ttl_only_drop_parts = 1 을 주면 파트의 일부만 만료된 경우 재작성하지 않고 파트 전체가 만료될 때까지 기다렸다가 통째로 드롭합니다. 일자 파티션과 궁합이 좋은 이유가 이것입니다 --- 하루치 파트는 함께 만료되므로 파일을 통째로 버릴 수 있습니다.
Skip Index --- ORDER BY 에 없는 컬럼으로 검색할 때 쓰는 보조 인덱스입니다. granule 을 건너뛸 수 있는지 판단하는 용도라 "skip" 입니다. 타입을 카디널리티에 맞춰 골라야 합니다.
- bloom_filter --- 확률적 존재 여부. 고유값 많은 ID(trace_id)에
- set(N) --- granule 내 고유값 N개. 등치(=/IN) 검색에 --- 범위 조건에는 동작하지 않습니다
- minmax --- granule 내 최소·최대. 범위 검색(duration, status >= 400)에
Projection --- 같은 데이터를 다른 정렬 순서로 한 벌 더 저장합니다. 문서 표현으로 "like materialized views but defined in part-level. It provides consistency guarantees along with automatic usage in queries" --- ClickHouse 가 쿼리에 맞는 쪽을 자동으로 고릅니다. 정렬 키 하나로 커버되지 않는 두 번째 조회 패턴이 있을 때 씁니다.
4. 그래서 왜 ClickHouse 인가
4.1 이 워크로드의 성격
3장의 원리를 스캐터 차트 요구사항에 대보면 이렇습니다.
- 넓은 시간 범위를 한 번에 스캔 → 컬럼 지향 + sparse index --- 필요한 컬럼의 필요한 granule 만
- 조회 컬럼은 6개 정도 → 컬럼 지향 --- 나머지 컬럼은 디스크에서 읽지 않음
- 초당 수천 건 append, 수정 없음 → MergeTree --- 새 파트만 쓰고 기존은 불변
- 개별 행 lookup 은 거의 없음 → sparse index 의 약점(단건 조회)이 문제가 되지 않음
- 보관 기간이 지나면 통째로 삭제 → 일자 파티션 + TTL
요구와 특성이 정확히 맞물립니다. 반대로 단건 조회가 잦고 수정이 많은 워크로드라면 ClickHouse 는 나쁜 선택입니다.
- OLTP (MySQL, PostgreSQL) --- 부적합. 행 지향 저장이라 이런 대량 범위 스캔에는 불리합니다
- Time-series (메트릭 특화) --- 부적합. 집계 지향이라 개별 요청 단위 보존·조회에 맞지 않는 제품이 많습니다
- OLAP (ClickHouse, Druid, Pinot) --- 적합. 컬럼 단위 읽기 + 고압축
어디까지나 이 워크로드 기준의 비교입니다. 제품군 일반론이 아니라 "넓은 범위 스캔 + append 전용 + 개별 행 보존"이라는 요구에 대한 적합도입니다.
4.2 OLAP 중에서 ClickHouse 를 고른 이유
성능보다 연동 비용이 결정적이었습니다.
OpenTelemetry Collector Contrib 에 ClickHouse Exporter 가 공식으로 들어 있습니다. 트레이스 지원은 문서 기준 beta 단계이고(이 글은 collector-contrib 0.96.0 기준 --- stability 와 기본값은 릴리스마다 바뀔 수 있습니다), 설정만으로 span 을 ClickHouse 로 흘려보낼 수 있습니다. 이 글 작성 시점 기준으로 Druid·Pinot 은 Collector Contrib 에 공식 exporter 가 없어, 같은 일을 하려면 커스텀 exporter 를 만들어야 합니다.
여기에 더해,
- 표준 SQL --- Grafana 의 ClickHouse 데이터소스로 그대로 쿼리. 2편에서 패널이 직접 SQL 을 날립니다
- 낮은 운영 복잡도 --- 단일 노드는 의존성 없이 기동, 클러스터도 ClickHouse Keeper 내장(ZooKeeper 불필요)
한 가지 주의할 점은 exporter 가 기본적으로 테이블을 자동 생성한다는 것입니다. 문서에는 create_schema 옵션이 기본 true 이며 "When set to true, will run DDL to create the database and tables" 라고 되어 있는데, 운영 배포에서는 false로 두고 스키마를 직접 관리할 것을 권장하고 있습니다.
7장에서 다룰 코덱·TTL 분리·projection 같은 튜닝을 자동 생성 스키마 위에 ALTER 로 얹을 수는 있지만, 재생성 시 유실되고 스키마 관리 주체가 둘로 갈립니다. 처음부터 직접 관리하라는 권고를 따르는 편이 좋습니다.
5. 수집 파이프라인 --- 기존 트레이싱을 건드리지 않고 분기
5.1 왜 분기해야 하는가
이미 분산 트레이싱을 운영 중인 환경이라면 대개 이런 파이프라인이 있습니다.
애플리케이션 -> OTel Collector -> (tail sampling) -> 트레이스 저장소
여기에 스캐터 차트를 얹을 때 기존 경로에 손대면 안 됩니다. 샘플링 정책이 정반대이기 때문입니다.
- 기존 트레이싱 경로 --- tail sampling(에러·슬로우 100%, 나머지 일부) · 트레이스 전체(span tree) 저장 · 개별 트레이스 상세 추적용
- 스캐터 차트 경로 --- 샘플링 없음(전체 트래픽) · 화면에 필요한 최소 컬럼만 저장 · 전체 분포·패턴 관찰용
스캐터 차트는 분포를 보는 화면이라 샘플링하면 의미가 훼손됩니다. 점 개수 자체가 트래픽량을 뜻하는데, 10% 만 남기면 그 화면은 더 이상 트래픽을 나타내지 않습니다.
전제가 하나 있습니다 --- "전체 트래픽"은 SDK 부터 collector 분기 지점까지 샘플링이 꺼져 있을(AlwaysOn) 때 이야기입니다. SDK·Agent 단계에서 head sampling 이 이미 걸려 있으면 collector 에 도착하기 전에 표본이 되어 있으므로, 이 경로만 샘플링을 꺼도 소용이 없습니다.
반대로 트레이싱 저장소에 전체 트래픽을 넣으면 비용이 감당되지 않습니다. 두 요구가 양립할 수 없으므로 경로를 나눕니다.
5.2 브로커를 경유하면 분기가 쉽다
메시지 브로커를 경유하는 구조라면 별도 consumer group 하나를 추가하는 것으로 끝납니다. 기존 collector 는 설정 한 줄도 바뀌지 않습니다.

브로커 없이 애플리케이션이 collector 로 직접 보내는 구조라면, collector 안에서 파이프라인을 둘로 나누면 됩니다. 같은 receiver 를 두 pipeline 이 공유할 수 있습니다.
service:
pipelines:
traces/tempo: # 기존 경로
receivers: [otlp]
processors: [memory_limiter, batch/tempo]
exporters: [otlp/tempo]
traces/clickhouse: # 신규 경로
receivers: [otlp] # 같은 receiver 공유
processors: [memory_limiter, filter/scatter, batch]
exporters: [clickhouse]
두 경로가 프로세서를 따로 가지므로 필터·배치 정책을 독립적으로 잡을 수 있습니다.
신규 경로의 batch 는 수치까지 명시해 둘 가치가 있습니다. MergeTree 는 INSERT 한 번이 파트 하나가 되므로(3.3절), 작은 INSERT 가 잦으면 파트가 폭증해 머지가 밀립니다. 배치가 그 완충입니다.
processors:
batch: # ClickHouse 경로 — 큰 배치로 bulk insert
timeout: 2s # 못 모아도 2초마다 내보냄 — 적재 지연의 상한
send_batch_size: 2048
send_batch_max_size: 4096
batch/tempo: # 기존 경로 — gRPC 메시지 크기 제한에 맞춘 작은 배치
timeout: 1s
send_batch_size: 256
timeout 은 2편 증분 조회에서 이벤트 시각이 적재 시각보다 늦게 도착하는 폭(배치 지연)의 평상시 상한이기도 합니다.6. 수집 대상 선정 --- 어떤 span 을 남기고 버릴 것인가
전체 트래픽을 받는다고 해서 모든 span 을 적재할 필요는 없습니다. 그리고 이 단계가 비용 통제에서 가장 효과가 큽니다. 적재하지 않은 데이터는 저장 비용도, 조회 비용도, 압축 비용도 들지 않습니다.
6.1 판단 기준
"이 span 이 화면에서 점 하나로 의미가 있는가"를 기준으로 삼았습니다. 구체적으로 세 가지를 봅니다.
- 사용자 관점의 처리 단위인가 --- 요청 하나의 진입점이거나, 눈에 띄는 하위 호출인가
- 응답시간이 의미를 갖는가 --- 이 span 의 duration 이 느려지면 사용자가 체감하는가
- 개수가 감당되는가 --- 요청 하나당 몇 개나 생기는가
6.2 버리는 것들
INTERNALspan 대부분 --- 애플리케이션 내부 메서드 호출입니다. 계측 라이브러리 설정에 따라 요청 하나당 수십~수백 개가 생길 수 있습니다. 개수는 폭증하는데 화면에서 점 하나로서의 의미는 약합니다.
다만 스케줄러(배치)는 예외입니다. INTERNAL 이지만 사용자 요청과 무관하게 자원을 점유하고, 스케줄러가 돌 때 진입 요청이 함께 느려지는 패턴이 실제로 보입니다. 그래서 스케줄러성 INTERNAL 만 골라 남깁니다.
비-HTTP CLIENTspan --- 여기가 가장 중요한 판단입니다. CLIENT 는 "외부에 요청을 보내고 응답을 기다리는" span 인데, 여기에는 DB 쿼리·캐시 조회·gRPC 호출이 전부 포함됩니다.
DB 쿼리를 점으로 찍으면 어떻게 될까요. 요청 하나가 쿼리 20개를 날리면 점이 20개 늘어납니다. 화면은 DB 호출로 뒤덮이고 정작 진입 요청이 묻힙니다. 그리고 DB 지연은 어차피 상위 요청의 duration 에 반영되므로, 진입점 점이 위로 올라가는 것으로 이미 관찰됩니다.
그래서 HTTP 속성이 있는 CLIENT만 남깁니다. 외부 API 호출은 별도로 보고 싶은 경우가 많기 때문입니다.
헬스체크 --- 오케스트레이터가 초 단위로 때리는데 응답시간이 항상 일정합니다. 화면 하단에 굵은 띠를 만들어 시야만 가립니다.
6.3 필터 구현
OTel Collector 의 filter 프로세서는 조건에 매칭되면 DROP 합니다. 남길 것이 아니라 버릴 것을 적는다는 점에 주의해야 합니다.
processors:
filter/scatter:
error_mode: ignore
traces:
span:
# 1) 내부 메서드 span 제외
- 'kind == SPAN_KIND_INTERNAL'
- 'kind == SPAN_KIND_UNSPECIFIED'
# 2) 비-HTTP CLIENT(DB·캐시·gRPC 등) 제외 — 실제 HTTP 호출만 유지
- 'kind == SPAN_KIND_CLIENT and attributes["http.request.method"] == nil and attributes["http.method"] == nil'
# 3) 헬스체크 제외
- 'IsMatch(name, ".*/actuator/health.*")'
2번 조건에서 속성을 두 개 보는 이유가 있습니다. OpenTelemetry 시맨틱 컨벤션이 안정화되면서 HTTP 메서드 속성이 http.method 에서 http.request.method 로 바뀌었습니다. 계측 라이브러리 버전이 섞여 있으면 둘 다 확인해야 합니다. 한쪽만 보면 구버전 계측을 쓰는 서비스의 HTTP Out 이 통째로 사라집니다.
error_mode: ignore 는 조건 평가 중 오류가 나도(예: 속성이 없는 span) 파이프라인을 중단하지 않게 합니다.

6.4 스케줄러를 살려내기
1번 조건이 INTERNAL 을 전부 버리므로, 스케줄러도 함께 사라집니다. 남기려면 조건을 좁혀야 합니다.
- 'kind == SPAN_KIND_INTERNAL and attributes["scheduling.type"] == nil and not IsMatch(name, ".*[Ss]cheduled.*")'
스케줄러 식별은 계측 방식에 따라 다릅니다. 커스텀 속성을 심을 수 있으면 그게 가장 확실하고, 아니면 span 이름 규칙으로 잡습니다. 이 부분은 각자 환경에 맞춰 조정이 필요한 지점입니다.
6.5 필터의 효과
적재 대상이 줄면 뒤따르는 모든 비용이 함께 줄어듭니다. DB 호출이 많은 서비스일수록 2번 조건의 효과가 큽니다. 계측 라이브러리가 만드는 INTERNAL span 수는 프레임워크와 설정에 따라 편차가 크므로, 필터 적용 전후로 적재량을 실제로 재보시길 권합니다.
-- 적재량 확인
SELECT span_category, count()
FROM apm.transactions
WHERE timestamp > now() - INTERVAL 1 HOUR
GROUP BY span_category ORDER BY count() DESC;
7. 스키마 설계
7.1 raw 를 그대로 조회하지 않는 이유
exporter 가 만드는 otel_traces 테이블은 OpenTelemetry 의 범용 구조입니다. 어떤 계측이 오더라도 담을 수 있어야 하므로, 리소스 속성·span 속성이 Map(String, String) 타입으로 들어 있습니다.
범용성의 대가는 조회 비용입니다.
- 서비스명 하나 꺼내려면 ResourceAttributes['service.name'] --- Map 접근이 매번 발생
- 정렬 키가 (ServiceName, SpanName, Timestamp, TraceId) 로, 스캐터 차트의 조회 패턴과 다름
- 화면이 쓰지 않는 컬럼이 다수 포함
그래서 적재용과 조회용을 분리합니다.
OTel Collector -> otel_traces (raw) -- Materialized View --> transactions (조회용)
범용 구조, TTL 1일 화면 전용, TTL 7일
Map 접근·타입 변환·카테고리 분류를 적재 시점에 한 번 해두면, 조회는 평평한 컬럼만 읽으면 됩니다.

7.2 컬럼 타입 고르기
CREATE TABLE apm.transactions
(
timestamp DateTime64(9) CODEC(Delta, ZSTD(1)),
trace_id FixedString(32) CODEC(ZSTD(1)),
span_id String CODEC(ZSTD(1)),
parent_span_id String CODEC(ZSTD(1)),
service_namespace LowCardinality(String),
service_name LowCardinality(String),
instance String CODEC(ZSTD(1)),
span_category LowCardinality(String),
uri String CODEC(ZSTD(1)),
path String CODEC(ZSTD(1)),
method LowCardinality(String),
status UInt16,
duration UInt32 CODEC(Delta, LZ4),
error_type String CODEC(ZSTD(1)),
error_message String CODEC(ZSTD(1)),
-- 적재 시각 — 증분 조회의 커서 (2편 8장)
inserted_at DateTime64(3) DEFAULT now64(3),
-- 종료 시각 — 화면 X축이자 모든 시간 조회의 축 (2편 7장)
ts_end DateTime64(3) MATERIALIZED toDateTime64(timestamp + toIntervalMillisecond(duration), 3),
INDEX idx_trace_id trace_id TYPE bloom_filter GRANULARITY 4,
INDEX idx_status status TYPE minmax GRANULARITY 4,
INDEX idx_duration duration TYPE minmax GRANULARITY 4,
INDEX idx_inserted_at inserted_at TYPE minmax GRANULARITY 1,
INDEX idx_ts_end ts_end TYPE minmax GRANULARITY 1
)
ENGINE = MergeTree()
PARTITION BY toDate(timestamp)
ORDER BY (service_namespace, service_name, toUnixTimestamp(timestamp), trace_id)
TTL toDateTime(timestamp) + INTERVAL 7 DAY
SETTINGS index_granularity = 8192, ttl_only_drop_parts = 1;
하나씩 근거를 보겠습니다.
LowCardinality(String) --- 공식 문서는 이렇게 설명합니다.
"LowCardinality is a superstructure that changes a data storage method and rules of data processing. ClickHouse applies dictionary coding to LowCardinality-columns."
원리는 단순합니다. 고유값을 딕셔너리에 모으고 실제 데이터는 인덱스로 저장합니다.
원본: ['order-api', 'user-service', 'order-api', 'order-api', 'user-service']
딕셔너리: ['order-api', 'user-service']
저장값: [0, 1, 0, 0, 1]
문자열 비교가 정수 비교로 바뀌고, 반복되는 작은 정수라 압축도 매우 잘 됩니다.
적용 기준이 중요합니다. 문서는 딕셔너리 고유값이 10,000개 미만일 때 효율이 좋고, "If a dictionary contains more than 100,000 distinct values, then ClickHouse can perform worse in comparison with using ordinary data types" 라고 명시합니다. 고유값이 많으면 오히려 느려집니다.
- 적용 --- service_name(고유값 10~100) · span_category(5) · method(7 내외)
- 적용 안 함 --- uri(경로 파라미터가 섞이면 고유값 폭발) · trace_id(전부 고유)
uri 가 판단이 갈리는 지점입니다. 라우트 템플릿(/api/users/{id})으로 정규화되어 들어오면 고유값이 수백 개 수준이라 적용해도 됩니다. 하지만 실제 경로(/api/users/12345)가 그대로 들어오면 고유값이 무한대에 가까워집니다. 계측이 라우트를 정규화해 주는지 먼저 확인하고 결정해야 합니다.
FixedString(32) --- trace id 는 32자 hex 로 길이가 고정입니다. 가변 길이 String 은 값마다 길이 정보를 함께 저장하지만 FixedString 은 그렇지 않아 약간 유리합니다. 다만 길이가 정말 고정인지 확인이 필요합니다 --- 짧으면 NUL 로 패딩되고, 길면 잘리는 것이 아니라 삽입 오류가 납니다.
UInt16**/** UInt32 --- status 는 HTTP 상태 코드라 최대 599, UInt16 이면 충분합니다. duration 은 밀리초 단위인데 UInt32 면 약 49일까지 표현됩니다. 타입을 넉넉하게 잡는 습관이 있다면 여기서는 반대로 가야 합니다. 작은 타입이 곧 적은 디스크이고 적은 스캔입니다.
inserted_at --- 이벤트가 일어난 시각(timestamp)이 아니라 DB 에 적재된 시각입니다. collector 가 배치·재시도로 늦게 보내면 이벤트 시각은 뒤섞여 도착하지만 적재 시각은 단조 증가합니다. "지난 조회 이후 새로 쌓인 것"을 묻는 커서는 이 성질이 필요합니다 --- 2편 8장의 증분 조회가 이 컬럼을 커서로 씁니다. DEFAULT 라 8장의 Materialized View 가 이 컬럼을 몰라도 적재 시점에 자동으로 채워집니다.
ts_end --- 화면 X축이자 모든 시간 범위 조회의 축인 종료 시각(시작 + duration)입니다. 조회 때마다 계산식으로 파생하면 인덱스를 못 타므로 MATERIALIZED 로 물리화해 두고 skip index 를 겁니다 --- 왜 이 컬럼이 필요한지는 2편 7장에서 자세히 다룹니다. DEFAULT 와 마찬가지로 INSERT 시점에 서버가 자동 계산합니다.
CODEC --- 3.2절에서 본 대로 시계열 컬럼에 Delta 를 앞세웁니다. duration 은 화면이 매번 읽는 컬럼이라 뒤를 LZ4(빠른 해제)로, timestamp 는 ZSTD(1)(높은 압축률)로 잡았습니다.
7.3 ORDER BY --- 조회 패턴이 결정한다
3.4절에서 던진 질문에 답할 차례입니다. "이 테이블에 들어올 조회는 어떤 컬럼으로 먼저 좁히는가?"
스캐터 차트의 조회는 거의 항상 이렇습니다.
특정 네임스페이스의 특정 서비스를, 특정 시간 범위로
대시보드 상단에 네임스페이스·서비스 드롭다운이 있고, 시간 범위 선택기가 있습니다. 그래서 정렬 키를 이렇게 잡습니다.
ORDER BY (service_namespace, service_name, toUnixTimestamp(timestamp), trace_id)
필터가 정렬 키의 프리픽스와 정확히 맞물립니다. 네임스페이스로 좁히고, 서비스로 좁히고, 그 안에서 시간 범위를 자릅니다. 각 단계가 granule 을 걸러냅니다.
마지막 trace_id 는 동률 행의 정렬을 결정적으로 만드는 tie-breaker 입니다. 앞의 세 컬럼이 같은 행이 여럿 있을 수 있기 때문입니다. 주의할 점 --- MergeTree 의 ORDER BY 는 정렬일 뿐 중복을 제거하지 않습니다. collector 는 at-least-once 라 재시도 시 같은 span 이 두 행으로 남을 수 있고, 중복 제거가 필요하면 ReplacingMergeTree 같은 별도 장치가 필요합니다.
순서를 바꾸면 어떻게 될까요. (timestamp, service_namespace, service_name) 으로 잡으면 시간 범위 조회는 잘 되지만, 서비스 필터는 인덱스를 못 씁니다. 전체 시간 범위의 granule 을 다 읽고 그 안에서 서비스를 걸러야 합니다. 정렬 키의 순서가 곧 조회 성능의 순서입니다.
7.4 두 번째 조회 패턴 --- 전체를 시간 범위로만
그런데 서비스를 지정하지 않고 전체를 시간 범위로만 보고 싶을 때가 있습니다. "지금 전사적으로 뭔가 이상한가"를 볼 때죠.
이 조회는 7.3의 정렬 키로는 도움을 못 받습니다. 프리픽스인 네임스페이스·서비스 조건이 없기 때문입니다.
이 패턴은 7.2의 ts_end + minmax skip index 가 함께 해결합니다. skip index 는 정렬 키와 달리 프리픽스가 없어도 granule 마다 min/max 를 대조해 시간 범위 밖을 걸러 냅니다. 서비스별로 정렬된 각 구간 안에서 시간 순서가 유지되므로 granule 별 min/max 가 촘촘하고, 프루닝 효율은 정렬 키에 준합니다.
같은 문제를 projection(시간 우선 정렬을 한 벌 더 저장 --- 1편 3.5)으로 풀 수도 있습니다. 다만 저장 공간과 INSERT 비용을 추가로 내는 방식이라, 조회 축이 종료 시각 하나로 통일된 이 설계에서는 ts_end 인덱스만으로 충분해 projection 은 쓰지 않았습니다.
실제로 인덱스를 타는지는 EXPLAIN 으로 확인할 수 있습니다.
EXPLAIN indexes = 1
SELECT count() FROM apm.transactions
WHERE ts_end BETWEEN now() - INTERVAL 1 HOUR AND now();
7.5 Skip Index --- 정렬 키 밖의 컬럼
정렬 키에 없는 컬럼으로 검색할 때를 위한 보조 인덱스입니다. 다섯 개를 걸었습니다.
INDEX idx_trace_id trace_id TYPE bloom_filter GRANULARITY 4,
INDEX idx_status status TYPE minmax GRANULARITY 4,
INDEX idx_duration duration TYPE minmax GRANULARITY 4,
INDEX idx_inserted_at inserted_at TYPE minmax GRANULARITY 1,
INDEX idx_ts_end ts_end TYPE minmax GRANULARITY 1
bloom_filterontrace_id --- 화면에서 점 하나를 클릭해 트레이스를 추적할 때 쓰입니다. 고유값이 극단적으로 많아 set 은 무의미하고, bloom filter 의 "확실히 없음" 판정으로 대부분의 granule 을 건너뜁니다.
minmaxon status --- WHERE status >= 400 같은 에러 필터용입니다. 처음에는 "카디널리티가 낮으니 set" 이라고 생각하기 쉬운데, set 인덱스는 등치( =/ IN**) 매칭 전용이라 범위 조건에는 동작하지 않습니다.** 범위 필터에는 minmax 가 맞습니다 --- granule 의 최대 status 가 400 미만이면(전부 정상 응답) 통째로 건너뜁니다.
minmaxonduration --- WHERE duration > 1000 같은 느린 요청 필터용입니다. granule 의 최대 duration 이 1000 이하면 읽을 필요가 없습니다.
minmaxoninserted_at --- 2편 증분 조회의 델타(WHERE inserted_at > 커서)용입니다. 파트가 적재 순서대로 생성되므로 minmax 만으로 과거 파트를 통째로 건너뛰고 최근 파트만 읽습니다. GRANULARITY 1 로 정밀도를 최대로 둡니다.
minmaxonts_end --- 모든 시간 범위 조회(WHERE ts_end BETWEEN ...)의 주 프루닝 축입니다. 정렬 키(시작 시각)와 거의 단조라 granule 별 min/max 가 촘촘합니다. 이 인덱스가 왜 정렬 키 대신 조회를 받치는지는 2편 7장의 주제입니다. 역시 GRANULARITY 1 입니다.
GRANULARITY 4 는 granule 4개마다 인덱스 항목 하나를 만든다는 뜻입니다. 1이면 정밀도가 최대지만 인덱스가 커지고, 8이면 반대입니다. 4가 무난한 출발점입니다.
7.6 파티션과 TTL --- 지우기 위한 설계
PARTITION BY toDate(timestamp)
TTL toDateTime(timestamp) + INTERVAL 7 DAY
3.5절에서 본 대로 파티션은 조회 가속 장치가 아닙니다. 여기서의 목적은 삭제입니다.
일자 파티션이면 하루치가 파트 그룹으로 묶여 있고, 7일이 지나면 그 그룹이 통째로 만료 조건을 만족합니다. 3.5절에서 본 ttl_only_drop_parts = 1 이 여기서 힘을 씁니다 --- 만료된 행을 골라 지우며 파트를 재작성하는 대신, 파트 전체가 만료될 때까지 기다렸다가 파일을 통째로 버립니다. I/O 가 훨씬 적어, 7.2의 CREATE TABLE SETTINGS 에도 함께 넣었습니다.
7.7 TTL 을 두 단계로 나누기
여기가 스토리지 비용에서 가장 크게 작용한 결정입니다.
otel_traces (raw) TTL 1일 <- Materialized View 변환의 입력. 화면은 조회하지 않음
transactions (조회) TTL 7일 <- 화면이 실제로 읽는 테이블
raw 는 Materialized View 가 읽고 변환하면 역할이 끝납니다. 화면은 건드리지 않으므로 오래 둘 이유가 없습니다. 반면 조회용은 화면이 요구하는 기간만큼 유지해야 합니다.
이 분리만으로 로컬 기준 스토리지가 약 70% 줄었습니다 (로컬 실측). raw 가 범용 구조라 부피가 크기 때문입니다.
클러스터 구성에서는 주의가 필요합니다. 분산 테이블 구조에 따라 조회용 테이블이 VIEW 형태가 되면 exporter 의 ttl 설정이 그대로 전파되어, raw 만 짧게 두려던 의도가 깨질 수 있습니다. 단일 노드에서 검증한 TTL 값을 클러스터에 그대로 옮기기 전에 실제 만료 동작을 확인 하시기 바랍니다. 저는 여기서 한 번 데이터를 잃을 뻔했습니다.
8. Materialized View 변환 --- 적재 시점에 비용을 치른다
8.1 ClickHouse 의 Materialized View 는 트리거다
ClickHouse 의 Materialized View 는 다른 DB 의 Materialized View 와 성격이 다릅니다. 공식 문서는 이렇게 못 박습니다.
"Unlike in transactional databases like Postgres, a ClickHouse materialized view is just a trigger that runs a query on blocks of data as they're inserted into a table."
INSERT 트리거입니다. 스냅샷을 저장했다가 갱신하는 물건이 아니라, 들어오는 블록마다 변환 쿼리를 실행해 대상 테이블에 씁니다.
두 가지 함의가 있습니다.
- 변환 비용은 조회 시점이 아니라 적재 시점에 지불됩니다 --- 조회는 빨라집니다
- 기존 데이터는 처리하지 않습니다. Materialized View 를 나중에 만들면 그 이전 데이터는 별도 INSERT INTO ... SELECT 로 채워야 합니다
두 번째가 실수하기 쉬운 지점입니다. Materialized View 를 만들고 "왜 데이터가 없지?" 하는 상황이 자주 나옵니다.
8.2 변환 내용
Materialized View 가 하는 일은 네 가지입니다.
CREATE MATERIALIZED VIEW apm.transactions_mv
TO apm.transactions
AS
SELECT
Timestamp AS timestamp,
toFixedString(TraceId, 32) AS trace_id,
SpanId AS span_id,
ParentSpanId AS parent_span_id,
-- (1) Map 접근을 평평한 컬럼으로
coalesce(nullIf(ResourceAttributes['service.namespace'], ''), 'default') AS service_namespace,
ServiceName AS service_name,
coalesce(nullIf(ResourceAttributes['service.instance.id'], ''), 'unknown') AS instance,
-- (2) SpanKind 를 화면 카테고리로 분류 (스케줄러 판정은 6.4 필터와 같은 규칙)
multiIf(
SpanKind = 'SPAN_KIND_SERVER', 'HTTP_IN',
SpanKind = 'SPAN_KIND_CLIENT', 'HTTP_OUT',
SpanKind = 'SPAN_KIND_CONSUMER', 'MESSAGING_IN',
SpanKind = 'SPAN_KIND_PRODUCER', 'MESSAGING_OUT',
SpanKind = 'SPAN_KIND_INTERNAL' AND (
SpanAttributes['scheduling.type'] != ''
OR SpanName LIKE '%scheduled%' OR SpanName LIKE '%Scheduled%'
), 'SCHEDULED',
'OTHER'
) AS span_category,
-- (3) 프로토콜별로 흩어진 식별자를 uri 하나로 통일 (fallback 체인의 이유는 8.4)
multiIf(
SpanKind IN ('SPAN_KIND_CONSUMER', 'SPAN_KIND_PRODUCER'),
concat('[', coalesce(nullIf(SpanAttributes['messaging.system'], ''), 'mq'), '] ',
coalesce(nullIf(SpanAttributes['messaging.destination.name'], ''), SpanName)),
SpanKind = 'SPAN_KIND_CLIENT',
concat('[OUT] ', coalesce(
nullIf(SpanAttributes['http.route'], ''),
nullIf(SpanAttributes['url.path'], ''),
nullIf(SpanAttributes['http.target'], ''),
SpanName)),
coalesce(
nullIf(SpanAttributes['http.route'], ''),
nullIf(SpanAttributes['url.path'], ''),
nullIf(SpanAttributes['http.target'], ''),
SpanName)
) AS uri,
-- (4) 단위·타입 변환 — exporter 의 Duration 은 나노초, HTTP status 는 속성에서
toUInt32(Duration / 1000000) AS duration,
toUInt16OrZero(coalesce(
nullIf(SpanAttributes['http.response.status_code'], ''),
nullIf(SpanAttributes['http.status_code'], ''),
'0'
)) AS status,
...
FROM apm.otel_traces;
두 가지 전제를 명시해 둡니다.
- 'SPAN_KIND_SERVER'라는 리터럴은 collector-contrib 0.96.0 이 저장하는 값입니다. exporter 는 버전에 따라 SpanKind 를 'Server' 형태로 저장한 이력이 있어(이후 스펙 enum 값으로 정합화), 문자열이 어긋나면 모든 span 이 OTHER 로 빠집니다. 도입할 때 SELECT DISTINCT SpanKind FROM apm.otel_traces 로 실제 값을 확인하고 MV 를 그에 맞추세요.
- SERVER → HTTP_IN 매핑은 진입점이 HTTP 뿐인 환경 기준입니다. SERVER 에는 gRPC 같은 다른 동기 RPC 서버 span 도 들어올 수 있습니다. 그런 환경이라면 CLIENT 분기처럼 HTTP 속성 존재를 확인해 나누거나, 카테고리 이름을 프로토콜 중립으로 잡는 편이 맞습니다.
8.3 Map 접근에서 조심할 것
nullIf 가 왜 필요한지 짚고 넘어가겠습니다.
ClickHouse 의 Map 은 없는 키를 조회하면 NULL 이 아니라 value 타입의 기본값을 반환합니다 --- Map(String, String) 이면 빈 문자열입니다. 그래서 coalesce 만 쓰면 fallback 이 동작하지 않습니다.
-- 잘못된 방식: 키가 없으면 '' 이 반환되고, coalesce 는 '' 을 값으로 취급
coalesce(SpanAttributes['http.route'], SpanName) -- 키가 없으면 SpanName 대신 '' 이 값이 됨
-- 올바른 방식: '' 을 NULL 로 바꾼 뒤 fallback
coalesce(nullIf(SpanAttributes['http.route'], ''), SpanName)
저는 이 차이를 모르고 짰다가 uri 컬럼이 전부 빈 문자열로 채워지는 것을 나중에 발견했습니다. Map 을 다룰 때는 nullIf를 습관처럼 붙이는 편이 안전합니다.
8.4 시맨틱 컨벤션 변화에 대비하기
uri 변환에서 속성을 여러 개 순서대로 시도하는 것이 보이실 겁니다.
coalesce(
nullIf(SpanAttributes['http.route'], ''), -- 라우트 템플릿 (선호)
nullIf(SpanAttributes['url.path'], ''), -- 신규 컨벤션
nullIf(SpanAttributes['http.target'], ''), -- 구버전 컨벤션
SpanName -- 최후 fallback
)
OpenTelemetry 시맨틱 컨벤션은 안정화 과정에서 속성 이름이 여러 번 바뀌었습니다. 계측 라이브러리 버전이 서비스마다 다르면 한 이름만 보면 일부 서비스의 데이터가 통째로 빕니다.
http.route 를 가장 앞에 두는 이유도 있습니다. 라우트 템플릿(/api/users/{id})이라 카디널리티가 낮습니다. 실제 경로(url.path)를 먼저 쓰면 uri 고유값이 폭발해 7.2절의 LowCardinality 판단까지 뒤집힙니다.
9. 마치며
1편 정리
여기까지가 데이터를 모아서 쌓는 부분입니다. 정리하면,
- 점 하나의 정의부터 정합니다 --- span 인지 요청인지, X축이 시작인지 종료인지. 뒤의 모든 설계가 여기서 따라옵니다
- ClickHouse 가 빠른 이유는 컬럼 지향 + sparse index 이고, 그래서 ORDER BY 는 정렬이 아니라 인덱스 정의입니다
- 기존 트레이싱 경로는 건드리지 말고 분기합니다. 샘플링 정책이 정반대이기 때문입니다
- 적재 시점 필터가 비용 통제에서 가장 효과가 큽니다. 조회 최적화보다 먼저 봐야 합니다
- 범용 raw 테이블을 그대로 조회하지 말고 Materialized View 로 조회 전용 테이블을 만듭니다. 변환 비용을 적재 시점으로 옮기는 거래입니다
특히 조심할 것
- LowCardinality 는 고유값이 많으면 오히려 느려집니다. uri 에 적용할지는 계측이 라우트를 정규화하는지 확인하고 결정하세요
- ClickHouse Map(String, String) 은 없는 키에 빈 문자열을 반환합니다. nullIf 없이 coalesce 를 쓰면 fallback 이 동작하지 않습니다
- OTel 시맨틱 컨벤션 변화 때문에 속성 이름은 여러 개를 순서대로 시도해야 합니다
- 클러스터 구성에서 TTL 전파 동작을 직접 확인하세요
2편 예고
2편에서는 이 데이터를 화면으로 만듭니다.
- Grafana 기본 패널로는 왜 안 되는가
- 패널 플러그인 스캐폴딩부터 개발 환경 구성까지
- 종료 시각이라는 조회 축을 스키마에 어떻게 새길 것인가 (계산식 조회의 함정)
- 만들고 나서야 보인 함정 3가지(무작위 절단, 픽셀보다 많은 점, 갱신 주기와 조회 범위의 곱)와 그 대응 --- 서버 격자 집계, 사전 집계 롤업, 증분 조회
- 화면에 나타나는 패턴을 읽는 법
참고 자료
OpenTelemetry
ClickHouse
OpenTelemetry + ClickHouse 로 APM 스캐터 차트 만들기 [1편]--- 수집과 저장 was originally published in 여기어때 기술블로그 on Medium, where people are continuing the conversation by highlighting and responding to this story.
