2P by GN⁺ | ★ favorite | 댓글 1개
  • ClickHouse는 JSON 문서를 문자열로 넣고 매번 파싱하는 병목을 피하려고, JSON 경로별 값을 진짜 컬럼형 저장소에 배치하는 새 JSON 타입을 도입함
  • 구현의 핵심은 VariantDynamic 타입이며, 같은 JSON 경로에 정수·문자열·배열처럼 서로 다른 타입이 들어와도 최소 공통 타입으로 억지 통합하지 않음
  • max_dynamic_paths 기본값 1024, max_dynamic_types 기본값 32로 서브컬럼과 타입별 파일 수를 제한해 파일 디스크립터와 merge 비용 증가를 제어함
  • 타입 힌트, SKIP, SKIP REGEXP로 경로별 저장 방식을 조정할 수 있고, 값은 C.a.b 같은 서브컬럼 문법으로 읽을 수 있음
  • 새 타입은 deprecated된 Object('json') 대체를 목표로 하며, JSON 키 경로를 기본 키나 data-skipping index에 쓰는 개선도 로드맵에 남아 있음

JSON을 컬럼형 저장소에 맞추는 과제

  • JSON은 로그, 관측성, 실시간 데이터 스트리밍, 모바일 앱 저장소, 머신러닝 파이프라인에서 반정형·비정형 데이터를 다루는 공통 형식으로 쓰임
  • ClickHouse는 진짜 컬럼 지향 데이터베이스로, 테이블을 디스크의 컬럼 데이터 파일 모음으로 저장해 압축과 벡터화된 필터·집계를 수행함
  • JSON에서도 같은 성능을 내려면 문서를 문자열 컬럼에 저장한 뒤 나중에 파싱하는 대신, 각 고유 JSON 경로의 값을 컬럼처럼 저장해야 함

새 JSON 타입이 다루는 네 가지 제약

  • 경로별 컬럼형 저장

    • JSON 경로별 값도 숫자형 같은 일반 컬럼처럼 압축하고, 벡터화된 방식으로 필터링·집계할 수 있어야 함
  • 동적으로 바뀌는 타입

    • 같은 JSON 경로 a에 정수, 실수, 배열처럼 서로 다른 타입이 들어올 수 있음
    • ClickHouse는 이를 미리 알 수 없고 타입끼리 호환되지 않을 수도 있어, 최소 공통 타입으로 합치면 정보가 손실될 수 있음
  • 컬럼 파일 폭증 방지

    • 모든 새로운 JSON 경로마다 새 컬럼 파일을 만들면 고유 키가 많은 데이터에서 디스크 파일 수가 급증함
    • 파일 디스크립터는 메모리를 쓰고, 처리해야 할 파일이 많아지면 merge 성능에도 영향을 줌
  • 희소 키의 밀집 저장

    • 고유하지만 희소한 JSON 키가 많을 때, 값이 없는 행마다 NULL이나 기본값을 반복 저장하지 않아야 함
    • 실제 값만 밀집 저장해야 PB 규모 분석에서도 확장 가능함

