Map 데이터 타입

Map 데이터 타입

Map(K, V) 데이터 타입은 키-값 쌍을 저장해요. 다른 데이터베이스와 달리 ClickHouse의 map은 유일(unique)하지 않아요. 즉 한 map에 같은 키를 가진 요소가 두 개 있을 수 있어요.

출처: 문서

본문

Map(K, V) 데이터 타입은 키-값 쌍을 저장해요. 다른 데이터베이스와 달리 ClickHouse의 map은 유일하지 않아요. 즉 한 map이 같은 키를 가진 요소를 두 개 담을 수 있어요. (그 이유는 map이 내부적으로 Array(Tuple(K, V))로 구현되어 있기 때문이에요.) m[k] 문법으로 map m에서 키 k의 값을 얻을 수 있어요. 또한 m[k]는 map을 스캔하므로, 이 연산의 실행 시간은 map의 크기에 선형적이에요.

매개변수 (Parameters)

  • K — Map 키의 타입. Nullable과 Nullable 타입과 중첩된 LowCardinality를 제외한 임의의 타입.
  • V — Map 값의 타입. 임의의 타입.

예시 (Examples)

map 타입 컬럼이 있는 테이블 만들기:

쿼리:

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 값 선택:

쿼리:

SELECT m['key2'] FROM tab;

응답:

┌─arrayElement(m, 'key2')─┐
│                      10 │
│                      20 │
│                      30 │
└─────────────────────────┘

요청한 키 k가 map에 없으면 m[k]는 값 타입의 기본값을 반환해요. 예를 들어 정수 타입은 0, 문자열 타입은 ''을 반환해요. 키가 map에 존재하는지 확인하려면 함수 mapContains를 사용할 수 있어요.

쿼리:

CREATE TABLE tab (m Map(String, UInt64)) ENGINE=Memory;
INSERT INTO tab VALUES ({'key1':100}), ({});
SELECT m['key1'] FROM tab;

응답:

┌─arrayElement(m, 'key1')─┐
│                     100 │
│                       0 │
└─────────────────────────┘

Tuple을 Map으로 변환 (Converting Tuple to Map)

Tuple() 타입의 값은 함수 CAST를 사용해 Map() 타입의 값으로 캐스팅할 수 있어요.

예시 (Example)

쿼리:

SELECT CAST(([1, 2, 3], ['Ready', 'Steady', 'Go']), 'Map(UInt8, String)') AS map;

응답:

┌─map───────────────────────────┐
│ {1:'Ready',2:'Steady',3:'Go'} │
└───────────────────────────────┘

Map의 서브컬럼 읽기 (Reading subcolumns of Map)

전체 map을 읽는 것을 피하려면 어떤 경우에는 서브컬럼 keys와 values를 사용할 수 있어요.

예시 (Example)

쿼리:

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; --   same as mapKeys(m)
SELECT m.values FROM tab; -- same as mapValues(m)

응답:

┌─m.keys─────────────────┐
│ ['key1','key2','key3'] │
└────────────────────────┘

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

MergeTree에서 버킷 Map 직렬화 (Bucketed Map Serialization in MergeTree)

기본적으로 MergeTree의 Map 컬럼은 단일 Array(Tuple(K, V)) 스트림으로 저장돼요. m['key']로 단일 키를 읽으려면 키가 하나만 필요해도 전체 컬럼 — 모든 행의 모든 키-값 쌍 — 을 스캔해야 해요. 서로 다른 키가 많은 map에서는 이것이 병목이 돼요.

버킷 직렬화(with_buckets)는 키를 해싱해 키-값 쌍을 여러 독립 서브스트림(버킷)으로 나눠요. 쿼리가 m['key']에 접근하면 그 키가 들어 있는 버킷만 디스크에서 읽고, 다른 모든 버킷은 건너뛰어요.

버킷 직렬화 활성화 (Enabling Bucketed Serialization)

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';

insert를 느려지게 하지 않으려면 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';

동작 방식 (How It Works)

데이터 파트가 with_buckets 직렬화로 쓰일 때:

  • 행당 평균 키 수가 블록 통계에서 계산돼요.
  • 버킷 수는 설정된 전략(아래 설정 참고)으로 결정돼요.
  • 각 키-값 쌍은 키 해싱으로 버킷에 배정돼요: bucket = hash(key) % num_buckets.
  • 각 버킷은 자신만의 키, 값, 오프셋을 가진 독립 서브스트림으로 저장돼요.
  • buckets_info 메타데이터 스트림이 버킷 수와 통계를 기록해요.

쿼리가 특정 키(m['key'])를 읽으면 옵티마이저가 표현식을 키 서브컬럼(m.key_<serialized_key>)으로 다시 써요. 직렬화 계층이 요청 키가 속한 버킷을 계산하고, 디스크에서 그 단일 버킷만 읽어요. 전체 map을 읽을 때(예: SELECT m)는 모든 버킷을 읽고 원래 map으로 다시 조립해요. 이는 여러 서브스트림을 읽고 병합하는 오버헤드 때문에 basic 직렬화보다 느려요.

