JSON 데이터 타입

JSON 데이터 타입

JSON 타입은 JavaScript Object Notation(JSON) 문서를 단일 컬럼에 저장해요. 동적이거나 예측할 수 없는 구조의 JSON 객체 안 특정 필드를 조회·필터링·집계하기 위해 설계되었고, JSON 객체를 별도의 서브컬럼으로 나눠 선택된 필드의 데이터 읽기를 크게 줄여줘요. ClickHouse 오픈소스에서 JSON 데이터 타입은 25.3 버전에서 프로덕션 준비(production ready)로 표시됐어요.

출처: 문서

본문

가이드를 찾고 있나요? (Looking for a guide?)

JSON 타입 사용의 예시, 고급 기능, 고려 사항에 대해서는 JSON 모범 사용(best practice) 가이드를 확인해 보세요.

JSON 타입은 JavaScript Object Notation(JSON) 문서를 단일 컬럼에 저장해요. ClickHouse 오픈소스에서 JSON 데이터 타입은 25.3 버전부터 프로덕션 준비(production ready)로 표시됐어요. 이전 버전에서는 이 타입을 프로덕션에 사용하는 것을 권장하지 않아요.

JSON 타입 컬럼을 선언하려면 다음 문법을 사용할 수 있어요.

<column_name> JSON
(
    max_dynamic_paths=N,
    max_dynamic_types=M,
    some.path TypeName,
    SKIP path.to.skip,
    SKIP REGEXP 'paths_regexp'
)

위 문법의 매개변수는 다음과 같이 정의돼요.

매개변수 설명 기본값
max_dynamic_paths 별도로 저장되는 단일 데이터 블록(예: MergeTree 테이블의 단일 데이터 파트) 안에서 얼마나 많은 경로를 서브컬럼으로 별도 저장할 수 있는지를 나타내는 옵션 매개변수. 이 제한을 초과하면 다른 모든 경로는 공유 데이터(shared data)라는 단일 구조에 함께 저장돼요. 이 매개변수를 바꾸지 않고 동적 경로 수 제한을 바꾸는 방법도 있어요. 1024
max_dynamic_types 1255 사이의 옵션 매개변수로, 별도로 저장되는 단일 데이터 블록(예: MergeTree 테이블의 단일 데이터 파트) 안에서 Dynamic 타입의 단일 경로 컬럼에 서로 다른 데이터 타입을 얼마나 많이 별도 저장할 수 있는지를 나타내요. 이 제한을 초과하면 모든 새 타입은 shared variant라는 단일 구조에 함께 저장돼요. 32
some.path TypeName JSON의 특정 경로에 대한 옵션 타입 힌트. 이런 경로는 항상 지정된 타입의 서브컬럼으로 저장돼요.
SKIP path.to.skip JSON 파싱 중 건너뛰어야 하는 특정 경로에 대한 옵션 힌트. 그런 경로는 JSON 컬럼에 절대 저장되지 않아요. 지정된 경로가 중첩 JSON 객체라면 전체 중첩 객체가 건너뛰어져요.
SKIP REGEXP 'path_regexp' JSON 파싱 중 경로를 건너뛰는 데 사용되는 정규식이 있는 옵션 힌트. 이 정규식과 일치하는 모든 경로는 JSON 컬럼에 절대 저장되지 않아요.

JSON 타입은 언제 사용할까 (When to use the JSON Type)

JSON 타입은 동적이거나 예측할 수 없는 구조를 가진 JSON 객체 안 특정 필드를 조회·필터링·집계하기 위해 설계됐어요. 이것은 JSON 객체를 별도의 서브컬럼으로 나눠 달성하는데, Map이나 문자열 파싱 같은 대안과 비교해 데이터 읽기를 크게 줄이고 선택된 필드에 대한 쿼리를 빠르게 해요.

하지만 이에는 중요한 트레이드오프가 있어요.

  • 느린 INSERTs — JSON을 서브컬럼으로 나누고, 타입 추론을 수행하고, 유연한 저장 구조를 관리하는 것은 단순한 String 컬럼으로 JSON을 저장하는 것보다 insert를 느리게 해요.
  • 전체 객체를 읽을 때는 더 느림 — 완전한 JSON 문서를 검색해야 한다면(특정 필드가 아니라) JSON 타입은 String 컬럼에서 읽는 것보다 느려요. 별도 서브컬럼에서 객체를 재구성하는 오버헤드는 필드 수준 쿼리를 하지 않을 때는 이점이 없어요.
  • 저장 오버헤드 — 별도의 서브컬럼을 유지하는 것은 단일 문자열 값으로 JSON을 저장하는 것보다 구조적 오버헤드를 추가해요.

이러한 경우 JSON 타입을 사용해요:

  • 데이터가 문서마다 변하는 키들로 동적이거나 예측할 수 없는 구조를 가질 때
  • 필드 타입이나 스키마가 시간이 지나며 바뀌거나 레코드마다 다를 때
  • 미리 예측할 수 없는 구조의 JSON 객체 안 특정 경로를 조회·필터링·집계해야 할 때
  • 로그, 이벤트, 일관되지 않은 스키마의 사용자 생성 콘텐츠 같은 반정형 데이터가 관련된 사용 사례일 때

이러한 경우 String 컬럼(또는 구조화 타입)을 사용해요:

  • 데이터 구조가 알려져 있고 일관적일 때 — 이 경우 일반 컬럼, Tuple, Array, Dynamic, Variant 타입을 대신 사용해요.
  • JSON 문서를 필드 수준 분석 없이 전체로만 저장하고 검색하는 불투명한 blob으로 취급할 때
  • 데이터베이스 안에서 개별 JSON 필드를 조회하거나 필터링할 필요가 없을 때
  • JSON이 단순히 전송/저장 형식이고 ClickHouse 안에서 분석되지 않을 때

JSON이 데이터베이스 안에서 분석되지 않는 불투명한 문서이고 저장하고 다시 검색만 한다면 String 필드로 저장해야 해요. JSON 타입의 이점은 동적 JSON 구조 안 특정 필드를 효율적으로 조회·필터링·집계해야 할 때만 나타나요. 또한 접근 방식을 혼합할 수 있어요 — 예측 가능한 최상위 필드에는 표준 컬럼을, 페이로드의 동적 부분에는 JSON 컬럼을 사용하는 식이에요.

JSON 만들기 (Creating JSON)

이 섹션에서는 JSON을 만들 수 있는 다양한 방법을 살펴볼게요.

테이블 컬럼 정의에서 JSON 사용

쿼리 (예시 1):

CREATE TABLE test (json JSON) ENGINE = Memory;
INSERT INTO test VALUES ('{"a" : {"b" : 42}, "c" : [1, 2, 3]}'), ('{"f" : "Hello, World!"}'), ('{"a" : {"b" : 43, "e" : 10}, "c" : [4, 5, 6]}');
SELECT json FROM test;

응답 (예시 1):

┌─json────────────────────────────────────────┐
│ {"a":{"b":"42"},"c":["1","2","3"]}          │
│ {"f":"Hello, World!"}                       │
│ {"a":{"b":"43","e":"10"},"c":["4","5","6"]} │
└─────────────────────────────────────────────┘

쿼리 (예시 2):

CREATE TABLE test (json JSON(a.b UInt32, SKIP a.e)) ENGINE = Memory;
INSERT INTO test VALUES ('{"a" : {"b" : 42}, "c" : [1, 2, 3]}'), ('{"f" : "Hello, World!"}'), ('{"a" : {"b" : 43, "e" : 10}, "c" : [4, 5, 6]}');
SELECT json FROM test;

응답 (예시 2):

┌─json──────────────────────────────┐
│ {"a":{"b":42},"c":["1","2","3"]}  │
│ {"a":{"b":0},"f":"Hello, World!"} │
│ {"a":{"b":43},"c":["4","5","6"]}  │
└───────────────────────────────────┘

::JSON으로 CAST 사용

특별한 문법 ::JSON을 사용해 다양한 타입을 캐스팅할 수 있어요.

String에서 JSON으로 CAST

쿼리:

SELECT '{"a" : {"b" : 42},"c" : [1, 2, 3], "d" : "Hello, World!"}'::JSON AS json;

응답:

┌─json───────────────────────────────────────────────────┐
│ {"a":{"b":"42"},"c":["1","2","3"],"d":"Hello, World!"} │
└────────────────────────────────────────────────────────┘
Tuple에서 JSON으로 CAST

쿼리:

SET enable_named_columns_in_function_tuple = 1;
SELECT (tuple(42 AS b) AS a, [1, 2, 3] AS c, 'Hello, World!' AS d)::JSON AS json;

응답:

┌─json───────────────────────────────────────────────────┐
│ {"a":{"b":"42"},"c":["1","2","3"],"d":"Hello, World!"} │
└────────────────────────────────────────────────────────┘
Map에서 JSON으로 CAST

쿼리:

SET use_variant_as_common_type=1;
SELECT map('a', map('b', 42), 'c', [1,2,3], 'd', 'Hello, World!')::JSON AS json;

응답:

┌─json───────────────────────────────────────────────────┐
│ {"a":{"b":"42"},"c":["1","2","3"],"d":"Hello, World!"} │
└────────────────────────────────────────────────────────┘

JSON 경로는 평면화되어 저장돼요. 즉 a.b.c 같은 경로에서 JSON 객체를 포맷할 때 객체를 { "a.b.c" : ... }로 구성해야 하는지 { "a": { "b": { "c": ... } } }로 구성해야 하는지 알 수 없어요. 우리 구현은 항상 후자를 가정해요. 예를 들어:

쿼리:

SELECT CAST('{"a.b.c" : 42}', 'JSON') AS json

은 다음을 반환해요.

응답:

   ┌─json───────────────────┐
1. │ {"a":{"b":{"c":"42"}}} │
   └────────────────────────┘

그리고 아닌 것:

   ┌─json───────────┐
