JSON.parse 성능: V8 엔진은 큰 페이로드를 어떻게 처리하는가
현대 V8(Node.js / Chrome / Deno의 엔진)은 JSON.parse에 일반적인 재귀 하강 파서를 쓰지 않습니다. 2019년 이후 V8은 C++로 작성된 전용 fast-path 파서를 탑재하고 있으며, UTF-16 문자열을 직접 다루고, young generation에 객체를 할당하며, transition tree(hidden class)로 배열 내 형제 객체들이 형태(shape)를 공유하도록 만듭니다. [{"id":1,"name":"a"},{"id":2,"name":"b"}, ...] 같은 페이로드라면 첫 번째 이후의 모든 객체는 동일한 hidden class로 생성되며, 이후 속성 접근은 monomorphic하게 인라이닝됩니다.
JSON.parse 비용은 (1) 문자열 토큰화/언이스케이프, (2) 결과 객체 그래프로 인한 GC 압력, (3) reviver 콜백 비용 — 이 세 가지가 지배합니다. reviver 함수를 추가하는 것은 가장 큰 성능 함정입니다. fast path를 강제로 포기시키고, 트리의 모든 키마다 JS 함수가 호출됩니다. 10MB 페이로드 기준 보통 4~8배 느려집니다. 필드 몇 개만 변환하면 된다면 먼저 parse한 뒤 결과를 직접 순회하는 게 낫습니다.
동질적인 객체로 이루어진 매우 큰 배열의 경우 V8이 종종 스트리밍 JSON 라이브러리보다 빠릅니다 — fast path가 그만큼 잘 튜닝되어 있기 때문입니다. 하지만 단일 문서가 100MB를 넘기면 JSON.parse는 원본 문자열과 결과 객체 트리를 동시에 메모리에 들고 있으므로 peak RSS가 파일 크기의 3~4배까지 치솟습니다. 이때는 simdjson, oboe.js, stream-json 같은 스트리밍 파서로 전환해야 합니다.
V8은 호출 간에 파서의 토큰화 버퍼를 캐싱하기 때문에, 많은 작은 페이로드를 타이트한 루프로 파싱하는 게 동일 총량을 한 번에 파싱하는 것보다 빠릅니다. 와이어 포맷을 통제할 수 있다면 NDJSON(라인당 JSON 1개)을 쓰세요. 점진적으로 파싱되고, 라인 단위로 에러에서 복구되며, 단순한 라인 버퍼 IO와도 잘 어울립니다.
// SLOW: reviver kills the fast path
const data = JSON.parse(raw, (key, val) => {
if (key === 'createdAt') return new Date(val);
return val;
});
// FAST: parse first, walk later
const data = JSON.parse(raw);
function walk(o) {
for (const k in o) {
if (k === 'createdAt' && typeof o[k] === 'string') o[k] = new Date(o[k]);
else if (o[k] && typeof o[k] === 'object') walk(o[k]);
}
}
walk(data);
// FASTER for huge files: NDJSON
import readline from 'node:readline';
const rl = readline.createInterface({ input: fs.createReadStream('big.ndjson') });
for await (const line of rl) {
const row = JSON.parse(line); // 1 record at a time
process(row);
}실서비스에서 반드시 마주치는 JSON 함정들
JSON은 단순해 보이고, 바로 그래서 뒷발을 칩니다. RFC 8259는 문법을 매우 엄격히 정의합니다 — 숫자는 IEEE-754 double 한 종류, 리터럴은 true / false / null, 문자열은 큰따옴표 UTF-8, 날짜·주석·트레일링 콤마·Infinity·NaN 모두 없음. 실서비스의 "이상한 JSON 버그"는 이 6개 규칙 중 하나가 현실에 의해 깨진 것입니다.
NaN과 Infinity는 valid JSON이 아닙니다. JavaScript의 JSON.stringify는 이 값들을 조용히 null로 바꿉니다 — { "p99": Infinity }가 { "p99": null }로 직렬화되고, 대시보드는 조용히 0을 표시합니다. 직렬화 전에 clamp / 특수 처리하세요. round-trip을 절대 신뢰하지 마세요.
Date 객체는 비대칭하게 round-trip 됩니다. JSON.stringify(new Date())는 Date.prototype.toJSON을 통해 ISO-8601 문자열을 출력하지만, JSON.parse는 그 문자열이 날짜라는 걸 모릅니다 — 문자열이 돌아옵니다. 네트워크 왕복 후 data.createdAt.getTime()을 호출하면 throw 합니다. zod, valibot, ajv 같은 스키마로 감싸거나, 타임스탬프가 문자열로 돌아온다는 점을 명시적으로 문서화해야 합니다.
BigInt는 하드 에러입니다 — JSON.stringify(1n)은 TypeError: Do not know how to serialize a BigInt를 던집니다. 64비트 ID를 BigInt로 반환하는 DB 드라이버(Postgres bigint, MySQL UNSIGNED BIGINT)에서 끊임없이 만나는 문제입니다. 실용적 해법은 (a) 앱 부팅 시 BigInt.prototype.toJSON = function() { return this.toString() }을 정의해 수신측이 문자열로 받는 걸 받아들이거나, (b) superjson 같이 타입을 보존하는 직렬화기를 쓰는 것입니다.
순환 참조도 TypeError입니다. DOM 트리든, parent 포인터를 가진 그래프든 JSON.stringify를 폭발시킵니다. replacer 파라미터로 알려진 순환 키를 건너뛸 수 있습니다(undefined 반환 시 drop). 디버깅용으로는 util.inspect나 structuredClone(내부적으로 cycle을 감지)이 더 친절합니다.
마지막으로 키 순서: 스펙은 객체가 unordered라고 하지만 V8의 JSON.stringify는 정수형 키를 먼저, 그다음 삽입 순서대로 키를 emit 합니다. 테스트가 정확한 JSON 문자열을 검증한다면 그건 구현 디테일을 테스트하는 것입니다 — 파싱된 객체 단위로 assert 하세요.
// NaN/Infinity silently become null
JSON.stringify({ p99: NaN, max: Infinity });
// => '{"p99":null,"max":null}' (BUG: silent data loss)
// Date asymmetric round-trip
const o = { at: new Date() };
const back = JSON.parse(JSON.stringify(o));
back.at.getTime(); // TypeError: back.at.getTime is not a function
// BigInt fix at app boot
BigInt.prototype.toJSON = function () { return this.toString(); };
// Circular reference handler
function safeStringify(obj) {
const seen = new WeakSet();
return JSON.stringify(obj, (k, v) => {
if (typeof v === 'object' && v !== null) {
if (seen.has(v)) return '[Circular]';
seen.add(v);
}
return v;
});
}JSON vs JSON5 vs JSONC — 올바른 방언 선택
JSON 자체는 frozen 표준입니다 — RFC 8259가 스펙이고, 사람을 배려하는 기능은 의도적으로 빠져있습니다. 이 엄격함은 와이어 포맷에는 좋지만 손으로 편집하는 설정 파일엔 끔찍하기 때문에 비공식 방언 두 개가 설정 영역을 양분하고 있습니다.
JSONC("JSON with comments")는 VS Code, TypeScript의 tsconfig.json, 대부분의 Microsoft 도구가 쓰는 방언입니다. RFC 8259에 정확히 두 가지를 더합니다 — 라인 주석(//)과 블록 주석(/* */). 트레일링 콤마는 VS Code 파서에서 허용되지만 어떤 명문 스펙에도 들어있지 않습니다. JSONC는 의도적으로 미니멀합니다 — 모든 JSONC 문서는 주석을 제외하면 JSON 문서입니다. TypeScript 프로젝트에선 별도 라이브러리가 필요 없습니다 — 컴파일러가 자체 tolerant 파서를 들고 있고, 작은 정규식으로 주석을 제거(문자열 처리 주의)하면 valid JSON이 됩니다.
JSON5는 더 야심찹니다 — 작은따옴표 문자열, 따옴표 없는 ES5 식별자 키, 멀티라인 문자열, 16진수, 앞뒤 소수점(.5, 5.), 숫자 앞 양의 부호, NaN과 Infinity, 트레일링 콤마를 모두 추가합니다. 대신 JSON5는 더 이상 JSON의 subset이 아닙니다 — JSON5 문서를 JSON.parse에 먹일 수 없습니다. 사람이 이기고 기계가 json5 npm 패키지를 쓸 수 있는 설정 파일에 쓰세요. 절대 와이어 포맷으로는 쓰지 마세요.
세 번째 후보 HJSON은 키와 짧은 문자열 값에서까지 따옴표를 뺍니다. JSON5보다 더 관대하지만 생태계가 좁습니다. 도구가 이미 표준화하지 않은 한 피하세요.
규칙: API와 IPC 메시지는 호환성을 위해 RFC 8259 strict JSON을 유지하세요(모든 언어에 안정된 파서가 있음). 사람이 읽고 편집하는 에디터/빌드 설정은 JSONC가 적합합니다. JSON5는 더 풍부한 기능이 필요하고 writer/reader를 모두 통제할 때만 꺼내세요. JSON5 문서가 JSON을 기대하는 시스템에 샐 경우 가장 나쁜 타이밍에 "Unexpected token" 오류로 터집니다.
// JSON (RFC 8259) — strict
{
"name": "app",
"ports": [3000, 3001]
}
// JSONC — comments + (de facto) trailing commas
{
// tsconfig.json style
"compilerOptions": {
"target": "ES2022", /* matches Node 18+ */
"strict": true,
},
}
// JSON5 — humans first
{
name: 'app', // unquoted key, single-quoted string
port: 0xABCD, // hex
rate: .5, // leading decimal
retries: +Infinity, // signed Infinity
// multi-line string
banner: "line1\
line2",
}