26.8 버전부터 with_buckets 직렬화는 원래 키 순서를 보존해요. 추가 bucket_indexes 서브스트림이 어떤 버킷에서 각 키-값 쌍이 나왔는지를 기록하므로, map은 버킷 순서가 아니라 쓰인 순서로 다시 조립돼요. 이전 버전이 쓴 파트에는 그 서브스트림이 없어요. 그런 파트의 map은 여전히 버킷 순서로 다시 조립되며, 삽입 순서가 디스크에 저장된 적이 없으므로 원래 키 순서를 복원할 수 없어요. 그런 파트를 (병합이나 OPTIMIZE FINAL로) 다시 쓰면 삽입 순서를 복구하는 대신 현재 갖고 있는 버킷 순서가 고정돼요. basic 직렬화에서는 삽입된 map의 키 순서가 항상 보존되어 왔어요.

버킷 수는 파트마다 다를 수 있어요. 버킷 수가 다른 파트들이 병합되면 새 파트의 버킷 수는 병합된 통계에서 다시 계산돼요. basic과 with_buckets 직렬화의 파트는 같은 테이블에 공존할 수 있고 투명하게 병합돼요.

설정 (Settings)

설정 기본값 설명
map_serialization_version basic Map 컬럼의 직렬화 형식. basic은 단일 배열 스트림으로 저장해요. with_buckets는 더 빠른 단일 키 읽기를 위해 키를 버킷으로 나눠요.
map_serialization_version_for_zero_level_parts basic 0레벨 파트(INSERT로 생성된)의 직렬화 형식. 쓰기 오버헤드를 피하기 위해 insert에 대해 basic을 유지하면서 병합된 파트는 with_buckets를 사용할 수 있게 해줘요.
max_buckets_in_map 32 버킷 수의 상한. 실제 수는 map_buckets_strategy에 따라 달라져요. 최대 허용 값은 256이에요.
map_buckets_strategy sqrt 평균 map 크기에서 버킷 수를 계산하는 전략: constant — 항상 max_buckets_in_map을 사용; sqrt — round(coefficient * sqrt(avg_size)) 사용; linear — round(coefficient * avg_size) 사용. 결과는 [1, max_buckets_in_map]으로 제한돼요.
map_buckets_coefficient 1.0 sqrt와 linear 전략의 배수. 전략이 constant일 때는 무시돼요.
map_buckets_min_avg_size 32 버킷화를 켜기 위한 행당 최소 평균 키 수. 평균이 이 임계값보다 낮으면 다른 설정과 무관하게 단일 버킷이 사용돼요. 임계값을 비활성화하려면 0으로 설정해요.

성능 트레이드오프 (Performance Trade-offs)

다음 표는 다양한 map 크기(행당 10~10,000 키)에서 with_buckets가 basic 직렬화에 비해 주는 성능 영향을 요약해요. 버킷 수는 32로 제한된 sqrt 전략으로 결정했어요. 정확한 수치는 키/값 타입, 데이터 분포, 하드웨어에 따라 달라져요.

연산 10 키 100 키 1,000 키 10,000 키 참고
단일 키 조회 (m['key']) 1.6–3.2x 빠름 4.5–7.7x 빠름 16–39x 빠름 21–49x 빠름 전체 컬럼 대신 한 버킷만 읽어요.
5개 키 조회 ~1x 1.5–3.1x 빠름 2.9–8.3x 빠름 4.5–6.7x 빠름 각 키가 자기 버킷을 읽어요. 일부 버킷이 겹칠 수 있어요.
PREWHERE (SELECT m WHERE m['key'] = ...) 1.5–3.0x 빠름 2.9–7.3x 빠름 5.3–31x 빠름 20–45x 빠름 PREWHERE 필터는 한 버킷만 읽어요. 전체 map 읽기는 일치하는 행만. 속도 향상은 선택도에 따라 달라져요 — 일치하는 그레뉼이 적을수록 전체 map I/O가 줄어요.
전체 map 스캔 (SELECT m) ~2x 느림 ~2x 느림 ~2x 느림 ~2x 느림 모든 버킷을 읽고 다시 조립해야 해요.
INSERT 1.5–2.5x 느림 1.5–2.5x 느림 1.5–2.5x 느림 1.5–2.5x 느림 키 해싱과 여러 서브스트림 쓰기의 오버헤드.

