ClickHouse에서 Map 타입 다루기

ClickHouse에서 Map 타입 다루기

OpenTelemetry에서 모든 트레이스 스팬은 resource attributes — 텔레메트리를 생성한 엔티티를 설명하는 키-값 메타데이터 — 를 담고 있어요. 키 집합이 서비스마다 달라서 ClickHouse의 Map 타입에 아주 잘 맞죠. 이 퀵스타트에서는 clickhouse-local로 실제 OTel 트레이스 데이터를 Map(LowCardinality(String), String) 컬럼에 로드하고, 맵 데이터를 쿼리·필터·집계·최적화하는 방법을 배워요.

출처: Working with the Map type in ClickHouse

본문

사전 요구사항 (Prerequisites)

만들게 될 것 (What you'll build)

OpenTelemetry에서 모든 트레이스 스팬은 resource attributes — 텔레메트리를 생성한 엔티티(서비스 이름, 호스트, 클라우드 리전, Kubernetes 파드 등)를 설명하는 키-값 메타데이터 — 의 집합을 담고 있어요. 키 집합은 서비스와 환경에 따라 달라져서 ClickHouse의 Map 타입에 자연스럽게 맞아요: 키는 동적이고 애플리케이션 전용이지만, 특정 행은 보통 그중 몇 개만 가져요. 이 퀵스타트에서는 clickhouse-local을 사용해서 CSV 파일의 실제 OTel 트레이스 데이터를 Map(LowCardinality(String), String) 컬럼이 있는 테이블에 로드하고, 맵 데이터를 쿼리·필터·집계·최적화하는 방법을 배워요.

1. 샘플 데이터 내려받기

데이터셋은 데모 마이크로서비스 애플리케이션에서 내보낸 OTel 트레이스 스팬 6,120개를 담고 있어요. 각 행에는 JSON 맵으로 된 동적 키-값 쌍을 담은 ResourceAttributesSpanAttributes 컬럼이 있어요. 파일을 참조하기 쉬운 디렉터리(예: ~/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, mapFilterARRAY JOIN은 SQL을 벗어나지 않고 맵 데이터를 탐색·변환하는 풍부한 툴킷을 제공해요.
  • -Map 집계 컴바이너(sumMap, avgMap, maxMap 등)는 각 키를 행 전체에 걸쳐 독립적으로 집계해요 — OTel 메트릭 카운터를 키 집합을 미리 알 필요 없이 롤업하기에 이상적이죠. 다른 컴바이너와도 합성됩니다 (예: sumMapIf).

다음 단계 (Next steps)

다음 퀵스타트를 확인해 보세요:

또는 레퍼런스 문서로 더 깊이 들어가 보세요:

더 알아보기 (Learn more)