1. │ {"a.b.c":"42"} │
   └────────────────┘

JSON 경로를 서브컬럼으로 읽기 (Reading JSON paths as sub-columns)

JSON 타입은 모든 경로를 별도의 서브컬럼으로 읽는 것을 지원해요. 요청된 경로의 타입이 JSON 타입 선언에 지정되지 않았다면 그 경로의 서브컬럼은 항상 Dynamic 타입이 돼요. 예를 들어:

쿼리:

CREATE TABLE test (json JSON(a.b UInt32, SKIP a.e)) ENGINE = Memory;
INSERT INTO test VALUES ('{"a" : {"b" : 42, "g" : 42.42}, "c" : [1, 2, 3], "d" : "2020-01-01"}'), ('{"f" : "Hello, World!", "d" : "2020-01-02"}'), ('{"a" : {"b" : 43, "e" : 10, "g" : 43.43}, "c" : [4, 5, 6]}');
SELECT json FROM test;

응답:

┌─json────────────────────────────────────────────────────────┐
│ {"a":{"b":42,"g":42.42},"c":["1","2","3"],"d":"2020-01-01"} │
│ {"a":{"b":0},"d":"2020-01-02","f":"Hello, World!"}          │
│ {"a":{"b":43,"g":43.43},"c":["4","5","6"]}                  │
└─────────────────────────────────────────────────────────────┘

쿼리 (JSON 경로를 서브컬럼으로 읽기):

SELECT json.a.b, json.a.g, json.c, json.d FROM test;

응답 (JSON 경로를 서브컬럼으로 읽기):

┌─json.a.b─┬─json.a.g─┬─json.c──┬─json.d─────┐
│       42 │ 42.42    │ [1,2,3] │ 2020-01-01 │
│        0 │ ᴺᵁᴸᴸ     │ ᴺᵁᴸᴸ    │ 2020-01-02 │
│       43 │ 43.43    │ [4,5,6] │ ᴺᵁᴸᴸ       │
└──────────┴──────────┴─────────┴────────────┘

getSubcolumn 함수를 사용해 JSON 타입에서 서브컬럼을 읽을 수도 있어요. 쿼리:

SELECT getSubcolumn(json, 'a.b'), getSubcolumn(json, 'a.g'), getSubcolumn(json, 'c'), getSubcolumn(json, 'd') FROM test;

응답:

┌─getSubcolumn(json, 'a.b')─┬─getSubcolumn(json, 'a.g')─┬─getSubcolumn(json, 'c')─┬─getSubcolumn(json, 'd')─┐
│                        42 │ 42.42                     │ [1,2,3]                 │ 2020-01-01              │
│                         0 │ ᴺᵁᴸᴸ                      │ ᴺᵁᴸᴸ                    │ 2020-01-02              │
│                        43 │ 43.43                     │ [4,5,6]                 │ ᴺᵁᴸᴸ                    │
└───────────────────────────┴───────────────────────────┴─────────────────────────┴─────────────────────────┘

대괄호 문법 json['key']로도 JSON 경로에 접근할 수 있어요. 중첩 접근은 체이닝으로 지원돼요. 쿼리:

SELECT json['a']['b'], json['c'], json['d'] FROM test;

응답:

┌─arrayElement(arrayElement(json, 'a'), 'b')─┬─arrayElement(json, 'c')─┬─arrayElement(json, 'd')─┐
│ 42                                         │ [1,2,3]                 │ 2020-01-01              │
│ 0                                          │ ᴺᵁᴸᴸ                    │ 2020-01-02              │
│ 43                                         │ [4,5,6]                 │ ᴺᵁᴸᴸ                    │
└────────────────────────────────────────────┴─────────────────────────┴─────────────────────────┘

대괄호 문법은 Nullable(JSON)에서도 동작하며, json.key와 같은 nullability 규칙을 따라 동일한 값과 타입을 반환해요. NULL을 나타낼 수 있는 경로(Dynamic, 또는 Nullable로 감쌀 수 있는 타입 경로)는 NULL 행에 대해 NULL을 주고, ArrayMap 같은 non-nullable 타입 경로는 거기서 기본값을 유지해요. optimize_functions_to_subcolumns가 켜져 있으면 체이닝된 대괄호 접근은 단일 JSON 경로로 평면화돼요. 그래서 json['a']['b']json.a.b처럼 a.b 경로를 읽어요. a가 객체 대신 스칼라를 담은 행은 a.b가 없으므로 NULL을 만들고요. 그 최적화 없이는 바깥 접근이 json['a']Dynamic 값에 대신 적용되고, 그런 행은 dynamic_throw_on_type_mismatch 설정을 따르게 돼요.

요청된 경로가 데이터에서 발견되지 않으면 NULL 값으로 채워져요. 쿼리:

SELECT json.non.existing.path FROM test;

응답:

┌─json.non.existing.path─┐
│ ᴺᵁᴸᴸ                   │
│ ᴺᵁᴸᴸ                   │
│ ᴺᵁᴸᴸ                   │
└────────────────────────┘

반환된 서브컬럼의 데이터 타입을 확인해 볼게요. 쿼리:

SELECT toTypeName(json.a.b), toTypeName(json.a.g), toTypeName(json.c), toTypeName(json.d) FROM test;

응답:

┌─toTypeName(json.a.b)─┬─toTypeName(json.a.g)─┬─toTypeName(json.c)─┬─toTypeName(json.d)─┐
│ UInt32               │ Dynamic              │ Dynamic            │ Dynamic            │
│ UInt32               │ Dynamic              │ Dynamic            │ Dynamic            │
│ UInt32               │ Dynamic              │ Dynamic            │ Dynamic            │
└──────────────────────┴──────────────────────┴────────────────────┴────────────────────┘

보시다시피 a.b의 타입은 JSON 타입 선언에서 지정한 대로 UInt32이고, 다른 모든 서브컬럼의 타입은 Dynamic이에요. 특별한 문법 json.some.path.:TypeName으로 Dynamic 타입의 서브컬럼을 읽을 수도 있어요. 쿼리:

SELECT
    json.a.g.:Float64,
    dynamicType(json.a.g),
    json.d.:Date,
    dynamicType(json.d)
FROM test

응답:

┌─json.a.g.:`Float64`─┬─dynamicType(json.a.g)─┬─json.d.:`Date`─┬─dynamicType(json.d)─┐
│               42.42 │ Float64               │     2020-01-01 │ Date                │
│                ᴺᵁᴸᴸ │ None                  │     2020-01-02 │ Date                │
│               43.43 │ Float64               │           ᴺᵁᴸᴸ │ None                │
└─────────────────────┴───────────────────────┴────────────────┴─────────────────────┘

Dynamic 서브컬럼은 어떤 데이터 타입으로도 캐스팅할 수 있어요. 이 경우 Dynamic 안의 내부 타입이 요청 타입으로 캐스팅될 수 없으면 예외가 던져져요. 쿼리:

SELECT json.a.g::UInt64 AS uint
FROM test;

응답:

┌─uint─┐
│   42 │
│    0 │
│   43 │
└──────┘

쿼리:

SELECT json.a.g::UUID AS float
FROM test;

응답:

Received exception from server:
Code: 48. DB::Exception: Received from localhost:9000. DB::Exception:
Conversion between numeric types and UUID is not supported.
Probably the passed UUID is unquoted:
while executing 'FUNCTION CAST(__table1.json.a.g :: 2, 'UUID'_String :: 1) -> CAST(__table1.json.a.g, 'UUID'_String) UUID : 0'.
(NOT_IMPLEMENTED)

Compact MergeTree 파트에서 서브컬럼을 효율적으로 읽으려면 MergeTree 설정 write_marks_for_substreams_in_compact_parts가 켜져 있는지 확인해요.

JSON 하위 객체를 서브컬럼으로 읽기 (Reading JSON sub-objects as sub-columns)

JSON 타입은 특별한 문법 json.^some.path를 사용해 중첩 객체를 JSON 타입의 서브컬럼으로 읽는 것을 지원해요. 쿼리:

CREATE TABLE test (json JSON) ENGINE = Memory;
INSERT INTO test VALUES ('{"a" : {"b" : {"c" : 42, "g" : 42.42}}, "c" : [1, 2, 3], "d" : {"e" : {"f" : {"g" : "Hello, World", "h" : [1, 2, 3]}}}}'), ('{"f" : "Hello, World!", "d" : {"e" : {"f" : {"h" : [4, 5, 6]}}}}'), ('{"a" : {"b" : {"c" : 43, "e" : 10, "g" : 43.43}}, "c" : [4, 5, 6]}');
SELECT json FROM test;

응답:

┌─json──────────────────────────────────────────────────────────────────────────────────────────────────────┐
│ {"a":{"b":{"c":"42","g":42.42}},"c":["1","2","3"],"d":{"e":{"f":{"g":"Hello, World","h":["1","2","3"]}}}} │
│ {"d":{"e":{"f":{"h":["4","5","6"]}}},"f":"Hello, World!"}                                                 │
│ {"a":{"b":{"c":"43","e":"10","g":43.43}},"c":["4","5","6"]}                                               │
└───────────────────────────────────────────────────────────────────────────────────────────────────────────┘

쿼리:

SELECT json.^a.b, json.^d.e.f FROM test;

응답:

┌─json.^`a`.b───────────────────┬─json.^`d`.e.f──────────────────────────┐
│ {"c":"42","g":42.42}          │ {"g":"Hello, World","h":["1","2","3"]} │
│ {}                            │ {"h":["4","5","6"]}                    │
│ {"c":"43","e":"10","g":43.43} │ {}                                     │
└───────────────────────────────┴────────────────────────────────────────┘

경로가 기본(map) 공유 데이터에 저장되면 전체 공유 데이터 구조를 스캔해야 하므로 하위 객체 서브컬럼 읽기가 비효율적일 수 있어요. map_with_buckets, advanced, advanced_chunked 공유 데이터 직렬화에서는 공유 데이터에서 서브컬럼을 읽는 것이 고도로 최적화돼요.

