본문으로 바로가기
본문으로 바로가기

Map(K, V)

데이터 타입 Map(K, V)은 key-value 쌍을 저장합니다.

다른 데이터베이스와 달리 ClickHouse에서 맵은 유일(unique)하지 않습니다. 즉, 하나의 맵에 동일한 키를 가진 두 개의 요소가 포함될 수 있습니다. (이는 맵이 내부적으로 Array(Tuple(K, V))로 구현되어 있기 때문입니다.)

m에서 키 k에 대한 값을 얻기 위해 m[k] 구문을 사용할 수 있습니다. 또한 m[k]는 맵 전체를 스캔하므로, 연산 시간은 맵 크기에 비례하여 선형적으로 증가합니다.

파라미터

  • K — 맵 키(Map keys)의 타입입니다. Nullable 타입 및 Nullable 타입과 중첩된 LowCardinality 타입을 제외한 임의의 타입입니다.
  • V — 맵 값(Map values)의 타입입니다. 임의의 타입입니다.

예시

맵 타입 컬럼을 가진 테이블을 생성합니다:

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가 맵에 포함되어 있지 않으면 m[k]는 값 타입의 기본값을 반환합니다. 예를 들어 정수 타입은 0, 문자열 타입은 ''을(를) 반환합니다. 맵에 키가 존재하는지 확인하려면 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)으로 변환하기

Tuple() 타입의 값은 함수 CAST를 사용하여 Map() 타입의 값으로 형변환할 수 있습니다.

예시

쿼리:

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

결과:

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

맵 서브컬럼 읽기

전체 맵을 모두 읽지 않도록, 상황에 따라 서브컬럼 keysvalues를 사용할 수 있습니다.

예시

쿼리:

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의 버킷형 맵 직렬화

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

버킷 기반 직렬화(with_buckets)는 키를 해시하여 key-value 쌍을 여러 개의 독립적인 하위 스트림(버킷)으로 분할합니다. 쿼리에서 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';

삽입 속도 저하를 방지하려면 제로 레벨 파트(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. 각 key-value 쌍은 키를 해시하여 버킷에 할당됩니다: bucket = hash(key) % num_buckets.
  4. 각 버킷은 자체 키, 값, 오프셋을 갖는 독립적인 하위 스트림으로 저장됩니다.
  5. buckets_info 메타데이터 스트림은 버킷 수와 통계를 기록합니다.

쿼리에서 특정 키(m['key'])를 읽으면 옵티마이저가 해당 식을 키 서브컬럼(m.key_<serialized_key>)으로 재작성합니다. 직렬화 계층은 요청된 키가 어느 버킷에 속하는지 계산하고, 디스크에서 해당 버킷 하나만 읽습니다.

전체 맵을 읽는 경우(예: SELECT m)에는 모든 버킷을 읽어 원래 맵으로 다시 재구성합니다. 이 방식은 여러 하위 스트림을 읽고 머지하는 오버헤드 때문에 basic 직렬화보다 느립니다.

버킷 수는 파트마다 다를 수 있습니다. 버킷 수가 서로 다른 파트를 머지하면 새 파트의 버킷 수는 머지된 통계를 기준으로 다시 계산됩니다. basicwith_buckets 직렬화가 적용된 파트는 동일한 테이블에 공존할 수 있으며, 투명하게 머지됩니다.

설정

설정기본값설명
map_serialization_versionbasic맵(Map) 컬럼의 직렬화 형식입니다. basic은 단일 배열 스트림으로 저장됩니다. with_buckets는 단일 키 읽기 성능을 높이기 위해 키를 버킷으로 분할합니다.
map_serialization_version_for_zero_level_partsbasic0레벨 파트(INSERT로 생성됨)의 직렬화 형식입니다. 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개)에서 basic 직렬화와 비교한 with_buckets의 성능 영향을 요약합니다. 버킷 수는 최대 32로 제한하는 sqrt 전략으로 결정되었습니다. 정확한 수치는 키/값 타입, 데이터 분포, 하드웨어에 따라 달라집니다.

작업키 10개키 100개키 1,000개키 10,000개참고
단일 키 조회 (m['key'])1.6–3.2배 더 빠름4.5–7.7배 더 빠름16–39배 더 빠름21–49배 더 빠름전체 컬럼이 아니라 버킷 1개만 읽습니다.
키 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 필터는 버킷 1개만 읽고, 전체 맵 읽기는 일치하는 행에 대해서만 수행됩니다. 성능 향상 폭은 선택도에 따라 달라집니다. 즉, 일치하는 그래뉼이 적을수록 전체 맵 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를 사용하십시오. 단일 키 조회는 4~8배 빨라집니다.
  • 큰 맵(키 100개 이상): with_buckets를 사용하십시오. 단일 키 조회는 16~49배 빨라집니다. 삽입 속도를 기준값에 가깝게 유지하려면 map_serialization_version_for_zero_level_parts = 'basic' 설정을 고려하십시오.
  • 전체 맵 스캔이 주된 워크로드인 경우: basic을 유지하십시오. 버킷 기반 직렬화는 전체 스캔 시 약 2배의 오버헤드를 추가합니다.
  • 혼합 워크로드(일부는 키 조회, 일부는 전체 스캔): zero-level 파트를 basic으로 설정한 with_buckets를 사용하십시오. PREWHERE 최적화는 필터와 관련된 버킷만 먼저 읽고, 그다음 일치하는 행에 대해서만 전체 맵을 읽으므로 전체적으로 상당한 성능 향상을 제공합니다.