Variant 타입: 타입을 억지로 통합하지 않는 기반

  • Variant 데이터 타입은 JSON과 별개로 쓸 수 있는 독립 기능이며, 하나의 테이블 컬럼 안에 서로 다른 데이터 타입 값을 저장하고 읽을 수 있음
  • 기존 ClickHouse 컬럼은 고정 타입을 가지며, 삽입 값은 해당 타입이어야 하거나 암묵적으로 변환됨
    • Nullable 컬럼은 값 파일 외에 NULL 마스크 파일을 사용함
    • Array는 배열 크기를 별도 파일에 저장하고, 이를 통해 오프셋을 계산함
  • Variant 컬럼은 같은 구체 타입의 값을 타입별 서브컬럼에 저장함
    • 예: 모든 Int64 값은 C.Int64.bin, 모든 String 값은 C.String.bin에 저장됨
  • 각 행이 어떤 타입을 쓰는지는 UInt8 discriminator 컬럼으로 추적함
    • discriminator 값은 정렬된 타입 이름 목록의 인덱스임
    • discriminator 255NULL 예약값임
    • 이 설계 때문에 Variant는 최대 255개 구체 타입을 가질 수 있음
  • 타입별 데이터 파일은 값이 있는 행만 담는 밀집 저장 구조임
    • 타입별 파일에는 NULL 값을 저장하지 않음
    • discriminator 행에서 실제 타입 파일의 행 위치를 찾기 위해 메모리상의 UInt64 오프셋 컬럼을 사용함
    • 이 오프셋은 디스크에 저장되지 않고 discriminator 컬럼 파일에서 즉석 생성될 수 있음
  • Variant는 임의 중첩을 지원함
    • Variant(T1, T2)Variant(T2, T1)의 타입 순서는 의미가 같음
    • Variant 내부에 다시 Variant를 넣을 수 있음
  • 특정 중첩 타입 값은 타입 이름을 서브컬럼처럼 붙여 읽음
    • 예: C.Int64

Dynamic 타입: 타입 목록을 미리 몰라도 저장

  • Dynamic 타입은 Variant 위에 구현된 독립 기능이며, JSON 문맥 밖에서도 사용할 수 있음
  • Dynamic은 Variant에 두 가지 기능을 더함
    • 하나의 컬럼 안에 임의 타입 값을 저장하되 타입 목록을 미리 지정하지 않아도 됨
    • 별도 컬럼 데이터 파일로 저장할 타입 수를 제한할 수 있음
  • 내부 저장 방식은 Variant와 같지만 C.dynamic_structure.bin 파일이 추가됨
    • 이 파일은 서브컬럼으로 저장된 타입 목록과 타입별 컬럼 데이터 파일 크기 통계를 담음
    • 해당 메타데이터는 서브컬럼 읽기와 데이터 파트 merge에 사용됨
  • Dynamic(max_types=N)은 별도 파일로 저장할 타입 수를 제한함
    • 0 <= N < 255
    • 기본값은 32
  • 제한에 도달하면 나머지 타입 값은 C.SharedVariant.bin 같은 단일 컬럼 파일에 저장됨
    • 이 파일의 타입은 String
    • 각 행은 <binary_encoded_data_type><binary_value> 구조의 문자열 값을 담음
    • 여러 타입 값을 하나의 컬럼 파일 안에 저장하고 다시 읽을 수 있음
  • Dynamic도 Variant처럼 타입 이름을 서브컬럼으로 사용해 특정 타입 값을 읽을 수 있음
    • 예: C.Int64

JSON 타입 선언과 저장 구조

  • JSON 타입은 임의 구조의 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
    • 기본값은 1024
    • 별도 서브컬럼으로 저장할 JSON 키 경로 수를 지정함
    • 제한을 넘는 경로는 특수 구조의 단일 서브컬럼에 함께 저장됨
  • max_dynamic_types
    • 기본값은 32
    • 값 범위는 0부터 254
    • 하나의 JSON 키 경로 컬럼에서 별도 컬럼 데이터 파일로 저장할 데이터 타입 수를 지정함
    • 제한을 넘는 새 타입은 특수 구조의 단일 컬럼 데이터 파일에 함께 저장됨
  • some.path TypeName
    • 특정 JSON 경로에 대한 타입 힌트
    • 해당 경로는 지정된 타입의 서브컬럼으로 항상 저장되어 성능 보장을 제공함
  • SKIP path.to.skip
    • 특정 JSON 경로를 파싱 중 건너뜀
    • 해당 경로는 JSON 컬럼에 저장되지 않음
    • 지정 경로가 중첩 JSON 객체이면 전체 중첩 객체가 건너뛰어짐
  • SKIP REGEXP 'path_regexp'
    • 정규식에 매칭되는 경로를 JSON 파싱 중 건너뜀
    • 매칭 경로는 JSON 컬럼에 저장되지 않음