JSON 결합 서브컬럼 읽기 (Reading JSON combined sub-columns)

JSON 타입은 특별한 문법 [email protected]를 사용해 경로를 결합 서브컬럼(combined sub-column) 으로 읽는 것을 지원해요. 주어진 경로에 대한 결합 서브컬럼은 다음을 반환해요.

  • 경로가 리터럴 값을 가지면 그 경로에 저장된 리터럴 값을 Dynamic으로.
  • 경로에 리터럴 값은 없지만 중첩 하위 경로가 있으면 그 경로의 JSON 하위 객체를 Dynamic으로.
  • 그 경로에 대해 리터럴 값도 하위 경로도 없으면 NULL.

이것은 경로가 행마다 스칼라 값 또는 중첩 객체를 가질 수 있을 때 유용하며, 리터럴 서브컬럼(json.a)과 하위 객체 서브컬럼(json.^a)을 따로 조회하는 것보다 더 편리해요. 다음 예시는 경로 a에 대한 세 가지 서브컬럼 타입을 모두 비교해요. 쿼리:

CREATE TABLE test (json JSON) ENGINE = Memory;
INSERT INTO test VALUES ('{"a" : 42, "b" : {"c" : 1, "d" : "Hello"}}'), ('{"a" : {"x": 1, "y": 2}, "b" : {"c" : 1}}'), ('{"c" : "World"}');
SELECT json FROM test;

응답:

┌─json────────────────────────────┐
│ {"a":42,"b":{"c":1,"d":"Hello"}}│
│ {"a":{"x":1,"y":2},"b":{"c":1}}│
│ {"c":"World"}                   │
└─────────────────────────────────┘

쿼리:

SELECT
    json.a,
    dynamicType(json.a),
    json.^a,
    toTypeName(json.^a),
    json.@a,
    dynamicType(json.@a)
FROM test;

응답:

┌─json.a─┬─dynamicType(json.a)─┬─json.^a───────┬─toTypeName(json.^a)─┬─json.@a───────┬─dynamicType(json.@a)─┐
│ 42     │ Int64               │ {}            │ JSON                │ 42            │ Int64                │
│ NULL   │ None                │ {"x":1,"y":2} │ JSON                │ {"x":1,"y":2} │ JSON                 │
│ NULL   │ None                │ {}            │ JSON                │ NULL          │ None                 │
└────────┴─────────────────────┴───────────────┴─────────────────────┴───────────────┴──────────────────────┘
  • 행 1: a는 리터럴 42를 담아요. json.a는 그것을 Dynamic(Int64)로 반환하고, json.^a는 빈 하위 객체 {}(a 아래에 중첩 키가 없음)를, json.@a는 리터럴 42를 반환해요.
  • 행 2: a는 중첩 객체를 담아요. json.aNULL(그 경로에 리터럴이 없음)을, json.^a는 하위 객체를 JSON으로, json.@a도 하위 객체를 Dynamic(JSON)으로 반환해요.
  • 행 3: a는 완전히 없어요. json.ajson.@a는 모두 NULL을 반환하고, json.^a는 빈 {}을 반환해요.

경로가 기본(map) 공유 데이터에 저장되면 전체 공유 데이터 구조를 스캔해야 하므로 결합 서브컬럼 읽기가 비효율적일 수 있어요. map_with_buckets, advanced, advanced_chunked 공유 데이터 직렬화에서는 공유 데이터에서 서브컬럼을 읽는 것이 고도로 최적화돼요.

경로에 대한 타입 추론 (Type inference for paths)

JSON 파싱 중 ClickHouse는 각 JSON 경로에 가장 적절한 데이터 타입을 감지하려고 해요. 이것은 입력 데이터로부터의 자동 스키마 추론과 유사하게 동작하며, 같은 설정으로 제어돼요.

몇 가지 예를 살펴볼게요. 쿼리:

SELECT JSONAllPathsWithTypes('{"a" : "2020-01-01", "b" : "2020-01-01 10:00:00"}'::JSON) AS paths_with_types settings input_format_try_infer_dates=1, input_format_try_infer_datetimes=1;

응답:

┌─paths_with_types─────────────────┐
│ {'a':'Date','b':'DateTime64(9)'} │
└──────────────────────────────────┘

쿼리:

SELECT JSONAllPathsWithTypes('{"a" : "2020-01-01", "b" : "2020-01-01 10:00:00"}'::JSON) AS paths_with_types settings input_format_try_infer_dates=0, input_format_try_infer_datetimes=0;

응답:

┌─paths_with_types────────────┐
│ {'a':'String','b':'String'} │
└─────────────────────────────┘

쿼리:

SELECT JSONAllPathsWithTypes('{"a" : [1, 2, 3]}'::JSON) AS paths_with_types settings schema_inference_make_columns_nullable=1;

응답:

┌─paths_with_types───────────────┐
│ {'a':'Array(Nullable(Int64))'} │
└────────────────────────────────┘

쿼리:

SELECT JSONAllPathsWithTypes('{"a" : [1, 2, 3]}'::JSON) AS paths_with_types settings schema_inference_make_columns_nullable=0;

응답:

┌─paths_with_types─────┐
│ {'a':'Array(Int64)'} │
└──────────────────────┘

JSON 객체 배열 다루기 (Handling arrays of JSON objects)

객체 배열을 포함하는 JSON 경로는 Array(JSON) 타입으로 파싱되고 경로의 Dynamic 컬럼에 삽입돼요. 객체 배열을 읽으려면 Dynamic 컬럼에서 서브컬럼으로 추출할 수 있어요. 쿼리:

CREATE TABLE test (json JSON) ENGINE = Memory;
INSERT INTO test VALUES
('{"a" : {"b" : [{"c" : 42, "d" : "Hello", "f" : [[{"g" : 42.42}]], "k" : {"j" : 1000}}, {"c" : 43}, {"e" : [1, 2, 3], "d" : "My", "f" : [[{"g" : 43.43, "h" : "2020-01-01"}]],  "k" : {"j" : 2000}}]}}'),
('{"a" : {"b" : [1, 2, 3]}}'),
('{"a" : {"b" : [{"c" : 44, "f" : [[{"h" : "2020-01-02"}]]}, {"e" : [4, 5, 6], "d" : "World", "f" : [[{"g" : 44.44}]],  "k" : {"j" : 3000}}]}}');
SELECT json FROM test;

응답:

┌─json────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┐
│ {"a":{"b":[{"c":"42","d":"Hello","f":[[{"g":42.42}]],"k":{"j":"1000"}},{"c":"43"},{"d":"My","e":["1","2","3"],"f":[[{"g":43.43,"h":"2020-01-01"}]],"k":{"j":"2000"}}]}} │
│ {"a":{"b":["1","2","3"]}}                                                                                                                                               │
│ {"a":{"b":[{"c":"44","f":[[{"h":"2020-01-02"}]]},{"d":"World","e":["4","5","6"],"f":[[{"g":44.44}]],"k":{"j":"3000"}}]}}                                                │
└─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┘

쿼리:

SELECT json.a.b, dynamicType(json.a.b) FROM test;

응답:

┌─json.a.b──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┬─dynamicType(json.a.b)────────────────────────────────────┐
│ ['{"c":"42","d":"Hello","f":[[{"g":42.42}]],"k":{"j":"1000"}}','{"c":"43"}','{"d":"My","e":["1","2","3"],"f":[[{"g":43.43,"h":"2020-01-01"}]],"k":{"j":"2000"}}'] │ Array(JSON(max_dynamic_types=16, max_dynamic_paths=256)) │
│ [1,2,3]                                                                                                                                                           │ Array(Nullable(Int64))                                   │
│ ['{"c":"44","f":[[{"h":"2020-01-02"}]]}','{"d":"World","e":["4","5","6"],"f":[[{"g":44.44}]],"k":{"j":"3000"}}']                                                  │ Array(JSON(max_dynamic_types=16, max_dynamic_paths=256)) │
└───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┴──────────────────────────────────────────────────────────┘

눈치챘겠지만 중첩 JSON 타입의 max_dynamic_types/max_dynamic_paths 매개변수가 기본값에 비해 줄어들었어요. 이것은 중첩 JSON 객체 배열에서 서브컬럼 수가 통제 불능으로 늘어나는 것을 피하기 위해 필요해요. 중첩 JSON 컬럼에서 서브컬럼을 읽어 보겠어요. 쿼리:

SELECT json.a.b.:`Array(JSON)`.c, json.a.b.:`Array(JSON)`.f, json.a.b.:`Array(JSON)`.d FROM test;

응답:

┌─json.a.b.:`Array(JSON)`.c─┬─json.a.b.:`Array(JSON)`.f───────────────────────────────────┬─json.a.b.:`Array(JSON)`.d─┐
│ [42,43,NULL]              │ [[['{"g":42.42}']],NULL,[['{"g":43.43,"h":"2020-01-01"}']]] │ ['Hello',NULL,'My']       │
│ []                        │ []                                                          │ []                        │
│ [44,NULL]                 │ [[['{"h":"2020-01-02"}']],[['{"g":44.44}']]]                │ [NULL,'World']            │
└───────────────────────────┴─────────────────────────────────────────────────────────────┴───────────────────────────┘

특별한 문법으로 Array(JSON) 서브컬럼 이름을 쓰지 않을 수 있어요. 쿼리:

SELECT json.a.b[].c, json.a.b[].f, json.a.b[].d FROM test;

응답:

┌─json.a.b.:`Array(JSON)`.c─┬─json.a.b.:`Array(JSON)`.f───────────────────────────────────┬─json.a.b.:`Array(JSON)`.d─┐
│ [42,43,NULL]              │ [[['{"g":42.42}']],NULL,[['{"g":43.43,"h":"2020-01-01"}']]] │ ['Hello',NULL,'My']       │
│ []                        │ []                                                          │ []                        │
│ [44,NULL]                 │ [[['{"h":"2020-01-02"}']],[['{"g":44.44}']]]                │ [NULL,'World']            │
└───────────────────────────┴─────────────────────────────────────────────────────────────┴───────────────────────────┘