대체 접근 방식

버킷 기반 직렬화가 사용 사례에 적합하지 않다면, 키 수준 접근 성능을 개선할 수 있는 대체 접근 방식이 두 가지 있습니다:

JSON 데이터 타입 사용

JSON 데이터 타입은 자주 나타나는 각 경로를 별도의 동적 서브컬럼으로 저장합니다. max_dynamic_paths 제한을 초과하는 경로는 공유 데이터 구조에 저장되며, 여기서는 단일 경로 읽기를 최적화하기 위해 advanced 직렬화를 사용할 수 있습니다. advanced 직렬화에 대한 자세한 내용은 블로그 게시물을 참조하십시오.

Aspect with bucketsJSON
단일 키 읽기하나의 버킷을 읽습니다(다른 키가 포함될 수 있음). 버킷 내 모든 key-value 쌍이 역직렬화됩니다.자주 나타나는 경로는 동적 서브컬럼에서 직접 읽습니다. 드물게 나타나는 경로는 공유 데이터에 저장되며, advanced 직렬화를 사용하면 해당 경로의 데이터만 읽습니다.
값 타입모든 값이 동일한 타입 V를 공유합니다.각 경로는 자체 타입을 가질 수 있습니다. 타입 힌트가 없는 경로는 Dynamic을 사용합니다.
스킵 인덱스 지원mapKeys/mapValues에 생성된 일부 인덱스 타입에서 작동합니다.스킵 인덱스는 모든 경로/값에 한 번에 생성할 수 없고, 특정 경로 서브컬럼에만 생성할 수 있습니다.
전체 컬럼 읽기버킷을 다시 조립해야 하므로 basic보다 약 2배 느립니다.Dynamic 타입 인코딩과 경로 재구성에 따른 오버헤드가 있습니다.
스토리지 오버헤드추가 메타데이터가 거의 없습니다.Dynamic 타입 인코딩, 경로 이름 저장, 그리고 advanced 직렬화의 추가 메타데이터로 인해 더 큽니다.
스키마 유연성테이블 생성 시 키와 값 타입이 고정됩니다.완전히 동적이므로 키와 값 타입이 행마다 달라질 수 있습니다. 알려진 경로에 대해서는 타입이 지정된 경로 힌트를 선언할 수 있습니다.

서로 다른 키에 서로 다른 값 타입이 필요하거나, 키 집합이 행마다 크게 달라지거나, 자주 액세스하는 키를 미리 알고 있어 직접 서브컬럼으로 접근할 수 있도록 타입이 지정된 경로로 선언할 수 있는 경우 JSON을 사용하십시오.

여러 맵 컬럼으로 수동 분할

애플리케이션에서 키 해시를 기준으로 단일 을 여러 컬럼으로 직접 분할할 수 있습니다:

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;

삽입 시 각 key-value 쌍을 컬럼 m{hash(key) % 4}로 라우팅합니다. 쿼리 시에는 해당 컬럼에서 읽습니다: m{hash('target_key') % 4}['target_key'].

측면버킷이 있는 수동 세그먼트 분할
사용 편의성투명하게 처리됨 — 스토리지 엔진이 처리함삽입 및 조회를 위해 애플리케이션 수준의 라우팅 로직이 필요함
수직 병합지원되지 않음 — 모든 버킷이 하나의 컬럼에 속함지원됨 — 각 컬럼은 독립적인 컬럼이므로 수직 병합 가능함
스키마 변경버킷 수는 각 파트별로 자동 조정됨세그먼트 수를 변경하려면 데이터를 다시 쓰거나 새 컬럼을 추가해야 함
쿼리 구문m['key']를 바로 사용할 수 있음올바른 컬럼을 계산해야 함: m0['key'], m1['key']
버킷 세분성파트별이며 데이터 통계에 맞게 조정됨테이블 생성 시 고정됨

수동 세그먼트 분할은 컬럼이 많은 테이블을 병합할 때 메모리 사용량을 줄이기 위해 수직 병합이 중요한 경우, 또는 세그먼트 수를 고정하고 명시적으로 제어해야 하는 경우에 유용합니다. 대부분의 사용 사례에서는 자동 버킷 기반 직렬화가 더 단순하며 충분합니다.

함께 보기