본문으로 건너뛰기
clickhouse검색
SQL과 쿼리

배열, Map, JSON 다루기 (arrayJoin, 람다, JSON 타입과 서브컬럼)

반정형 데이터를 ClickHouse에서 다루는 세 가지 길인 Array, Map, JSON 타입을 비교하고, arrayJoin과 람다 함수, JSON 서브컬럼 조회, 저장 비용과 성능 차이를 실전 예제로 정리합니다.

ArrayMapJSONarrayJoin람다반정형2026-10 기준, 약 13분상태 표시 설명

세 가지 그릇 비교

타입모양잘 맞는 데이터조회 성능
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.^useruser 아래 전체를 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
분석에 핵심인 필드처음부터 일반 컬럼

공식 문서

이 문서에서