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'] 등 |
| 버킷 세분화 | 파트별, 데이터 통계에 적응 | 테이블 생성 시 고정 |
수동 샤딩은 많은 컬럼이 있는 테이블의 병합 중 메모리 사용을 줄이는 수직 병합이 중요하거나, 샤드 수를 명시적으로 고정·제어해야 할 때 유리해요. 대부분의 사용 사례에는 자동 버킷 직렬화가 더 간단하고 충분해요.