메인 콘텐츠로 건너뛰기

가이드를 찾고 계신가요?

예시, 고급 기능, JSON 타입 사용 시 고려 사항은 JSON 모범 사례 가이드에서 확인하십시오.
JSON 타입은 JavaScript Object Notation(JSON) 문서를 단일 컬럼에 저장합니다.
ClickHouse Open-Source에서는 버전 25.3부터 JSON 데이터 타입이 프로덕션 환경에서 사용 가능한 것으로 표시됩니다. 이전 버전에서는 이 타입을 프로덕션 환경에서 사용하는 것을 권장하지 않습니다.
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 테이블의 단일 데이터 파트(data part) 전체에서) 몇 개의 경로를 서브컬럼으로 별도 저장할 수 있는지를 나타냅니다.

이 한도를 초과하면 나머지 모든 경로는 공유 데이터라는 단일 구조에 함께 저장됩니다.

또한 이 매개변수를 변경하지 않고도 동적 경로 수 제한을 변경할 수 있는 방법도 있습니다.
1024
max_dynamic_types1255 사이의 선택적 매개변수로, Dynamic 타입의 단일 경로 컬럼에서 별도로 저장되는 단일 데이터 블록 전체에 걸쳐(예: MergeTree 테이블의 단일 데이터 파트(data part) 전체에서) 서로 다른 데이터 타입을 몇 개까지 별도로 저장할 수 있는지를 나타냅니다.

이 한도를 초과하면 모든 새로운 타입은 shared variant라는 단일 구조에 함께 저장됩니다.
32
some.path TypeNameJSON의 특정 경로에 대한 선택적 타입 힌트입니다. 이러한 경로는 항상 지정된 타입의 서브컬럼으로 저장됩니다.
SKIP path.to.skipJSON 파싱 중 건너뛸 특정 경로에 대한 선택적 힌트입니다. 이러한 경로는 JSON 컬럼에 저장되지 않습니다. 지정한 경로가 중첩된 JSON 객체인 경우, 해당 중첩 객체 전체를 건너뜁니다.
SKIP REGEXP 'path_regexp'JSON 파싱 중 경로를 건너뛰는 데 사용하는 정규식이 포함된 선택적 힌트입니다. 이 정규식과 일치하는 모든 경로는 JSON 컬럼에 저장되지 않습니다.

JSON 타입을 사용해야 하는 경우

JSON 타입은 구조가 동적이거나 예측하기 어려운 JSON 객체 안의 특정 필드를 쿼리하고, 필터링하고, 집계할 수 있도록 설계되었습니다. 이를 위해 JSON 객체를 별도의 서브컬럼으로 분할하며, 그 결과 읽어야 하는 데이터 양이 크게 줄어들고 Map 또는 문자열 파싱 같은 대안보다 선택한 필드에 대한 쿼리 속도가 크게 빨라집니다. 하지만 여기에는 중요한 절충점이 있습니다:
  • 더 느린 INSERTs - JSON을 서브컬럼으로 분할하고, 타입을 추론하고, 유연한 저장 구조를 관리해야 하므로 JSON을 단순한 String 컬럼으로 저장하는 것보다 삽입이 더 느립니다.
  • 전체 객체를 읽을 때 더 느림 - 특정 필드가 아니라 전체 JSON 문서를 가져와야 한다면 JSON 타입은 String 컬럼에서 읽는 것보다 더 느립니다. 필드 수준 쿼리를 수행하지 않는 경우에는 분리된 서브컬럼에서 객체를 다시 구성하는 오버헤드가 아무런 이점을 주지 않습니다.
  • 스토리지 오버헤드 - 별도의 서브컬럼을 유지하면 JSON을 하나의 문자열 값으로 저장할 때보다 구조적 오버헤드가 추가됩니다.

다음과 같은 경우 JSON 타입을 사용합니다:

  • 데이터 구조가 동적이거나 예측하기 어렵고, 문서마다 키가 다릅니다
  • 필드 타입이나 스키마가 시간에 따라 바뀌거나 레코드마다 다릅니다
  • 구조를 미리 예측할 수 없는 JSON 객체 내의 특정 경로에 대해 쿼리, 필터링 또는 집계를 수행해야 합니다
  • 사용 사례에 로그, 이벤트, 또는 스키마가 일관되지 않은 사용자 생성 콘텐츠와 같은 반정형 데이터가 포함됩니다

String 컬럼(또는 구조화된 타입)을 사용해야 하는 경우:

  • 데이터 구조가 이미 정해져 있고 일관적인 경우 - 이때는 일반 컬럼이나 Tuple, Array, Dynamic, Variant 타입을 대신 사용하십시오
  • JSON 문서를 필드 수준에서 분석하지 않고, 전체를 하나의 불투명한 blob으로 저장하고 그대로 다시 읽어오기만 하는 경우
  • 데이터베이스 내에서 개별 JSON 필드에 대해 쿼리하거나 필터링할 필요가 없는 경우
  • JSON이 ClickHouse 내부에서 분석 대상이 아니라 단순한 전송/저장 포맷인 경우
