메인 콘텐츠로 건너뛰기
데이터 타입 Map(K, V)는 키-값 쌍을 저장합니다. 다른 데이터베이스와 달리 ClickHouse에서는 맵의 키가 고유하지 않습니다. 즉, 하나의 맵에 동일한 키를 가진 요소가 두 개 있을 수 있습니다. (이는 맵이 내부적으로 Array(Tuple(K, V))로 구현되기 때문입니다.) m에서 키 k의 값을 가져오려면 m[k] 구문을 사용할 수 있습니다. 또한 m[k]는 맵 전체를 스캔하므로, 이 연산의 런타임은 맵 크기에 선형적으로 비례합니다. 매개변수
  • K — Map 키의 타입입니다. NullableNullable 타입이 중첩된 LowCardinality를 제외한 임의의 타입을 사용할 수 있습니다.
  • V — Map 값의 타입입니다. 임의의 타입을 사용할 수 있습니다.
예시 맵 타입 컬럼이 있는 테이블을 생성합니다:
Query
CREATE TABLE tab (m Map(String, UInt64)) ENGINE=Memory;
INSERT INTO tab VALUES ({'key1':1, 'key2':10}), ({'key1':2,'key2':20}), ({'key1':3,'key2':30});
key2의 값을 선택하려면:
Query
SELECT m['key2'] FROM tab;
Response
┌─arrayElement(m, 'key2')─┐
│                      10 │
│                      20 │
│                      30 │
└─────────────────────────┘
요청한 키 k가 맵에 없으면 m[k]는 값 타입의 기본값을 반환합니다. 예를 들어 정수 타입은 0, 문자열 타입은 ''를 반환합니다. 맵에 키가 존재하는지 확인하려면 mapContains 함수를 사용할 수 있습니다.
Query
CREATE TABLE tab (m Map(String, UInt64)) ENGINE=Memory;
INSERT INTO tab VALUES ({'key1':100}), ({});
SELECT m['key1'] FROM tab;
Response
┌─arrayElement(m, 'key1')─┐
│                     100 │
│                       0 │
└─────────────────────────┘

Tuple을 Map으로 변환하기

Tuple() 타입의 값은 CAST 함수를 사용해 Map() 타입의 값으로 변환할 수 있습니다: 예시
Query
SELECT CAST(([1, 2, 3], ['Ready', 'Steady', 'Go']), 'Map(UInt8, String)') AS map;
Response
┌─map───────────────────────────┐
│ {1:'Ready',2:'Steady',3:'Go'} │
└───────────────────────────────┘

맵의 서브컬럼 읽기

전체 맵을 읽지 않으려면 일부 경우에는 서브컬럼 keysvalues를 사용할 수 있습니다. 예시
Query
CREATE TABLE tab (m Map(String, UInt64)) ENGINE = Memory;
INSERT INTO tab VALUES (map('key1', 1, 'key2', 2, 'key3', 3));

SELECT m.keys FROM tab; --   mapKeys(m)와 동일
SELECT m.values FROM tab; -- mapValues(m)와 동일
Response
┌─m.keys─────────────────┐
│ ['key1','key2','key3'] │
└────────────────────────┘

┌─m.values─┐
│ [1,2,3]  │
└──────────┘

MergeTree의 버킷형 맵 직렬화

기본적으로 MergeTree의 Map 컬럼은 단일 Array(Tuple(K, V)) 스트림으로 저장됩니다. m['key']로 특정 키 하나를 읽으려면, 하나의 키만 필요하더라도 모든 행의 모든 키-값 쌍이 들어 있는 전체 컬럼을 스캔해야 합니다. 서로 다른 키가 많은 맵에서는 이것이 병목이 됩니다. 버킷형 직렬화(with_buckets)는 키를 해시하여 키-값 쌍을 여러 개의 독립적인 하위 스트림(버킷)으로 분할합니다. 쿼리에서 m['key']에 접근하면 해당 키가 들어 있는 버킷만 디스크에서 읽고, 나머지 버킷은 모두 건너뜁니다.

버킷 단위 직렬화 활성화