경로 뒤의 [] 수는 배열 레벨을 나타내요. 예를 들어 json.path[][]json.path.:Array(Array(JSON))로 변환돼요. Array(JSON) 안의 경로와 타입을 확인해 볼게요. 쿼리:

SELECT DISTINCT arrayJoin(JSONAllPathsWithTypes(arrayJoin(json.a.b[]))) FROM test;

응답:

┌─arrayJoin(JSONAllPathsWithTypes(arrayJoin(json.a.b.:`Array(JSON)`)))──┐
│ ('c','Int64')                                                         │
│ ('d','String')                                                        │
│ ('f','Array(Array(JSON(max_dynamic_types=8, max_dynamic_paths=64)))') │
│ ('k.j','Int64')                                                       │
│ ('e','Array(Nullable(Int64))')                                        │
└───────────────────────────────────────────────────────────────────────┘

Array(JSON) 컬럼에서 서브컬럼을 읽어 볼게요. 쿼리:

SELECT json.a.b[].c.:Int64, json.a.b[].f[][].g.:Float64, json.a.b[].f[][].h.:Date FROM test;

응답:

┌─json.a.b.:`Array(JSON)`.c.:`Int64`─┬─json.a.b.:`Array(JSON)`.f.:`Array(Array(JSON))`.g.:`Float64`─┬─json.a.b.:`Array(JSON)`.f.:`Array(Array(JSON))`.h.:`Date`─┐
│ [42,43,NULL]                       │ [[[42.42]],[],[[43.43]]]                                     │ [[[NULL]],[],[['2020-01-01']]]                            │
│ []                                 │ []                                                           │ []                                                        │
│ [44,NULL]                          │ [[[NULL]],[[44.44]]]                                         │ [[['2020-01-02']],[[NULL]]]                               │
└────────────────────────────────────┴──────────────────────────────────────────────────────────────┴───────────────────────────────────────────────────────────┘

중첩 JSON 컬럼에서 하위 객체 서브컬럼을 읽을 수도 있어요. 쿼리:

SELECT json.a.b[].^k FROM test

응답:

┌─json.a.b.:`Array(JSON)`.^`k`─────────┐
│ ['{"j":"1000"}','{}','{"j":"2000"}'] │
│ []                                   │
│ ['{}','{"j":"3000"}']                │
└──────────────────────────────────────┘

NULL을 가진 JSON 키 다루기 (Handling JSON keys with NULL)

우리 JSON 구현에서는 null과 값의 부재를 동등하게 간주해요. 쿼리:

SELECT '{}'::JSON AS json1, '{"a" : null}'::JSON AS json2, json1 = json2

응답:

┌─json1─┬─json2─┬─equals(json1, json2)─┐
│ {}    │ {}    │                    1 │
└───────┴───────┴──────────────────────┘

이것은 원래 JSON 데이터가 NULL 값을 가진 어떤 경로를 포함했는지, 아니면 전혀 포함하지 않았는지를 판별하는 것이 불가능함을 뜻해요.

점을 가진 JSON 키 다루기 (Handling JSON keys with dots)

내부적으로 JSON 컬럼은 모든 경로와 값을 평면화된 형태로 저장해요. 이것은 기본적으로 다음 두 객체가 동일하게 간주됨을 뜻해요.

{"a" : {"b" : 42}}
{"a.b" : 42}

둘 다 내부적으로 경로 a.b와 값 42의 쌍으로 저장돼요. JSON 포맷 중에 우리는 항상 점으로 구분된 경로 부분을 기반으로 중첩 객체를 형성해요. 쿼리:

SELECT '{"a" : {"b" : 42}}'::JSON AS json1, '{"a.b" : 42}'::JSON AS json2, JSONAllPaths(json1), JSONAllPaths(json2);

응답:

┌─json1────────────┬─json2────────────┬─JSONAllPaths(json1)─┬─JSONAllPaths(json2)─┐
│ {"a":{"b":"42"}} │ {"a":{"b":"42"}} │ ['a.b']             │ ['a.b']             │
└──────────────────┴──────────────────┴─────────────────────┴─────────────────────┘

보시다시피 초기 JSON {"a.b" : 42}는 이제 {"a" : {"b" : 42}}로 포맷돼요. 이 제한은 다음과 같은 유효한 JSON 객체를 파싱하는 것도 실패하게 해요. 쿼리:

SELECT '{"a.b" : 42, "a" : {"b" : "Hello World!"}}'::JSON AS json;

응답:

Code: 117. DB::Exception: Cannot insert data into JSON column: Duplicate path found during parsing JSON object: a.b. You can enable setting type_json_skip_duplicated_paths to skip duplicated paths during insert: In scope SELECT CAST('{"a.b" : 42, "a" : {"b" : "Hello, World"}}', 'JSON') AS json. (INCORRECT_DATA)

점이 있는 키를 유지하고 중첩 객체로 포맷하는 것을 피하고 싶다면 설정 json_type_escape_dots_in_keys(25.8 버전부터 사용 가능)를 켤 수 있어요. 이 경우 파싱 중 JSON 키의 모든 점이 %2E로 이스케이프되고 포맷 중 다시 언이스케이프돼요. 쿼리:

SET json_type_escape_dots_in_keys=1;
SELECT '{"a" : {"b" : 42}}'::JSON AS json1, '{"a.b" : 42}'::JSON AS json2, JSONAllPaths(json1), JSONAllPaths(json2);

응답:

┌─json1────────────┬─json2────────┬─JSONAllPaths(json1)─┬─JSONAllPaths(json2)─┐
│ {"a":{"b":"42"}} │ {"a.b":"42"} │ ['a.b']             │ ['a%2Eb']           │
└──────────────────┴──────────────┴─────────────────────┴─────────────────────┘

쿼리:

SET json_type_escape_dots_in_keys=1;
SELECT '{"a.b" : 42, "a" : {"b" : "Hello World!"}}'::JSON AS json, JSONAllPaths(json);

응답:

┌─json──────────────────────────────────┬─JSONAllPaths(json)─┐
│ {"a.b":"42","a":{"b":"Hello World!"}} │ ['a%2Eb','a.b']    │
└───────────────────────────────────────┴────────────────────┘

이스케이프된 점이 있는 키를 서브컬럼으로 읽으려면 서브컬럼 이름에 이스케이프된 점을 사용해야 해요. 쿼리:

SET json_type_escape_dots_in_keys=1;
SELECT '{"a.b" : 42, "a" : {"b" : "Hello World!"}}'::JSON AS json, json.`a%2Eb`, json.a.b;

응답:

┌─json──────────────────────────────────┬─json.a%2Eb─┬─json.a.b─────┐
│ {"a.b":"42","a":{"b":"Hello World!"}} │ 42         │ Hello World! │
└───────────────────────────────────────┴────────────┴──────────────┘

참고: 식별자 파서와 분석기의 제한 때문에 서브컬럼 json.a.b``은 서브컬럼 json.a.b과 동등하며 이스케이프된 점이 있는 경로를 읽지 않아요. 쿼리:

SET json_type_escape_dots_in_keys=1;
SELECT '{"a.b" : 42, "a" : {"b" : "Hello World!"}}'::JSON AS json, json.`a%2Eb`, json.`a.b`, json.a.b;

응답:

┌─json──────────────────────────────────┬─json.a%2Eb─┬─json.a.b─────┬─json.a.b─────┐
│ {"a.b":"42","a":{"b":"Hello World!"}} │ 42         │ Hello World! │ Hello World! │
└───────────────────────────────────────┴────────────┴──────────────┴──────────────┘

또한 점이 있는 키를 포함한 JSON 경로에 힌트를 지정하려면(SKIP/SKIP REGEX 섹션에서도) 힌트에 이스케이프된 점을 사용해야 해요. 쿼리:

SET json_type_escape_dots_in_keys=1;
SELECT '{"a.b" : 42, "a" : {"b" : "Hello World!"}}'::JSON(`a%2Eb` UInt8) as json, json.`a%2Eb`, toTypeName(json.`a%2Eb`);

응답:

┌─json────────────────────────────────┬─json.a%2Eb─┬─toTypeName(json.a%2Eb)─┐
│ {"a.b":42,"a":{"b":"Hello World!"}} │         42 │ UInt8                  │
└─────────────────────────────────────┴────────────┴────────────────────────┘

쿼리:

SET json_type_escape_dots_in_keys=1;
SELECT '{"a.b" : 42, "a" : {"b" : "Hello World!"}}'::JSON(SKIP `a%2Eb`) as json, json.`a%2Eb`;

응답:

┌─json───────────────────────┬─json.a%2Eb─┐
│ {"a":{"b":"Hello World!"}} │ ᴺᵁᴸᴸ       │
└────────────────────────────┴────────────┘

데이터에서 JSON 타입 읽기 (Reading JSON type from data)

모든 텍스트 형식(JSONEachRow, TSV, CSV, CustomSeparated, Values 등)이 JSON 타입 읽기를 지원해요.

예시:

쿼리:

SELECT json FROM format(JSONEachRow, 'json JSON(a.b.c UInt32, SKIP a.b.d, SKIP d.e, SKIP REGEXP \'b.*\')', '
{"json" : {"a" : {"b" : {"c" : 1, "d" : [0, 1]}}, "b" : "2020-01-01", "c" : 42, "d" : {"e" : {"f" : ["s1", "s2"]}, "i" : [1, 2, 3]}}}
{"json" : {"a" : {"b" : {"c" : 2, "d" : [2, 3]}}, "b" : [1, 2, 3], "c" : null, "d" : {"e" : {"g" : 43}, "i" : [4, 5, 6]}}}
{"json" : {"a" : {"b" : {"c" : 3, "d" : [4, 5]}}, "b" : {"c" : 10}, "e" : "Hello, World!"}}
{"json" : {"a" : {"b" : {"c" : 4, "d" : [6, 7]}}, "c" : 43}}
{"json" : {"a" : {"b" : {"c" : 5, "d" : [8, 9]}}, "b" : {"c" : 11, "j" : [1, 2, 3]}, "d" : {"e" : {"f" : ["s3", "s4"], "g" : 44}, "h" : "2020-02-02 10:00:00"}}}
')

응답:

┌─json──────────────────────────────────────────────────────────┐
│ {"a":{"b":{"c":1}},"c":"42","d":{"i":["1","2","3"]}}          │
│ {"a":{"b":{"c":2}},"d":{"i":["4","5","6"]}}                   │
│ {"a":{"b":{"c":3}},"e":"Hello, World!"}                       │
│ {"a":{"b":{"c":4}},"c":"43"}                                  │
│ {"a":{"b":{"c":5}},"d":{"h":"2020-02-02 10:00:00.000000000"}} │
└───────────────────────────────────────────────────────────────┘

CSV/TSV 등 같은 텍스트 형식에서는 JSON이 JSON 객체를 포함한 문자열에서 파싱돼요. 쿼리:

SELECT json FROM format(TSV, 'json JSON(a.b.c UInt32, SKIP a.b.d, SKIP REGEXP \'b.*\')',
'{"a" : {"b" : {"c" : 1, "d" : [0, 1]}}, "b" : "2020-01-01", "c" : 42, "d" : {"e" : {"f" : ["s1", "s2"]}, "i" : [1, 2, 3]}}
{"a" : {"b" : {"c" : 2, "d" : [2, 3]}}, "b" : [1, 2, 3], "c" : null, "d" : {"e" : {"g" : 43}, "i" : [4, 5, 6]}}
{"a" : {"b" : {"c" : 3, "d" : [4, 5]}}, "b" : {"c" : 10}, "e" : "Hello, World!"}
{"a" : {"b" : {"c" : 4, "d" : [6, 7]}}, "c" : 43}
{"a" : {"b" : {"c" : 5, "d" : [8, 9]}}, "b" : {"c" : 11, "j" : [1, 2, 3]}, "d" : {"e" : {"f" : ["s3", "s4"], "g" : 44}, "h" : "2020-02-02 10:00:00"}}')

응답:

┌─json──────────────────────────────────────────────────────────┐
│ {"a":{"b":{"c":1}},"c":"42","d":{"i":["1","2","3"]}}          │
│ {"a":{"b":{"c":2}},"d":{"i":["4","5","6"]}}                   │
│ {"a":{"b":{"c":3}},"e":"Hello, World!"}                       │
│ {"a":{"b":{"c":4}},"c":"43"}                                  │
│ {"a":{"b":{"c":5}},"d":{"h":"2020-02-02 10:00:00.000000000"}} │
└───────────────────────────────────────────────────────────────┘

JSON 안 동적 경로 한계에 도달 (Reaching the limit of dynamic paths inside JSON)

JSON 데이터 타입은 내부적으로 제한된 수의 경로만 별도 서브컬럼으로 저장할 수 있어요. 기본적으로 이 한계는 1024지만, 타입 선언에서 max_dynamic_paths 매개변수로 바꿀 수 있어요. 한계에 도달하면 JSON 컬럼에 삽입되는 모든 새 경로는 단일 공유 데이터 구조에 저장돼요. 그런 경로를 서브컬럼으로 읽는 것은 여전히 가능하지만 덜 효율적일 수 있어요(공유 데이터 섹션 참고). 이 한계는 테이블을 쓸 수 없게 만들 수 있는 엄청난 수의 서로 다른 서브컬럼을 피하기 위해 필요해요. 몇 가지 다른 시나리오에서 한계에 도달하면 어떤 일이 일어나는지 살펴볼게요.

데이터 파싱 중 한계 도달

데이터에서 JSON 객체를 파싱하는 동안 현재 데이터 블록에 대해 한계에 도달하면 모든 새 경로가 공유 데이터 구조에 저장돼요. 다음 두 개의 내부 검사 함수 JSONDynamicPaths, JSONSharedDataPaths를 사용할 수 있어요. 쿼리:

SELECT json, JSONDynamicPaths(json), JSONSharedDataPaths(json) FROM format(JSONEachRow, 'json JSON(max_dynamic_paths=3)', '
{"json" : {"a" : {"b" : 42}, "c" : [1, 2, 3]}}
{"json" : {"a" : {"b" : 43}, "d" : "2020-01-01"}}
{"json" : {"a" : {"b" : 44}, "c" : [4, 5, 6]}}
{"json" : {"a" : {"b" : 43}, "d" : "2020-01-02", "e" : "Hello", "f" : {"g" : 42.42}}}
{"json" : {"a" : {"b" : 43}, "c" : [7, 8, 9], "f" : {"g" : 43.43}, "h" : "World"}}
')

응답:

┌─json───────────────────────────────────────────────────────────┬─JSONDynamicPaths(json)─┬─JSONSharedDataPaths(json)─┐
│ {"a":{"b":"42"},"c":["1","2","3"]}                             │ ['a.b','c','d']        │ []                        │
│ {"a":{"b":"43"},"d":"2020-01-01"}                              │ ['a.b','c','d']        │ []                        │
│ {"a":{"b":"44"},"c":["4","5","6"]}                             │ ['a.b','c','d']        │ []                        │
│ {"a":{"b":"43"},"d":"2020-01-02","e":"Hello","f":{"g":42.42}}  │ ['a.b','c','d']        │ ['e','f.g']               │
│ {"a":{"b":"43"},"c":["7","8","9"],"f":{"g":43.43},"h":"World"} │ ['a.b','c','d']        │ ['f.g','h']               │
└────────────────────────────────────────────────────────────────┴────────────────────────┴───────────────────────────┘

보시다시피 경로 ef.g를 넣은 후 한계에 도달했고, 그것들이 공유 데이터 구조에 삽입됐어요.

MergeTree 테이블 엔진에서 데이터 파트 병합 중

MergeTree 테이블에서 여러 데이터 파트를 병합하는 동안 결과 데이터 파트의 JSON 컬럼은 동적 경로 한계에 도달할 수 있고, 소스 파트의 모든 경로를 서브컬럼으로 저장하지 못할 수 있어요. 이 경우 ClickHouse는 병합 후 어떤 경로가 서브컬럼으로 유지되고 어떤 경로가 공유 데이터 구조에 저장될지를 선택해요. 대부분의 경우 ClickHouse는 non-null 값이 가장 많은 경로를 유지하고 가장 희귀한 경로를 공유 데이터 구조로 옮기려고 하지만, 이는 구현에 따라 달라져요. 그런 병합의 예를 살펴볼게요. 먼저 JSON 컬럼이 있는 테이블을 만들고, 동적 경로 한계를 3으로 설정한 다음 5개의 서로 다른 경로로 값을 넣어요. 쿼리:

CREATE TABLE test (id UInt64, json JSON(max_dynamic_paths=3)) ENGINE=MergeTree ORDER BY id;
SYSTEM STOP MERGES test;
INSERT INTO test SELECT number, formatRow('JSONEachRow', number as a) FROM numbers(5);
INSERT INTO test SELECT number, formatRow('JSONEachRow', number as b) FROM numbers(4);
INSERT INTO test SELECT number, formatRow('JSONEachRow', number as c) FROM numbers(3);
INSERT INTO test SELECT number, formatRow('JSONEachRow', number as d) FROM numbers(2);
INSERT INTO test SELECT number, formatRow('JSONEachRow', number as e) FROM numbers(1);

각 insert는 단일 경로를 가진 JSON 컬럼이 있는 별도의 데이터 파트를 만들어요. 쿼리:

SELECT
    count(),
    groupArrayArrayDistinct(JSONDynamicPaths(json)) AS dynamic_paths,
    groupArrayArrayDistinct(JSONSharedDataPaths(json)) AS shared_data_paths,
    _part
FROM test
GROUP BY _part
ORDER BY _part ASC

응답:

┌─count()─┬─dynamic_paths─┬─shared_data_paths─┬─_part─────┐
│       5 │ ['a']         │ []                │ all_1_1_0 │
│       4 │ ['b']         │ []                │ all_2_2_0 │
│       3 │ ['c']         │ []                │ all_3_3_0 │
│       2 │ ['d']         │ []                │ all_4_4_0 │
│       1 │ ['e']         │ []                │ all_5_5_0 │
└─────────┴───────────────┴───────────────────┴───────────┘

이제 모든 파트를 하나로 병합하고 무슨 일이 일어나는지 볼게요. 쿼리:

SELECT
    count(),
    groupArrayArrayDistinct(JSONDynamicPaths(json)) AS dynamic_paths,
    groupArrayArrayDistinct(JSONSharedDataPaths(json)) AS shared_data_paths,
    _part
FROM test
GROUP BY _part
ORDER BY _part ASC

응답:

┌─count()─┬─dynamic_paths─┬─shared_data_paths─┬─_part─────┐
│      15 │ ['a','b','c'] │ ['d','e']         │ all_1_5_2 │
└─────────┴───────────────┴───────────────────┴───────────┘

보시다시피 ClickHouse는 가장 빈번한 a, b, c 경로를 유지하고 d, e 경로를 공유 데이터 구조로 옮겼어요.

공유 데이터 구조 (Shared data structure)

이전 섹션에서 설명했듯이 max_dynamic_paths 한계에 도달하면 모든 새 경로가 단일 공유 데이터 구조에 저장돼요. 이 섹션에서 공유 데이터 구조의 세부사항과 그것으로부터 경로 서브컬럼을 읽는 방법을 살펴볼게요. JSON 컬럼 내용 검사에 사용되는 함수의 세부사항은 “내부 검사 함수” 섹션을 참고해요.

메모리 안 공유 데이터 구조

메모리에서 공유 데이터 구조는 그냥 평면화된 JSON 경로에서 이진 인코딩 값으로의 매핑을 저장하는 Map(String, String) 타입의 서브컬럼이에요. 그것에서 경로 서브컬럼을 추출하려면 이 Map 컬럼의 모든 행을 반복하고 요청된 경로와 그 값을 찾으려고 합니다.

MergeTree 파트 안 공유 데이터 구조

MergeTree 테이블에서는 모든 것을 디스크(로컬 또는 원격)에 저장하는 데이터 파트로 데이터를 저장해요. 그리고 디스크의 데이터는 메모리와 다르게 저장될 수 있어요. 현재 MergeTree 데이터 파트에는 4가지 다른 공유 데이터 구조 직렬화가 있어요: map, map_with_buckets, advanced, advanced_chunked. 직렬화 버전은 MergeTree 설정 object_shared_data_serialization_versionobject_shared_data_serialization_version_for_zero_level_parts(0레벨 파트는 데이터를 테이블에 삽입하는 동안 생성된 파트이고, 병합 중 파트는 더 높은 레벨을 가져요)로 제어돼요. 참고: 공유 데이터 구조 직렬화를 바꾸는 것은 v3 객체 직렬화 버전에서만 지원돼요.

Map

map 직렬화 버전에서 공유 데이터는 메모리처럼 Map(String, String) 타입의 단일 컬럼으로 직렬화돼요. 이 직렬화에서 경로 서브컬럼을 읽으려면 ClickHouse가 전체 Map 컬럼을 읽고 메모리에서 요청된 경로를 추출해요. 이 직렬화는 데이터 쓰기와 전체 JSON 컬럼 읽기에 효율적이지만, 경로 서브컬럼 읽기에는 효율적이지 않아요.

Map with buckets

map_with_buckets 직렬화 버전에서 공유 데이터는 Map(String, String) 타입의 N개 컬럼(“버킷”)으로 직렬화돼요. 각 버킷은 경로의 부분집합만 담아요. 이 직렬화에서 경로 서브컬럼을 읽으려면 ClickHouse가 단일 버킷에서 전체 Map 컬럼을 읽고 메모리에서 요청된 경로를 추출해요. 이 직렬화는 데이터 쓰기와 전체 JSON 컬럼 읽기에 덜 효율적이지만, 필요한 버킷의 데이터만 읽기 때문에 경로 서브컬럼 읽기에는 더 효율적이에요. 버킷 수 N은 MergeTree 설정 object_shared_data_buckets_for_compact_part(기본 8)와 object_shared_data_buckets_for_wide_part(기본 32)로 제어돼요. 두 설정의 최대 허용 값은 256이에요.

Advanced

advanced 직렬화 버전에서 공유 데이터는 요청된 경로의 데이터만 읽을 수 있게 해주는 몇 가지 추가 정보를 저장해 경로 서브컬럼 읽기 성능을 극대화하는 특별한 데이터 구조로 직렬화돼요. 이 직렬화는 버킷도 지원하므로 각 버킷은 경로의 부분집합만 담아요. 이 직렬화는 데이터 쓰기에 꽤 비효율적이라(그래서 0레벨 파트에 이 직렬화를 사용하는 것은 권장되지 않아요) 전체 JSON 컬럼 읽기는 map 직렬화보다 약간 덜 효율적이지만, 경로 서브컬럼 읽기에는 매우 효율적이에요. 참고: 데이터 구조 안에 몇 가지 추가 정보를 저장하기 때문에 디스크 저장 크기가 mapmap_with_buckets 직렬화보다 이 직렬화에서 더 커요. 새 공유 데이터 직렬화의 더 자세한 개요와 구현 세부사항은 블로그 포스트를 읽어보세요.

Advanced chunked

advanced_chunked 직렬화는 advanced와 같지만 직렬화 중에 행을 더 작은 청크로 나누는 것을 지원해요. 이것은 많은 고유 경로가 있는 JSON 컬럼의 병합 중 전체 행 범위 대신 한 번에 한 청크만큼의 데이터만 구체화하면 되므로 최대 메모리 사용을 줄여요. 청크 크기는 MergeTree 설정 object_shared_data_target_chunk_rows(기본 8192)로 제어돼요. 이것은 엄격한 제한이 아니에요. 마지막 청크가 대상의 절반보다 작으면 이전 청크와 병합되므로 실제 청크 크기는 target/2부터 1.5 * target까지 다양해요.

MergeTree 파트 안 JSON 동적 경로 수 제어 (Controlling the number of dynamic paths inside JSON in MergeTree parts)

JSON에서 동적 경로에 제한을 설정하는 주요 방법은 JSON 타입 선언 안의 max_dynamic_paths 매개변수를 사용하는 것이에요. 하지만 기존 컬럼에 대해 max_dynamic_paths를 바꾸려면 ALTER TABLE <table> MODIFY COLUMN <column> JSON(max_dynamic_paths=K)를 실행해야 하고, 이는 기존 모든 파트를 다시 쓰는 백그라운드 뮤테이션을 시작해요. 그런 뮤테이션은 정말 무거울 수 있고 뮤테이션이 끝날 때까지 서버 성능에 영향을 줄 수 있어요. 이것을 피하기 위해 새 데이터 파트에 대한 MergeTree 테이블의 동적 경로 제한을 바꾸는 데 도움을 주는 3가지 설정을 사용할 수 있어요.

  • merge_max_dynamic_subcolumns_in_wide_part — Wide 데이터 파트로 병합하는 동안 각 JSON 컬럼의 동적 서브컬럼 수를 제한하는 MergeTree 설정.
  • merge_max_dynamic_subcolumns_in_compact_part — Compact 데이터 파트로 병합하는 동안 각 JSON 컬럼의 동적 서브컬럼 수를 제한하는 MergeTree 설정.
  • max_dynamic_subcolumns_in_json_type_parsing — JSON 데이터를 JSON 컬럼으로 파싱하는 동안 각 JSON 컬럼의 동적 서브컬럼 수를 제한하는 세션 설정.

참고: 설명된 설정의 값이 더 높아도 동적 경로 제한은 max_dynamic_paths 매개변수에 지정된 값을 초과할 수 없어요.

내부 검사 함수 (Introspection functions)

JSON 컬럼의 내용을 검사하는 데 도움을 주는 몇 가지 함수가 있어요.

예시 (Examples)

2020-01-01 날짜의 GH Archive 데이터셋 내용을 조사해 볼게요. 쿼리:

SELECT arrayJoin(distinctJSONPaths(json))
FROM s3('s3://clickhouse-public-datasets/gharchive/original/2020-01-01-*.json.gz', NOSIGN, JSONAsObject)

응답:

┌─arrayJoin(distinctJSONPaths(json))─────────────────────────┐
│ actor.avatar_url                                           │
│ actor.display_login                                        │
│ actor.gravatar_id                                          │
│ actor.id                                                   │
│ actor.login                                                │
│ actor.url                                                  │
│ created_at                                                 │
│ id                                                         │
│ org.avatar_url                                             │
│ org.gravatar_id                                            │
│ org.id                                                     │
│ org.login                                                  │
│ org.url                                                    │
│ payload.action                                             │
│ payload.before                                             │
│ payload.comment._links.html.href                           │
│ payload.comment._links.pull_request.href                   │
│ payload.comment._links.self.href                           │
│ payload.comment.author_association                         │
│ payload.comment.body                                       │
│ payload.comment.commit_id                                  │
│ payload.comment.created_at                                 │
│ payload.comment.diff_hunk                                  │
│ payload.comment.html_url                                   │
│ payload.comment.id                                         │
│ payload.comment.in_reply_to_id                             │
│ payload.comment.issue_url                                  │
│ payload.comment.line                                       │
│ payload.comment.node_id                                    │
│ payload.comment.original_commit_id                         │
│ payload.comment.original_position                          │
│ payload.comment.path                                       │
│ payload.comment.position                                   │
│ payload.comment.pull_request_review_id                     │
...
│ payload.release.node_id                                    │
│ payload.release.prerelease                                 │
│ payload.release.published_at                               │
│ payload.release.tag_name                                   │
│ payload.release.tarball_url                                │
│ payload.release.target_commitish                           │
│ payload.release.upload_url                                 │
│ payload.release.url                                        │
│ payload.release.zipball_url                                │
│ payload.size                                               │
│ public                                                     │
│ repo.id                                                    │
│ repo.name                                                  │
│ repo.url                                                   │
│ type                                                       │
└─arrayJoin(distinctJSONPaths(json))─────────────────────────┘

쿼리:

SELECT arrayJoin(distinctJSONPathsAndTypes(json))
FROM s3('s3://clickhouse-public-datasets/gharchive/original/2020-01-01-*.json.gz', NOSIGN, JSONAsObject)
SETTINGS date_time_input_format = 'best_effort'

응답:

┌─arrayJoin(distinctJSONPathsAndTypes(json))──────────────────┐
│ ('actor.avatar_url',['String'])                             │
│ ('actor.display_login',['String'])                          │
│ ('actor.gravatar_id',['String'])                            │
│ ('actor.id',['Int64'])                                      │
│ ('actor.login',['String'])                                  │
│ ('actor.url',['String'])                                    │
│ ('created_at',['DateTime'])                                 │
│ ('id',['String'])                                           │
│ ('org.avatar_url',['String'])                               │
│ ('org.gravatar_id',['String'])                              │
│ ('org.id',['Int64'])                                        │
│ ('org.login',['String'])                                    │
│ ('org.url',['String'])                                      │
│ ('payload.action',['String'])                               │
│ ('payload.before',['String'])                               │
│ ('payload.comment._links.html.href',['String'])             │
│ ('payload.comment._links.pull_request.href',['String'])     │
│ ('payload.comment._links.self.href',['String'])             │
│ ('payload.comment.author_association',['String'])           │
│ ('payload.comment.body',['String'])                         │
│ ('payload.comment.commit_id',['String'])                    │
│ ('payload.comment.created_at',['DateTime'])                 │
│ ('payload.comment.diff_hunk',['String'])                    │
│ ('payload.comment.html_url',['String'])                     │
│ ('payload.comment.id',['Int64'])                            │
│ ('payload.comment.in_reply_to_id',['Int64'])                │
│ ('payload.comment.issue_url',['String'])                    │
│ ('payload.comment.line',['Int64'])                          │
│ ('payload.comment.node_id',['String'])                      │
│ ('payload.comment.original_commit_id',['String'])           │
│ ('payload.comment.original_position',['Int64'])             │
│ ('payload.comment.path',['String'])                         │
│ ('payload.comment.position',['Int64'])                      │
│ ('payload.comment.pull_request_review_id',['Int64'])        │
...
│ ('payload.release.node_id',['String'])                      │
│ ('payload.release.prerelease',['Bool'])                     │
│ ('payload.release.published_at',['DateTime'])               │
│ ('payload.release.tag_name',['String'])                     │
│ ('payload.release.tarball_url',['String'])                  │
│ ('payload.release.target_commitish',['String'])             │
│ ('payload.release.upload_url',['String'])                   │
│ ('payload.release.url',['String'])                          │
│ ('payload.release.zipball_url',['String'])                  │
│ ('payload.size',['Int64'])                                  │
│ ('public',['Bool'])                                         │
│ ('repo.id',['Int64'])                                       │
│ ('repo.name',['String'])                                    │
│ ('repo.url',['String'])                                     │
│ ('type',['String'])                                         │
└─arrayJoin(distinctJSONPathsAndTypes(json))──────────────────┘

JSON 타입으로 ALTER MODIFY COLUMN

기존 테이블을 ALTER하고 컬럼 타입을 새 JSON 타입으로 바꿀 수 있어요. 현재는 String 타입에서의 ALTER만 지원돼요. 예시 (Example) 쿼리:

CREATE TABLE test (json String) ENGINE=MergeTree ORDER BY tuple();
INSERT INTO test VALUES ('{"a" : 42}'), ('{"a" : 43, "b" : "Hello"}'), ('{"a" : 44, "b" : [1, 2, 3]}'), ('{"c" : "2020-01-01"}');
ALTER TABLE test MODIFY COLUMN json JSON;
SELECT json, json.a, json.b, json.c FROM test;

응답:

┌─json─────────────────────────┬─json.a─┬─json.b──┬─json.c─────┐
│ {"a":"42"}                   │ 42     │ ᴺᵁᴸᴸ    │ ᴺᵁᴸᴸ       │
│ {"a":"43","b":"Hello"}       │ 43     │ Hello   │ ᴺᵁᴸᴸ       │
│ {"a":"44","b":["1","2","3"]} │ 44     │ [1,2,3] │ ᴺᵁᴸᴸ       │
│ {"c":"2020-01-01"}           │ ᴺᵁᴸᴸ   │ ᴺᵁᴸᴸ    │ 2020-01-01 │
└──────────────────────────────┴────────┴─────────┴────────────┘

지연 타입 힌트 (Lazy Type Hints) (Beta)

이 기능은 베타이며 설정 enable_json_lazy_type_hints를 켜야 해요. ALTER TABLE ... MODIFY COLUMN으로 JSON 컬럼에 타입 힌트를 추가하거나 수정하면 ClickHouse는 일반적으로 모든 데이터 파트를 다시 써서 새 타입 힌트를 구체화해요. 수백 테라바이트의 대량 과거 데이터가 있는 테이블에서는 이것이 극도로 비싸질 수 있어요.

지연 타입 힌트(lazy type hints) 는 기존 데이터를 다시 쓰지 않고 메타데이터 전용 연산으로 타입 힌트를 추가할 수 있게 해줘요.

  • 기존 파트(Old parts): 타입 힌트는 쿼리 시점에 Dynamic에서 힌트된 타입으로 캐스팅되어 적용돼요.
  • 새 파트(New parts): 타입 힌트는 INSERT 연산 중에 구체화돼요.
  • 병합(Merges): 타입 힌트는 파트가 병합될 때 구체화돼요.

이것은 타입 힌트를 즉시 추가할 수 있고, 데이터는 일반적인 백그라운드 병합이 발생하면서 점진적으로 변환됨을 뜻해요.

지연 타입 힌트 활성화

SET enable_json_lazy_type_hints = 1;

예시 (Example)

쿼리:

-- Create a table and insert data
CREATE TABLE test_lazy (json JSON) ENGINE = MergeTree ORDER BY tuple();
INSERT INTO test_lazy VALUES ('{"user_id": "123", "score": "95.5"}');

-- Enable lazy type hints
SET enable_json_lazy_type_hints = 1;

-- Add type hints - this completes instantly without mutation
ALTER TABLE test_lazy MODIFY COLUMN json JSON(user_id UInt64, score Float64);

-- Query the data - type hints are applied at read time
SELECT json.user_id, toTypeName(json.user_id), json.score, toTypeName(json.score) FROM test_lazy;

응답:

┌─json.user_id─┬─toTypeName(json.user_id)─┬─json.score─┬─toTypeName(json.score)─┐
│          123 │ UInt64                   │       95.5 │ Float64                │
└──────────────┴──────────────────────────┴────────────┴────────────────────────┘

뮤테이션이 발생하지 않았는지 검증 (Verifying No Mutation Occurred)

system.mutations 테이블을 확인해 ALTER가 뮤테이션 없이 완료됐는지 검증할 수 있어요.

SELECT * FROM system.mutations WHERE table = 'test_lazy' AND NOT is_done;

지연 타입 힌트가 켜져 있으면 이 쿼리는 행을 반환하지 않아, 연산이 메타데이터 전용이었음을 확인해줘요. 이것은 위에서 설명한 타입 힌트 변경에 적용되며, 대신 거부되는 경우는 제한을 참고해요.

타입 힌트 구체화 (Materializing Type Hints)

기존 데이터에서 타입 힌트를 구체화하려면 다음 중 하나를 할 수 있어요.

  • 백그라운드 병합 대기: ClickHouse가 파트가 병합될 때 타입 힌트를 자동으로 구체화해요.
  • 강제 병합: OPTIMIZE TABLE test_lazy FINAL로 모든 파트를 즉시 병합해요.
  • 파트 다시 쓰기: ALTER TABLE test_lazy REWRITE PARTS로 새 메타데이터로 파트를 다시 써요.

제한 (Limitations)

  • 쿼리 시점 타입 변환은 구체화된 타입에 비해 상당한 성능 오버헤드가 있을 수 있어요. 특히 큰 JSON 객체에서 그렇죠.
  • 이 기능은 typed_paths(타입 힌트)를 수정할 때만 적용돼요. max_dynamic_paths, SKIP, SKIP REGEXP 같은 다른 JSON 매개변수는 여전히 뮤테이션이 필요해요.
  • 타입 힌트 수정(또는 타입 경로 제거)은 위치적으로 유지되는(positionally-persisted) 구조에서 해당 서브컬럼이 사용될 때 메타데이터 전용이 아니며 거부돼요.
    • 기본/정렬 키 또는 파티션 키 — 변경이 금지돼요. 온디스크 기본 인덱스/파티션 값을 메타데이터 전용 ALTER로 다시 빌드할 수 없으니까요(다른 키 컬럼과 마찬가지로).
    • 명시적 데이터 스킵 인덱스 — 먼저 인덱스를 제거하거나, enable_json_lazy_type_hints를 꺼서 인덱스를 다시 빌드하는 전체 뮤테이션으로 변경을 실행해요.
    • 정렬 키(ORDER BY)가 서브컬럼을 읽는 프로젝션 — 먼저 프로젝션을 제거해요. 메타데이터 전용 ALTER는 프로젝션의 기본 인덱스를 다시 빌드할 수 없으니까요.

그런 구조에서 사용되지 않는 경로에 힌트를 추가하거나, 사용된 서브컬럼의 온디스크 타입을 바꾸지 않는 변경(예: 무관한 타입 경로 추가)은 메타데이터 전용으로 유지돼요.

JSON 타입 값 사이의 비교 (Comparison between values of the JSON type)

JSON 객체는 Map과 유사하게 비교돼요. 예를 들어: 쿼리:

CREATE TABLE test (json1 JSON, json2 JSON) ENGINE=Memory;
INSERT INTO test FORMAT JSONEachRow
{"json1" : {}, "json2" : {}}
{"json1" : {"a" : 42}, "json2" : {}}
{"json1" : {"a" : 42}, "json2" : {"a" : 41}}
{"json1" : {"a" : 42}, "json2" : {"a" : 42}}
{"json1" : {"a" : 42}, "json2" : {"a" : [1, 2, 3]}}
{"json1" : {"a" : 42}, "json2" : {"a" : "Hello"}}
{"json1" : {"a" : 42}, "json2" : {"b" : 42}}
{"json1" : {"a" : 42}, "json2" : {"a" : 42, "b" : 42}}
{"json1" : {"a" : 42}, "json2" : {"a" : 41, "b" : 42}}

SELECT json1, json2, json1 < json2, json1 = json2, json1 > json2 FROM test;

응답:

┌─json1──────┬─json2───────────────┬─less(json1, json2)─┬─equals(json1, json2)─┬─greater(json1, json2)─┐
│ {}         │ {}                  │                  0 │                    1 │                     0 │
│ {"a":"42"} │ {}                  │                  0 │                    0 │                     1 │
│ {"a":"42"} │ {"a":"41"}          │                  0 │                    0 │                     1 │
│ {"a":"42"} │ {"a":"42"}          │                  0 │                    1 │                     0 │
│ {"a":"42"} │ {"a":["1","2","3"]} │                  0 │                    0 │                     1 │
│ {"a":"42"} │ {"a":"Hello"}       │                  1 │                    0 │                     0 │
│ {"a":"42"} │ {"b":"42"}          │                  1 │                    0 │                     0 │
│ {"a":"42"} │ {"a":"42","b":"42"} │                  1 │                    0 │                     0 │
│ {"a":"42"} │ {"a":"41","b":"42"} │                  0 │                    0 │                     1 │
└────────────┴─────────────────────┴────────────────────┴──────────────────────┴───────────────────────┘

참고: 2개의 경로가 서로 다른 데이터 타입의 값을 포함할 때, Variant 데이터 타입의 비교 규칙에 따라 비교돼요.

JSON용 데이터 스킵 인덱스 (Data skipping indexes for JSON)

데이터 스킵 인덱스JSON 컬럼과 함께 세 가지 방식으로 사용할 수 있어요.

  • 특정 서브컬럼의 인덱스 — 알려진 JSON 경로에 일반 컬럼처럼 표준 스킵 인덱스를 만들어요. 이것은 그 경로의 값들을 인덱싱해요.
  • JSONAllPaths 기반 경로 인덱스 — 각 그레뉼에 존재하는 경로의 집합을 인덱싱해 조회된 경로를 담을 수 없는 그레뉼을 건너뛰어요.
  • JSONAllValues 기반 값 인덱스텍스트 인덱스를 사용해 모든 JSON 경로에 걸친 모든 값을 인덱싱해 단일 인덱스로 모든 JSON 서브컬럼에 대한 전체 텍스트 검색을 가속화해요.

특정 서브컬럼의 인덱스

일반 컬럼과 같은 문법으로 어떤 JSON 서브컬럼에도 스킵 인덱스를 만들 수 있어요. 어떤 지원 인덱스 타입이든 동작해요(minmax, set, bloom_filter, tokenbf_v1, ngrambf_v1 등). 인덱스 표현식에서 JSON 서브컬럼을 참조하는 방법은 두 가지가 있어요.

  • JSON 타입 힌트에 선언된 타입 경로 — 이름으로 직접 접근: json.a.
  • 명시적 캐스트가 있는 동적 경로:: 캐스트 문법 사용: json.b::String.

여러 서브컬럼을 결합한 표현식도 사용할 수 있어요. 예: json.a || json.b::String.

예시 (Example)

쿼리:

CREATE TABLE sensor_data
(
    data JSON(sensor_id UInt32),
    INDEX idx_sensor data.sensor_id TYPE minmax GRANULARITY 1,
    INDEX idx_location data.location::String TYPE bloom_filter GRANULARITY 1
)
ENGINE = MergeTree
ORDER BY tuple()
SETTINGS index_granularity = 1;

INSERT INTO sensor_data SELECT toJSONString(map('sensor_id', number, 'location', 'room_' || toString(number))) FROM numbers(4);
INSERT INTO sensor_data SELECT toJSONString(map('sensor_id', number, 'location', 'room_' || toString(number))) FROM numbers(4, 4);

타입 서브컬럼 data.sensor_idminmax 인덱스는 스캔을 일치하는 그레뉼로 좁혀요. 쿼리:

EXPLAIN indexes = 1 SELECT * FROM sensor_data WHERE data.sensor_id < 2;

응답:

...
    Indexes:
      Skip
        Name: idx_sensor
        Description: minmax GRANULARITY 1
        Parts: 1/2
        Granules: 2/8

캐스트 서브컬럼 data.location::Stringbloom_filter 인덱스도 동작해요. 쿼리:

EXPLAIN indexes = 1 SELECT * FROM sensor_data WHERE data.location::String = 'room_5';

응답:

...
    Indexes:
      Skip
        Name: idx_location
        Description: bloom_filter GRANULARITY 1
        Parts: 1/2
        Granules: 1/8

JSONAllPaths 기반 경로 인덱스

JSONAllPaths 함수를 사용해 JSON 컬럼에 데이터 스킵 인덱스를 만들 수도 있어요. 이것은 mapKeys를 통한 Map 컬럼의 스킵 인덱스 생성과 유사하게 동작해요. 인덱스는 각 그레뉼에 존재하는 JSON 경로의 집합을 저장하고, 조회된 경로를 담을 수 없는 그레뉼을 건너뛰는 데 사용해요.

지원되는 인덱스 타입

JSONAllPaths는 다음 스킵 인덱스 타입과 함께 사용할 수 있어요.

  • bloom_filterequals, in, IS NOT NULL을 지원해요.
  • tokenbf_v1equalsIS NOT NULL을 지원해요.
  • ngrambf_v1equalsIS NOT NULL을 지원해요.
  • text(역 인덱스) — equals, in, IS NOT NULL을 지원해요.
예시 (Example)

쿼리:

CREATE TABLE events
(
    data JSON,
    INDEX idx JSONAllPaths(data) TYPE bloom_filter GRANULARITY 1
)
ENGINE = MergeTree
ORDER BY tuple();

INSERT INTO events VALUES ('{"user": {"name": "Alice"}, "action": "login"}');
INSERT INTO events VALUES ('{"metric": {"cpu": 0.95}, "host": "srv1"}');

EXPLAIN indexes = 1로 스킵 인덱스가 사용되고 있는지 확인할 수 있어요. 경로가 한 파트에만 존재하면 인덱스는 다른 파트를 건너뛰어요. 쿼리:

EXPLAIN indexes = 1 SELECT * FROM events WHERE data.user.name = 'Alice';

응답:

...
    Indexes:
      Skip
        Name: idx
        Description: bloom_filter GRANULARITY 1
        Parts: 1/2
        Granules: 1/2

경로가 어떤 파트에도 존재하지 않으면 모든 파트와 그레뉼이 건너뛰어져요. 쿼리:

EXPLAIN indexes = 1 SELECT * FROM events WHERE data.nonexistent = 1;

응답:

...
    Indexes:
      Skip
        Name: idx
        Description: bloom_filter GRANULARITY 1
        Parts: 0/2
        Granules: 0/2

IS NOT NULL도 인덱스를 사용해요 — 경로가 없으면(값이 NULL이므로) 그레뉼을 건너뛰어요. 쿼리:

EXPLAIN indexes = 1 SELECT * FROM events WHERE data.user.name IS NOT NULL;

응답:

...
    Indexes:
      Skip
        Name: idx
        Description: bloom_filter GRANULARITY 1
        Parts: 1/2
        Granules: 1/2
동작 방식 (How it works)

JSONAllPaths(json_column) 표현식은 JSON 값에 존재하는 모든 경로를 담은 Array(String)을 만들어요. 스킵 인덱스는 이 경로 문자열을 데이터 구조(블룸 필터 또는 역 인덱스)에 저장해요. 쿼리가 json.some.path로 필터링하면 인덱스는 문자열 "some.path"가 각 그레뉼에 대한 인덱스에 존재하는지 확인하고, 존재하지 않는 그레뉼을 건너뛰어요. 일반 경로 접근만 인덱스와 매칭되며, 선택적으로 타입 힌트나 캐스트(json.a.b, json.a.b.:Int64, json.a.b::String)와 함께 가능해요. 하위 객체 서브컬럼(json.^a)과 결합 리터럴+하위 객체 서브컬럼(json.@a)에 대한 필터는 인덱스를 사용하지 않아요. a의 어떤 하위 경로가 존재할 때마다 이 서브컬럼들은 NULL이 아니므로, JSONAllPathsa` 경로 자체가 존재하는 것은 동등한 조건이 아니기 때문이에요.

누락된 경로와의 안전성 (Safety with missing paths)

JSON 경로가 그레뉼에서 없으면 서브컬럼은 다음으로 평가돼요.

  • Dynamic 타입(예: json.path)과 Nullable 타입 서브컬럼(예: json.path.:Int64)의 경우 NULLNULL과의 비교는 항상 false를 반환하므로 건너뛰기가 안전해요.
  • non-Nullable CAST 표현식의 타입 기본값(예: json.path::Int64는 경로가 없으면 0 생성) — 비교된 값이 기본값과 다를 때만 건너뛰기가 안전해요. 인덱스가 이 차이를 자동으로 처리해요.

JSONAllValues로 전체 텍스트 검색 (Full-text search with JSONAllValues)

JSONAllValues 함수를 통해 텍스트 인덱스로 JSON 컬럼의 전체 텍스트 검색을 가속화할 수 있어요. JSONAllValues는 JSON 컬럼의 모든 값을 Array(String)으로 반환하며, 텍스트 인덱스로 인덱싱할 수 있어요. JSONAllValues(json_column)의 단일 인덱스가 모든 JSON 경로를 덮으므로, 각 경로마다 별도 인덱스를 만들지 않고도 모든 서브컬럼에 대한 전체 텍스트 검색이 가능해요. 세부사항과 예시는 텍스트 인덱스 문서의 JSONAllValues 기반 값 인덱스를 참고해요.

JSON 타입 더 잘 쓰기 위한 팁 (Tips for better usage of the JSON type)

JSON 컬럼을 만들고 데이터를 불러오기 전에 다음 팁을 고려해요.

  • 데이터를 조사하고 타입이 있는 경로 힌트를 최대한 많이 지정해요. 저장과 읽기가 훨씬 더 효율적이 돼요.
  • 필요할 경로와 절대 필요하지 않을 경로를 생각해요. 필요 없는 경로는 SKIP 섹션에, 필요하면 SKIP REGEXP 섹션에도 지정해요. 이것은 저장을 개선해요.
  • max_dynamic_paths 매개변수를 매우 높은 값으로 설정하지 마세요. 저장과 읽기가 덜 효율적이 될 수 있어요. 메모리, CPU 등 시스템 매개변수에 크게 의존하지만, 일반적인 경험칙은 로컬 파일 시스템 저장에는 max_dynamic_paths를 10 000보다, 원격 파일 시스템 저장에는 1024보다 크게 설정하지 않는 것이에요.

더 알아보기 (Learn more)