JSON이 데이터베이스 내부에서 분석되지 않는 불투명한 문서이고, 단지 저장했다가 다시 읽어오기만 한다면 String 필드로 저장해야 합니다. JSON 타입의 장점은 동적인 JSON 구조 내 특정 필드에 대해 효율적으로 쿼리, 필터링, 집계해야 할 때에만 발휘됩니다.방식을 혼합해 사용할 수도 있습니다. 예측 가능한 최상위 필드에는 표준 컬럼을 사용하고, payload의 동적인 부분에는 JSON 컬럼을 사용할 수 있습니다.

JSON 만들기

이 섹션에서는 JSON을 만드는 다양한 방법을 살펴보겠습니다.

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

Query (Example 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;
Response (Example 1)
┌─json────────────────────────────────────────┐
│ {"a":{"b":"42"},"c":["1","2","3"]}          │
│ {"f":"Hello, World!"}                       │
│ {"a":{"b":"43","e":"10"},"c":["4","5","6"]} │
└─────────────────────────────────────────────┘
Query (Example 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;
Response (Example 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을 사용하면 다양한 타입을 CAST할 수 있습니다.

StringJSON으로 CAST

Query
SELECT '{"a" : {"b" : 42},"c" : [1, 2, 3], "d" : "Hello, World!"}'::JSON AS json;
Response
┌─json───────────────────────────────────────────────────┐
│ {"a":{"b":"42"},"c":["1","2","3"],"d":"Hello, World!"} │
└────────────────────────────────────────────────────────┘

TupleJSON으로 CAST

Query
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;
Response
┌─json───────────────────────────────────────────────────┐
│ {"a":{"b":"42"},"c":["1","2","3"],"d":"Hello, World!"} │
└────────────────────────────────────────────────────────┘

MapJSON으로 CAST

Query
SET use_variant_as_common_type=1;
SELECT map('a', map('b', 42), 'c', [1,2,3], 'd', 'Hello, World!')::JSON AS json;
Response
┌─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 경로를 서브컬럼으로 읽기

JSON 타입은 모든 경로를 각각의 서브컬럼으로 읽을 수 있습니다. JSON 타입 선언에서 요청한 경로의 타입을 지정하지 않으면, 해당 경로의 서브컬럼은 항상 Dynamic 타입이 됩니다. 예시:
Query
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;
Response
┌─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"]}                  │
└─────────────────────────────────────────────────────────────┘
Query (Reading JSON paths as sub-columns)
SELECT json.a.b, json.a.g, json.c, json.d FROM test;
Response (Reading JSON paths as sub-columns)
┌─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 타입에서 서브컬럼을 읽어올 수 있습니다:
Query
SELECT getSubcolumn(json, 'a.b'), getSubcolumn(json, 'a.g'), getSubcolumn(json, 'c'), getSubcolumn(json, 'd') FROM test;
Response
┌─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]                 │ ᴺᵁᴸᴸ                    │
└───────────────────────────┴───────────────────────────┴─────────────────────────┴─────────────────────────┘
데이터에서 요청한 경로를 찾을 수 없으면 NULL 값으로 채워집니다:
Query
SELECT json.non.existing.path FROM test;
Response
┌─json.non.existing.path─┐
│ ᴺᵁᴸᴸ                   │
│ ᴺᵁᴸᴸ                   │
│ ᴺᵁᴸᴸ                   │
└────────────────────────┘
반환된 서브컬럼의 데이터 타입을 확인해 보겠습니다:
Query
SELECT toTypeName(json.a.b), toTypeName(json.a.g), toTypeName(json.c), toTypeName(json.d) FROM test;
Response
┌─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 유형의 서브컬럼도 읽을 수 있습니다:
Query
SELECT
    json.a.g.:Float64,
    dynamicType(json.a.g),
    json.d.:Date,
    dynamicType(json.d)
FROM test
Response
┌─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 서브컬럼은 어떤 데이터 타입으로도 CAST할 수 있습니다. 이 경우 Dynamic 내부의 타입을 요청한 타입으로 CAST할 수 없으면 예외가 발생합니다:
Query
SELECT json.a.g::UInt64 AS uint
FROM test;
Response
┌─uint─┐
│   42 │
│    0 │
│   43 │
└──────┘
Query
SELECT json.a.g::UUID AS float
FROM test;
Response
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 하위 객체를 서브컬럼으로 읽기

JSON 타입은 특수 구문 json.^some.path를 사용해 중첩된 객체를 JSON 타입의 서브컬럼으로 읽을 수 있습니다:
Query
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;
Response
┌─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"]}                                               │
└───────────────────────────────────────────────────────────────────────────────────────────────────────────┘
Query
SELECT json.^a.b, json.^d.e.f FROM test;
Response
┌─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 공유 데이터 직렬화를 사용하면 공유 데이터에서 서브컬럼을 읽는 작업이 고도로 최적화됩니다.

JSON combined 서브컬럼 읽기

JSON 타입은 특수 구문 json.@some.path를 사용해 경로를 combined 서브컬럼으로 읽을 수 있습니다. 지정한 경로의 combined 서브컬럼은 다음을 반환합니다:
  • 해당 경로에 리터럴 값이 있으면, 그 경로에 저장된 리터럴 값을 Dynamic으로 반환합니다.
  • 해당 경로에 리터럴 값은 없지만 중첩된 하위 경로가 있으면, 그 경로의 JSON 하위 객체를 Dynamic으로 반환합니다.
  • 해당 경로에 리터럴 값도 하위 경로도 없으면 NULL을 반환합니다.
이 기능은 경로에 따라 서로 다른 행에서 스칼라 값이나 중첩 객체가 저장될 수 있을 때 유용하며, 리터럴 서브컬럼(json.a)과 하위 객체 서브컬럼(json.^a)을 각각 따로 쿼리하는 것보다 더 편리합니다. 다음 예시는 경로 a에 대한 3가지 서브컬럼 타입을 모두 비교합니다:
Query
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;
Response
┌─json────────────────────────────┐
│ {"a":42,"b":{"c":1,"d":"Hello"}}│
│ {"a":{"x":1,"y":2},"b":{"c":1}}│
│ {"c":"World"}                   │
└─────────────────────────────────┘
Query
SELECT
    json.a,
    dynamicType(json.a),
    json.^a,
    toTypeName(json.^a),
    json.@a,
    dynamicType(json.@a)
FROM test;
Response
┌─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                 │
└────────┴─────────────────────┴───────────────┴─────────────────────┴───────────────┴──────────────────────┘
  • Row 1: a에는 리터럴 42가 있습니다. json.a는 이를 Dynamic(Int64)로 반환하고, json.^a는 빈 하위 객체 {}를 반환하며(a 아래에 중첩된 키가 없음), json.@a는 리터럴 42를 반환합니다.
  • Row 2: a에는 중첩된 객체가 있습니다. json.aNULL을 반환하고(해당 경로에 리터럴 값이 없음), json.^a는 하위 객체를 JSON으로 반환하며, json.@a도 하위 객체를 Dynamic(JSON)로 반환합니다.
  • Row 3: a가 아예 없습니다. json.ajson.@a는 모두 NULL을 반환하고, json.^a는 빈 {}를 반환합니다.
경로가 기본(map) 공유 데이터에 저장된 경우, 결합 서브컬럼을 읽으려면 전체 공유 데이터 구조를 스캔해야 하므로 비효율적일 수 있습니다. map_with_buckets 또는 advanced 공유 데이터 직렬화를 사용하면 공유 데이터에서 서브컬럼을 읽는 작업이 크게 최적화됩니다.

경로에 대한 타입 추론

JSON을 파싱하는 동안 ClickHouse는 각 JSON 경로에 가장 적합한 데이터 타입을 판별하려고 합니다. 이는 입력 데이터의 자동 스키마 추론과 비슷하게 동작하며, 같은 설정으로 제어됩니다: 몇 가지 예시를 살펴보겠습니다:
Query
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;
Response
┌─paths_with_types─────────────────┐
│ {'a':'Date','b':'DateTime64(9)'} │
└──────────────────────────────────┘
Query
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;
Response
┌─paths_with_types────────────┐
│ {'a':'String','b':'String'} │
└─────────────────────────────┘
Query
SELECT JSONAllPathsWithTypes('{"a" : [1, 2, 3]}'::JSON) AS paths_with_types settings schema_inference_make_columns_nullable=1;
Response
┌─paths_with_types───────────────┐
│ {'a':'Array(Nullable(Int64))'} │
└────────────────────────────────┘
Query
SELECT JSONAllPathsWithTypes('{"a" : [1, 2, 3]}'::JSON) AS paths_with_types settings schema_inference_make_columns_nullable=0;
Response
┌─paths_with_types─────┐
│ {'a':'Array(Int64)'} │
└──────────────────────┘

JSON 객체 배열 처리

객체 배열을 포함하는 JSON 경로는 Array(JSON) 유형으로 파싱되어, 해당 경로의 Dynamic 컬럼에 삽입됩니다. 객체 배열을 읽으려면 Dynamic 컬럼에서 하위 컬럼으로 추출할 수 있습니다:
Query
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;
Response
┌─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"}}]}}                                                │
└─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┘
Query
SELECT json.a.b, dynamicType(json.a.b) FROM test;
Response
┌─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 컬럼에서 서브컬럼을 읽어보겠습니다:
Query
SELECT json.a.b.:`Array(JSON)`.c, json.a.b.:`Array(JSON)`.f, json.a.b.:`Array(JSON)`.d FROM test;
Response
┌─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) 하위 컬럼 이름을 일일이 쓰지 않고도 됩니다.
Query
SELECT json.a.b[].c, json.a.b[].f, json.a.b[].d FROM test;
Response
┌─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) 내부의 경로와 타입을 확인해 보겠습니다.
Query
SELECT DISTINCT arrayJoin(JSONAllPathsWithTypes(arrayJoin(json.a.b[]))) FROM test;
Response
┌─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) 컬럼에서 서브컬럼을 읽어보겠습니다:
Query
SELECT json.a.b[].c.:Int64, json.a.b[].f[][].g.:Float64, json.a.b[].f[][].h.:Date FROM test;
Response
┌─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 컬럼에서 하위 객체의 서브컬럼도 읽을 수 있습니다:
Query
SELECT json.a.b[].^k FROM test
Response
┌─json.a.b.:`Array(JSON)`.^`k`─────────┐
│ ['{"j":"1000"}','{}','{"j":"2000"}'] │
│ []                                   │
│ ['{}','{"j":"3000"}']                │
└──────────────────────────────────────┘

