JSON Toolkit

같은 JSON인데 네 도구가 네 가지 결과를 낸다: 포맷터 선택법

핵심 요약: 포맷팅은 무손실이 아닙니다. 앞에 0이 붙은 수, BOM, 20자리 정수가 든 같은 JSON을 Python, Node, jq, json.tool에 넣으면 어떤 것은 오류를 내고 어떤 것은 조용히 값을 바꿉니다. 10개 자체 실행 샘플로 네 경로를 비교해 선택표와 복사 가능한 엄격 검증 스크립트를 제시합니다.

Intermediate약 6분 소요2026-11-03 발행2026-11-03 업데이트
같은 JSON에 대해 네 분석 경로가 서로 다른 결과를 보여주는 그림

결론: 포맷터 사이의 가장 치명적인 차이는 들여쓰기 칸 수가 아니라, 같은 입력이 오류를 내느냐 아니면 조용히 데이터를 바꾸느냐입니다. 기능 목록을 읽는 것보다 경계값 샘플 하나를 돌려보는 편이 훨씬 정확합니다.

사람들은 포맷팅을 "다시 정렬하는 것"으로 생각하지만, 실제로 각 경로는 자기 파서로 텍스트를 다시 해석합니다. 파서가 관대하면 원래 떠야 할 오류가 조용한 값 변경으로 바뀌고, 화면에는 여전히 "포맷 성공"이라는 초록 불이 켜져 있습니다.

1. 현상: 같은 입력, 네 경로, 네 결과

실험 환경은 Python 3.11.1, Node v22.13.1, jq 1.7입니다. 직접 만든 짧은 JSON 10개를 표준 입력으로 넣었고, 디스크에 쓰지 않고 설정도 바꾸지 않았습니다. 경로는 json.loads, JSON.parse, jq -c ., python -m json.tool 네 가지입니다. 표에서 ERR은 예외로 중단, SILENT는 오류 없이 출력이 입력과 달라진 경우입니다.

