본문으로 건너뛰기
yutils

JSON 은 어떻게 파싱될까?

JSON 파서 내부 — lexer 상태, 왜 trailing comma 가 금지인지, 왜 큰 정수가 정밀도를 잃는지 (IEEE 754), streaming JSON 의 동작, 그리고 시니어 엔지니어도 놀라는 파서 quirk.

약 9분 읽기

JSON 은 100 줄짜리 정규 문법 정의로 끝나는 단순한 형식이다. 그러나 파서를 직접 만들어 보면 — 왜 trailing comma 가 금지인지, 왜 JSON.parse('{"id":9999999999999999}') 10000000000000000 을 반환하는지, 왜 streaming 파서가 필요한지 — 결정을 거듭해야 한다. 이 가이드는 JSON 파서의 내부 구조와 흔히 놀라는 quirk 를 정리한다.

JSON 의 문법 — 전체 5 가지 값

value  := object | array | string | number | true | false | null
object := { "key" : value , "key" : value ... }
array  := [ value , value ... ]
string := " (escaped chars) "
number := -? digits ( .digits )? ( [eE] [+-]? digits )?

그게 전부. 함수도 변수도 주석도 trailing comma 도 없음. 단순함이 강점 — 파서 구현 100 줄 안 가능.

파서의 2 단계 — Lexer → Parser

대부분의 JSON 파서는 두 단계로 분리:

1. Lexer (tokenizer) — 문자 시퀀스 → token

입력: {"id": 42, "name": "Yu"}

토큰:
  LBRACE      {
  STRING      "id"
  COLON       :
  NUMBER      42
  COMMA       ,
  STRING      "name"
  COLON       :
  STRING      "Yu"
  RBRACE      }

Lexer 가 공백·줄바꿈을 무시하고 의미 있는 단위만 추출. 한 글자 씩 읽으면서 상태 머신 (state machine) 진행:

  • " 만남 → STRING state, 다음 " 까지
  • 숫자/- 만남 → NUMBER state
  • t/f/n 만남 → keyword (true/false/null) state
  • 예외 문자 → 에러

2. Parser — token 시퀀스 → 트리

재귀 하강 (recursive descent) 이 가장 흔함:

parseValue() {
  switch (peek()) {
    case LBRACE: return parseObject();
    case LBRACKET: return parseArray();
    case STRING: return consume().value;
    case NUMBER: return parseNumber(consume());
    case "true": consume(); return true;
    ...
  }
}

parseObject() {
  expect(LBRACE);
  while (peek() !== RBRACE) {
    const key = expect(STRING).value;
    expect(COLON);
    obj[key] = parseValue();
    if (peek() === COMMA) consume();
    else break;     // ← 여기서 trailing comma 처리 분기
  }
  expect(RBRACE);
  return obj;
}

왜 trailing comma 가 금지일까

{"a": 1, "b": 2,}    ← JSON 에러
[1, 2, 3,]           ← JSON 에러

Douglas Crockford 가 JSON 을 RFC 4627 로 표준화 (2006) 할 때 "minimal grammar" 를 우선했다. trailing comma 는:

  • 파서 분기 추가 — parseObject() 의 comma 이후 RBRACE 허용 분기 필요
  • PHP 의 PHP-JSON / 일부 API 응답 직렬화에서 의도치 않은 빈 요소 표현 가능 (Python [1,2,] = 길이 2 vs 길이 3 혼동)
  • spec 단순화 vs DX 트레이드오프 — 단순화 채택

결과 — diff 가 깨진다. 라인 추가 시 이전 마지막 줄에 comma 추가 해야 함. JavaScript / Python / Go / Rust 다 trailing comma 허용 문법인데 JSON 만 X. 가장 자주 들리는 불평.

대안 — JSON5 / JSONC 가 trailing comma + 주석 허용. tsconfig.json 이 JSONC. 표준 JSON 은 여전히 X.

숫자의 정밀도 폭탄 — IEEE 754

JSON spec 은 숫자 표기에 제한 없음:

{"id": 9999999999999999}   ← spec 상 valid

그러나 JavaScript 의 JSON.parse() 결과는 Number 타입 — IEEE 754 double precision. 안전한 정수 범위 ±2^53 (= 9,007,199,254,740,992). 그 이상은 정밀도 손실:

JSON.parse('{"id":9999999999999999}').id
// 10000000000000000  ← 1 손실

Number.MAX_SAFE_INTEGER
// 9007199254740991  (= 2^53 - 1)

Twitter API 가 옛날 이 사고에 당함. snowflake ID 가 64-bit 인데 JavaScript 클라이언트가 정밀도 잃음. 해결 — ID 를 string 으로 직렬화:

// Bad
{"id": 1234567890123456789}

// Good
{"id": "1234567890123456789"}

다른 언어:

  • Pythonjson.loads() 가 big int 허용 (int 무한 정밀도). 정밀도 손실 X.
  • Gojson.Unmarshal default 가 float64. json.Number 타입 사용해야 정밀도 보존.
  • Java — Jackson 의 BigInteger / BigDecimal 옵션.

직접 확인 — JSON 포매터 / 검증기 에 큰 정수 박으면 트리 뷰에서 정밀도 손실 즉시 보임.

BigInt 와 JSON

JavaScript 의 BigInt 도입 (2020) 으로 큰 정수 지원. 그러나 JSON.stringify(123n) TypeError — spec 이 BigInt 직렬화 미정의.

