상황에 맞게 JSON 사용하기

상황에 맞게 JSON 사용하기

ClickHouse는 반정형·동적 데이터를 위한 네이티브 JSON 컬럼 타입을 제공합니다. 중요하게도 이것은 데이터 형식이 아닌 컬럼 타입이며, 데이터 구조가 동적일 때만 사용해야 합니다. 이 문서에서는 JSON 타입을 언제 쓰고 언제 피해야 하는지, 그리고 주의사항을 설명할게요.

출처: 문서

본문

ClickHouse는 이제 반정형·동적 데이터를 위해 설계된 네이티브 JSON 컬럼 타입을 제공합니다. 이것은 컬럼 타입이지 데이터 형식이 아닙니다 라는 점을 분명히 하는 것이 중요합니다. JSON을 ClickHouse에 문자열로 삽입하거나 JSONEachRow 같은 지원 형식으로 삽입할 수는 있지만, 그것이 JSON 컬럼 타입을 사용한다는 뜻은 아닙니다. JSON 타입은 데이터 구조가 동적일 때만 사용해야 하며, 단순히 JSON을 저장한다고 해서 사용해서는 안 됩니다.

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

JSON 타입은 동적이거나 예측할 수 없는 구조를 가진 JSON 객체 내의 특정 필드를 조회, 필터링, 집계하기 위해 설계되었습니다. JSON 객체를 별도의 하위 컬럼으로 분할함으로써 이를 달성하며, Map이나 문자열 파싱 같은 대안에 비해 데이터 읽기를 극적으로 줄이고 선택된 필드에 대한 쿼리를 빠르게 합니다. 하지만 여기에는 중요한 트레이드오프가 있습니다:

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

다음과 같은 경우 JSON 타입을 사용하세요:

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

다음과 같은 경우 String 컬럼(또는 구조화된 타입)을 사용하세요:

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

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

JSON 사용에 관한 고려 사항과 팁

JSON 타입은 경로를 하위 컬럼으로 평탄화하여 효율적인 컬럼형 저장을 가능하게 합니다. 하지만 유연성에는 책임이 따릅니다. 효과적으로 사용하려면:

  • 알려진 하위 컬럼의 타입을 지정하기 위해 컬럼 정의의 힌트를 사용해 경로 타입을 지정하고 불필요한 타입 추론을 피하세요.
  • 값이 필요 없다면 SKIP과 SKIP REGEXP경로를 건너뛰어 저장 공간을 줄이고 성능을 개선하세요.
  • max_dynamic_paths너무 높게 설정하지 마세요 - 큰 값은 리소스 소비를 늘리고 효율성을 떨어뜨립니다. 경험상 10,000 미만으로 유지하세요.

타입 힌트 타입 힌트는 불필요한 타입 추론을 피하는 방법 이상을 제공합니다. 저장과 처리의 간접(indirection)을 완전히 제거합니다. 타입 힌트가 있는 JSON 경로는 항상 전통적인 컬럼처럼 저장되어 쿼리 시점의 디스크리미네이터 컬럼(discriminator columns)이나 동적 해석을 우회합니다. 즉 잘 정의된 타입 힌트를 사용하면 중첩된 JSON 필드가 처음부터 최상위 필드로 모델링된 것과 동일한 성능과 효율을 얻을 수 있습니다. 결과적으로 대부분 일관되지만 JSON의 유연성을 활용하는 데이터셋의 경우, 타입 힌트는 스키마나 수집 파이프라인을 재구성하지 않고도 성능을 유지하는 편리한 방법을 제공합니다.

고급 기능

  • JSON 컬럼은 다른 컬럼처럼 프라이머리 키에 사용할 수 있습니다. 하위 컬럼에는 코덱을 지정할 수 없습니다.
  • JSONAllPathsWithTypes()JSONDynamicPaths() 같은 함수로 인트로스펙션을 지원합니다.
  • .^ 구문으로 중첩된 하위 객체를 읽을 수 있습니다.
  • 쿼리 구문이 표준 SQL과 다를 수 있으며 중첩 필드에 특별한 캐스팅이나 연산자가 필요할 수 있습니다.

추가 지침은 ClickHouse JSON 문서를 참고하거나 블로그 포스트 A New Powerful JSON Data Type for ClickHouse를 살펴보세요.

예시

Python PyPI 데이터셋의 행을 나타내는 다음 JSON 샘플을 고려해 보세요:

{
  "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 행)이라도 그런 스키마에 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 데이터셋을 고려해 보세요. NDJSON으로 배포되는 이 데이터셋의 각 행은 발표된 학술 논문을 나타냅니다. 예제 행은 아래와 같습니다:

{
  "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 타입을 사용할 수는 있지만, TupleNested 타입으로 구조를 명시적으로 정의할 수도 있습니다:

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 컬럼에 대한 타입 힌트를 제공합니다. 이것은 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))"
  }
}

또는 이전 스키마와 JSON tags 컬럼을 사용해 모델링할 수 있습니다. 이 방식이 일반적으로 선호되며 ClickHouse가 수행해야 하는 추론을 최소화합니다. 이제 tags 하위 컬럼의 타입을 추론할 수 있습니다.

더 알아보기 (Learn more)