NULL 값을 가진 JSON 키 처리

JSON 구현에서는 null과 값이 없는 경우를 동일하게 간주합니다:
Query
SELECT '{}'::JSON AS json1, '{"a" : null}'::JSON AS json2, json1 = json2
Response
┌─json1─┬─json2─┬─equals(json1, json2)─┐
│ {}    │ {}    │                    1 │
└───────┴───────┴──────────────────────┘
이는 원본 JSON 데이터에 NULL 값인 특정 경로가 있었는지, 아니면 해당 경로 자체가 아예 없었는지를 판별할 수 없다는 의미입니다.

점이 포함된 JSON 키 처리

내부적으로 JSON 컬럼은 모든 경로와 값을 평탄화된 형태로 저장합니다. 즉, 기본적으로 다음 2개의 객체는 동일한 것으로 간주됩니다:
{"a" : {"b" : 42}}
{"a.b" : 42}
둘 다 내부적으로는 경로 a.b와 값 42의 쌍으로 저장됩니다. JSON을 포맷할 때는 점으로 구분된 경로 파트를 기준으로 항상 중첩된 객체를 만듭니다:
Query
SELECT '{"a" : {"b" : 42}}'::JSON AS json1, '{"a.b" : 42}'::JSON AS json2, JSONAllPaths(json1), JSONAllPaths(json2);
Response
┌─json1────────────┬─json2────────────┬─JSONAllPaths(json1)─┬─JSONAllPaths(json2)─┐
│ {"a":{"b":"42"}} │ {"a":{"b":"42"}} │ ['a.b']             │ ['a.b']             │
└──────────────────┴──────────────────┴─────────────────────┴─────────────────────┘
보시는 것처럼 원래 JSON {"a.b" : 42}는 이제 {"a" : {"b" : 42}} 형태로 포맷됩니다. 이러한 제한 때문에 다음과 같이 유효한 JSON 객체도 파싱에 실패합니다:
Query
SELECT '{"a.b" : 42, "a" : {"b" : "Hello World!"}}'::JSON AS json;
Response
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로 이스케이프되며, 포맷 시 다시 원래대로 복원됩니다.
Query
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);
Response
┌─json1────────────┬─json2────────┬─JSONAllPaths(json1)─┬─JSONAllPaths(json2)─┐
│ {"a":{"b":"42"}} │ {"a.b":"42"} │ ['a.b']             │ ['a%2Eb']           │
└──────────────────┴──────────────┴─────────────────────┴─────────────────────┘
Query
SET json_type_escape_dots_in_keys=1;
SELECT '{"a.b" : 42, "a" : {"b" : "Hello World!"}}'::JSON AS json, JSONAllPaths(json);
Response
┌─json──────────────────────────────────┬─JSONAllPaths(json)─┐
│ {"a.b":"42","a":{"b":"Hello World!"}} │ ['a%2Eb','a.b']    │
└───────────────────────────────────────┴────────────────────┘
점(.)이 이스케이프된 키를 하위 컬럼으로 읽으려면 하위 컬럼 이름에도 이스케이프된 점(.)을 사용해야 합니다:
Query
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;
Response
┌─json──────────────────────────────────┬─json.a%2Eb─┬─json.a.b─────┐
│ {"a.b":"42","a":{"b":"Hello World!"}} │ 42         │ Hello World! │
└───────────────────────────────────────┴────────────┴──────────────┘
참고: 식별자 파서와 분석기의 제한으로 인해 하위 컬럼 json.`a.b` 은 하위 컬럼 json.a.b 와 동일하며, 이스케이프된 점이 포함된 경로는 읽지 못합니다:
Query
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;
Response
┌─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 섹션에서 사용하려면 힌트에서 점을 이스케이프해야 합니다:
Query
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`);
Response
┌─json────────────────────────────────┬─json.a%2Eb─┬─toTypeName(json.a%2Eb)─┐
│ {"a.b":42,"a":{"b":"Hello World!"}} │         42 │ UInt8                  │
└─────────────────────────────────────┴────────────┴────────────────────────┘
Query
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`;
Response
┌─json───────────────────────┬─json.a%2Eb─┐
│ {"a":{"b":"Hello World!"}} │ ᴺᵁᴸᴸ       │
└────────────────────────────┴────────────┘