권장 사항 (Recommendations)

  • 작은 map(평균 32키 미만): basic 직렬화를 유지해요. 작은 map에는 버킷화 오버헤드가 정당화되지 않아요. 기본 map_buckets_min_avg_size = 32가 이를 자동으로 강제해요.
  • 중간 map(32–100 키): 쿼리가 개별 키를 자주 접근한다면 sqrt 전략과 함께 with_buckets를 사용해요. 단일 키 조회에서 4–8배 빨라져요.
  • 큰 map(100+ 키): with_buckets를 사용해요. 단일 키 조회가 16–49배 빨라져요. insert 속도를 기준선에 가깝게 유지하려면 map_serialization_version_for_zero_level_parts = 'basic'을 고려해요.
  • 전체 map 스캔이 작업을 지배한다면: basic을 유지해요. 버킷 직렬화는 전체 스캔에 ~2배 오버헤드를 추가해요.
  • 혼합 작업(일부 키 조회, 일부 전체 스캔): 0레벨 파트를 basic으로 설정한 with_buckets를 사용해요. PREWHERE 최적화가 필터에 관련 버킷만 읽고, 일치하는 행에 대해서만 전체 map을 읽어 상당한 순 속도 향상을 줘요.

대안 (Alternative Approaches)

버킷 Map 직렬화가 사용 사례에 맞지 않으면 키 수준 접근 성능을 개선하는 두 가지 대안이 있어요.

JSON 데이터 타입 사용 (Using the JSON Data Type)

JSON 데이터 타입은 각 빈번한 경로를 별도의 다이나믹 서브컬럼으로 저장해요. max_dynamic_paths 제한을 초과하는 경로는 공유 데이터 구조로 가는데, 이것은 최적화된 단일 경로 읽기를 위해 advanced 직렬화를 사용할 수 있어요. advanced 직렬화의 자세한 개요는 블로그 포스트를 참고해요.

측면 버킷이 있는 Map JSON
단일 키 읽기 한 버킷을 읽어요(다른 키를 포함할 수 있음). 버킷의 모든 키-값 쌍이 역직렬화돼요. 빈번한 경로는 다이나믹 서브컬럼에서 직접 읽어요. 드문 경로는 공유 데이터로 가고, advanced 직렬화로는 정확한 경로의 데이터만 읽어요.
값 타입 모든 값이 같은 타입 V를 공유해요 각 경로가 자신만의 타입을 가질 수 있어요. 타입 힌트가 없는 경로는 Dynamic을 사용해요.
스킵 인덱스 지원 mapKeys/mapValues에 생성된 일부 인덱스 타입과 동작해요 스킵 인덱스는 특정 경로 서브컬럼에만 만들 수 있고, 모든 경로/값에 한 번에 만들 수는 없어요.
전체 컬럼 읽기 버킷 재조립으로 basic보다 ~2배 느려요 Dynamic 타입 인코딩과 경로 재구성의 오버헤드.
저장 오버헤드 최소한의 추가 메타데이터 Dynamic 타입 인코딩, 경로 이름 저장, advanced 직렬화의 추가 메타데이터 때문에 더 큼.
스키마 유연성 테이블 생성 시 키·값 타입 고정 완전 동적 — 키와 값 타입이 행마다 달라질 수 있어요. 알려진 경로에 타입 경로 힌트를 선언할 수 있어요.

서로 다른 키에 서로 다른 값 타입이 필요하거나, 키 집합이 행마다 크게 달라지거나, 자주 접근하는 키를 미리 알고 타입 경로로 선언해 직접 서브컬럼 접근할 수 있을 때 JSON을 사용해요.

여러 Map 컬럼으로 수동 샤딩 (Manual Sharding into Multiple Map Columns)

애플리케이션 수준에서 키 해시로 단일 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;

삽입하는 동안 각 키-값 쌍을 컬럼 m{hash(key) % 4}로 라우팅해요. 쿼리하는 동안 특정 컬럼 m{hash('target_key') % 4}['target_key']에서 읽어요.

측면 버킷이 있는 Map 수동 샤딩
사용 편의성 투명 — 스토리지 엔진이 처리해요 insert와 select에 애플리케이션 수준 라우팅 로직 필요
수직 병합 지원 안 됨 — 모든 버킷이 한 컬럼에 속해요 지원됨 — 각 Map 컬럼이 독립 컬럼이고 수직으로 병합될 수 있어요
스키마 변경 버킷 수가 파트마다 자동으로 적응해요 샤드 수를 바꾸려면 데이터를 다시 쓰거나 새 컬럼을 추가해야 해요
쿼리 문법 m['key']가 바로 동작해요 올바른 컬럼을 계산해야 해요: m0['key'], m1['key'] 등
버킷 세분화 파트별, 데이터 통계에 적응 테이블 생성 시 고정

수동 샤딩은 많은 컬럼이 있는 테이블의 병합 중 메모리 사용을 줄이는 수직 병합이 중요하거나, 샤드 수를 명시적으로 고정·제어해야 할 때 유리해요. 대부분의 사용 사례에는 자동 버킷 직렬화가 더 간단하고 충분해요.

더 알아보기 (Learn more)