Workaround — toJSON() 메서드 또는 reviver:

BigInt.prototype.toJSON = function() { return this.toString(); };

JSON.stringify({id: 1234567890123456789n});
// '{"id":"1234567890123456789"}'

String escape — 보이지 않는 함정

JSON string 안 허용 escape:

\"    " (double quote)
\\    \ (backslash)
\/    / (slash, 선택)
\b    backspace
\f    form feed
\n    newline
\r    carriage return
\t    tab
\uXXXX  Unicode codepoint (4 hex)

Surrogate pair 처리: U+10000 이상은 🎉 두 개로 분리 ("🎉"). 일부 파서가 unpaired surrogate 를 허용해 invalid UTF-8 생성 — 보안 issue.

중복 키의 운명

{"a": 1, "a": 2}    ← spec 상 valid 인가?

RFC 8259 — "key 이름은 unique 해야 한다" 권고. 그러나 강제 X. 대부분의 파서는:

  • 마지막 값 채택 (JavaScript / Python / Go)
  • 첫 값 채택 (일부 옛 파서)
  • 배열로 보존 (CouchDB 등 특수)

보안 이슈 — 인증 토큰 검증 시 한 파서는 첫 값, 다른 파서는 마지막 값 채택 → 우회 공격 가능. 이중 직렬화 환경 (proxy → API) 에서 주의.

Streaming JSON — 메모리 한계

JSON.parse() 는 전체 문자열을 한 번에 파싱. 1 GB JSON 은 메모리 1 GB+. AWS Lambda / Cloud Function 메모리 한도 초과.

해결책 — streaming parser. 토큰 단위로 콜백:

  • SAX-style — onObjectStart / onKey / onValue callback. 사용자가 트리 만들 책임.
  • JSONPath streaming — 특정 경로만 추출. 큰 배열 안 element 하나씩 처리. JSONStream (Node) /ijson (Python).
  • JSON Lines (JSONL / NDJSON) — 한 줄당 하나의 JSON 객체. 줄 단위 streaming 자연. 로그·analytics 표준.
{"user": "alice", "ts": 1700000000}
{"user": "bob", "ts": 1700000001}
{"user": "carol", "ts": 1700000002}
// 각 줄이 별개 JSON. 전체를 메모리에 안 올림.

MongoDB EJSON — JSON 의 type 보강

BSON (MongoDB binary) 은 ObjectId / Date / Decimal128 같은 type 보유. JSON 은 string/number/boolean/null/object/array 5 가지뿐. MongoDB Extended JSON 은 wrapper 객체로 type 표현:

{
  "_id": { "$oid": "507f1f77bcf86cd799439011" },
  "created": { "$date": "2026-05-22T00:00:00Z" },
  "price": { "$numberDecimal": "19.99" }
}

MongoDB Extended JSON 가 16 가지 wrapper 인식. JSON 포매터 / 검증기 의 트리 뷰도 EJSON 자동 인식 옵션.

흔한 함정

1. JSON.stringify 의 undefined

JSON.stringify({a: undefined, b: 1})  // '{"b":1}'  ← a 제거
JSON.stringify([undefined])           // '[null]'    ← null 변환
JSON.stringify(undefined)             // undefined   ← 함수 자체가 undefined 반환

2. NaN / Infinity

JSON.stringify({x: NaN})       // '{"x":null}'
JSON.stringify({x: Infinity}) // '{"x":null}'

JSON spec 이 NaN / Infinity 미정의 → null 로 대체. 손실. 보존이 필요하면 string 으로.

3. 순환 참조

const a = {};
a.self = a;
JSON.stringify(a);  // TypeError: Converting circular structure to JSON

4. Date 의 자동 toJSON

JSON.stringify({d: new Date()})
// '{"d":"2026-05-22T05:30:00.000Z"}'
// Date 의 toJSON() 이 ISO 8601 string 반환

// parse 시 자동 복원 X
JSON.parse('{"d":"2026-05-22T05:30:00.000Z"}').d
// "2026-05-22T05:30:00.000Z" (string!)

Date 복원은 reviver 필요:

JSON.parse(str, (k, v) =>
  typeof v === "string" && /^\d{4}-\d{2}-\d{2}T/.test(v)
    ? new Date(v) : v);

5. 큰 입력 typing freeze

JSON.parse 자체가 동기. 대용량 입력 시 main thread block. JSON 포매터 / 검증기 는 입력 4 KB 초과 시 useDebouncedValue 로 처리 (PR #77 패턴).

참고 자료

요약

  • JSON 은 value 5 종 + object / array 만. 100 줄 안 파서 가능.
  • Lexer (state machine) → Parser (recursive descent) 2 단계.
  • trailing comma 금지 = spec 단순화의 트레이드오프. JSON5/JSONC 허용.
  • Number = IEEE 754 double. ±2^53 밖 정수는 정밀도 손실. ID 는 string 으로.
  • 중복 키 미정의 — 파서마다 다름. 보안 이슈 가능.
  • Streaming = JSONL / NDJSON. 큰 데이터는 줄 단위.
  • MongoDB EJSON 으로 Date / ObjectId / Decimal128 type 보강.
  • 실험 — JSON 포매터 / 검증기 / JSON Path 추출기 / MongoDB Extended JSON / JSON → TypeScript / JSON Schema 생성기.
가이드 목록으로