데이터에서 JSON 타입 읽기

모든 형식 (JSONEachRow, TSV, CSV, CustomSeparated, Values, 등)에서는 JSON 타입을 읽을 수 있습니다. 예시:
Query
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"}}}
')
Response
┌─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 객체를 포함하는 문자열로부터 파싱됩니다:
Query
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"}}')
Response
┌─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 내부 동적 경로 한도에 도달하는 경우

JSON 데이터 타입은 내부적으로 제한된 수의 경로만 별도의 서브컬럼으로 저장할 수 있습니다. 기본적으로 이 한도는 1024이며, 타입 선언에서 max_dynamic_paths 매개변수를 사용해 변경할 수 있습니다. 한도에 도달하면 JSON 컬럼에 새로 삽입되는 모든 경로는 하나의 공유 데이터 구조에 저장됩니다. 이러한 경로도 여전히 서브컬럼으로 읽을 수 있지만, 효율이 다소 떨어질 수 있습니다(공유 데이터 관련 섹션 참조). 이 한도는 서로 다른 서브컬럼 수가 과도하게 늘어나 테이블을 사용할 수 없게 되는 상황을 방지하기 위해 필요합니다. 이제 몇 가지 시나리오에서 한도에 도달하면 어떤 일이 발생하는지 살펴보겠습니다.

데이터 파싱 중 제한에 도달하는 경우

데이터에서 JSON 객체를 파싱하는 동안 현재 데이터 블록의 제한에 도달하면 새로운 모든 경로는 공유 데이터 구조에 저장됩니다. 다음 두 가지 인트로스펙션 함수 JSONDynamicPaths, JSONSharedDataPaths를 사용할 수 있습니다:
Query
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"}}
')
Response
┌─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는 null이 아닌 값을 가장 많이 포함한 경로를 유지하고, 가장 드문 경로를 공유 데이터 구조로 이동하려고 합니다. 다만 이는 구현에 따라 달라질 수 있습니다. 이러한 머지의 예시를 살펴보겠습니다. 먼저 JSON 컬럼이 있는 테이블을 만들고, 동적 경로 한도를 3으로 설정한 다음, 서로 다른 5개의 경로를 가진 값을 삽입하겠습니다:
Query
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);
각 삽입 작업은 JSON 컬럼에 단일 경로만 포함된 별도의 데이터 파트를 생성합니다:
Query
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
Response
┌─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 │
└─────────┴───────────────┴───────────────────┴───────────┘
이제 모든 파트를 하나로 머지한 뒤 어떤 일이 일어나는지 살펴보겠습니다:
Query
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
Response
┌─count()─┬─dynamic_paths─┬─shared_data_paths─┬─_part─────┐
│      15 │ ['a','b','c'] │ ['d','e']         │ all_1_5_2 │
└─────────┴───────────────┴───────────────────┴───────────┘
보시다시피, ClickHouse는 가장 빈도가 높은 경로인 a, b, c는 유지하고 de 경로는 공유 데이터 구조로 옮겼습니다.

공유 데이터 구조

이전 섹션에서 설명했듯이 max_dynamic_paths 제한에 도달하면 모든 새 경로는 하나의 공유 데이터 구조에 저장됩니다. 이 섹션에서는 공유 데이터 구조의 세부 사항과 이 구조에서 경로 서브컬럼을 읽는 방법을 살펴봅니다. JSON 컬럼의 내용을 검사하는 데 사용되는 함수에 관한 자세한 내용은 “인트로스펙션 함수” 섹션을 참조하십시오.

메모리상의 공유 데이터 구조

메모리상의 공유 데이터 구조는 평탄화된 JSON 경로를 바이너리로 인코딩된 값에 매핑해 저장하는 Map(String, String) 유형의 서브컬럼일 뿐입니다. 여기서 경로 서브컬럼을 추출하려면 이 Map 컬럼의 모든 행을 순회하면서 요청된 경로와 해당 값을 찾기만 하면 됩니다.

MergeTree 파트의 공유 데이터 구조

MergeTree 테이블에서는 모든 데이터를 디스크(로컬 또는 원격)에 저장하는 데이터 파트에 보관합니다. 또한 디스크의 데이터는 메모리와 다른 방식으로 저장될 수 있습니다. 현재 MergeTree 데이터 파트에는 3가지 공유 데이터 구조 직렬화 방식이 있습니다: map, map_with_bucketsadvanced입니다. 직렬화 버전은 MergeTree 설정 object_shared_data_serialization_versionobject_shared_data_serialization_version_for_zero_level_parts로 제어됩니다 (제로 수준 파트는 테이블에 데이터를 삽입할 때 생성되는 파트이며, 머지 과정에서 생성되는 파트는 더 높은 수준을 가집니다). 참고: 공유 데이터 구조 직렬화 변경은 v3 object serialization version에서만 지원됩니다

map 직렬화 버전에서는 공유 데이터가 메모리에 저장되는 것과 동일하게 Map(String, String) 타입의 단일 컬럼으로 직렬화됩니다. 이 직렬화 방식에서 경로 서브컬럼을 읽으려면 ClickHouse가 전체 Map 컬럼을 읽은 다음 메모리에서 요청된 경로를 추출합니다. 이 직렬화 방식은 데이터를 쓰고 전체 JSON 컬럼을 읽을 때는 효율적이지만, 경로 서브컬럼을 읽을 때는 효율적이지 않습니다.

버킷을 사용하는 맵

map_with_buckets serialization version에서는 shared data가 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 직렬화 버전에서는 공유 데이터를, 요청한 경로의 데이터만 읽을 수 있도록 추가 정보를 저장해 경로 서브컬럼 읽기 성능을 극대화하는 특수한 데이터 구조로 직렬화합니다. 또한 이 직렬화는 버킷도 지원하므로 각 버킷에는 경로의 일부 집합만 포함됩니다. 이 직렬화는 데이터 쓰기에는 상당히 비효율적이므로 zero-level 파트에는 사용을 권장하지 않습니다. 전체 JSON 컬럼을 읽는 효율도 map 직렬화보다 약간 떨어지지만, 경로 서브컬럼을 읽는 데에는 매우 효율적입니다. 참고: 데이터 구조 내부에 추가 정보를 일부 저장하므로, 이 직렬화를 사용하면 mapmap_with_buckets 직렬화보다 디스크 저장 크기가 더 커집니다. 새로운 공유 데이터 직렬화에 대한 더 자세한 개요와 구현 세부 정보는 블로그 글을 참조하십시오.

MergeTree 파트 내 JSON의 동적 경로 수 제어하기

JSON에서 동적 경로의 한도를 설정하는 주요 방법은 JSON 타입 선언에서 max_dynamic_paths 매개변수를 사용하는 것입니다. 하지만 기존 컬럼의 max_dynamic_paths를 변경하려면 ALTER TABLE <table> MODIFY COLUMN <column> JSON(max_dynamic_paths=K)를 실행해야 하며, 그러면 기존의 모든 파트를 재작성하는 백그라운드 뮤테이션이 시작됩니다. 이러한 뮤테이션은 매우 부담이 클 수 있으며 완료될 때까지 서버 성능에 영향을 줄 수 있습니다. 이를 피하려면 다음 3가지 설정을 사용해 새 데이터 파트에 대해 MergeTree 테이블의 동적 경로 한도를 변경할 수 있습니다.
  • 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 매개변수에 지정된 값을 초과할 수 없습니다.

인트로스펙션 함수

JSON 컬럼의 내용을 검사하는 데 유용한 함수는 다음과 같습니다: 예시 2020-01-01 날짜의 GH Archive 데이터셋 내용을 살펴보겠습니다:
Query
SELECT arrayJoin(distinctJSONPaths(json))
FROM s3('s3://clickhouse-public-datasets/gharchive/original/2020-01-01-*.json.gz', JSONAsObject)
Response
┌─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))─────────────────────────┘
Query
SELECT arrayJoin(distinctJSONPathsAndTypes(json))
FROM s3('s3://clickhouse-public-datasets/gharchive/original/2020-01-01-*.json.gz', JSONAsObject)
SETTINGS date_time_input_format = 'best_effort'
Response
┌─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))──────────────────┘

ALTER MODIFY COLUMN으로 JSON 타입으로 변경하기

기존 테이블을 수정해 컬럼 타입을 새로운 JSON 타입으로 변경할 수 있습니다. 현재는 String 타입에서만 ALTER를 지원합니다. 예시
Query
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;
Response
┌─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 (실험적)

이 기능은 실험 단계이며, 설정 allow_experimental_json_lazy_type_hints이 활성화되어 있어야 합니다.
ALTER TABLE ... MODIFY COLUMN을 사용해 JSON 컬럼에 type hint를 추가하거나 수정하면, ClickHouse는 일반적으로 새 type hint를 구체화하기 위해 모든 데이터 파트를 재작성합니다. 대규모 이력 데이터(수백 TB)를 보유한 테이블에서는 이 작업 비용이 매우 클 수 있습니다. Lazy type hints를 사용하면 기존 데이터를 재작성하지 않고 메타데이터만 변경하는 방식으로 type hint를 추가할 수 있습니다.
  • 기존 파트: type hint는 Dynamic에서 힌트된 유형으로 CAST하여 쿼리 시점에 적용됩니다.
  • 새 파트: type hint는 INSERT 작업 중에 구체화됩니다.
  • 머지: 파트가 머지될 때 type hint가 구체화됩니다.
즉, type hint를 즉시 추가할 수 있으며, 이후 일반적인 백그라운드 머지가 진행되면서 데이터가 점진적으로 변환됩니다.

Lazy Type Hints 활성화

SET allow_experimental_json_lazy_type_hints = 1;

예시

Query
-- 테이블 생성 및 데이터 삽입
CREATE TABLE test_lazy (json JSON) ENGINE = MergeTree ORDER BY tuple();
INSERT INTO test_lazy VALUES ('{"user_id": "123", "score": "95.5"}');

-- 실험적 설정 활성화
SET allow_experimental_json_lazy_type_hints = 1;

-- 타입 힌트 추가 - 뮤테이션 없이 즉시 완료됨
ALTER TABLE test_lazy MODIFY COLUMN json JSON(user_id UInt64, score Float64);

-- 데이터 쿼리 - 타입 힌트는 읽기 시점에 적용됨
SELECT json.user_id, toTypeName(json.user_id), json.score, toTypeName(json.score) FROM test_lazy;
Response
┌─json.user_id─┬─toTypeName(json.user_id)─┬─json.score─┬─toTypeName(json.score)─┐
│          123 │ UInt64                   │       95.5 │ Float64                │
└──────────────┴──────────────────────────┴────────────┴────────────────────────┘

뮤테이션이 발생하지 않았는지 확인하기

system.mutations 테이블을 확인하면 ALTER가 뮤테이션 없이 완료되었는지 확인할 수 있습니다:
SELECT * FROM system.mutations WHERE table = 'test_lazy' AND NOT is_done;
Lazy Type Hints가 활성화된 상태에서는 이 쿼리가 아무 행도 반환하지 않으므로, 작업이 메타데이터에만 적용되었음을 확인할 수 있습니다.

타입 힌트 구체화하기

기존 데이터에서 타입 힌트를 구체화하려면 다음 방법 중 하나를 사용할 수 있습니다:
  1. 백그라운드 머지를 기다립니다: ClickHouse는 파트가 머지될 때 타입 힌트를 자동으로 구체화합니다
  2. 머지를 강제합니다: OPTIMIZE TABLE test_lazy FINAL을 사용하여 모든 파트를 즉시 머지합니다
  3. 파트를 재작성합니다: ALTER TABLE test_lazy REWRITE PARTS를 사용하여 새 메타데이터로 파트를 재작성합니다

제한 사항

  • 이 기능은 실험적이며 향후 버전에서 변경될 수 있습니다
  • 쿼리 시점의 타입 변환은 미리 구체화된 타입에 비해 상당한 성능 오버헤드를 유발할 수 있으며, 특히 큰 JSON 객체에서 그렇습니다
  • 이 기능은 typed_paths(type hints)를 수정할 때만 적용되며, max_dynamic_paths, SKIP, SKIP REGEXP 같은 다른 JSON 매개변수는 여전히 뮤테이션이 필요합니다

JSON 타입 값 간 비교

JSON 객체는 맵과 유사한 방식으로 비교됩니다. 예시:
Query
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;
Response
┌─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용 데이터 스키핑 인덱스

데이터 스키핑 인덱스JSON 컬럼에 다음 3가지 방식으로 사용할 수 있습니다:
  1. 특정 서브컬럼에 대한 인덱스 — 일반 컬럼과 마찬가지로, 알려진 JSON 경로에 표준 스킵 인덱스를 생성합니다. 이렇게 하면 해당 경로의 이 인덱싱됩니다.
  2. JSONAllPaths를 사용하는 경로 기반 인덱스 — 각 그래뉼에 존재하는 경로 집합을 인덱싱하여, 질의한 경로를 포함할 수 없는 그래뉼을 건너뛸 수 있습니다.
  3. JSONAllValues를 사용하는 값 기반 인덱스텍스트 인덱스를 사용해 모든 JSON 경로 전반의 모든 값을 인덱싱함으로써, 단일 인덱스로 모든 JSON 서브컬럼에 대한 전문 검색을 가속화합니다.

특정 서브컬럼에 대한 인덱스

일반 컬럼에 사용하는 것과 동일한 구문으로 모든 JSON 서브컬럼에 스킵 인덱스를 생성할 수 있습니다. 지원되는 인덱스 유형은 모두 사용할 수 있습니다(minmax, set, bloom_filter, tokenbf_v1, ngrambf_v1 등). 인덱스 표현식에서 JSON 서브컬럼을 참조하는 방법은 두 가지입니다:
  • JSON 타입 힌트에 선언된 타입이 지정된 경로 — 이름으로 직접 접근합니다: json.a.
  • 명시적 캐스트를 사용하는 동적 경로:: 캐스트 구문을 사용합니다: json.b::String.
예를 들어 json.a || json.b::String처럼 여러 서브컬럼을 결합한 표현식도 사용할 수 있습니다.

예시

Query
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 인덱스는 스캔 범위를 일치하는 그래뉼로 좁힙니다:
Query
EXPLAIN indexes = 1 SELECT * FROM sensor_data WHERE data.sensor_id < 2;
Response
...
    Indexes:
      Skip
        Name: idx_sensor
        Description: minmax GRANULARITY 1
        Parts: 1/2
        Granules: 2/8
data.location::String cast 서브컬럼의 bloom_filter 인덱스도 동작합니다:
Query
EXPLAIN indexes = 1 SELECT * FROM sensor_data WHERE data.location::String = 'room_5';
Response
...
    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 (inverted index) — equals, in, IS NOT NULL을 지원합니다.

예시

Query
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을 사용해 스킵 인덱스가 사용되는지 확인할 수 있습니다. 경로가 하나의 파트에만 존재하면 인덱스가 다른 파트는 건너뜁니다:
Query
EXPLAIN indexes = 1 SELECT * FROM events WHERE data.user.name = 'Alice';
Response
...
    Indexes:
      Skip
        Name: idx
        Description: bloom_filter GRANULARITY 1
        Parts: 1/2
        Granules: 1/2
어느 파트에도 해당 경로가 없으면 모든 파트와 그래뉼을 건너뜁니다:
Query
EXPLAIN indexes = 1 SELECT * FROM events WHERE data.nonexistent = 1;
Response
...
    Indexes:
      Skip
        Name: idx
        Description: bloom_filter GRANULARITY 1
        Parts: 0/2
        Granules: 0/2
IS NOT NULL도 인덱스를 사용합니다 — 경로가 없는 그래뉼은 건너뜁니다(값이 NULL이기 때문입니다):
Query
EXPLAIN indexes = 1 SELECT * FROM events WHERE data.user.name IS NOT NULL;
Response
...
    Indexes:
      Skip
        Name: idx
        Description: bloom_filter GRANULARITY 1
        Parts: 1/2
        Granules: 1/2

작동 방식

JSONAllPaths(json_column) 표현식은 JSON 값에 존재하는 모든 경로가 담긴 Array(String)을 생성합니다. 스킵 인덱스는 이러한 경로 문자열을 해당 데이터 구조(블룸 필터 또는 inverted index)에 저장합니다. 쿼리에서 json.some.path를 기준으로 필터링하면, 인덱스는 각 그래뉼마다 "some.path" 문자열이 인덱스에 있는지 확인하고, 없으면 해당 그래뉼을 건너뜁니다.

누락된 경로에 대한 안전성

그래뉼에 JSON 경로가 없으면 서브컬럼은 다음과 같이 평가됩니다.
  • Dynamic 유형(예: json.path)과 널 허용 서브컬럼(예: json.path.:Int64)은 NULL로 평가됩니다. NULL과의 비교는 항상 false를 반환하므로 스키핑은 안전합니다.
  • 널 허용이 아닌 CAST 표현식은 해당 유형의 기본값으로 평가됩니다(예: json.path::Int64는 경로가 없으면 0이 됨). 이 경우 스키핑은 비교값이 기본값과 다를 때만 안전합니다. 인덱스가 이 차이를 자동으로 처리합니다.

JSONAllValues를 사용한 전문 검색

텍스트 인덱스JSONAllValues 함수를 통해 JSON 컬럼에 대한 전문 검색을 가속화하는 데 사용할 수 있습니다. JSONAllValues는 JSON 컬럼의 모든 값을 Array(String)으로 반환하며, 이 값들은 텍스트 인덱스로 인덱싱할 수 있습니다. JSONAllValues(json_column)에 단일 인덱스를 생성하면 모든 JSON 경로를 포괄할 수 있으므로, 각 경로마다 별도의 인덱스를 만들지 않고도 모든 서브컬럼에서 전문 검색을 수행할 수 있습니다. 자세한 내용과 예시는 텍스트 인덱스 문서의 JSONAllValues를 사용한 값 기반 인덱스를 참조하십시오.

JSON 타입을 더 효과적으로 사용하기 위한 팁

JSON 컬럼을 생성하고 데이터를 로드하기 전에 다음 팁을 고려하십시오:
  • 데이터를 살펴보고 가능한 한 많은 경로 힌트(path hint)와 타입을 지정하십시오. 이렇게 하면 저장 및 읽기 효율이 훨씬 높아집니다.
  • 어떤 경로가 필요하고 어떤 경로가 전혀 필요하지 않은지 미리 판단하십시오. 필요하지 않은 경로는 SKIP 섹션에 지정하고, 필요하면 SKIP REGEXP 섹션에도 지정하십시오. 이렇게 하면 저장 효율이 향상됩니다.
  • max_dynamic_paths 매개변수를 지나치게 큰 값으로 설정하지 마십시오. 저장 및 읽기 효율이 떨어질 수 있습니다. 이는 메모리, CPU 등 시스템 매개변수에 크게 좌우되지만, 일반적인 경험칙으로는 로컬 파일 시스템 스토리지에서는 max_dynamic_paths를 10 000보다 크게 설정하지 않고, 원격 파일 시스템 스토리지에서는 1024보다 크게 설정하지 않는 것이 좋습니다.

추가 자료

마지막 수정일 2026년 6월 10일