문법과 형식 FAQ: 주석, 후행 쉼표, 정밀도
핵심 요약: 주석이 허용되는지, 후행 쉼표가 왜 오류인지, 키에 따옴표가 필요한지, 9007199254740993이 왜 바뀌는지 — 열두 가지 결론을 각각 RFC 8259로 확인합니다.

핵심부터 말하면, 프로세스 경계를 넘는 모든 것에는 순수한 RFC 8259 JSON을 쓰세요. 큰따옴표, 주석 없음, 후행 쉼표 없음, BOM 없는 UTF-8. 주석이 필요한 설정에는 JSONC나 JSON5를 쓰고 런타임에서 다시 변환하세요. 2^53-1을 넘는 정수는 서버에서 문자열로 직렬화하세요.
이 질문들의 공통점은 "동작해야 할 것 같은데" 명세는 실제로 매우 엄격하다는 것입니다.
1. 무엇이 유효한 JSON인가
JSON, JSON5, JSONC의 차이는?
표준 JSON은 큰따옴표 문자열만 인식하고 주석도 후행 쉼표도 허용하지 않습니다. JSON5와 JSONC는 모두 그 위에 기능을 더합니다:
| 기능 | JSON(RFC 8259) | JSONC | JSON5 |
|---|---|---|---|
| 큰따옴표 문자열 | 지원 | 지원 | 지원 |
| 작은따옴표 문자열 | 미지원 | 미지원 | 지원 |
| 주석 | 미지원 | 지원 | 지원 |
| 후행 쉼표 | 미지원 | 미지원 | 지원 |
| 따옴표 없는 키 | 미지원 | 미지원 | 지원 |
| 16진수 | 미지원 | 미지원 | 지원 |
NaN / Infinity |
미지원 | 미지원 | 지원 |
| 파일 확장자 | .json |
.jsonc |
.json5 |
선택 원칙은 간단합니다. 프로세스 경계를 넘을 때는 항상 표준 JSON을 쓰고, 설정 파일은 에디터가 지원하는 것에 따라 JSONC나 JSON5를 쓸 수 있습니다.
tsconfig.json과 .vscode/settings.json은 에디터가 JSONC로 파싱해 주석을 담을 수 있지만, 엄격한
JSON.parse에 넣으면 여전히 오류가 납니다.
JSON에 주석을 넣을 수 있나?
아니요. RFC 8259의 문법에는 주석 생성 규칙이 없어 //나 /* */가 있으면 파싱이 실패합니다.
설정에 설명을 남기고 싶다면 세 가지 대안이 있습니다:
{
"_comment": "以下超时单位是毫秒",
"timeout": 3000,
"retry": {
"$doc": "重试次数,0 表示不重试",
"count": 3
}
}
- 독자가 그냥 무시하는
_comment같은 관례 필드를 씁니다. $접두사 필드로 업무 필드와 충돌할 확률을 줄입니다.- 설정에는 JSONC나 YAML을 쓰고 런타임에서 표준 JSON으로 변환합니다.
후행 쉼표가 왜 오류를 내나?
문법에서 배열과 객체 모두 "요소 + 구분자" 구조이고 마지막 요소 뒤에는 쉼표가 허용되지 않기 때문입니다.
{
"a": 1,
"b": 2,
}
위 조각은 } 앞에 쉼표가 하나 더 있고, 오류는 파서 구현에 따라 쉼표나 닫는 중괄호에서 보고됩니다.
JavaScript에서 이전할 때 가장 쉽게 빠지는 함정이기도 합니다. JS 객체 리터럴은 후행 쉼표를 허용하지만 JSON은 아닙니다.
작은따옴표가 허용되나?
아니요. 문자열은 큰따옴표를 써야 하며 'text'는 문법 오류입니다.
데이터가 Python repr(), 셸 스크립트, 손으로 쓴 설정에서 복사되었다면 파서가 관대하기를 기대하지 말고 도구로 먼저 변환하세요.
키에 따옴표가 필요 없나?
아니요. 객체 멤버 이름은 큰따옴표로 감싼 문자열이어야 합니다.
{ name: "json" }
이것은 JavaScript 객체 리터럴이고 JSON이 아닙니다. 따옴표를 더하면 유효합니다:
{ "name": "json" }
최상위가 배열이나 문자열일 수 있나?
예. RFC 8259는 최상위에 어떤 JSON 값이든 허용합니다. 객체, 배열, 문자열, 숫자, true, false, null 모두 유효합니다.
그러나 엔지니어링 실무에서는 두 이유로 최상위에 객체나 배열만 쓰는 것을 권장합니다:
- 호환성: 옛 파서(RFC 4627 시대)는 최상위가 객체나 배열이어야 합니다.
- 보안: 최상위 배열은 역사적으로 JSON 하이재킹 위험을 지녔습니다. 현대 브라우저는 고쳤지만 옛 시스템은 여전히 그 관례를 가정합니다.
그래서 "hello"도 유효한 JSON이고 ["a", "b"]도 그렇지만, {"data": [...]}를 반환하는 API가 더 안전합니다.
2. 인코딩과 문자열
파일 앞의 BOM이 파싱을 깨나?
예. RFC 8259는 JSON 텍스트를 UTF-8로 전송하고 바이트 순서 표시(BOM)를 추가해서는 안 된다고 명시합니다.
BOM은 파일 맨 앞의 세 바이트 EF BB BF입니다. 완전히 보이지 않지만 문자열의 첫 위치를 차지해
파서가 "첫 문자가 {가 아니다"라고 결론내고 오류를 냅니다. 그런데 보고되는 행과 열이 대개 1행 1열이라 이해하기 어렵습니다.
Windows Notepad의 "UTF-8로 저장"은 기본으로 BOM을 포함합니다. 확인하고 제거하려면:
# Check: output starting with efbbbf means a BOM is present
head -c 3 data.json | xxd
# Remove: skip the first 3 bytes with tail
tail -c +4 data.json > clean.json
현대 JavaScript의 JSON.parse는 BOM을 용인하지만(V8이 내부에서 건너뜀), Java, Go, Python의 엄격한 구현은 그렇지 않아
"같은 파일이 언어마다 다른 결과를 낸다"의 흔한 원인이 됩니다.
중국어 문자를 \uXXXX로 변환해야 하나?
아니요. RFC 8259는 JSON 텍스트를 UTF-8로 요구하므로 중국어가 직접 나타날 수 있고 {"name": "张三"}은 완전히 유효합니다.
\uXXXX는 동등한 대체 표기일 뿐이며 문자를 직접 표현할 수 없는 채널에 사용합니다. 경험 법칙:
- HTTP 요청 본문, 파일, 데이터베이스: 중국어를 직접 쓰고 문자당 3바이트를 아낍니다.
- HTTP 헤더, URL 파라미터, 다른 문자열 내장: 인코딩 문제를 피하려 이스케이프를 씁니다.
반드시 이스케이프해야 하는 문자는 제어 문자(U+0000부터 U+001F)뿐입니다. 예를 들어 줄 바꿈은 \n으로 써야 하며,
문자열 안에 실제 줄 바꿈을 넣을 수 없습니다.
이모지를 문자열에 직접 넣을 수 있나?
예, 하지만 UTF-16 코드 단위 두 개를 차지한다는 점을 기억하세요.
이모지와 많은 드문 문자(예: 𠮷)는 BMP 밖에 있어 JavaScript에서 "😀".length === 2입니다.
그래서 문자 수로 검증하기 쉽게 틀립니다:
const text = "😀";
console.log(text.length); // 2, number of UTF-16 code units
console.log([...text].length); // 1, number of code points
console.log(Buffer.byteLength(text)); // 4, number of UTF-8 bytes
닉네임 길이 제한을 .length로 계산하면 이모지 하나가 슬롯 두 개를 소비하고, 백엔드가 바이트로 세면 네 개입니다.
정의를 맞출 때 코드 포인트, 코드 단위, 바이트 중 무엇을 뜻하는지 분명히 밝히세요.
3. 숫자와 특수 값
큰 정수 9007199254740993이 왜 9007199254740992가 되나?
JavaScript의 JSON.parse가 숫자를 IEEE 754 배정밀도 부동소수점으로 변환하고, 최대 안전 정수가 2^53-1이기 때문입니다.
Number.MAX_SAFE_INTEGER === 9007199254740991 // true
9007199254740993 // actually yields 9007199254740992
2^53-1을 넘는 숫자는 마지막 자릿수가 반올림될 수 있습니다. 전형적인 피해자는 스노우플레이크 알고리즘이 생성한 19자리 사용자 ID, 주문 번호, 나노초 타임스탬프입니다.
선호도순 세 가지 수정:
- 서버에서 ID를 문자열로 직렬화:
"id": "9007199254740993", 정밀도 문제를 근본에서 제거 - 무손실 파싱 라이브러리 사용:
JSON.parse를 대체하는 라이브러리가 큰 정수를 그대로 보존합니다. 예:json-bigint - 표시용으로 정규식 추출: 전체 문서를 파싱하지 말고 대상 필드를 텍스트로 매칭
JSON.stringify도 대칭 문제가 있습니다. 안전 정수 범위를 벗어난 숫자는 직렬화 후 원래 값을 유지하지 못할 수 있습니다.
NaN과 Infinity가 JSON에 들어갈 수 있나?
아니요. JSON 숫자 생성 규칙은 십진 표기만 허용하고 NaN, Infinity, -Infinity는 유효한 JSON 값이 아닙니다.
JavaScript의 접근은 "조용한 저하"입니다:
JSON.stringify({ value: NaN }); // '{"value":null}'
JSON.stringify({ value: Infinity }); // '{"value":null}'
오류 없이 NaN이 그냥 null이 됩니다. "프런트엔드가 NaN을 보냈는데 백엔드가 null을 받았다"의 고전 장면입니다.
그런 의미를 전달해야 하면 문자열이나 합의된 센티널 값으로 명시하세요:
{ "ratio": null, "ratioState": "not-a-number" }
숫자를 1e3이나 0.5로 쓸 수 있나?
예. JSON 숫자는 소수와 지수를 지원하므로 1e3, 1E3, 0.5, -0.5 모두 유효합니다.
그러나 제한이 있습니다:
.으로 시작하거나 끝날 수 없습니다:.5와5.는 모두 무효라0.5와5를 쓰세요.- 앞의 0 불가:
01은 무효입니다. - 16진수 불가:
0x1F는 무효이며 JSON5 확장입니다. -0은 유효하지만JSON.stringify(-0)은0을 출력해 부호를 잃습니다.
4. 객체 의미
중복 키는 어떻게 되나?
동작은 구현 정의입니다. RFC 8259는 키 이름이 "유일해야 한다"고만 말하고 중복 시 동작을 명시하지 않습니다.
실무에서는 절대다수 구현이 마지막 값을 취합니다:
JSON.parse('{"a":1,"a":2}') // { a: 2 }
그러나 의존할 수 없습니다. 일부 Java 라이브러리, Go의 encoding/json, Python의 json는 버전마다 다릅니다.
일부는 첫 값을, 일부는 오류를, 일부는 마지막 값을 취합니다. 중복 키를 오류로 취급하는 것이 언어 간에 가장 안전합니다.
중복 키는 눈으로 찾을 수 없습니다. minify된 JSON에서는 같은 키가 수천 문자 떨어져 있을 수 있습니다. 스크립트를 쓰세요:
# Extract keys that appear more than once at the same level, as a rough check
jq -r 'paths(scalars) | join(".")' data.json | sort | uniq -d
undefined는 무엇이 되나?
경우에 따라 다르고, 버그가 가장 많은 지점입니다:
JSON.stringify({ a: undefined }); // '{}' the key is dropped entirely
JSON.stringify({ a: undefined, b: 1 }); // '{"b":1}'
JSON.stringify([undefined]); // '[null]' becomes null inside arrays
JSON.stringify({ a: function () {} }); // '{}' functions are dropped too
같은 undefined가 객체 안에서는 "필드가 사라짐", 배열 안에서는 "null이 됨"을 뜻합니다.
백엔드가 "필드 없음"과 "필드가 null"에 다른 분기를 타면 여기서 불일치가 생깁니다.
"값 없음"을 명시적으로 표현해야 할 때는 undefined 대신 null을 쓰세요.
{"__proto__": {...}}를 파싱하면 어떻게 되나?
JSON.parse 자체는 안전합니다. __proto__가 평범한 자체 속성으로 취급되어 프로토타입을 바꾸지 않습니다.
위험한 것은 파싱 이후의 병합 연산입니다:
const patch = JSON.parse('{"__proto__": {"isAdmin": true}}');
Object.assign({}, patch); // some implementations pollute the prototype
올바른 접근은 자체 속성만 순회하고 __proto__, constructor, prototype을 건너뛰는 것입니다.
디테일은 성능과 보안 FAQ에 있습니다.
5. 빠른 참조
| 문법 | 유효 | 비고 |
|---|---|---|
{"a": 1,} |
아니요 | 후행 쉼표 |
{'a': 1} |
아니요 | 작은따옴표 |
{a: 1} |
아니요 | 따옴표 없는 키 |
{"a": 1} // comment |
아니요 | 주석 |
"hello" |
예 | 최상위 문자열은 유효하지만 API에서는 드묾 |
{"a": 1e3} |
예 | 지수 지원 |
{"a": 01} |
아니요 | 앞의 0 |
{"a": NaN} |
아니요 | 무효 리터럴, stringify가 null로 바꿈 |
\uFEFF{"a":1} |
아니요 | BOM은 JSON 텍스트의 일부가 아님 |
{"a":1,"a":2} |
논쟁 | 구현이 마지막 값을 취하지만 의존하지 말 것 |