배열, Map, JSON 다루기 (arrayJoin, 람다, JSON 타입과 서브컬럼)
반정형 데이터를 ClickHouse에서 다루는 세 가지 길인 Array, Map, JSON 타입을 비교하고, arrayJoin과 람다 함수, JSON 서브컬럼 조회, 저장 비용과 성능 차이를 실전 예제로 정리합니다.
세 가지 그릇 비교
| 타입 | 모양 | 잘 맞는 데이터 | 조회 성능 |
|---|---|---|---|
Array(T) | 같은 타입 값의 목록 | 태그, 상품 ID 목록, 시계열 샘플 | 매우 좋음. 컬럼 하나에 원소 전체가 연속 저장 |
Map(K, V) | 키와 값 쌍 | 라벨, 속성 가방(키가 수백 개 안쪽) | 좋음. .keys, .values 서브컬럼으로 읽음 |
JSON | 중첩 객체, 키마다 타입이 다름 | 로그 페이로드, 이벤트 속성 전체 | 경로마다 서브컬럼으로 저장되어 자주 쓰는 경로는 일반 컬럼처럼 빠름 |
String + JSON 함수 | 원문 문자열 | 스키마가 전혀 없고 가끔만 보는 데이터 | 조회할 때마다 파싱. 느림 |
"구조가 보이면 컬럼으로, 반복되는 같은 타입은 Array로, 키가 유동적이면 Map 또는 JSON으로" 가 기본 원칙입니다. 전체 타입 선택은 데이터 타입 고르기에서 다룹니다.
Array 기본
CREATE TABLE orders
(
order_id UInt64,
ordered_at DateTime,
item_ids Array(UInt32),
prices Array(Decimal(10, 2)),
tags Array(LowCardinality(String))
)
ENGINE = MergeTree ORDER BY (ordered_at, order_id);| 함수 | 하는 일 |
|---|---|
length(a), empty(a), notEmpty(a) | 길이, 비었는지 |
has(a, x), hasAll(a, b), hasAny(a, b) | 포함 여부 |
indexOf(a, x), a[1], a[-1] | 위치, 원소 (1부터 시작, 음수는 뒤에서) |
arraySort, arrayReverseSort, arrayDistinct, arrayCompact | 정렬, 중복 제거 |
arraySum, arrayAvg, arrayMin, arrayMax, arrayProduct | 원소 집계 |
arraySlice, arrayConcat, arrayFlatten, arrayZip | 자르기, 붙이기, 평탄화, 짝짓기 |
arrayStringConcat(a, ','), splitByChar(',', s) | 문자열 변환 |
range(n), arrayEnumerate(a), arrayEnumerateUniq(a) | 번호 배열 |
arrayDifference, arrayCumSum | 차분, 누적합 |
-- 태그 둘 중 하나라도 있고, 금액 합이 10만 원 넘는 주문
SELECT order_id, arraySum(prices) AS total
FROM orders
WHERE hasAny(tags, ['promo', 'coupon']) AND total > 100000
ORDER BY total DESC
LIMIT 20;람다 함수: arrayMap, arrayFilter, arrayExists
고차 함수는 함수(x -> 식, 배열) 모양입니다. 람다를 쓰면 배열을 풀지 않고 원소마다 변환과 조건을 적용합니다.
SELECT
arrayMap(x -> x * 1.1, prices) AS prices_with_tax,
arrayFilter(x -> x > 10000, prices) AS big_items,
arrayExists(x -> x > 100000, prices) AS has_expensive,
arrayAll(x -> x > 0, prices) AS all_positive,
arrayFirst(x -> x > 10000, prices) AS first_big,
arrayCount(x -> x > 10000, prices) AS n_big
FROM orders
LIMIT 3;두 배열을 같이 돌리려면 인자를 두 개 받습니다. arrayMap((id, p) -> (id, p), item_ids, prices)처럼 길이가 같은 배열을 짝지어 튜플 배열을 만들고, arraySort((id, p) -> -p, item_ids, prices)처럼 다른 배열 기준으로 정렬할 수도 있습니다.
팁
arrayReduce('sum', a)처럼 집계 함수 이름을 문자열로 넘겨 배열에 적용할 수 있고,arrayReduce('uniq', a),arrayReduce('quantile(0.9)', a)도 됩니다. 집계 함수 전체를 배열에 쓰는 뒷문입니다.
ARRAY JOIN: 배열을 행으로 펴기
배열 원소마다 행을 만들어야 할 때(상품별 매출, 태그별 집계) ARRAY JOIN을 씁니다. LEFT ARRAY JOIN은 빈 배열인 행도 남깁니다.
SELECT item_id, count() AS orders, sum(price) AS gmv
FROM orders
ARRAY JOIN item_ids AS item_id, prices AS price
WHERE ordered_at >= today() - 7
GROUP BY item_id
ORDER BY gmv DESC
LIMIT 10;- 여러 배열을 한 ARRAY JOIN에 나열하면 같은 위치끼리 묶입니다(길이가 같아야 함).
arrayEnumerate(item_ids) AS idx를 함께 쓰면 원소 위치도 얻습니다.- SELECT 안에서
arrayJoin(배열)함수를 쓰면 같은 효과를 내면서 다른 컬럼은 복제됩니다. - 집계가 목적이라면 ARRAY JOIN보다
-Array컴비네이터(sumArray,uniqArray)가 행을 늘리지 않아 빠릅니다. 집계 함수와 컴비네이터
Nested와 Tuple
Nested(a UInt32, b String)는 내부적으로 a Array(UInt32), b Array(String) 두 배열 컬럼으로 저장됩니다(flatten_nested = 1 기본). col.a, col.b로 읽고 ARRAY JOIN col로 함께 펼칩니다. 두 배열 길이가 같다는 것을 INSERT 때 검사해 줍니다.
Tuple(UInt32, String)은 고정된 위치의 묶음이고 t.1, t.2 또는 이름 있는 튜플이면 t.name으로 읽습니다. 배열 안에 튜플을 넣어 Array(Tuple(id UInt32, score Float32))처럼 레코드 목록을 표현합니다.
Map
키가 유동적이지만 값 타입이 하나인 데이터(라벨, 헤더, 카운터)에 맞습니다.
CREATE TABLE metrics
(
ts DateTime,
name LowCardinality(String),
labels Map(LowCardinality(String), String),
value Float64
)
ENGINE = MergeTree ORDER BY (name, ts);
SELECT labels['pod'] AS pod, avg(value)
FROM metrics
WHERE name = 'cpu_usage' AND mapContains(labels, 'pod')
GROUP BY pod;m['k']는 키가 없으면 값 타입의 기본값을 돌려줍니다. 존재 확인은mapContains.mapKeys(m),mapValues(m),mapFilter((k, v) -> ..., m),mapApply,mapFromArrays(keys, values).- 저장은
.keys,.values두 배열 서브컬럼입니다.labels.keys만 읽으면 값은 읽지 않습니다. - 특정 키를 아주 자주 조회하면
MATERIALIZED labels['pod']컬럼으로 뽑아 두고 그 컬럼에 스킵 인덱스를 거는 편이 빠릅니다. 스킵 인덱스와 프로젝션
JSON 타입 (25.3부터 GA)
옛 Object('json')을 대체하는 새 JSON 타입입니다. 핵심은 경로마다 서브컬럼을 자동으로 만들어 컬럼 저장의 이점을 그대로 누린다는 점입니다.
CREATE TABLE events
(
ts DateTime64(3),
kind LowCardinality(String),
data JSON(
user.id UInt64, -- 타입을 고정하는 경로
max_dynamic_paths = 256, -- 따로 저장할 경로 수 상한
SKIP debug.trace -- 저장하지 않을 경로
)
)
ENGINE = MergeTree ORDER BY (kind, ts);
INSERT INTO events FORMAT JSONEachRow
{"ts":"2026-10-21 09:00:00.000","kind":"click","data":{"user":{"id":7,"plan":"pro"},"page":"/pricing","dur":1.5}}조회 문법:
| 쓰기 | 뜻 |
|---|---|
data.user.plan | 서브컬럼 읽기. 타입은 Dynamic (여러 타입이 섞일 수 있음) |
data.dur.:Float64 | 특정 타입으로 읽기. 다른 타입인 행은 NULL |
data.^user | user 아래 전체를 JSON으로 |
data['some key.with dots'] | 점이나 공백이 들어간 키 (26.9부터) |
dynamicType(data.dur) | 그 행에서 실제 저장된 타입 이름 |
JSONAllPaths(data), JSONDynamicPaths(data), JSONSharedDataPaths(data) | 경로 목록 확인 |
SELECT
data.page AS page,
avg(data.dur.:Float64) AS avg_dur,
uniq(data.user.id) AS users
FROM events
WHERE kind = 'click' AND ts >= now() - INTERVAL 1 DAY
GROUP BY page
ORDER BY users DESC;동작 원리와 비용
- 경로마다 별도 컬럼 파일이 생기므로 자주 쓰는 경로는 일반 컬럼처럼 빠릅니다.
- 경로 수가
max_dynamic_paths를 넘으면 나머지는 공유 데이터(키와 값을 함께 저장한 영역)에 들어가 조회가 느려집니다. 로그처럼 키가 폭발하는 데이터는 상한을 넉넉히 두거나 키를 정리해서 넣습니다. - 한 경로에 타입이 여러 개 섞이면
Dynamic이 그 안에서 타입별로 나눠 저장합니다.max_dynamic_types가 상한입니다. - 타입을 아는 경로는 선언에서 고정하면(
user.id UInt64) 검사와 조회가 가장 빠르고, 정렬 키나 인덱스에도 쓸 수 있습니다. 서브컬럼은 아직 기본 키에 직접 넣을 수 없으므로 자주 거르는 값은MATERIALIZED컬럼으로 뽑아 둡니다. - 머지 과정에서 경로 수가 상한을 넘으면 일부 경로가 공유 데이터로 밀릴 수 있어, 조회 성능이 파트마다 달라 보일 때가 있습니다.
JSONDynamicPaths로 확인합니다.
주의 옛
Object('json')타입은 더 쓰지 않습니다. 새 JSON은allow_experimental_json_type없이 바로 쓸 수 있는 GA 타입이고,Variant와Dynamic타입 위에 만들어졌습니다. 25.3 이전 서버와 섞인 클러스터에서는 쓰지 않습니다.
String으로 두고 JSON 함수로 읽기
스키마가 전혀 없고 아주 가끔 보는 원문이라면 String으로 두고 함수로 꺼냅니다. 파싱은 simdjson 기반이라 빠른 편이지만 매 조회마다 전체 문자열을 읽습니다.
SELECT
JSONExtractString(raw, 'user', 'plan') AS plan,
JSONExtractInt(raw, 'user', 'id') AS uid,
JSONExtractArrayRaw(raw, 'items') AS items_raw,
JSON_VALUE(raw, '$.page') AS page,
JSONHas(raw, 'debug') AS has_debug
FROM raw_events
LIMIT 5;JSONExtract(raw, 'items', 'Array(Tuple(id UInt32, qty UInt8))')처럼 타입을 문자열로 넘겨 한 번에 구조화할 수 있습니다.- 원문을 그대로 보관하되 자주 쓰는 필드는
MATERIALIZED JSONExtractString(raw, 'page')컬럼으로 뽑아 두는 혼합 전략이 흔합니다. - 수집 단계에서 구조화할 수 있으면 JSON 타입이나 일반 컬럼이 늘 낫습니다. 포맷별 옵션은 입출력 포맷에 있습니다.
어떤 걸 고를까
| 상황 | 추천 |
|---|---|
| 태그, ID 목록처럼 같은 타입 반복 | Array(T), 문자열이면 Array(LowCardinality(String)) |
| 키 수백 개 안쪽, 값 타입 하나 (라벨) | Map(LowCardinality(String), String) |
| 키가 수천 개, 타입이 섞임, 중첩 깊음 (이벤트 속성) | JSON, 자주 쓰는 경로는 타입 고정 |
| 거의 안 보는 원문 보관 | String + 필요한 필드만 MATERIALIZED |
| 분석에 핵심인 필드 | 처음부터 일반 컬럼 |