ClickHouse에서 Map 타입 다루기
ClickHouse에서 Map 타입 다루기
OpenTelemetry에서 모든 트레이스 스팬은 resource attributes — 텔레메트리를 생성한 엔티티를 설명하는 키-값 메타데이터 — 를 담고 있어요. 키 집합이 서비스마다 달라서 ClickHouse의 Map 타입에 아주 잘 맞죠. 이 퀵스타트에서는 clickhouse-local로 실제 OTel 트레이스 데이터를 Map(LowCardinality(String), String) 컬럼에 로드하고, 맵 데이터를 쿼리·필터·집계·최적화하는 방법을 배워요.
본문
사전 요구사항 (Prerequisites)
- 머신에 clickhouse-local이 설치되어 있어요. 시작하려면 clickhouse-local 설치 가이드를 참고하세요.
만들게 될 것 (What you'll build)
OpenTelemetry에서 모든 트레이스 스팬은 resource attributes — 텔레메트리를 생성한 엔티티(서비스 이름, 호스트, 클라우드 리전, Kubernetes 파드 등)를 설명하는 키-값 메타데이터 — 의 집합을 담고 있어요. 키 집합은 서비스와 환경에 따라 달라져서 ClickHouse의 Map 타입에 자연스럽게 맞아요: 키는 동적이고 애플리케이션 전용이지만, 특정 행은 보통 그중 몇 개만 가져요. 이 퀵스타트에서는 clickhouse-local을 사용해서 CSV 파일의 실제 OTel 트레이스 데이터를 Map(LowCardinality(String), String) 컬럼이 있는 테이블에 로드하고, 맵 데이터를 쿼리·필터·집계·최적화하는 방법을 배워요.
1. 샘플 데이터 내려받기
데이터셋은 데모 마이크로서비스 애플리케이션에서 내보낸 OTel 트레이스 스팬 6,120개를 담고 있어요. 각 행에는 JSON 맵으로 된 동적 키-값 쌍을 담은 ResourceAttributes와 SpanAttributes 컬럼이 있어요. 파일을 참조하기 쉬운 디렉터리(예: ~/data/data-otel-traces.csv)에 저장하세요.
data-otel-traces.csv 내려받기 (2.9 MB)
단일 행의 모습은 이래요:
Timestamp: 2025-12-26 00:00:45.759467000
TraceId: 0da128e6e3c01bc38b6b43a33e5fa522
SpanId: 3774f759424e4006
ParentSpanId: 2fdd1e5b66605098
SpanName: orders receive
SpanKind: SPAN_KIND_CONSUMER
ServiceName: accountingservice
Duration: 5361
StatusCode: STATUS_CODE_UNSET
ResourceAttributes: {"host.name":"f19476836e47","os.type":"linux","process.pid":"1","process.command_args":"[\"./accountingservice\"]","process.executable.path":"...
SpanAttributes: {"network.transport":"tcp","messaging.destination.name":"orders","messaging.kafka.message.offset":"232260","messaging.message.body.size":"216"...
2. 테이블을 만들고 데이터 로드하기
clickhouse-local을 실행하고 CSV와 일치하는 스키마로 다음 테이블을 만들어요. 핵심 컬럼은 ResourceAttributes Map(LowCardinality(String), String)예요. OTel attribute 키는 상대적으로 작고 반복되는 집합에서 나오기 때문에 키 타입에 LowCardinality를 사용해요.
CREATE TABLE otel_traces
(
Timestamp DateTime64(9),
TraceId String,
SpanId String,
ParentSpanId String,
SpanName LowCardinality(String),
SpanKind LowCardinality(String),
ServiceName LowCardinality(String),
Duration UInt64,
StatusCode LowCardinality(String),
ResourceAttributes Map(LowCardinality(String), String),
SpanAttributes Map(LowCardinality(String), String)
)
ENGINE = MergeTree()
ORDER BY (ServiceName, SpanName, toUnixTimestamp(Timestamp));
이제 file 테이블 엔진을 사용해 CSV를 로드해요. 파일을 저장한 경로로 조정하세요:
INSERT INTO otel_traces
SELECT * FROM file('~/data/data-otel-traces.csv', CSVWithNames);
데이터가 로드됐는지 확인하세요:
SELECT count() FROM otel_traces;
6,120행이 보일 거예요.
3. 데이터 쿼리하기
특정 키 접근하기 — 대괄호 문법으로 맵에서 값을 꺼내요. 주어진 행에 키가 없으면 값 타입의 기본값( String은 빈 문자열)을 얻어요:
SELECT
ServiceName,
SpanName,
ResourceAttributes['host.name'] AS host,
ResourceAttributes['k8s.pod.name'] AS pod,
ResourceAttributes['deployment.environment'] AS env
FROM otel_traces
LIMIT 10;
맵 값으로 필터링하기 — 특정 서비스 이름의 모든 스팬을 찾아보세요:
SELECT
Timestamp,
SpanName,
Duration / 1e6 AS duration_ms
FROM otel_traces
WHERE ResourceAttributes['service.name'] = 'cartservice'
ORDER BY Timestamp
LIMIT 10;
키가 존재하는지 확인하기 — 모든 스팬이 Kubernetes 메타데이터를 갖는 건 아니에요. mapContains로 어떤 스팬이 갖고 있는지 찾아보세요:
SELECT
ServiceName,
SpanName,
mapContains(ResourceAttributes, 'k8s.node.name') AS has_node_info
FROM otel_traces
LIMIT 10;
데이터셋 전체에 존재하는 모든 키 검사하기 — 어떤 계측(instrumentation)이 무엇을 만들어 내는지 이해하는 데 유용해요:
SELECT DISTINCT arrayJoin(mapKeys(ResourceAttributes)) AS key
FROM otel_traces
ORDER BY key;
ARRAY JOIN으로 맵을 행으로 펼치기 — 각 키-값 쌍을 자체 행으로 바꿔서, attribute 인벤토리를 만들거나 대시보드에 공급할 때 유용해요:
SELECT
ServiceName,
key,
value
FROM otel_traces
ARRAY JOIN
mapKeys(ResourceAttributes) AS key,
mapValues(ResourceAttributes) AS value
WHERE ServiceName = 'cartservice'
LIMIT 20;
mapFilter로 맵 필터링하기 — 각 스팬에서 Kubernetes 관련 attribute만 추출해요:
SELECT
ServiceName,
mapFilter((k, v) -> k LIKE 'k8s.%', ResourceAttributes) AS k8s_attrs
FROM otel_traces
WHERE mapContains(ResourceAttributes, 'k8s.pod.name')
LIMIT 10;
오류 스팬과 리소스 컨텍스트 찾기 — 일반 컬럼 필터와 맵 접근을 결합해요:
SELECT
Timestamp,
ServiceName,
SpanName,
ResourceAttributes['host.name'] AS host,
ResourceAttributes['k8s.pod.name'] AS pod,
SpanAttributes['error.type'] AS error_type,
SpanAttributes['error.message'] AS error_message
FROM otel_traces
WHERE StatusCode = 'STATUS_CODE_ERROR';
4. -Map 컴바이너로 맵 전체에 집계하기
ClickHouse의 -Map 집계 컴바이너를 사용하면 임의의 집계 함수를 Map 컬럼에 적용해서 각 키에 대해 독립적으로 동작하게 할 수 있어요. 결과 자체가 Map이에요 — 키당 항목 하나에 집계값이 들어가죠. 이는 OTel 메트릭에 특히 강력해요. 카운터나 게이지가 맵 값으로 저장되거든요.
시연을 위해 각 행이 HTTP 상태 코드 카운트를 Map(String, UInt64)로 기록하는 작은 메트릭 테이블을 만들어 볼게요:
CREATE TABLE otel_http_status_counts
(
Timestamp DateTime,
ServiceName LowCardinality(String),
StatusCounts Map(String, UInt64)
)
ENGINE = MergeTree()
ORDER BY (ServiceName, Timestamp);
INSERT INTO otel_http_status_counts VALUES
('2025-12-26 10:00:00', 'cart-service', {'2xx': 150, '4xx': 12, '5xx': 3}),
('2025-12-26 10:01:00', 'cart-service', {'2xx': 200, '4xx': 8, '5xx': 1}),
('2025-12-26 10:00:00', 'inventory-service', {'2xx': 90, '4xx': 5}),
('2025-12-26 10:01:00', 'inventory-service', {'2xx': 110, '4xx': 3, '5xx': 2}),
('2025-12-26 10:00:00', 'payment-service', {'2xx': 50, '5xx': 10}),
('2025-12-26 10:01:00', 'payment-service', {'2xx': 45, '4xx': 2, '5xx': 15});
이제 sumMap을 사용해서 서비스별 상태 코드별 카운트를 합산해요:
SELECT
ServiceName,
sumMap(StatusCounts) AS total_by_status
FROM otel_http_status_counts
GROUP BY ServiceName;
-Map 접미사는 어떤 집계 함수에서도 동작하므로, minMap, maxMap, avgMap도 똑같이 쉽게 쓸 수 있어요:
SELECT
ServiceName,
avgMap(StatusCounts) AS avg_by_status,
maxMap(StatusCounts) AS peak_by_status
FROM otel_http_status_counts
GROUP BY ServiceName;
다른 컴바이너와도 결합할 수 있어요. 예를 들어 sumMapIf는 조건부로 집계할 수 있어요 — 여기서는 서비스에 이미 오류가 있던 분(minute) 윈도우만 합산해요:
SELECT
ServiceName,
sumMapIf(StatusCounts, StatusCounts['5xx'] > 0) AS totals_in_error_windows
FROM otel_http_status_counts
GROUP BY ServiceName;
OTel에 이게 왜 중요한가: OTel Collector가 분 단위 상태 코드 세부 정보를 ClickHouse에 쓰면, sumMap을 사용해 단일 쿼리로 시간별 또는 일별 총계로 롤업할 수 있어요 — ARRAY JOIN도, unpivot도, 키 집합을 미리 알 필요도 없죠. 어떤 행에 나타나는 키든 자동으로 결과에 포함돼요.
5. 자주 쿼리되는 키 최적화하기
같은 맵 키로 계속 필터링한다면 — host.name이 흔한 예 — 그 키를 매터리얼라이즈드 컬럼으로 추출할 수 있어요. 그러면 매 쿼리마다 맵을 선형 스캔하는 것을 피할 수 있어요:
ALTER TABLE otel_traces
ADD COLUMN HostName String
MATERIALIZED ResourceAttributes['host.name'];
기존 데이터에 대해서는 컬럼을 backfill하세요:
ALTER TABLE otel_traces MATERIALIZE COLUMN HostName;
이제 WHERE HostName = 'prod-cart-01'은 전체 맵 대신 단일 전용 컬럼을 읽어요. 이는 자주 쿼리하는 모든 attribute에 대해 OTel ClickHouse 스키마에서 권장되는 패턴이에요.
핵심 요점 (Key takeaways)
Map(LowCardinality(String), String)은 OTel attribute에 관용적인 타입이에요 — 다양한 키 집합을 처리할 만큼 유연하고,LowCardinality가 키 저장을 효율적으로 유지해요.- 대괄호 문법(
map['key'])이 값을 접근하는 가장 흔한 방법이지만, 선형으로 스캔한다는 걸 기억하세요 — 키 수십 개의 맵에는 좋지만 수백 개에는 이상적이지 않아요. - 매터리얼라이즈드 컬럼이 탈출구예요: 맵 키가 핫 필터 대상이 되면, 이를 실제 컬럼으로 승격해서 인덱스된 컬럼형 접근을 가능하게 해요.
mapContains,mapKeys,mapValues,mapFilter와ARRAY JOIN은 SQL을 벗어나지 않고 맵 데이터를 탐색·변환하는 풍부한 툴킷을 제공해요.-Map집계 컴바이너(sumMap,avgMap,maxMap등)는 각 키를 행 전체에 걸쳐 독립적으로 집계해요 — OTel 메트릭 카운터를 키 집합을 미리 알 필요 없이 롤업하기에 이상적이죠. 다른 컴바이너와도 합성됩니다 (예:sumMapIf).
다음 단계 (Next steps)
다음 퀵스타트를 확인해 보세요:
또는 레퍼런스 문서로 더 깊이 들어가 보세요: