ClickHouse는 이제 반정형 데이터와 동적 데이터를 위해 설계된 네이티브 JSON 컬럼 타입을 제공합니다. 이는 데이터 포맷이 아니라 컬럼 타입이라는 점을 명확히 해야 합니다. JSON은 문자열로 또는 JSONEachRow와 같은 지원 포맷을 통해 ClickHouse에 삽입할 수 있지만, 그렇다고 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 타입은 경로를 서브컬럼으로 평탄화해 효율적인 열 지향 저장을 가능하게 합니다. 하지만 유연한 만큼 주의도 필요합니다. 효과적으로 사용하려면 다음 사항을 권장합니다.
- 알려진 서브컬럼에 대해 타입을 지정해 불필요한 타입 추론을 피할 수 있도록 컬럼 정의의 힌트를 사용해 경로의 타입을 지정하세요.
- 값이 필요하지 않다면 SKIP 및 SKIP REGEXP를 사용해 경로를 건너뛰세요. 이렇게 하면 저장 공간을 줄이고 성능을 개선할 수 있습니다.
max_dynamic_paths를 너무 크게 설정하지 마세요. 값이 너무 크면 리소스 사용량이 늘고 효율이 떨어집니다. 일반적으로 10,000 미만으로 유지하는 것이 좋습니다.
타입 힌트타입 힌트는 단순히 불필요한 타입 추론을 피하는 방법에 그치지 않습니다. 저장 및 처리 과정의 간접 단계를 아예 없애 줍니다. 타입 힌트가 있는 JSON 경로는 항상 일반 컬럼과 같은 방식으로 저장되므로 discriminator columns이나 쿼리 시점의 동적 판별이 필요하지 않습니다. 즉, 타입 힌트가 잘 정의되어 있으면 중첩된 JSON 필드도 처음부터 최상위 필드로 모델링한 것과 동일한 성능과 효율을 얻을 수 있습니다. 따라서 대부분 일관적이면서도 JSON의 유연성이 여전히 필요한 데이터셋에서는 스키마나 수집 파이프라인을 재구성하지 않고도 성능을 유지할 수 있는 실용적인 방법이 됩니다.
- JSON 컬럼은 다른 컬럼과 마찬가지로 프라이머리 키(primary key)에 사용할 수 있습니다. 하위 컬럼에는 코덱을 지정할 수 없습니다.
JSONAllPathsWithTypes() 및 JSONDynamicPaths()와 같은 함수를 통해 내부 검사를 수행할 수 있습니다.
.^ 구문을 사용해 중첩된 하위 객체를 읽을 수 있습니다.
- 쿼리 구문은 표준 SQL과 다를 수 있으며, 중첩 필드에는 특별한 형 변환이나 연산자가 필요할 수 있습니다.
자세한 내용은 ClickHouse JSON 문서를 참고하거나, 블로그 게시물 ClickHouse를 위한 강력한 새 JSON 데이터 타입을 살펴보십시오.
다음 JSON 예시는 Python PyPI 데이터셋의 한 행을 나타냅니다.
{
"date": "2022-11-15",
"country_code": "ES",
"project": "clickhouse-connect",
"type": "bdist_wheel",
"installer": "pip",
"python_minor": "3.9",
"system": "Linux",
"version": "0.3.0"
}
이 스키마가 고정되어 있고 타입을 명확히 정의할 수 있다고 가정해 보겠습니다. 데이터가 NDJSON 포맷(한 줄에 JSON 행 1개)이라고 해도 이러한 스키마에 JSON 타입을 사용할 필요는 없습니다. 스키마를 일반적인 타입으로 정의하면 됩니다.
CREATE TABLE pypi (
`date` Date,
`country_code` String,
`project` String,
`type` String,
`installer` String,
`python_minor` String,
`system` String,
`version` String
)
ENGINE = MergeTree
ORDER BY (project, date)
그리고 JSON 행을 삽입합니다:
INSERT INTO pypi FORMAT JSONEachRow
{"date":"2022-11-15","country_code":"ES","project":"clickhouse-connect","type":"bdist_wheel","installer":"pip","python_minor":"3.9","system":"Linux","version":"0.3.0"}
250만 편의 학술 논문이 포함된 arXiv dataset을 살펴보겠습니다. NDJSON 형식으로 제공되는 이 데이터셋의 각 행은 출판된 학술 논문 1편을 나타냅니다. 예시 행은 아래와 같습니다:
{
"id": "2101.11408",
"submitter": "Daniel Lemire",
"authors": "Daniel Lemire",
"title": "Number Parsing at a Gigabyte per Second",
"comments": "Software at https://github.com/fastfloat/fast_float and\n https://github.com/lemire/simple_fastfloat_benchmark/",
"journal-ref": "Software: Practice and Experience 51 (8), 2021",
"doi": "10.1002/spe.2984",
"report-no": null,
"categories": "cs.DS cs.MS",
"license": "http://creativecommons.org/licenses/by/4.0/",
"abstract": "With disks and networks providing gigabytes per second ....\n",
"versions": [
{
"created": "Mon, 11 Jan 2021 20:31:27 GMT",
"version": "v1"
},
{
"created": "Sat, 30 Jan 2021 23:57:29 GMT",
"version": "v2"
}
],
"update_date": "2022-11-07",
"authors_parsed": [
[
"Lemire",
"Daniel",
""
]
]
}
여기서 사용하는 JSON은 중첩 구조를 포함하고 있어 복잡하지만, 구조를 예측할 수 있습니다. 필드의 수와 유형은 바뀌지 않습니다. 이 예시에서는 JSON 타입을 사용할 수도 있지만, 튜플과 Nested 타입을 사용해 구조를 명시적으로 정의할 수도 있습니다:
CREATE TABLE arxiv
(
`id` String,
`submitter` String,
`authors` String,
`title` String,
`comments` String,
`journal-ref` String,
`doi` String,
`report-no` String,
`categories` String,
`license` String,
`abstract` String,
`versions` Array(Tuple(created String, version String)),
`update_date` Date,
`authors_parsed` Array(Array(String))
)
ENGINE = MergeTree
ORDER BY update_date
다시 데이터를 JSON 형식으로 삽입할 수 있습니다:
INSERT INTO arxiv FORMAT JSONEachRow
{"id":"2101.11408","submitter":"Daniel Lemire","authors":"Daniel Lemire","title":"Number Parsing at a Gigabyte per Second","comments":"Software at https://github.com/fastfloat/fast_float and\n https://github.com/lemire/simple_fastfloat_benchmark/","journal-ref":"Software: Practice and Experience 51 (8), 2021","doi":"10.1002/spe.2984","report-no":null,"categories":"cs.DS cs.MS","license":"http://creativecommons.org/licenses/by/4.0/","abstract":"With disks and networks providing gigabytes per second ....\n","versions":[{"created":"Mon, 11 Jan 2021 20:31:27 GMT","version":"v1"},{"created":"Sat, 30 Jan 2021 23:57:29 GMT","version":"v2"}],"update_date":"2022-11-07","authors_parsed":[["Lemire","Daniel",""]]}
tags라는 또 다른 컬럼이 추가되었다고 가정해 보겠습니다. 이것이 단순한 문자열 목록이라면 Array(String)로 모델링할 수 있지만, 여기서는 서로 다른 타입이 섞인 임의의 태그 구조를 추가할 수 있다고 가정하겠습니다(score는 문자열일 수도 있고 정수일 수도 있습니다). 수정된 JSON 문서는 다음과 같습니다:
{
"id": "2101.11408",
"submitter": "Daniel Lemire",
"authors": "Daniel Lemire",
"title": "Number Parsing at a Gigabyte per Second",
"comments": "Software at https://github.com/fastfloat/fast_float and\n https://github.com/lemire/simple_fastfloat_benchmark/",
"journal-ref": "Software: Practice and Experience 51 (8), 2021",
"doi": "10.1002/spe.2984",
"report-no": null,
"categories": "cs.DS cs.MS",
"license": "http://creativecommons.org/licenses/by/4.0/",
"abstract": "With disks and networks providing gigabytes per second ....\n",
"versions": [
{
"created": "Mon, 11 Jan 2021 20:31:27 GMT",
"version": "v1"
},
{
"created": "Sat, 30 Jan 2021 23:57:29 GMT",
"version": "v2"
}
],
"update_date": "2022-11-07",
"authors_parsed": [
[
"Lemire",
"Daniel",
""
]
],
"tags": {
"tag_1": {
"name": "ClickHouse user",
"score": "A+",
"comment": "A good read, applicable to ClickHouse"
},
"28_03_2025": {
"name": "professor X",
"score": 10,
"comment": "Didn't learn much",
"updates": [
{
"name": "professor X",
"comment": "Wolverine found more interesting"
}
]
}
}
}
이 경우 arXiv 문서를 전체를 JSON으로 모델링하거나 JSON tags 컬럼만 추가할 수 있습니다. 아래에 두 가지 예시를 모두 제공합니다:
CREATE TABLE arxiv
(
`doc` JSON(update_date Date)
)
ENGINE = MergeTree
ORDER BY doc.update_date
JSON 정의에서 update_date 컬럼에 대한 type hint를 제공합니다. update_date를 정렬/프라이머리 키(primary key)에 사용하기 때문입니다. 이렇게 하면 ClickHouse가 이 컬럼이 NULL이 될 수 없음을 알 수 있고, 어떤 update_date 서브컬럼을 사용해야 하는지도 정확히 판단할 수 있습니다(타입마다 여러 개가 있을 수 있으므로, 그렇지 않으면 모호해집니다).
이 테이블에 데이터를 삽입한 다음 JSONAllPathsWithTypes 함수와 PrettyJSONEachRow 출력 형식을 사용해 이후 추론된 스키마를 확인할 수 있습니다:
INSERT INTO arxiv FORMAT JSONAsObject
{"id":"2101.11408","submitter":"Daniel Lemire","authors":"Daniel Lemire","title":"Number Parsing at a Gigabyte per Second","comments":"Software at https://github.com/fastfloat/fast_float and\n https://github.com/lemire/simple_fastfloat_benchmark/","journal-ref":"Software: Practice and Experience 51 (8), 2021","doi":"10.1002/spe.2984","report-no":null,"categories":"cs.DS cs.MS","license":"http://creativecommons.org/licenses/by/4.0/","abstract":"With disks and networks providing gigabytes per second ....\n","versions":[{"created":"Mon, 11 Jan 2021 20:31:27 GMT","version":"v1"},{"created":"Sat, 30 Jan 2021 23:57:29 GMT","version":"v2"}],"update_date":"2022-11-07","authors_parsed":[["Lemire","Daniel",""]],"tags":{"tag_1":{"name":"ClickHouse user","score":"A+","comment":"A good read, applicable to ClickHouse"},"28_03_2025":{"name":"professor X","score":10,"comment":"Didn't learn much","updates":[{"name":"professor X","comment":"Wolverine found more interesting"}]}}}
SELECT JSONAllPathsWithTypes(doc)
FROM arxiv
FORMAT PrettyJSONEachRow
{
"JSONAllPathsWithTypes(doc)": {
"abstract": "String",
"authors": "String",
"authors_parsed": "Array(Array(Nullable(String)))",
"categories": "String",
"comments": "String",
"doi": "String",
"id": "String",
"journal-ref": "String",
"license": "String",
"submitter": "String",
"tags.28_03_2025.comment": "String",
"tags.28_03_2025.name": "String",
"tags.28_03_2025.score": "Int64",
"tags.28_03_2025.updates": "Array(JSON(max_dynamic_types=16, max_dynamic_paths=256))",
"tags.tag_1.comment": "String",
"tags.tag_1.name": "String",
"tags.tag_1.score": "String",
"title": "String",
"update_date": "Date",
"versions": "Array(JSON(max_dynamic_types=16, max_dynamic_paths=256))"
}
}
1 row in set. Elapsed: 0.003 sec.
또는 앞서 살펴본 스키마와 JSON tags 컬럼을 사용해 이를 모델링할 수도 있습니다. 일반적으로는 이 방법이 더 선호되며, ClickHouse에서 필요한 추론을 최소화할 수 있습니다:
CREATE TABLE arxiv
(
`id` String,
`submitter` String,
`authors` String,
`title` String,
`comments` String,
`journal-ref` String,
`doi` String,
`report-no` String,
`categories` String,
`license` String,
`abstract` String,
`versions` Array(Tuple(created String, version String)),
`update_date` Date,
`authors_parsed` Array(Array(String)),
`tags` JSON()
)
ENGINE = MergeTree
ORDER BY update_date
INSERT INTO arxiv FORMAT JSONEachRow
{"id":"2101.11408","submitter":"Daniel Lemire","authors":"Daniel Lemire","title":"Number Parsing at a Gigabyte per Second","comments":"Software at https://github.com/fastfloat/fast_float and\n https://github.com/lemire/simple_fastfloat_benchmark/","journal-ref":"Software: Practice and Experience 51 (8), 2021","doi":"10.1002/spe.2984","report-no":null,"categories":"cs.DS cs.MS","license":"http://creativecommons.org/licenses/by/4.0/","abstract":"With disks and networks providing gigabytes per second ....\n","versions":[{"created":"Mon, 11 Jan 2021 20:31:27 GMT","version":"v1"},{"created":"Sat, 30 Jan 2021 23:57:29 GMT","version":"v2"}],"update_date":"2022-11-07","authors_parsed":[["Lemire","Daniel",""]],"tags":{"tag_1":{"name":"ClickHouse user","score":"A+","comment":"A good read, applicable to ClickHouse"},"28_03_2025":{"name":"professor X","score":10,"comment":"Didn't learn much","updates":[{"name":"professor X","comment":"Wolverine found more interesting"}]}}}
이제 tags 서브컬럼의 타입을 추론할 수 있습니다.
SELECT JSONAllPathsWithTypes(tags)
FROM arxiv
FORMAT PrettyJSONEachRow
{
"JSONAllPathsWithTypes(tags)": {
"28_03_2025.comment": "String",
"28_03_2025.name": "String",
"28_03_2025.score": "Int64",
"28_03_2025.updates": "Array(JSON(max_dynamic_types=16, max_dynamic_paths=256))",
"tag_1.comment": "String",
"tag_1.name": "String",
"tag_1.score": "String"
}
}
1 row in set. Elapsed: 0.002 sec.