JSON Toolkit

여섯 가지 타입, 숫자 정밀도, 인코딩 함정

핵심 요약: JSON의 타입은 여섯 가지뿐이지만, 그 각각이 조용히 데이터의 모습을 바꿔 놓습니다. 이 글은 숫자, 문자열, null의 함정을 한 번에 정리합니다.

초급약 12분 소요2026-10-30 발행2026-10-30 업데이트
여섯 가지 타입, 숫자 정밀도, 인코딩 함정 표지

핵심부터 말하면, 먼저 ID나 금액 필드가 2^53 - 1을 넘는지 또는 소수점 이하 정밀도가 많이 필요한지 확인하고, 다음으로 문자열 길이 제한이 코드 포인트를 세는지 UTF-16 단위를 세는지 확인하세요. 안전한 정수 범위를 벗어나는 값은 파서가 정밀도를 지켜 줄 것이라 믿지 말고 문자열로 전달해야 합니다.

JSON의 타입은 대부분의 사람이 생각하는 것보다 적고, 함정은 더 많습니다. 숫자는 값이 흔들리고, 문자열은 깨지며, null은 키가 없는 것과 다른 개념입니다.

1. 타입의 지형

타입 리터럴 예 JSON에서의 제약 흔한 매핑
Object {"a": 1} 키는 문자열, 값은 무엇이든 가능 Object / Map / struct
Array [1, "a", null] 요소의 타입이 달라도 됨 Array / List / slice
String "text" UTF-8, 큰따옴표 String
Number 1, -1.5, 1e9 앞자리 0 금지, + 금지, NaN 금지 double / Decimal
Boolean true / false 소문자 bool
null null 유일한 null 값 null / Option::None

존재하지 않는 두 가지에 주목하세요. 날짜 타입은 없고, 정수 타입도 없습니다. 모든 날짜는 문자열이며, 정수와 부동소수점은 하나의 "number" 타입을 공유합니다.

2. 숫자: 기본 답은 배정밀도

2.1 안전한 정수의 경계

IEEE 754 배정밀도가 정확히 표현할 수 있는 가장 큰 정수는 2^53 - 1 = 9007199254740991입니다. 이를 넘으면 인접한 정수 사이에 간격이 생깁니다:

JSON.parse('{"id": 9007199254740993}').id
// 9007199254740992

JSON.stringify({ id: 9007199254740993 })
// '{"id":9007199254740992}'

이것은 JSON의 문제가 아니라 "텍스트 → 배정밀도" 단계의 문제입니다. 중간에 JavaScript가 끼는 파이프라인(게이트웨이, BFF, 프런트엔드, Node 서비스)이라면 어디서든 마주칩니다.

해결책 (권장 순서대로):

  1. ID를 파이프라인 전체에서 문자열로 사용: {"id": "9007199254740993"}, 가장 문제가 적습니다.
  2. 서버에서 고정밀 타입으로 파싱: Java의 BigInteger / BigDecimal, Go의 json.Number, Python의 decimal.Decimal
  3. 프런트엔드에서 JSON.parse의 reviver나 큰 수를 지원하는 파서(예: json-bigint)로 필드를 BigInt로 변환하되, BigInt는 JSON.stringify에 바로 넘길 수 없다는 점을 기억하세요.

2.2 소수는 정확한 값이 아니다

0.1 + 0.2            // 0.30000000000000004
JSON.stringify(0.1 + 0.2)
// "0.30000000000000004"

금액은 항상 최소 화폐 단위(센트)의 정수 또는 문자열로 표현하고, 부동소수점으로 누적 계산하지 마세요.

2.3 직렬화 중의 숫자 함정

JSON.stringify({ a: 1e21 })        // {"a":1e+21}
JSON.stringify({ a: -0 })          // {"a":0}
JSON.stringify({ a: Infinity })    // {"a":null}
JSON.stringify({ a: NaN })         // {"a":null}
JSON.stringify({ a: 10n })         // TypeError: Do not know how to serialize a BigInt

Infinity와 NaN은 조용히 null로 바뀝니다. "데이터가 그냥 사라진" 유형 중 추적이 가장 어려운 부류입니다.

2.4 언어별 기본 동작

언어 기본 숫자 타입 명시적 처리가 필요한 경우
JavaScript double 큰 정수, BigInt 직렬화
Go float64 (encoding/json) json.Number 또는 Decoder.UseNumber()로 정밀도 유지
Java (Jackson) 리터럴에서 추론한 int/long/double/BigDecimal DeserializationFeature.USE_BIG_DECIMAL_FOR_FLOATS 강제
Python int(임의 정밀도) / float 소수 정밀도를 유지하려면 parse_float=Decimal
Rust (serde_json) i64/u64/f64 임의 정밀도는 arbitrary_precision 활성화 필요

3. 문자열: UTF-16으로 바라본 UTF-8 바이트 스트림

3.1 길이는 문자 수와 같지 않다

JavaScript에서 "🎉".length는 2입니다. 이모지가 보충 평면에 속하고 서로게이트 쌍으로 표현되기 때문입니다. 서버는 "문자 수"로 길이를 제한하는데 프런트엔드는 length로 검증한다면, 양쪽은 반드시 어긋납니다.

'🎉'.length                 // 2
Array.from('🎉').length     // 1
[...'👨‍👩‍👧'].length           // 5 (including zero-width joiners)

정확한 계산이 필요하면 Intl.Segmenter(자소 클러스터 기준)를 쓰거나 백엔드에서 Unicode 코드 포인트를 세고, API 문서에 정의를 분명히 적으세요.

3.2 단독 서로게이트