CREATE TABLE tab (id UInt64, m Map(String, UInt64))
ENGINE = MergeTree ORDER BY id
SETTINGS
    map_serialization_version = 'with_buckets',
    max_buckets_in_map = 32,
    map_buckets_strategy = 'sqrt';
삽입 성능 저하를 방지하려면, 레벨 0 파트(INSERT 시 생성됨)에는 basic 직렬화를 유지하고 병합된 파트에만 with_buckets를 사용할 수 있습니다:
CREATE TABLE tab (id UInt64, m Map(String, UInt64))
ENGINE = MergeTree ORDER BY id
SETTINGS
    map_serialization_version = 'with_buckets',
    map_serialization_version_for_zero_level_parts = 'basic',
    max_buckets_in_map = 32,
    map_buckets_strategy = 'sqrt';

동작 방식

데이터 파트가 with_buckets 직렬화로 기록되면 다음과 같이 동작합니다.
  1. 행당 평균 키 수를 블록 통계에서 계산합니다.
  2. 버킷 수는 구성된 전략에 따라 결정됩니다(설정 참조).
  3. 각 키-값 쌍은 키를 해시하여 버킷에 할당됩니다: bucket = hash(key) % num_buckets.
  4. 각 버킷은 자체 키, 값, 오프셋을 가진 독립적인 서브스트림으로 저장됩니다.
  5. buckets_info 메타데이터 스트림에는 버킷 수와 통계가 기록됩니다.
쿼리가 특정 키(m['key'])를 읽으면 옵티마이저가 표현식을 키 서브컬럼(m.key_<serialized_key>)으로 재작성합니다. 직렬화 계층은 요청된 키가 어느 버킷에 속하는지 계산하고, 해당 버킷 하나만 디스크에서 읽습니다. 전체 맵을 읽을 때(예: SELECT m)는 모든 버킷을 읽어 원래 맵으로 재구성합니다. 여러 서브스트림을 읽고 머지하는 오버헤드가 있으므로 basic 직렬화보다 더 느립니다.
with_buckets 직렬화를 사용하면 맵 값 내부의 키 순서가 원래 삽입 순서와 달라질 수 있습니다. 키는 해시에 따라 여러 버킷에 분산되며, 삽입 순서가 아니라 버킷 순서대로 다시 조합됩니다. basic 직렬화에서는 삽입된 맵의 키 순서가 유지됩니다.
버킷 수는 파트마다 다를 수 있습니다. 버킷 수가 서로 다른 파트가 머지되면 새 파트의 버킷 수는 머지된 통계를 바탕으로 다시 계산됩니다. basic 직렬화와 with_buckets 직렬화는 같은 테이블에 함께 존재할 수 있으며, 머지 시에도 투명하게 처리됩니다.

설정

SettingDefaultDescription
map_serialization_versionbasicMap 컬럼의 직렬화 포맷입니다. basic은 단일 배열 스트림으로 저장합니다. with_buckets는 단일 키 읽기 성능을 높이기 위해 키를 버킷으로 나눕니다.
map_serialization_version_for_zero_level_partsbasic0-레벨 파트(INSERT로 생성됨)의 직렬화 포맷입니다. 삽입 시 쓰기 오버헤드를 피할 수 있도록 basic을 유지하면서, 병합된 파트에는 with_buckets를 사용할 수 있습니다.
max_buckets_in_map32버킷 수의 상한입니다. 실제 개수는 map_buckets_strategy에 따라 달라집니다. 허용되는 최댓값은 256입니다.
map_buckets_strategysqrt평균 맵 크기를 기준으로 버킷 수를 계산하는 전략입니다. constant — 항상 max_buckets_in_map을 사용합니다. sqrtround(coefficient * sqrt(avg_size))를 사용합니다. linearround(coefficient * avg_size)를 사용합니다. 결과는 [1, max_buckets_in_map] 범위로 제한됩니다.
map_buckets_coefficient1.0sqrtlinear 전략에 적용되는 계수입니다. 전략이 constant이면 무시됩니다.
map_buckets_min_avg_size32버킷 분할을 활성화하기 위한 행당 평균 최소 키 수입니다. 평균이 이 임계값보다 낮으면 다른 설정과 관계없이 단일 버킷이 사용됩니다. 임계값을 비활성화하려면 0으로 설정하십시오.