샘플 python json.loads node JSON.parse jq -c . python -m json.tool
S1 {"a":1,"b":[1,2]} OK OK OK OK
S2 {"a":1,} ERR ERR ERR ERR
S3 {'a':1} ERR ERR ERR ERR
S4 {"a":01} ERR char 6 ERR position 6 SILENT {"a":1} ERR
S5 {"a":1 /*x*/} ERR char 7 ERR ERR ERR
S6 \ufeff{"a":1} ERR utf-8-sig ERR position 0 SILENT {"a":1} ERR
S7 {"id":9007199254740993} OK 충실 SILENT 9007199254740992 OK 충실 OK
S8 {"a":1,"a":2} SILENT {"a":2} SILENT {"a":2} SILENT {"a":2} OK
S9 {"a":1 ERR char 6 ERR ERR EOF ERR
S10 <html>502</html> ERR char 0 ERR Unexpected token '<' ERR ERR

열 개 중 네 개(S4·S6·S7·S8)가 "오류 없이 값이 바뀐" 경우였습니다. 초록 성공 표시만 믿으면 40% 확률로 원본이 아닌 데이터를 쥐게 됩니다. 더 곤란한 건 이들이 구문 오류 목록에 없다는 점입니다. 파일은 열리고, 복사되고, 내보내집니다. 잘못은 구문이 아니라 내용에 있습니다.

2. 원인: 구문 층과 의미 층은 별개다

RFC 8259의 1.2절은 ECMA-404가 허용하는 관행을 상호운용성을 위해 피하라고 권하고, 4절은 객체 이름이 SHOULD로 유일해야 한다고 하면서 중복 키 처리 방법은 정하지 않으며, 6절은 숫자에 대해 상호운용 권고만 줍니다. ECMA-404는 한 걸음 더 나아가 "숫자의 의미론에는 무관심하다"고 못 박고 표현만 규정합니다. 즉 명세는 넓은 경계 영역을 구현자에게 넘겼습니다.

따라서 "포맷팅"은 두 층으로 갈립니다. 구문 층은 텍스트가 적법한지 판단하고, 의미 층은 그 텍스트가 어떤 값이 될지 결정합니다. 앞에는 표준 답이 있고 뒤에는 없습니다. jq가 01을 1로 읽고 앞의 BOM을 건너뛰는 것은 의미 층의 선택이고, Node가 20자리 정수를 IEEE 754 배정밀도에 밀어 넣는 것은 런타임의 선택이며, 네 경로가 {"a":1,"a":2}에서 마지막 값을 택하는 것은 주류 파서의 공통 관행입니다. 어느 것도 "파싱 가능"을 어기지 않지만 모두 데이터를 바꿉니다.

JSON 파싱 오류 해결 - Unexpected token 단계별 점검 은 구문 층을 다루며 오류 원문을 좌표로 바꿉니다. 이 글은 그 앞단계, 즉 도구가 아예 오류를 내지 않으면 좌표조차 얻을 수 없다는 지점을 다룹니다.

3. 조용한 값 변경 네 가지를 하나씩

네 샘플만 따로 보면 변경폭이 너무 작아 눈으로 놓치기 쉽습니다.

{"a": 01}                          // 앞의 0: jq는 {"a":1} 출력
\ufeff{"a": 1}                     // BOM: jq는 앞 3바이트를 건너뜀
{"id": 9007199254740993}           // 20자리 정수: JSON.parse는 ...992 출력
{"a": 1, "a": 2}                   // 중복 키: 네 경로 모두 {"a":2}만 유지

앞의 0과 BOM은 "입력이 원래 부적법한데 도구가 관대했던" 유형입니다. 01은 표준 JSON에서 불법(RFC 8259 6절)이고 BOM도 JSON 텍스트 앞에 올 수 없습니다. Python과 Node는 거부하고 jq는 계속 진행합니다. 로그 정제에는 편하지만 계약 검증에서는 재앙입니다. 상류는 잘못된 데이터를 보냈으니 고쳐야 한다고 생각하는데, 하류는 이미 "정상처럼 보이는" 결과를 갖고 있습니다.

큰 정수는 "입력은 완전히 적법하지만 런타임이 담지 못하는" 유형입니다. 9007199254740993은 적법한 JSON 숫자지만 2^53-1을 넘어 JSON.parse는 9007199254740992로만 표현합니다. Python의 int와 jq는 원값을 지킵니다. 주문 번호나 Snowflake ID라면 마지막 한 자리가 어긋나는 순간 대조가 맞지 않습니다.

중복 키는 "명세가 정하지 않아 구현이 각자 고르는" 유형입니다. 네 경로 모두 경고 없이 뒤 값을 택합니다. RFC 8259가 유일성을 SHOULD로만 말하므로 버그는 아니지만, 설정을 이어 붙이거나 패치를 병합할 때 가장 찾기 어려운 부류입니다.

4. 오류 보고: 좌표를 주는 곳과 한 줄만 주는 곳

실패한 여섯 샘플에서도 정보량 차이가 큽니다. Python의 JSONDecodeError는 msg·lineno·colno·pos를 함께 주고, Node v22는 position N (line 1 column M)과 문제 구절의 원문 조각을 붙이며, python -m json.tool은 Python 정보를 재사용합니다. jq는 parse error: ... at line N 한 줄뿐이며 문자 오프셋이 없습니다.

한 줄로 압축된 JSON에서는 행·열 번호가 거의 무의미합니다. 필요한 건 문자 오프셋입니다. 아래 스크립트는 검증을 "오류 + 오프셋 + 전후 문맥"으로 통일합니다.

import json, sys

def strict_check(text: str) -> None:
    try:
        json.loads(text)
        print("OK")
    except json.JSONDecodeError as e:
        print(f"ERR {e.msg} | char {e.pos} | line {e.lineno} col {e.colno}")
        lo, hi = max(0, e.pos - 30), min(len(text), e.pos + 30)
        print("...", text[lo:hi].replace("\n", "\\n"), "...")
        sys.exit(1)

strict_check(open(sys.argv[1], encoding="utf-8").read())

jq .보다 엄격(앞의 0과 BOM 거부)하고 e.pos를 줍니다. line 1 column 23611을 바이트 위치로 환산하는 법과, 한글 문서에서 문자 오프셋과 바이트 오프셋이 세 배까지 벌어지는 이유는 압축된 JSON 디버깅 - 1행 23611열에서 실제로 깨진 바이트까지 에서 다뤘으므로 여기서 반복하지 않습니다.

5. 고치는 법: 포맷팅을 "엄격 검증" + "보기 좋게"로 분리

결론은 간단합니다. "포맷" 버튼 하나에 검증과 미화를 겸하게 하지 마십시오. 먼저 엄격 파서로 적법성을 판단하고, 그다음 정렬 방식을 정합니다. 첫 단계는 한 줄로 떼어낼 수 있습니다.

python3 -m json.tool input.json > /dev/null && echo "구문 OK" || echo "구문 부적법"
python3 -c "import json,sys; d=json.load(open(sys.argv[1],encoding='utf-8')); print(type(d).__name__, len(d))" input.json

첫 줄은 "파싱 가능한가"에만 답하고, 둘째 줄이 "파싱된 구조가 기대와 같은가"를 확인합니다. 큰 정수가 있으면 JSON.parse 대신 Python이나 jq로 읽고, BOM 가능성이 있으면 도구가 알아서 넘기길 바라지 말고 encoding='utf-8-sig'를 명시하며, 키 중복 가능성이 있으면 파싱 전에 따로 훑어야 합니다(네 경로 모두 경고하지 않습니다). 이를 CI에 넣는 것은 에디터, 명령행, 자동화 도구 체인 의 파이프라인에 데이터 충실도 단정문 한 겹을 더한 것입니다.

6. 고르는 법: 네 경로 비교표

경로 강점 위험 적합한 곳
python json.loads / json.tool 엄격, 행·열과 문자 오프셋 제공, 정수 정밀도 유지 앞의 0과 BOM 거부, 인코딩 명시 필요 계약 검증, CI, 로그 추적
node JSON.parse 브라우저 기본, 오류에 원문 조각 포함 큰 정수 조용히 절삭, BOM에서 오류 프런트 디버깅, 브라우저 도구
jq -c . 파이프 친화적, 가장 빠른 결과 앞의 0과 BOM 조용히 통과, 문자 오프셋 없음 운영 파이프, 필드 추출
브라우저 도구 /parse·/editor 트리 보기, 키 정렬, 경로 복사, 설치 불필요 동작이 페이지 파서에 달려 있어 한 번 검증 필요 육안 확인, 임시 장애 대응

판단 기준은 "기능이 많은가"가 아니라 "내 데이터에 대해 거짓말을 하지 않는가"입니다. 주문 ID·금액·기기 ID가 들어있으면 Python이나 충실성을 명시한 도구를, 구조만 보고 저장하지 않는 디버깅이라면 브라우저가 가장 편합니다. 이 사이트의 /parse는 브라우저 안에서 구문 검증과 포맷팅을 하므로 "먼저 생김새 보기"에 맞지만, 큰 정수는 위 스크립트로 다시 확인하십시오.

7. 프라이버시는 경계 중 하나일 뿐

"데이터가 업로드되는가"는 온라인 도구에서 가장 자주 나오는 질문이지만, "데이터가 바뀌는가"와는 독립적입니다. 로컬에서 오프라인으로 도는 도구도 20자리 정수를 절삭할 수 있고, 반대로 엄격하게 오류를 내는 온라인 도구가 원문을 서버로 POST할 수도 있습니다. 둘은 따로 검증해야 하며 서로 대체할 수 없습니다. 프라이버시 FAQ가 2분에 끝나는 확인 세 가지를 준다면, 이 글이 채우는 나머지 절반은 아래 샘플로 도구가 조용히 값을 바꾸지 않는지 확인하는 일입니다.

8. 그대로 복사하는 자체 점검 목록

S1~S10을 한 줄씩 cases.txt에 저장해 후보 도구에 붙여 넣고 세 가지를 확인합니다. 실패해야 할 샘플이 실제로 실패하는지, 실패 시 구체적 위치가 나오는지, 성공한 샘플의 출력이 입력과 한 글자도 다르지 않은지. 특히 S4·S6·S7·S8을 집중 확인해 SILENT가 한 줄이라도 있으면 그 경로는 단독으로 데이터 검증에 쓸 수 없습니다.

여기에 공학적 제약 세 가지를 더합니다. 엄격 검증을 pre-commit 훅에 넣어 운영 중에 정수 불일치를 발견하지 않도록 하고, 다언어 환경에서는 파일을 항상 BOM 없는 UTF-8로 쓰며, 제3자에게 받은 JSON은 원본 응답 본문을 보존하고 포맷팅 결과는 읽기에만 쓰고 되쓰지 않습니다.

자주 묻는 질문

포맷이 통과하면 JSON에 문제가 없다는 뜻인가요?

아닙니다. S4·S6·S7·S8은 모두 포맷에 "성공"했지만 앞의 0, BOM, 20자리 정수, 중복 키가 바뀌거나 사라졌습니다. 포맷은 "어떤 파서가 받아들였다"는 것만 증명하며, 파싱된 값이 보낸 값과 같음을 증명하지 않습니다. 핵심 필드는 값마다 단정문을 두십시오.

왜 jq는 Python이 거부하는 앞의 0과 BOM을 받아들이나요?

jq는 자체 어휘 분석기를 갖고 있어 01 같은 숫자에 관대하고 앞의 BOM도 건너뜁니다. Python과 Node는 RFC 8259를 엄격히 따릅니다. 버전 버그가 아니라 관대한 파서와 엄격한 파서 사이의 선택입니다. 정제 작업에는 전자가 편하고 계약 검증에는 후자가 필수입니다.

큰 정수가 9007199254740992가 되는 걸 어떻게 막나요?

JavaScript의 Number는 IEEE 754 배정밀도이고 안전한 정수 상한이 2^53-1입니다. 20자리 ID를 JSON.parse로 읽으면 반드시 잘립니다. 이런 ID는 문자열로 저장하거나 Python int, jq, BigInt 지원 파서를 쓰십시오. 프런트에서 정말 숫자가 필요하면 파싱 전에 해당 필드를 문자열로 바꾸세요.

온라인 도구와 로컬 CLI 중 무엇을 써야 하나요?

데이터 민감도와 용도로 나눕니다. 구조 확인이나 필드 찾기라면 브라우저가 가장 빠르고, 파이프라인과 충실도 단정이 필요하면 python -m json.tool에 엄격 검사기를 더하십시오. 어느 쪽이든 먼저 S1~S10을 돌려 오류와 출력이 기대와 맞는지 확인한 뒤, 어떤 부류의 데이터를 넣을지 결정하세요.