실제로 페이로드를 망가뜨리는 JSON 실수 10가지
흥미로운 순서가 아니라 빈도순으로, 각 항목이 실제로 내놓는 오류 메시지와 함께 정리했습니다.
이 페이지의 모든 주장은 직접 측정했거나 출처가 있습니다. 둘 다 아닌 경우에는 그렇다고 명시합니다.
“유효하지 않은 JSON” 티켓은 거의 전부 열 가지 중 하나입니다. 파서는 위치를 알려 주고, 가끔은 문자도 알려 주지만, 원인은 결코 알려 주지 않습니다. 대략적인 빈도순으로, 여러분이 봤을 메시지와 해결책을 함께 정리합니다.
1. 끝 쉼표
압도적으로 흔합니다. 하루 종일 쓰는 다른 형식은 전부 이걸 허용하니까요.
{ "a": 1, "b": 2, }
V8이 주는 메시지는 쉼표도, 여러분이 한 일도 언급하지 않습니다.
Expected double-quoted property name in JSON at position 18 (line 1 column 19)
파서는 쉼표를 소비하고 다음 키를 기대했다가 }를 만났습니다. 이번엔 같은 일을 배열에서 해 보죠.
Unexpected token ']', "[1,2,]" is not valid JSON
똑같은 실수인데 문구가 완전히 다릅니다. 파서 안에서 배열 경로는 다른 문법 규칙에서 실패하기 때문입니다. 무슨 일이 일어났는지 알아보려고 오류 문자열을 검색해도 쓸 만한 게 안 나오는 이유가 바로 이 비대칭입니다.
파이썬은 더 직설적이지만, 그것도 최근 일입니다. 3.13 이상에서는,
Illegal trailing comma before end of object
3.12 이하에서는 같은 입력이 Expecting property name enclosed in double quotes: line 1 column 19 (char 18)을 냅니다. 같은 인터프리터, 파일 속의 같은 결함인데, CI가 고정해 둔 버전에 따라 설명이 두 가지로 갈리는 셈입니다.
해결: 쉼표를 지우세요. { "a": 1, "b": 2 }.
2. 작은따옴표
json.dumps() 대신 print()나 str()를 거친 파이썬 dict입니다.
{'ok': True}
이건 JSON이 아니고, 한 번도 JSON이었던 적이 없습니다. 첫 따옴표에서 실패합니다.
Expected property name or '}' in JSON at position 1 (line 1 column 2)
True도 함께 보세요. 첫 실패 뒤에 대기 중인 별개의 두 번째 실패입니다. JSON의 불리언은 소문자입니다.
{"ok": true}
발생 지점에서 고치세요. json.dumps(obj), 그리고 출력이 UTF-8 파일이나 HTTP 본문으로 간다면 json.dumps(obj, ensure_ascii=False)를 써서 악센트가 붙은 문자가 \uXXXX 이스케이프로 바뀌지 않고 읽히는 형태로 남게 하세요. 압축된 형태를 원하면 separators=(",", ":")도 함께.
3. 따옴표 없는 키
JavaScript 객체 리터럴을 JSON 칸에 그대로 붙여 넣은 경우입니다.
{ name: "ada", active: true }
Expected property name or '}' in JSON at position 2 (line 1 column 3)
JSON은 모든 키가 큰따옴표로 묶인 문자열이기를 요구합니다. 작은따옴표도, 맨 이름도, 숫자도 안 됩니다. {"name": "ada", "active": true}. 앞 항목과 같은 부류의 붙여넣기이고 해결책도 같습니다. 콘솔 로그가 아니라 제대로 된 직렬화기로 런타임에서 값을 꺼내세요.
4. 이스케이프되지 않은 제어 문자
문자열 리터럴 안에 진짜 줄바꿈이 들어간 경우입니다.
{"note": "line one
line two"}
Bad control character in string literal in JSON at position 18 (line 1 column 19)
파이썬은 이를 Invalid control character at: line 1 column 19 (char 18)이라고 부릅니다. 어느 쪽이든 파서가 하는 말은, 이스케이프 형태만 허용되는 문자열 안에 U+0020 미만의 문자가 나타났다는 것입니다.
{"note": "line one\nline two"}
탭도 같은 문제이고 더 찾기 어렵습니다. 값에 붙여 넣은 탭은 그냥 공백처럼 보이니까요. 근본 원인은 거의 언제나 문자열 연결로 조립한 JSON입니다. 줄바꿈이 든 필드가 그대로 끼워지는 것이죠. 마지막 절에서 이 문제를 제대로 다룹니다.
5. 윈도우 경로
{"path": "C:\Users\ada\config.json"}
\U도 \a도 유효한 이스케이프가 아닙니다. 파이썬은 명확합니다. Invalid \escape: line 1 column 13 (char 12). V8은 Bad escaped character in JSON at position 13 (line 1 column 14)이라고 하며 백슬래시가 아니라 U를 가리킵니다. 합법적인 이스케이프는 \" \\ \/ \b \f \n \r \t와 \uXXXX, 아홉 가지뿐입니다. 나머지는 전부 오류이고, 이는 올바른 설계인 동시에 끊임없이 사람을 놀라게 합니다.
{"path": "C:\\Users\\ada\\config.json"}
거의 모든 API에서 윈도우 위의 슬래시는 아무 문제 없이 동작하고, 백슬래시를 두 번 쓰는 수고도 들지 않습니다. 끼워 넣을 텍스트 덩어리가 있는데 손으로 하기 싫다면 이스케이프 도구가 처리해 주고, 언이스케이프가 반대 방향을 맡습니다.
6. 보이지 않는 문자
오후 한나절을 통째로 잡아먹는 것이 이겁니다. 변종이 둘 있습니다.
줄바꿈 없는 공백(U+00A0). 문서 페이지나 채팅 클라이언트, PDF에서 조각을 복사하면 토큰 사이의 공백이 공백이 아닐 수 있습니다. RFC 8259가 토큰 사이에 허용하는 공백 문자는 정확히 넷, 스페이스, 탭, 캐리지 리턴, 라인 피드뿐입니다. U+00A0은 거기에 없으므로 문법 오류이고, 옆 문자와 똑같이 보입니다.
둥근 따옴표. Word와 구글 문서는 입력하는 동안 곧은 따옴표 U+0022를 조판용 쌍인 U+201C와 U+201D로 자동 교정합니다. JSON은 U+0022만 받습니다. 화면에서는 따옴표가 완벽하게 붙어 보이는 문서에 문자열 구분자가 하나도 없는 상황이 벌어집니다.
두 변종 모두 코드포인트를 짚어 주는 메시지를 내지 않습니다. 문자가 어디에 떨어졌는지에 따라 Expected double-quoted property name in JSON at position 8이 되거나, 보통 공백과 구별되지 않는 문자를 그대로 되찍어 주는 Unexpected token ' '이 됩니다. 문서를 검증기에 붙여 넣으세요. 정확한 오프셋에서 문자와 코드포인트를 짚어 주므로 가장 빠른 길입니다. JSON 복구 도구는 그것들을 걷어내고 무엇을 제거했는지 알려 줍니다.
7. 주석
{
// 사용자 표시 이름
"name": "ada"
}
V8은 Expected property name or '}' in JSON at position 4 (line 2 column 3)을 보고합니다. 파이썬은 Expecting property name enclosed in double quotes: line 2 column 3 (char 4)에서 멈춥니다. 둘 다 슬래시를 가리키고, 둘 다 주석이라는 낱말을 꺼내지 않기 때문에, 문자열이 하나도 없는 줄에 대한 따옴표 문제처럼 읽힙니다.
JSON에는 주석 문법이 없습니다. 사람들이 주석으로 파싱 지시문을 실어 나르던 탓에 Crockford가 의도적으로 없앴습니다. 소비하는 쪽을 통제할 수 있다면 JSONC(VS Code가 자신의 설정에 쓰는 것)가 주석과 끝 쉼표를 허용하고, JSON5는 훨씬 더 많은 것을 허용합니다. 통제할 수 없다면 그 산문을 필드로, 아니면 설명이 원래 있어야 할 자리인 스키마로 옮기세요. 설정 형식을 고르는 중이라면 자세한 논의에 10분을 쓸 만합니다.
8. NaN과 Infinity
{"ratio": NaN}
Unexpected token 'N', "{"ratio": NaN}" is not valid JSON
함정은 파이썬이 이걸 기본값으로 내보낸다는 점입니다. json.dumps({"ratio": float("nan")})은 {"ratio": NaN}을 만들고 아무것도 던지지 않습니다. CPython의 인코더가 의도적으로 관대하고, 자기 디코더는 그 값을 다시 받아들이기 때문입니다. 파이썬이 아닌 소비자는 전부 이를 거부합니다.
json.dumps(obj, allow_nan=False) # 유효하지 않은 JSON을 내보내는 대신 ValueError를 던진다
오늘 직렬화 계층에서 이걸 켜세요. 프로덕션까지 도달한 NaN은 여러분이 막지 못한 나눗셈입니다. 그리고 그것을 발견할 장소로는 고객의 파서 안보다 인코더 쪽이 낫습니다.
9. 중복된 키
{"id": 1, "id": 2}
오류가 전혀 없습니다. RFC 8259는 키가 고유해야 한다(SHOULD)고만 말하고, 그렇지 않을 때의 동작은 정의하지 않은 채 둡니다. JavaScript와 파이썬은 둘 다 마지막 것을 취하므로 이것은 {"id": 2}로 파싱되고 첫 번째 값은 흔적도 없이 사라집니다. 첫 번째를 취하는 파서도 있고, 오류를 던지는 것도 있습니다. 이 목록에서 유일하게 조용히 넘어가는 항목이고, 그래서 가장 나쁜 항목입니다. 페이로드를 검증기에 통과시키세요. 조용히 접어 버리는 대신 중복을 짚어 줍니다.
10. 숫자
이 자리는 두 가지 실패가 나눠 씁니다.
앞자리 0. {"code": 007}은 유효하지 않습니다. JSON 문법이 허용하는 것은 단독의 0, 또는 1에서 9 사이의 숫자 뒤에 숫자가 이어지는 형태뿐이고 그 외에는 없습니다. 앞에 0이 붙는 우편번호, 국가번호, 부품번호는 문자열입니다. {"code": "007"}.
2^53-1을 넘는 정수. {"id": 12345678901234567890}은 문제없이 파싱되어 다른 숫자로 돌아옵니다. JavaScript가 이를 IEEE 754 double로 저장하고 Number.MAX_SAFE_INTEGER가 9007199254740991이기 때문입니다. 오류도 경고도 없이, 레코드만 달라집니다. 큰 ID는 문자열로 보내세요. 그 외의 모든 대처가 왜 우회책에 그치는지는 긴 버전에서 다룹니다.
구조적인 해결
이 목록의 절반(2, 3, 4, 5번)은 같은 습관에서 나옵니다. 직렬화기가 아닌 것으로 JSON을 만드는 습관, 대개는 문자열 연결이나 콘솔 로그입니다.
# 하나같이 알맞은 입력만 기다리고 있는 버그다
body = '{"note": "' + note + '", "path": "' + path + '"}'
note에 줄바꿈이 들어가면 깨집니다. path에 백슬래시가 들어가면 깨집니다. 둘 중 어느 쪽에든 따옴표가 들어가면 깨지고, 그 입력이 사용자에게서 온 것이라면 그건 서식 문제가 아니라 인젝션입니다.
body = json.dumps({"note": note, "path": path}, allow_nan=False)
직렬화기는 이스케이프해야 할 것을 이스케이프하고, 따옴표를 씌워야 할 것에 씌우며, 표현할 수 없는 것을 거부합니다. 취향의 문제가 아닙니다. JSON을 손으로 템플릿화한다는 것은 RFC 8259 7절의 이스케이프 규칙을 모든 분기에서 정확히 다시 구현한다는 뜻이고, 그걸 해내는 사람은 없습니다.
망가진 생성기가 아니라 망가진 문서를 건네받았다면, JSON 복구 도구가 위의 수정들을 적용하고 자신이 가한 변경을 전부 목록으로 출력합니다. 출력을 믿기 전에 어딘가에서 추측이 들어갔는지 직접 확인할 수 있습니다.