성능 트레이드오프

다음 표는 다양한 맵 크기(행당 키 10개~10,000개)에서 with_bucketsbasic 직렬화(serialization)와 비교했을 때의 성능 영향을 요약합니다. 버킷 수는 최대 32로 제한하는 sqrt 전략으로 결정되었습니다. 정확한 수치는 키/값 타입, 데이터 분포, 하드웨어에 따라 달라집니다.
Operation10 keys100 keys1,000 keys10,000 keysNotes
단일 키 조회 (m['key'])1.6–3.2배 빠름4.5–7.7배 빠름16–39배 빠름21–49배 빠름전체 컬럼 대신 버킷 하나만 읽습니다.
키 5개 조회~1배1.5–3.1배 빠름2.9–8.3배 빠름4.5–6.7배 빠름각 키는 해당 버킷을 읽으며, 일부 버킷은 겹칠 수 있습니다.
PREWHERE (SELECT m WHERE m['key'] = ...)1.5–3.0배 빠름2.9–7.3배 빠름5.3–31배 빠름20–45배 빠름PREWHERE 필터는 버킷 하나만 읽고, 전체 맵은 일치하는 행에 대해서만 읽습니다. 속도 향상 폭은 선택도에 따라 달라집니다 — 일치하는 그래뉼이 적을수록 전체 맵 I/O가 줄어듭니다.
전체 맵 스캔 (SELECT m)~2배 느림~2배 느림~2배 느림~2배 느림모든 버킷을 읽어 다시 조합해야 합니다.
INSERT1.5–2.5배 느림1.5–2.5배 느림1.5–2.5배 느림1.5–2.5배 느림키를 해싱하고 여러 하위 스트림에 쓰는 데 오버헤드가 발생합니다.

권장 사항

  • 작은 맵(평균 키 수 32개 미만): basic 직렬화를 유지하십시오. 작은 맵에는 버킷 사용에 따른 오버헤드가 크지 않아 이 방식이 더 적합합니다. 기본값 map_buckets_min_avg_size = 32가 이를 자동으로 적용합니다.
  • 중간 크기 맵(키 32~100개): 쿼리에서 개별 키에 자주 접근한다면 sqrt 전략과 함께 with_buckets를 사용하십시오. 단일 키 lookup은 4~8배 빨라집니다.
  • 큰 맵(키 100개 이상): with_buckets를 사용하십시오. 단일 키 lookup은 16~49배 더 빠릅니다. 삽입 속도를 기준 수준에 가깝게 유지하려면 map_serialization_version_for_zero_level_parts = 'basic' 사용을 고려하십시오.
  • 전체 맵 스캔이 워크로드의 대부분을 차지하는 경우: basic을 유지하십시오. 버킷 직렬화는 전체 스캔에서 약 2배의 오버헤드를 추가합니다.
  • 혼합 워크로드(일부 키 lookup, 일부 전체 스캔): 제로 수준 파트를 basic으로 설정한 with_buckets를 사용하십시오. PREWHERE 최적화는 필터에 해당하는 버킷만 먼저 읽고, 그다음 일치하는 행에 대해서만 전체 맵을 읽으므로 전체적으로 상당한 성능 향상을 얻을 수 있습니다.

대체 접근 방식

버킷화된 Map 직렬화가 사용 사례에 맞지 않는다면, 키 수준 접근 성능을 개선할 수 있는 대체 방법은 두 가지가 있습니다:

JSON 데이터 타입 사용

