Floe Database는 고속 JSON 접근을 지원한다. 새 블로그 시리즈에서 로컬 JSON 전문가 Jin이 경험과 테스트 결과를 공유한다.
URL: https://floedb.ai/blog/why-json-isnt-a-problem-for-databases-anymore
Floe Database는 고속 JSON 접근을 지원합니다. 새로운 블로그 시리즈에서 로컬 JSON 전문가 Jin이 자신의 경험과 테스트 결과를 공유합니다.
이번 글에서는 JSON의 바이너리 인코딩이 반복 쿼리를 어떻게 가속하는지, 왜 BSON만으로는 그 역할을 충분히 하기 어렵고, 데이터베이스에서 궁극적으로 VARIANT가 JSON을 대체할 수도 있는지 살펴봅니다. 이러한 바이너리 인코딩의 내부 설계 트레이드오프는 흥미롭지만, 의외로 자주 논의되지는 않습니다.
아주 단순한 바이너리 인코딩조차도 조회 성능을 실질적으로 개선할 수 있다는 점을 보게 될 것입니다. 글을 다 읽고 나면, 이런 인코딩이 할 수 있는 일과 할 수 없는 일을 명확히 이해하게 됩니다.
JSON 텍스트를 파싱하는 것은 비용이 큽니다. 바이너리 JSON은 그 작업의 상당 부분을 피할 수 있습니다. 이는 의심의 여지가 없습니다. 하지만 정확히 얼마나 비싼 걸까요? 마이크로벤치마크로 직관에 수치를 붙여 봅시다.
여기서는 가장 빠른 JSON 파서 중 하나인 simdjson을 사용합니다. on-demand API는 (물론 워크로드와 머신에 따라 다르지만) 멀티 GB/s 처리량을 낼 수 있습니다. 아래는 2019년형 Intel MacBook Pro에서의 결과입니다:
--------------------------------------------------------------------------------------------------------------------
Benchmark Time CPU Iterations
--------------------------------------------------------------------------------------------------------------------
BM_simdjson_object_ondemand/1000/0/min_time:1.000/min_warmup_time:1.000 2267 ns 2265 ns 498638
BM_simdjson_object_ondemand/1000/250/min_time:1.000/min_warmup_time:1.000 3867 ns 3863 ns 367326
BM_simdjson_object_ondemand/1000/500/min_time:1.000/min_warmup_time:1.000 5414 ns 5409 ns 262413
BM_simdjson_object_ondemand/1000/750/min_time:1.000/min_warmup_time:1.000 7207 ns 7193 ns 197417
BM_simdjson_array_ondemand/1000/0/min_time:1.000/min_warmup_time:1.000 1089 ns 1087 ns 1285819
BM_simdjson_array_ondemand/1000/250/min_time:1.000/min_warmup_time:1.000 1941 ns 1926 ns 803453
BM_simdjson_array_ondemand/1000/500/min_time:1.000/min_warmup_time:1.000 2604 ns 2599 ns 500731
BM_simdjson_array_ondemand/1000/750/min_time:1.000/min_warmup_time:1.000 3428 ns 3413 ns 395744
BM_simdjson_twitter/min_time:1.000/min_warmup_time:1.000 136784 ns 136620 ns 10839
의도적으로 최소한으로 구성했습니다. 데이터 생성, I/O, 파서 생성 오버헤드는 제외합니다(장시간 프로세스에서는 simdjson이 이를 권장합니다). 그리고 오직 파싱 + 쿼리만 측정합니다. 여기서는 세 가지 워크로드를 사용합니다:
오브젝트 조회(Object lookup): 키 1000개(정렬됨), 각 키의 값은 int인 JSON 오브젝트. 서로 다른 키 이름을 조회: "000", "250", "500", "750".
배열 조회(Array lookup): 정수 1000개로 이루어진 JSON 배열. 인덱스를 조회: 0, 250, 500, 750.
현실적인 워크로드(Realistic workload): twitter.json (617KB), 쿼리: doc["statuses"].at(75)["user"]["name"].
twitter.json 같은 현실적인 워크로드는 단일 쿼리를 파싱하고 답하는 데 ~136784ns가 걸립니다. 100만 행이면 거의 2분 30초로, 커피 한 잔 내리기에 충분한 시간입니다! 어떤 현대적인 분석 DB라도 그 정도 시간에 수십억 행을 처리할 수 있습니다.
더 흥미로운 점은, 합성(synthetic) 케이스에서 오브젝트와 배열 모두에서 뒤쪽 키/인덱스로 갈수록 조회 시간이 선형으로 증가한다는 것입니다. BM_simdjson_array_ondemand/1000/750는 BM_simdjson_array_ondemand/1000/0보다 약 3배 느립니다(1000은 전체 길이, 750은 조회 인덱스).
이는 직관에 어긋납니다. 정렬된 키의 조회는 이상적으로 O(log N), 배열 접근은 **O(1)**이어야 합니다. 진짜 핵심은 일반 텍스트 JSON에는 랜덤 액세스가 없다는 점입니다. 앞의 요소를 건너뛰고 뒤의 요소만 바로 찾을 수 없으며, 앞부분을 지나가며 확인해야 합니다. 필요하지도 않은 데이터를 매번 디코드해야만 합니다!
그렇습니다. simdjson은 빠릅니다(멀티 GB/s 파싱). 하지만 조회할 때마다 파싱할 필요가 없다면 더 빠릅니다.
그렇다면 JSON을 위한 간단한 바이너리 인코딩은 어떻게 설계할까요? 먼저 모든 JSON 값 타입을 표현할 방법이 필요합니다. 공식 스펙에 따르면 JSON은 string, number, boolean, null, array, object를 지원합니다. 이 모두는 작은 타입 태그로 커버할 수 있습니다:
enum class VarType : uint8_t {
null = 0,
object = 1,
array = 2,
bool_true = 3,
bool_false = 4,
string = 5,
number = 6
};
다음으로, 트리 구조(배열/오브젝트)와 리프 데이터(문자열/숫자/불리언/null)를 모두 표현할 수 있는 노드 포맷이 필요합니다. 최소 구조는 다음과 같습니다:
typedef struct VarNode {
// 첫 번째 필드는 빠른 확인을 위해 `var_type`임이 보장됨
VarType type : 4;
// object/array: 전체 요소 수
// string/number: 페이로드의 바이트 길이
// true/false/null: 미사용(0)
uint32_t length : 28;
// 데이터 레이아웃:
// - object/array: [VarMetaEntry * length] 다음에 [VarNode * length] (및 해당 페이로드)
// - strings: 원시 UTF-8 데이터(널 종료 없음)
// - number: 단순화를 위해 원본 숫자 문자열
} VarNode;
(참고: 여기서는 명확성을 위해 비트필드를 사용했습니다. 플랫폼 간 안정성을 위해서는 보통 명시적 패킹과 마스킹이 더 나은 선택입니다.)
마지막으로, 배열과 오브젝트에는 앞부분을 전부 디코드하지 않고도 자식으로 바로 점프할 수 있는 인덱스가 필요합니다. 각 엔트리는 자식 노드에 대한 상대 오프셋을 저장합니다:
// 배열/오브젝트 엔트리를 위한 메타데이터.
// - Array: 인덱스 기반 O(1) 접근.
// - Object: 키 기준으로 메타데이터를 정렬하면 O(log N) 조회 가능.
typedef struct VarMetaEntry {
uint32_t value_ptr; // 이 필드에서 타겟 VarNode까지의 상대 오프셋
} VarMetaEntry;
설계를 설명하기 위한 몇 가지 예시 인코딩:
{"b": 200, "a": 100} // (참고: 키는 정렬됩니다!)
[VarNode type=object length=2][VarMetaEntry * 2: "a"와 "b"에 대한 오프셋]
[VarNode type=string length=1][data="a"][VarNode type=number length=3][data="100"]
[VarNode type=string length=1][data="b"][VarNode type=number length=3][data="200"]
[100,true,"string"]
[VarNode type=array length=3][VarMetaEntry * 3: 각 요소에 대한 오프셋]
[VarNode type=number length=3][data="100"]
[VarNode type=bool_true]
[VarNode type=string length=6][data="string"]
{"a": [100,true,"string"]}
[VarNode type=object length=1][VarMetaEntry * 1]
[VarNode type=string length=1][data="a"]
[VarNode type=array length=3]
[VarMetaEntry * 3]
[VarNode type=number length=3][data="100"]
[VarNode type=bool_true]
[VarNode type=string length=6][data="string"]
이 단순한 설계에도 흥미로운 점이 많습니다. 불리언의 경우 값이 타입 태그 자체에 인코딩됩니다. null의 경우 VarNode 전체가 단지 0이므로 비트 단위 비교가 매우 쉽습니다. 숫자는 원래 문자열 형태를 그대로 보존하므로(말장난을 하자면 double-guessing을 하지 않아도 됩니다), 조회 시점에 어떤 타입으로 캐스팅할지 결정할 수 있습니다.
인덱스 메타데이터는 요소 노드로의 상대 오프셋 목록에 불과합니다. 이를 통해 배열에서는 O(1) 랜덤 액세스가 가능하고, 오브젝트에서는(키가 정렬되어 있다는 가정하에) 이진 탐색으로 O(log N) 키 조회가 가능합니다. 구조와 데이터는 설계상 자체 완결(self-contained)입니다. 이는 여러 쿼리를 수행하며 중첩된 자식을 반복 추출하는 워크로드에서 중요합니다. 시작과 끝만 잡아서 서브트리를 잘라낼 수 있으며, 추가적인 사이드 테이블이 필요 없습니다. 즉, 기존 바이너리 JSON 문서 위에 std::string_view 스타일의 뷰를 바로 만들 수 있습니다.
이 간단한 설계를 사용하면 JSON을 한 번만 파싱하고, 반복 조회를 위해 바이너리 인코딩을 저장할 수 있습니다. 동일한 워크로드를 다시 측정하되(이번에는 조회만 카운트) 결과는 다음과 같습니다:
-------------------------------------------------------------------------------------------------------------
Benchmark Time CPU Iterations
-------------------------------------------------------------------------------------------------------------
BM_binaryjson_object/1000/0/min_time:1.000/min_warmup_time:1.000 48.2 ns 48.0 ns 28791182
BM_binaryjson_object/1000/249/min_time:1.000/min_warmup_time:1.000 17.5 ns 17.4 ns 75834313
BM_binaryjson_object/1000/499/min_time:1.000/min_warmup_time:1.000 12.8 ns 12.8 ns 113965680
BM_binaryjson_object/1000/749/min_time:1.000/min_warmup_time:1.000 16.7 ns 16.6 ns 82031570
BM_binaryjson_array/1000/0/min_time:1.000/min_warmup_time:1.000 6.56 ns 6.55 ns 207271683
BM_binaryjson_array/1000/249/min_time:1.000/min_warmup_time:1.000 6.71 ns 6.69 ns 211780759
BM_binaryjson_array/1000/499/min_time:1.000/min_warmup_time:1.000 6.59 ns 6.58 ns 211003215
BM_binaryjson_array/1000/749/min_time:1.000/min_warmup_time:1.000 6.73 ns 6.71 ns 208599360
BM_binaryjson_twitter/min_time:1.000/min_warmup_time:1.000 58.3 ns 58.1 ns 23862443
이제 twitter.json에 대한 쿼리(doc["statuses"].at(75)["user"]["name"])는 58.3ns밖에 걸리지 않습니다. 100만 행이면 대략 0.0583초입니다! 매번 simdjson으로 파싱하던 것과 비교하면 2,346배 빨라진 셈입니다.
배열 접근도 배열의 앞이든 뒤든(캐시 라인 효과는 있을 수 있지만) 상수 시간입니다. 오브젝트의 경우 이진 탐색이 키 조회 시간을 줄여 줍니다. 얕은 히트는 12.8ns까지 낮아질 수 있고, 더 깊은 탐색(비교가 더 많음)은 48.2ns 수준으로 올라갑니다.
이렇게 간단한 바이너리 인코딩 설계의 짧은 여정은 끝났습니다. SIMD 순회(traversal) 같은 최적화를 더할 수도 있지만, 이제는 다른 현실적인 사례로 시선을 돌릴 때입니다.
바이너리 JSON을 사용했을 때의 성능 이점을 보았습니다. 하지만 어떤 바이너리 JSON 포맷을 채택해야 할까요? 대부분은 BSON만 쓰면 되지 않나라고 생각할 수 있습니다. 그러나 BSON에는 눈에 띄는 트레이드오프가 있습니다:
CBOR나 MessagePack 같은 포맷도 있지만, 이들 역시 랜덤 액세스를 제공하지 않습니다. 즉, 이들은 빠르고 반복적인 조회가 아니라, 컴팩트한 전송과 직렬화에 최적화되어 있습니다.
Postgres의 JSONB는 다른 트레이드오프를 택합니다. 배열의 랜덤 액세스와 더 빠른 키 조회를 지원하지만, Postgres 내부(특히 TOAST)와 잘 맞도록 엔지니어링되어 있습니다. 예를 들어 오브젝트/배열 인덱싱에 고정 스트라이드로 offset-or-length를 쓰는 방식을 사용합니다. 숫자도 Postgres의 numeric 타입 시스템으로 파싱됩니다.
한편 YDB는 대부분의 경우에 충분히 좋은 단순한 설계를 갖고 있지만, 트레이드오프가 있습니다. 문자열과 숫자가 전역 테이블(global table)에 들어가므로, 각 중첩 구조는 더 이상 자체 완결이 아닙니다. 요소 하나를 추출하려면 전역 테이블을 함께 참조해야 합니다.
핵심은 모든 바이너리 인코딩이 서로 다른 목표를 위해 최적화되어 있다는 것입니다. 각각은 설계 트레이드오프의 집합입니다.
어떤 종류의 쿼리를 제공할까요? 예를 들어 대부분이 스칼라($.a.b.c) 추출이라면, 모든 키를 평탄화하고 역색인(inverted index)을 구축하는 간단한 아이디어가 있습니다. 충돌이 발생할 경우 검증을 위해 키가 최소 트리 구조를 가리키도록 할 수도 있습니다. 하지만 배열 전체를 추출하는 것을 지원하려면, 요소들을 서로 가깝게 배치하고 추출을 위한 서브트리 경계를 쉽게 계산할 수 있게 하는 편이 낫습니다.
디스크 공간 절약이 최우선일까요? 그렇다면 문자열 키와 값들을 모두 중복 제거하여 테이블에 저장하고 트리 구조에서 이를 참조하게 할 수 있습니다. 인덱싱 메타데이터를 얼마나 감당할 수 있을까요? 또한 지원해야 하는 최대 문서 크기를 안다면, 더 작은 오프셋을 사용할 수 있습니다(예: 4바이트 대신 1바이트. Parquet VARIANT 타입에는 이 최적화가 있습니다).
대개 직렬화는 한 번만 일어나므로, 더 나은 인코딩을 위해 초기에 더 많은 시간을 쓸 의향이 있나요? 그렇다면 키를 정렬하고 인덱스를 구축할 수 있습니다. 머티리얼라이제이션(materialization)도 중요합니다. 서브트리를 쿼리로 가져올 때 결과를 물리화해야 할 수도 있는데, 이상적으로는 새 할당 없이 서브트리에 대한 “뷰”를 제공하는 것이 좋습니다(런타임 메모리 할당은 비싸고 지연 시간을 예측하기 어렵게 만듭니다).
배열 요소 O(1) 접근을 지원하려면 기본 인덱싱이 필요합니다. 인덱스는 또한 앞의 모든 데이터를 디코드하지 않고도 데이터를 스킵하게 해 주고, 서브트리 경계 계산을 쉽게 해야 합니다. 예를 들어 길이 1,000짜리 배열에 $.a[500]을 실행한다면 오프셋을 직접 결정할 수 있어야 합니다.
중복 키를 허용하나요? 정렬은 키에 대한 이진 탐색을 가능하게 합니다. 또한 포맷을 표준화(canonicalize)하여, 동일한 값이지만 키 순서가 다른 두 JSON 문자열이 같은 바이너리 JSON으로 인코딩되도록 돕습니다. 이는 바이트 단위의 빠른 비교에도 유용할 수 있습니다.
현대 JSON 파서는 숫자 리터럴이 정수인지, double인지, decimal인지 감지할 수 있습니다. 이는 파싱 비용을 치르는 대신 값을 더 효율적으로 저장할 수 있게 합니다. 또한 루트로 스칼라 값을 허용할까요? BSON은 루트가 문서(document)라고 가정하는데, 이렇게 하면 루트 타입을 저장하는 1바이트를 절약할 수 있습니다.
결국 어떤 바이너리 JSON 인코딩을 채택할지에 대한 답은—예상하셨겠지만—워크로드에 따라 달라집니다. 반복 조회가 있는 분석 워크로드에서는 랜덤 액세스가 있으면 좋다는 수준이 아니라, 기본 조건입니다.
레이크하우스 세계로 가면서 Parquet은 기본 저장 포맷이 되었습니다. 지금까지는 JSON에 초점을 맞췄지만, JSON은 반정형 데이터(semi-structured data)의 한 예일 뿐입니다. XML도 또 다른 예죠. Parquet은 VARIANT라는 더 일반적인 추상화를 지원합니다. VARIANT는 다양한 반정형 데이터를 표현할 수 있는 단일 타입입니다. 타임스탬프나 UUID 같은 더 풍부한 데이터 타입을 가진, 범용 컨테이너—즉 ‘더 많은 타입을 가진 JSON’으로 생각할 수 있습니다.
참고로 스펙을 보면 인코딩이 어떻게 생겼는지 알 수 있습니다. 여기서는 전체 스펙을 다루지 않고 흥미로운 부분 몇 가지만 보겠습니다.
먼저 값 노드 레이아웃입니다:
7 2 1 0
+------------------------------------+------------+
value | value_header | basic_type | <-- value_metadata
+------------------------------------+------------+
| |
: value_data : <-- 0 or more bytes
| |
+-------------------------------------------------+
Parquet VARIANT는 네 가지 “기본 타입(basic type)”을 정의합니다: primitive, short string, object, array. 여기에는 Parquet의 논리 타입(logical types) 중 일부인 20개의 primitive 타입이 포함됩니다.
또한 짧은 문자열(64바이트 미만)에 대한 최적화가 있습니다. 문자열 길이를 value_header의 6비트에 패킹하여 별도의 길이 필드가 필요 없게 합니다(몇 바이트를 절약합니다?!). 오브젝트와 배열의 경우 value_header에 인덱싱 오프셋을 1바이트로 저장할지 4바이트로 저장할지 나타내는 플래그도 포함되는데, 이것 역시 작지만 유용한 최적화입니다.
배열의 인덱싱 스킴은 직관적입니다. N + 1개의 오프셋이 있고, 마지막 오프셋은 배열 페이로드의 끝 위치를 나타냅니다.
오브젝트는 조금 더 복잡합니다. 두 개의 오프셋 리스트를 사용합니다(첫 번째 리스트는 기술적으로는 ID 리스트입니다). 첫 번째 리스트는 전역 메타데이터에 저장된 문자열 테이블로의 키 참조를 담고, 두 번째 리스트는 해당 키에 대응하는 값 노드를 가리킵니다. 모든 문자열 값이 아니라 키만 중복 제거하는 흥미로운 선택인데, 키의 카디널리티가 값보다 훨씬 낮다는 점을 고려하면 타당합니다. 키 테이블은 (옵션으로) 정렬될 수 있지만, 실제 이점이 명확하진 않습니다.
또 하나의 미묘한 설계 선택은 오프셋 리스트가 키 순서로 정렬되지만, 값 노드 자체는 물리적으로 정렬될 필요가 없다는 점입니다. 이런 유연성이 주는 이점이 분명하진 않은데, 개별 오브젝트 엔트리의 시작/끝 경계를 계산하기 더 어렵게 만들 수 있습니다.
그럼에도 이어지는 한 가지 동기는 앞서 우리가 사용했던 것과 동일합니다. 공유 메타데이터를 제외하면, 각 중첩 VARIANT 값은 자체 완결된 영역으로 저장됩니다.
즉, 반정형 데이터에 대한 현실 세계의 바이너리 인코딩이 여기 있습니다. 더 많은 타입과 조절 노브(knob)가 있지만, 전체 구조는 유사합니다. 성능을 더 밀어붙이기 위해 Parquet VARIANT는 shredding도 지원합니다. 즉 JSON을 부분적으로 또는 완전히 컬럼으로 분해해 압축을 개선하고 predicate pushdown을 가능하게 합니다. 물론 트레이드오프는 바이너리 문서와 분해된 컬럼들 간의 일관성을 유지해야 한다는 점입니다.
2026년 현재, 데이터베이스에 JSON을 저장해야 하냐는 질문은 사실상 결론이 났습니다. 생태계는 대체로 바이너리 표현으로 수렴했습니다. MongoDB는 BSON을, Postgres는 JSONB(SQL/JSON 경로 연산자 포함)를, Snowflake 같은 시스템은 반정형 데이터를 VARIANT를 통해 일급 타입으로 다룹니다. 초기에 드는 직렬화 비용을 감당할 수 있다면, 빠른 조회를 위해 바이너리 JSON은 대체로 최선의 선택입니다.
또한 데이터 시스템에는 더 많은 것을 기대해야 합니다. 즉, 필요할 때 바이너리 JSON을 똑똑하게 인코딩하고 shredding까지 할 수 있어야 합니다. 레이크하우스로 이동하면서 Apache Iceberg는 v3부터 VARIANT를 지원합니다. 우리는 JSON에서 시작했지만, 반정형 데이터는 VARIANT와 함께 JSON을 넘어갈 것입니다. 이 글의 논의가 이런 바이너리 인코딩의 역량과 한계를 이해하는 데 도움이 되었길 바랍니다.
그리고 요즘 글들처럼 LLM도 최소 한 번은 언급해야겠죠. LLM이 API를 통해 구조화된 응답을 반환할 때도 “JSON을 말한다”는 사실을 알고 계셨나요? 강력한 자연어 모델에게 자신을 다시 JSON으로 쥐어짜 넣으라고 하는 건 다소 우스꽝스럽지만, 현실이 그렇습니다. 실제로 이는 파이프라인에서 중요한 단계이며, 이제는 TOON이라는 것도 있습니다. TOON은 LLM을 위해 토큰 효율성과 정확도를 높이도록 설계된 JSON 인코딩으로, 여전히 사람도 읽을 수 있는 형태를 유지합니다.
반정형 데이터는 앞으로도 계속될 것이고, 저는 이를 환영합니다.
여러분은 툴링에서 반정형 데이터를 어떻게 다루고 있나요? LinkedIn에서 알려주세요.