"\ud800"처럼 홀로 있는 상위 서로게이트는 문법적으로 유효한 JSON입니다(\uXXXX는 어떤 네 자리 16진수든 받습니다). 하지만 유효한 Unicode 텍스트는 아닙니다. ES2019 이후 JSON.stringify는 단독 서로게이트를 \ud800으로 이스케이프해 출력이 "well-formed"임을 보장합니다. 언어를 넘나들며 전송할 때 상대방이 Unicode를 엄격히 검증하면 이런 값은 거부됩니다.

3.3 \u2028과 \u2029

줄 구분자와 문단 구분자는 JSON에서는 유효하지만, JavaScript 소스 코드나 <script> 태그에 직접 넣으면 문법 오류가 납니다. JSON을 HTML에 인라인할 때는 항상 이들을 이스케이프하거나, 문자열에서 JSON.parse로 파싱하는 방식으로 바꾸세요.

3.4 중국어 문자가 깨지는 세 가지 원인

  1. 파일 자체가 GBK인데 UTF-8로 읽음
  2. 응답 헤더가 charset=gbk를 선언했지만 내용은 실제로 UTF-8
  3. "문자열로 디코딩했다가 다시 인코딩"하는 미들웨어를 거치며 중국어 문자가 두 번째 트랜스코딩됨

조사 순서: 먼저 바이트(xxd)를 보고, 다음 응답 헤더, 마지막으로 미들웨어를 봅니다.

4. null과 "이 키가 존재하지 않음"

JSON에서 이 둘은 다릅니다:

{"nickname": null}
{}
  • 전자는 "필드는 존재하고 값이 비어 있음"을 뜻합니다.
  • 후자는 "필드가 존재하지 않음"을 뜻합니다.

TypeScript에서는 string | null과 nickname?: string의 차이가 되고, Java에서는 "명시적 null"과 "기본값"의 차이가 됩니다. 합의된 관례가 없으면 이런 분열이 생깁니다:

// Frontend A's approach
if (data.nickname === undefined) { /* treat it as having no nickname */ }
// Frontend B's approach
if (!data.nickname) { /* handle null and the empty string together */ }

권장: API 문서에 "값이 없을 때는 일관되게 null을 쓰고 키를 생략하지 않는다"거나 반대쪽을 적으세요. 어느 한쪽만 고르면 됩니다. API를 갱신할 때는 PATCH 의미론을 써서 "보내지 않은 것인지, null로 보낸 것인지"의 모호함을 피하세요.

5. 배열과 객체

5.1 배열은 "동질적"이지 않다

[1, "two", {"three": 3}, null]

문법적으로는 유효하지만 타입 정의를 쓰기가 매우 어려워집니다. API에서는 배열 요소를 되도록 동질하게 유지하고, 이질적인 경우에는 type 판별 필드를 둔 객체 배열로 바꾸세요.

5.2 객체 키에는 순서가 없지만, 많은 사람이 순서에 의존한다

명세는 객체를 "이름/값 쌍의 순서 없는 집합"이라고 말합니다. 실제로 V8은 삽입 순서를 유지합니다(정수 키는 정렬되는 예외가 있습니다). 키 순서에 로직을 세우는 것("첫 번째 키"를 가져오는 등)은 구현 세부에 의존하는 일입니다.

5.3 희소 배열과 구멍은 존재하지 않는다

JavaScript의 [1, , 3]은 [1,null,3]으로 직렬화됩니다. 구멍이 null이 되는 것입니다. JSON에는 "슬롯"이라는 개념이 없습니다.

6. 바로 적용할 수 있는 체크리스트

  • [ ] 모든 ID, 금액, 과대 정수는 API 계약에서 명시적으로 문자열입니다
  • [ ] API 문서가 null과 필드 생략의 의미 차이를 설명합니다
  • [ ] 문자열 길이 제한이 코드 포인트를 세는지 UTF-16 단위를 세는지 밝힙니다
  • [ ] 직렬화 전에 Infinity / NaN / undefined를 걸러 내거나 명시적으로 null로 바꿉니다
  • [ ] HTML에 인라인하는 JSON은 이스케이프합니다
  • [ ] 프런트엔드와 백엔드 각각 "큰 정수 + 이모지 + null"을 다루는 계약 테스트 케이스가 있습니다

FAQ

2^53 - 1을 넘는 정수는 어떻게 처리해야 하나?

가장 문제가 적은 방법은 ID 같은 필드를 파이프라인 전체에서 문자열로 보내는 것입니다. 예를 들어 {"id": "9007199254740993"}입니다. 서버에서는 Java의 BigInteger / BigDecimal, Go의 json.Number, Python의 decimal.Decimal로 정밀도를 유지할 수 있습니다. 프런트엔드에서는 json-bigint 같은 파서로 필드를 BigInt로 바꿀 수 있지만, BigInt를 JSON.stringify에 바로 넘길 수 없다는 점을 기억하세요.

Infinity와 NaN은 왜 직렬화 후에 null이 되나?

JSON의 number 타입은 NaN과 Infinity 같은 비표준 리터럴을 받지 않으므로, JSON.stringify가 오류 없이 조용히 null로 바꿉니다. "데이터가 그냥 사라진" 유형 중 추적이 가장 어려운 부류입니다. 직렬화 전에 이런 값을 명시적으로 걸러 내거나, 비즈니스 관례에 따라 null로 바꾸고 로그를 남기세요.

null을 보내야 하나, 필드를 아예 생략해야 하나?

어느 쪽이든 되지만, API 문서에서 하나를 골라 분명히 밝혀야 합니다. 그렇지 않으면 프런트엔드마다 다른 가정을 하게 됩니다. 예시에서 {"nickname": null}과 {}는 TypeScript에서 각각 string | null과 nickname?: string에 대응합니다. API를 갱신할 때는 PATCH 의미론을 써서 "보내지 않은 것인지, null로 보낸 것인지"의 모호함을 피하세요.