JSON 데이터 타입은 자주 등장하는 각 경로를 별도의 동적 서브컬럼으로 저장합니다. max_dynamic_paths 제한을 초과하는 경로는 공유 데이터 구조로 들어가며, 여기서는 단일 경로 읽기를 최적화하기 위해 advanced 직렬화를 사용할 수 있습니다. advanced 직렬화에 대한 자세한 내용은 블로그 글을 참조하십시오.
측면버킷이 있는 MapJSON
단일 키 읽기하나의 버킷을 읽습니다(다른 키가 포함될 수 있음). 버킷 안의 모든 key-value 쌍이 역직렬화됩니다.자주 등장하는 경로는 동적 서브컬럼에서 직접 읽습니다. 자주 등장하지 않는 경로는 공유 데이터로 들어가며, advanced 직렬화를 사용하면 해당 경로의 데이터만 읽습니다.
값 타입모든 값이 동일한 타입 V를 공유합니다각 경로는 서로 다른 타입을 가질 수 있습니다. 타입 힌트가 없는 경로는 Dynamic을 사용합니다.
스킵 인덱스 지원mapKeys/mapValues에 생성된 일부 인덱스 타입에서 작동합니다스킵 인덱스는 특정 경로 서브컬럼에만 생성할 수 있으며, 모든 경로/값에 대해 한 번에 생성할 수는 없습니다.
전체 컬럼 읽기버킷을 다시 조합해야 하므로 basic보다 약 2배 느립니다Dynamic 타입 인코딩과 경로 재구성으로 인한 오버헤드가 있습니다.
저장 오버헤드추가 메타데이터가 거의 없습니다Dynamic 타입 인코딩, 경로 이름 저장, 그리고 advanced 직렬화의 추가 메타데이터로 인해 더 큽니다.
스키마 유연성테이블 생성 시 키와 값 타입이 고정됩니다완전히 동적입니다 — 키와 값 타입이 행마다 달라질 수 있습니다. 알려진 경로에 대해서는 직접 서브컬럼에 접근할 수 있도록 타입이 지정된 경로 힌트를 선언할 수 있습니다.
서로 다른 키에 서로 다른 값 타입이 필요하거나, 행마다 키 집합이 크게 달라지거나, 자주 접근하는 키를 미리 알고 있어 직접 서브컬럼에 접근할 수 있도록 타입이 지정된 경로로 선언할 수 있는 경우에는 JSON을 사용하십시오.

여러 맵 컬럼으로 수동 세그먼트 분할

애플리케이션 수준에서 키 해시를 기준으로 단일 Map을 여러 컬럼으로 수동으로 나눌 수 있습니다:
CREATE TABLE tab (
    id UInt64,
    m0 Map(String, UInt64),
    m1 Map(String, UInt64),
    m2 Map(String, UInt64),
    m3 Map(String, UInt64)
) ENGINE = MergeTree ORDER BY id;
삽입 시 각 키-값 쌍을 컬럼(column) m{hash(key) % 4}로 라우팅합니다. 쿼리 시에는 해당 컬럼에서 읽습니다: m{hash('target_key') % 4}['target_key'].
측면버킷을 사용하는 Map수동 세그먼트 분할
사용 편의성투명하게 처리됨 — 스토리지 엔진이 처리함삽입 및 조회를 위해 애플리케이션 수준의 라우팅 로직이 필요함
수직 병합지원되지 않음 — 모든 버킷이 하나의 컬럼에 속함지원됨 — 각 Map 컬럼은 서로 독립적이며 수직으로 병합할 수 있음
스키마 변경버킷 수가 각 파트(part)별로 자동 조정됨세그먼트 수를 변경하려면 데이터를 다시 쓰거나 새 컬럼을 추가해야 함
쿼리 구문m['key']를 바로 사용할 수 있음올바른 컬럼을 계산해야 함: m0['key'], m1['key']
버킷 세분화 수준파트별이며 데이터 통계에 맞게 조정됨테이블 생성 시 고정됨
수동 세그먼트 분할은 컬럼이 많은 테이블을 머지할 때 메모리 사용량을 줄이기 위해 수직 병합이 중요하거나, 세그먼트 수를 고정하고 명시적으로 제어해야 할 때 유용합니다. 대부분의 사용 사례에서는 자동 버킷 직렬화가 더 단순하며 충분합니다. 관련 항목
마지막 수정일 2026년 6월 10일