JSON 경로를 컬럼처럼 읽는 방식

  • JSON 컬럼의 각 고유 leaf 경로 값은 디스크에 두 방식 중 하나로 저장됨
    • 타입 힌트가 있는 경로는 일반 컬럼 데이터 파일로 저장됨
    • 타입이 동적으로 바뀔 수 있는 경로는 Dynamic 서브컬럼으로 저장됨
  • JSON 타입은 object_structure라는 특수 파일을 사용함
    • 동적 경로에 대한 메타데이터를 담음
    • 각 동적 경로의 non-null 값 통계를 담음
    • 서브컬럼 읽기와 데이터 파트 merge에 사용됨
  • 컬럼 파일 폭증은 두 단계 제한으로 제어됨
    • max_dynamic_types는 하나의 JSON 키 경로 안에서 별도 파일로 저장할 타입 수를 제한함
    • max_dynamic_paths는 별도 서브컬럼으로 저장할 JSON 키 경로 수를 제한함
  • max_dynamic_paths 제한을 넘는 추가 동적 JSON 경로는 shared data로 저장됨
    • 예시 파일은 C.object_shared_data.size0.bin, C.object_shared_data.paths.bin, C.object_shared_data.values.bin
    • object_shared_data.valuesString 타입임
    • 각 엔트리는 <binary_encoded_data_type><binary_value> 구조를 가짐
  • shared data에 대해서도 object_structure.bin에 추가 통계를 저장함
    • 현재 shared data 컬럼에 저장된 경로 중 처음 10000개 경로에 대해 non-null 값 통계를 저장함

JSON 경로 문법과 중첩 객체

  • JSON 타입은 각 경로의 leaf 값을 경로명 기반 서브컬럼으로 읽을 수 있음
    • 예: C.a.b
  • 타입 힌트가 지정되지 않은 경로의 값은 항상 Dynamic 타입을 가짐
    • 예: C.a.d의 타입은 Dynamic
  • Dynamic 타입의 하위 타입 서브컬럼은 특수 JSON 문법으로 읽음
    • 예: C.a.d.:Int64
  • 중첩 JSON 객체는 JSON_column.^some.path 문법으로 JSON 타입 서브컬럼처럼 읽을 수 있음
    • 예: C.^a
  • 현재 dot 문법은 성능 이유로 중첩 객체를 읽지 않음
    • 경로별 리터럴 값 읽기에는 현재 저장 구조가 효율적임
    • 경로별 전체 하위 객체를 읽으려면 더 많은 데이터를 읽어야 해서 느려질 수 있음
    • 객체 반환에는 .^ 문법이 필요함
    • ClickHouse는 두 가지 . 문법을 통합할 계획임

Compact discriminator 직렬화

  • 많은 동적 JSON 경로는 값 타입이 대부분 같을 수 있음
  • 고유하지만 희소한 JSON 경로가 많으면 각 경로의 discriminator 파일은 주로 255, 즉 NULL 값을 담게 됨
  • 이런 파일은 압축은 잘 되지만, 모든 행의 값이 같으면 여전히 중복이 많음
  • ClickHouse는 discriminator 직렬화를 위한 compact format을 구현함
    • 일반적으로 UInt8 discriminator 값을 모두 쓰는 대신, 대상 granule의 discriminator가 모두 같으면 3개 값만 직렬화함
    • compact granule format 표시자
    • 해당 granule의 값 개수 표시자
    • discriminator 값
  • 이 최적화는 MergeTree 설정 use_compact_variant_discriminators_serialization으로 제어됨
    • 기본값은 활성화임
    • 일반 index granularity 기준 8192개 값 대신 3개 값만 저장하는 경우가 생김

릴리스 상태와 다음 단계

  • 새 JSON 타입은 deprecated된 Object('json') 타입을 대체하도록 설계됨
  • 구현은 ClickHouse 24.08 릴리스에서 테스트 목적의 experimental 기능으로 제공됨
  • JSON 로드맵에는 JSON 키 경로를 테이블 기본 키나 데이터 스키핑 인덱스(data-skipping index) 안에서 사용하는 개선이 포함됨
  • Variant와 Dynamic 같은 구성요소는 JSON 외에도 XML, YAML 같은 추가 반정형 타입 지원의 기반이 됨
  • ClickHouse Cloud 사용자가 새 JSON 데이터 타입을 테스트하려면 ClickHouse 지원팀에 연락해 private preview 접근을 요청해야 함

댓글과 토론

Hacker News 의견들
  • ClickHouse에서 이 기능을 보니 반가움
    Snowflake도 IPO 전 백서에서 같은 기능, 즉 JSON을 몰래 컬럼으로 펼치는 방식을 언급했음
    Snowflake가 예상보다 빠르게 느껴지는 이유가 설명되고, 내부적으로 대단한 일을 많이 해두고 Apple처럼 다듬어진 제품으로 제공한 셈임

  • ClickHouse에 대해 좋은 얘기는 많이 들었지만, 매번 쓰려다 보면 데이터를 안정적으로 넣는 방법에서 막힘
    찾아보면 결국 “ClickHouse와 Kafka를 조합하라”로 귀결되고, 그 순간 계속할 의욕이 사라짐
    Kafka와 ZooKeeper를 띄우지 않고 ClickHouse에 안정적으로 데이터를 적재하는 구성이 있는지 궁금함

    • 회사에서는 Vector로 ClickHouse에 적재하고 있고, 버퍼링과 재시도를 해줘서 잘 동작함
      Vector는 여러 소스와 싱크를 지원하는 비교적 단순한 수집 도구임
      설정 파일 하나와 단일 바이너리만 있으면 실행할 수 있고, JSON 보강이나 재구성 같은 ETL도 꽤 가능하며 여러 스트림을 하나로 합치는 고급 파이프라인 연산도 일부 지원함
      Kafka 얘기가 나왔으니 Redpanda도 볼 만함
      Kafka 호환이지만 운영이 훨씬 쉽고 Java나 ZooKeeper가 필요 없음
      이 경우에도 Vector를 Redpanda와 ClickHouse 사이 라우터로 쓰면 되고, 버퍼링은 Redpanda가 맡게 됨
      또 다른 선택지는 RudderStack인데, 파이프라인 설정용 UI까지 갖춘 더 풍부한 도구라 다른 용도로도 사용 중임
    • 흥미롭지만 개인적으로는 특별히 겪어본 문제는 아님. 더 자세히 알고 싶음
      오픈소스 ClickHouse 구성을 찾는 건지, 아니면 이 문제를 해결해주는 관리형 ClickHouse 서비스를 찾는 건지도 중요함
      TinybirdClickHouse Cloud는 Kafka 없이도 수집 커넥터를 제공하는 관리형 ClickHouse 서비스임
      ETL 도구인 Estuary는 최근 Dekaf를 내놨는데, Kafka 호환 API를 노출해서 Kafka 브로커처럼 보이게 하므로 실제 Kafka 없이도 ClickHouse와 연결할 수 있음
      다만 이게 오픈소스 Estuary Flow 프로젝트에 포함되어 있는지는 확실하지 않고, 아마 아닐 것 같음
      그냥 ClickHouse를 실험해보고 싶다면 clickhouse-local이나 chDB를 쓸 수 있음
      DuckDB처럼 서버 없이 실행되고 로컬 파일을 다루기에 좋음
      스트림이 필요 없고 파일만 다룬다면 인프로세스/서버리스 변환 엔진처럼 사용할 수도 있음
      파일이 도착하면 chDB로 읽고 필요한 처리를 한 뒤 ClickHouse 바이너리 형식으로 내보내 메인 ClickHouse에 직접 삽입하는 패턴이며, VM이나 Lambda에서 돌리기 좋음
    • 나도 매번 같은 단계에서 막힘
      예를 들어 집 안 IoT 기기가 MQTT 브로커를 거쳐 HomeAssistant로 데이터를 보내고, 최종 목적지는 ClickHouse나 데이터베이스나 S3라고 하자
      A에서 B로 데이터를 옮길 때 업로드는 성공했지만 ACK를 못 받아 중복 행이 생기지 않아야 하고, 업로드 실패 시 재시도해서 누락 행도 없어야 하며, 로컬 시스템이 죽어도 데이터가 휘발되지 않아야 함
      지금까지 가장 쉬운 방법은 데이터를 로컬 파일(JSON, Parquet 등)에 쓰고 5분마다 새 파일을 만든 뒤 오래된 파일을 S3에 동기화하는 것이었음
      그런데 그다음 또 막힘
      S3의 새 파일을 반복이나 예외 상황 없이 계속 적재하려면 어떻게 해야 하는지, 애초에 중간 파일이 정말 필요했는지도 의문임
    • ClickHouse에 대한 내 경험과 지식은 3~4년 전 기준이라 지금은 틀릴 수 있음
      배치로 하는 방법은 많지만, 실시간 insert into table 스타일이나 직접 ch.write(data) 같은 걸 원한다면 내가 알기로는 배치 없이 방법이 없음
      약 3년 전 금융 데이터 분석 도구 프로젝트에서 ClickHouse를 그만둔 주된 이유 중 하나였음
      ClickHouse에는 WAL 같은 트랜잭션 로그가 없어서, 데이터 생산자가 똑똑해야 하거나 S3, Kafka, Kinesis 같은 큐 성격의 서비스를 두고 배치를 처리해야 함
    • “ClickHouse와 Kafka를 조합하라”는 자료들은 아마 오래된 지식일 가능성이 큼
      Kafka가 필요한 건 배치를 대신 처리하게 하고 싶을 때임
      ClickHouse도 비동기 삽입으로 배치를 처리할 수 있음
      https://clickhouse.com/blog/asynchronous-data-inserts-in-cli...
  • 이 기능이 정말 기대됨
    Elasticsearch에 로그를 저장할 때 가장 큰 골칫거리 중 하나가 처음 본 값으로 타입이 고정되는 문제였음
    실험적 지원에서 빨리 벗어나면 좋겠음

    • ELK/Kibana가 왜 그런 방식을 택했는지 이해가 안 됨. 훨씬 단순한 해결책은 각 필드명에 데이터 타입을 붙이는 것
      예를 들어 {"value": 42}{"value": "foo"}가 있다면, 인덱싱할 때 {"value::int": 42}{"value::str": "foo"}로 넣으면 됨
      이제 서로 충돌하지 않는 두 개의 별도 필드가 생김
      검색할 때는 질의 언어가 타입을 알도록 만드는 게 논리적인 선택임
      value=42는 정수 필드를 검색하고, value="42"는 문자열 필드를 검색하면 됨
      어떤 데이터 타입을 검색해야 하는지 애매한 상황이 생기지 않음
      KQL에는 이게 없는데, 여러 설계 실수 중 하나라고 봄
      배열이나 객체를 포함한 어떤 데이터 타입에도 같은 방식을 적용할 수 있음
      단점은 사실상 없음
      특정 프로젝트에서 성공적으로 구현해봤음
      굳이 하나 꼽자면 필드 수가 늘어난다는 점인데, 애초에 서로 다른 데이터 집합이므로 자연스러운 결과임
  • ClickHouse의 JSON 지원이 더 좋아지길 기다려왔음
    새 타입은 유망해 보이고, 동적 컬럼과 하위 타입을 지정하지 않아도 되는 점이 특히 유용함

  • ClickHouse를 평가 중이라면 Apache Pinot도 살펴볼 만함
    ClickHouse는 단일 머신 설치를 염두에 두고 설계된 뒤 클러스터 지원이 추가된 형태라, 예를 들어 노드를 추가했을 때 데이터를 재분산하기가 쉽지 않음
    Pinot은 수평 확장이 훨씬 쉽고, Pinot의 star-tree 인덱스도 볼 만함 [1]
    다차원 분석, 예를 들어 피벗 테이블 같은 작업을 한다면 star-tree를 활용할 때 성능 차이가 매우 큼
    [1] https://docs.pinot.apache.org/basics/indexing/star-tree-inde...

    • “ClickHouse가 단일 머신 설치용으로 설계됐다”는 말은 틀렸음
      ClickHouse는 처음부터 분산 구성을 염두에 두고 설계됐고, 데이터센터 간 설치도 포함됨
      오픈소스화되기 전부터 대규모 운영 클러스터에서 쓰였음
      2016년 6월 오픈소스로 공개될 당시 가장 큰 클러스터는 6개 데이터센터에 걸친 394대였고, 가장 먼 데이터센터 간 RTT는 25ms였음
    • 완전히 틀렸음
      ClickHouse는 Yandex가 만들었고 첫날부터 클러스터 준비가 되어 있었음
    • Apache Doris도 선택지임
      읽어본 바로는 성능 특성이 ClickHouse에 더 가까워 보임
      단, 아직 둘 다 직접 써보진 않았음
      그리고 ClickHouse가 자체 방식을 쓰는 데 비해 Doris는 MySQL풍 클라이언트 커넥터가 있어서 기존 도구와 통합하기 더 쉬울 수 있음
    • 사용 사례가 뭔지 궁금함
      엄청난 양의 데이터 분석인지, 아니면 다른 용도인지가 중요함
  • ClickHouse는 훌륭함
    적당한 규모의 OLAP 데이터베이스, 약 6억 행과 압축 전 300GB 정도에 쓰고 있는데 던지는 작업을 문제없이 처리함
    지금은 중첩 튜플로 해결하는 사용 사례에 이번 새 JSON 데이터 타입이 더 잘 맞기를 기대함

    • 우리도 비슷한데 한 테이블에 7억 행, 전체로는 25억 행 정도임
      OTEL을 클러스터에 밀어 넣기 시작해서 빠르게 늘고 있음
      어떤 쿼리도 ClickHouse를 흔들지 못하는 것처럼 보임
      마법 같고, 노드당 48코어도 도움이 됨
    • 300GB 정도면 Postgres로도 충분하지 않나?
  • 몇 주 전에 써봤을 때 ClickHouse가 컬럼명을 기반으로 파일 이름을 만들다 보니, 이상한 JSON 키 때문에 파일명이 매우 길어지고 슬래시가 들어가서 파일 시스템과 잘 맞지 않아 오류가 났음
    이게 고쳐졌는지 궁금함

  • 같은 JSON 경로 a에 정수 두 개와 부동소수점 하나가 있을 때 세 값을 모두 디스크에 부동소수점으로 저장하고 싶지 않다는 예시는 흥미로움
    JS와 정확히 같은 방식으로 하고 싶다면 전부 부동소수점으로 저장하는 게 맞음
    하지만 JSON 표준은 JS와 같은 방식이어야 한다고 말하지 않음

    • Variant 타입은 JSON 지원과 독립적으로 존재하므로, 제대로 처리하는 게 좋아 보임
  • 특정 JSON 부분인 정수, 문자열, 배열을 저장한다기보다 컬럼에 임의의 JSON 타입을 저장하는 방식처럼 보임
    Swift, Kotlin, Rust의 “필드가 있는 enum”이나 Haskell의 대수적 데이터 타입과 비슷하고, 다른 많은 언어에는 없는 기능임

  • 몇 년 전 기억이라 지금은 멀어졌지만, Google Capacitor가 protobuf를 저장하는 방식도 이렇지 않았나 싶음
    protobuf는 표현할 수 있는 내용 면에서 JSON과 거의 동등함