JSON Toolkit

압축된 JSON 디버깅 - 1행 23611열에서 실제로 깨진 바이트까지

핵심 요약: 4MB짜리 한 줄 JSON이 "1행 23611열"이라고 알려 주면 아무 도움이 되지 않습니다. 로컬에서 직접 측정한 샘플을 통해 Python, Node, jq가 왜 서로 다른 숫자를 보고하는지, 문자 오프셋으로 어떻게 깨진 바이트에 바로 도달하는지, 문법을 고친 뒤에도 데이터를 조용히 망치는 세 가지 함정을 설명합니다.

중급약 7분 소요2026-11-01 발행2026-11-01 업데이트
압축된 JSON의 오류 위치 찾기 표지

핵심부터 말하면, 행과 열 번호는 여러 줄 문서에서만 의미가 있습니다. JSON이 한 줄로 압축되면 모든 오류가 1행에 살게 되므로, 필요한 것은 행 번호가 아니라 문자 인덱스입니다.

압축된 JSON 문서는 정확히 한 줄뿐이라, 오류 메시지의 행/열 쌍은 400만 바이트 위치를 쓸모없는 한 구절로 접어 버립니다. 우회법은 행/열을 완전히 건너뛰고 문자 오프셋을 읽는 것입니다. 메시지 끝의 소박한 (char 23610)이 실제로 당신을 구하는 숫자입니다.

아래 숫자는 모두 로컬에서 생성했습니다. 샘플은 13,000개의 청구 이벤트를 담은 한 줄 문서로 총 2,011,084자이며, 이벤트 #154의 event_id 뒤에 쉼표 하나를 일부러 지웠습니다. 환경은 Python 3.11.1, Node 22.13.1, jq 1.7입니다.

1. 먼저 사실부터: 세 파서, 세 숫자

같은 깨진 파일을 세 도구에 넣습니다:

Python 3.11.1  Expecting ',' delimiter: line 1 column 23611 (char 23610)
Node  22.13.1  Expected ',' or '}' after property value in JSON at position 23610 (line 1 column 23611)
jq    1.7      Expected separator between values at line 1, column 23619     exit code 5

나란히 비교하면:

도구 보고되는 값 단위 기준점 행/열이 실패하는 이유
Python JSONDecodeError.pos 23610 문자 오프셋 버퍼 시작 믿을 수 있는 유일한 값
Python lineno / colno 1 / 23611 행과 열 행 시작 한 줄뿐이라 아무것도 말해 주지 않음
Node position 23610 문자 오프셋 버퍼 시작 신뢰할 수 있지만 메시지가 필드를 숨김
jq column 23619 문자 오프셋 문제 토큰의 시작 Python과 9만큼 다름

결론은 명확합니다. Python의 char N과 Node의 position N은 같은 숫자이며 정확히 일치합니다. jq는 자체 방식을 쓰므로 그것을 맞춰 보는 데 시간을 낭비하지 마세요.

세 숫자가 다른 이유는 누군가 계산을 틀려서가 아니라 서로 다른 것을 세기 때문입니다. Python과 Node는 파서가 멈춘 지점을 보고하고, jq는 파싱에 실패한 토큰이 시작하는 지점을 보고합니다. 그 사이에는 이미 소비된 문자 몇 개가 놓여 있으며, 이 샘플에서는 9개입니다.

2. 이런 파일에서 1행 N열이 소음인 이유

행 번호는 "마지막 줄 바꿈 이후 시간이 꽤 흘렀다"고 가정하는 파생 값입니다. 압축된 문서에는 보통 끝에 \n 하나뿐이므로:

  • lineno는 항상 1;
  • colno는 "그 한 줄의 시작에서 몇 번째 문자인가"로 퇴화하고;
  • 그 줄은 400만 자 폭입니다.

편집기에서 23611열로 점프하면 쓸모 있는 것이 보이지 않습니다. 더 나쁜 것은 less나 vim 같은 도구가 극단적으로 긴 줄에서 기어가듯 느려져, 화살표 키로 커서를 조금 옮기는 데 20분을 태운다는 점입니다.

뒤집어 보면 오프셋이 원래의 양이고, 행/열은 그로부터 계산되는 파생 값입니다. 원래의 양을 사용하세요.

3. 방법 1: Python에서는 e.pos를 쓰고 lineno는 무시하라

JSONDecodeError는 세 개의 숫자를 담고 있으며, 읽을 가치가 있는 것은 pos 하나뿐입니다. lineno와 colno는 한 줄 문서에서 아무것도 남지 않는 파생 값입니다.

# locate.py - Python 3.11.1
import json
import sys

path = sys.argv[1]
raw = open(path, encoding='utf-8').read()

try:
    json.loads(raw)
except json.JSONDecodeError as error:
    print(f'{type(error).__module__}.{type(error).__name__}: {error}')
    start = max(0, error.pos - 60)
    end = min(len(raw), error.pos + 60)
    print(f'\nchar {start}..{end} of {len(raw)}:')
    print(raw[start:end])
    print(' ' * (error.pos - start) + '^')
    sys.exit(1)

print('valid JSON')

깨진 파일에 실행합니다:

$ python3 locate.py bench/minified-json-error-position/broken.json
json.decoder.JSONDecodeError: Expecting ',' delimiter: line 1 column 23611 (char 23610)

char 23550..23670 of 2011083:
ua":"Mozilla/5.0 Chrome/141.0.0.0"},{"event_id":"evt_000154""user_id":100154,"email":"user154@example.com","plan":"pro",
                                                            ^

캐럿이 쉼표가 빠진 지점을 표시합니다. 20분이 한 명령이 됩니다.

창은 얼마나 넓게 잡아야 하나: ±60자면 거의 항상 완전한 키/값 쌍을 덮습니다. 너무 좁으면 문맥을 잃고, 너무 넓으면 출력이 화면을 채웁니다. 깊게 중첩된 문서는 ±120까지 넓힐 수 있습니다.

4. 방법 2: 컨테이너에 Python이 없다면 jq와 cut

운영 컨테이너에는 Python이 없는 경우가 흔하지만, jq는 거의 항상 있거나 apt-get install jq 한 번으로 얻을 수 있습니다. jq가 주는 것은 요란한 실패입니다:

$ jq . bench/minified-json-error-position/broken.json
jq: parse error: Expected separator between values at line 1, column 23619
$ echo $?
5

종료 코드 5는 잘못된 입력을 뜻하므로 set -e 아래에서는 grep이 1을 반환할 때와 같은 방식으로 스크립트를 중단합니다. 이는 기능입니다.

jq에는 문제 바이트를 출력하는 플래그가 없으므로 위치를 cut에 넘기세요:

# Note -c (characters), not -b (bytes)
$ cut -c23550-23670 bench/minified-json-error-position/broken.json
"ua":"Mozilla/5.0 Chrome/141.0.0.0"},{"event_id":"evt_000154""user_id":100154,"email":"user154@example.com","plan":"pro",

쉼표를 되돌려 놓으면 jq가 실제로 잘하는 일을 할 수 있습니다:

$ jq -r '.events | length' bench/minified-json-error-position/payload.json
13000
$ jq -r '.events[154] | "\(.event_id) \(.plan) \(.amount_cents)"' bench/minified-json-error-position/payload.json
evt_000154 pro 74479

5. 방법 3: 런타임이 없거나 데이터를 디스크에 닿게 할 수 없을 때 — 브라우저에 붙여 넣기

처음 두 방법은 무언가 설치되어 있고 파일을 쓸 권한이 필요합니다. 맨 노트북을 쓰거나, 페이로드가 고객 이메일과 IP 주소로 가득해 "어떤 웹사이트에 붙여 넣었다"는 문장을 사고 보고서에 넣고 싶지 않다면 세 번째 방법이 맞습니다.

전체 문자열을 온라인 JSON 파서에 붙여 넣으면 문제 문자에 캐럿을 두고 행과 열을 표시합니다. 로컬 도구의 장점은 그 4MB가 기기를 떠나지 않고 다른 사람의 로그에 남지 않는다는 것입니다.

두 가지 주의:

  1. 모든 처리를 브라우저에서 한다고 명시하는 도구를 고르세요. 콘텐츠를 백엔드로 POST하는 도구는 절대 아닙니다;
  2. 붙여 넣기는 매우 큰 파일을 견디지만, 수십 MB를 넘으면 탭 자체가 메모리를 먹습니다 — 그 시점에는 방법 1로 돌아가세요.

6. 문자 오프셋은 바이트 오프셋이 아니다: CJK 데이터의 두 번째 함정

이것은 중국어/영어가 섞인 페이로드에서 두 방법 모두를 물어뜯습니다.

JSONDecodeError.pos는 문자 오프셋(Unicode 코드 포인트 개수)입니다. str을 파싱한 결과이기 때문입니다. cut -c는 문자 단위로 잘라 그것과 맞습니다. 하지만 head -c, tail -c, dd는 바이트 단위로 자르며, UTF-8에서는 한 번만 어긋나도 모든 것이 밀립니다.

중국어 샘플로 구체화합니다(주문 5개, 148자, 222바이트):

>>> raw = open('bench/minified-json-error-position/cn.json', encoding='utf-8').read()
>>> raw[25:35]                                    # characters 25-35
'城市":"杭州"},'
>>> open('bench/minified-json-error-position/cn.json','rb').read()[25:35]      # bytes 25-35
b'DD00000","'

같은 오프셋인데 문자 뷰는 필드 이름에 닿고, 바이트 뷰는 주문 ID의 꼬리에 닿습니다. UTF-8에서 CJK 문자 하나는 3바이트를 차지하고, 어긋남은 위치가 커질수록 누적됩니다.

규칙: e.pos를 읽은 뒤에는 cut -c나 Python 슬라이스로 자르고, 절대 head -c / dd bs=1 skip=N을 쓰지 마세요. 바이트 도구만 있다면 먼저 변환하세요: len(raw[:error.pos].encode('utf-8')).

7. 문법은 고쳤는데 데이터는 여전히 틀림: 세 가지 조용한 함정

이것이 디버깅이 "파싱됐다"에서 멈출 수 없는 이유입니다. 다음 각각은 아예 예외를 던지지 않거나, 값이 사용된 뒤에야 던집니다.

함정 1: 중복 키 — 마지막이 이기고 아무도 불평하지 않는다

RFC 8259 section 4는 명확합니다. 객체 안의 이름은 고유해야 하며(SHOULD), 그렇지 않으면 동작은 예측할 수 없습니다. 대부분의 구현은 마지막 항목을 유지하고, 일부는 예외를 던집니다. 여기서 측정한 세 도구는 모두 마지막을 유지합니다:

Input   {"order_id":"A-1001","total":4200,"order_id":"A-1002"}
Node    {"order_id":"A-1002","total":4200}
Python  {'order_id': 'A-1002', 'total': 4200}
jq      {"order_id":"A-1002","total":4200}

경고는 하나도 없습니다. 덮어쓴 필드가 amount_cents였다면, 대사가 3센트 틀렸을 때에야 알게 됩니다.

함정 2: NaN과 Infinity — Python은 받아들이고 Node는 아니다

RFC 8259 section 6은 숫자 리터럴에 NaN도 Infinity도 없다고 분명히 밝힙니다. Python의 json.loads는 Python 자체의 float에 그런 값이 있기 때문에 기본적으로 둘 다 받아들입니다:

>>> json.loads('{"discount":NaN}')
{'discount': nan}
>>> json.dumps(json.loads('{"discount":NaN}'))
'{"discount": NaN}'    # written straight back out

그 출력을 Node에 넘기면 다음을 얻습니다:

SyntaxError: Unexpected token 'N', "{"discount":NaN}" is not valid JSON

즉 잘못된 데이터가 Python을 통과해 하류 소비자에서 터집니다.

함정 3: 2^53을 넘는 정수는 조용히 정밀도를 잃는다

하나의 문서, 두 개의 서로 다른 값:

JSON source   {"order_no":12345678901234567890}
Python        12345678901234567890          <- kept as-is
Node          12345678901234567000          <- last three digits flattened

한 줄로 설명하면: JavaScript의 Number.MAX_SAFE_INTEGER는 9007199254740991(2^53-1)입니다. MDN은 reviver가 값이 이미 JavaScript 숫자로 파싱된 뒤에 실행된다고 특별히 지적하므로, 정밀도는 reviver 전에 이미 사라져 역직렬화 측에서 복구할 수 없습니다. 유일한 해결책은 생성자가 큰 ID를 문자열로 직렬화하는 것입니다.

세 함정 비교:

함정 예외가 나는가 누가 먼저 알아채는가 어떻게 차단하는가
중복 키 아니오 하류 대사 object_pairs_hook으로 명시적 검사
NaN / Infinity Node는 예외, Python은 조용 하류 소비자 parse_constant로 경계에서 거부
2^53 초과 정수 아니오 동등성 검사를 하는 코드 문자열로 전송

앞의 두 가지를 한 번에 잡기

# check.py - Python 3.11.1
import json
import sys
from collections import Counter


def no_dupes(pairs):
    counts = Counter(key for key, _ in pairs)
    dupes = sorted(key for key, n in counts.items() if n > 1)
    if dupes:
        raise ValueError(f'duplicate key(s): {dupes}')
    return dict(pairs)


def reject_constant(name):
    raise ValueError(f'non-standard JSON constant: {name}')


raw = open(sys.argv[1], encoding='utf-8').read()
try:
    data = json.loads(raw, object_pairs_hook=no_dupes, parse_constant=reject_constant)
except json.JSONDecodeError as error:
    start, end = max(0, error.pos - 60), min(len(raw), error.pos + 60)
    print(f'{error}\n\nchar {start}..{end} of {len(raw)}:')
    print(raw[start:end])
    print(' ' * (error.pos - start) + '^')
    sys.exit(1)
except ValueError as error:
    print(f'rejected: {error}')
    sys.exit(1)

print(f'valid JSON, {len(raw)} chars')

솔직히 말한 비용: 같은 2,011,084자 샘플에서 이 엄격한 버전은 35.2~36.2 ms, 느슨한 버전은 9.4~9.9 ms로 약 3.7배 느립니다(각 5회씩 5라운드, 라운드별 중앙값 기준 3.6~3.8배) — object_pairs_hook이 객체마다 Counter를 실행하기 때문입니다. CI의 정상 경로에서는 빼 두고, 배포물이 수상해 보일 때만 켜세요.

8. 체크리스트

파일을 고친 뒤에는 읽기 좋게 예쁘게 출력하고 싶어집니다. 하지만 같은 샘플은 축소 시(separators=(',',':')) 2,011,084자, indent=2에서 2,869,100자로 43% 더 큽니다. 그 무게는 Git 히스토리, CI 캐시, 네트워크 전송으로 흘러가므로, 예쁜 버전은 로컬에 두고 배포 전에 한 줄로 다시 눌러 담으세요.

  • [ ] 먼저 (char N) / position N을 찾고 line 1 column N은 무시한다;
  • [ ] Python은 e.pos, jq는 cut -c와 짝지어 쓴다 — 같은 문자 오프셋이므로 jq의 column과 맞추지 않는다;
  • [ ] CJK 문서는 문자 단위로 자른다(cut -c, Python 슬라이스). 절대 head -c / dd를 쓰지 않는다;
  • [ ] 컨테이너 스크립트는 jq 종료 코드 5를 "입력 잘못됨"으로 처리한다;
  • [ ] 수상한 데이터는 object_pairs_hook + parse_constant로 다시 확인하고 약 3.7배 비용을 받아들인다;
  • [ ] 2^53을 넘는 ID는 문자열로 전송한다. reviver가 구해 줄 것이라 기대하지 않는다;
  • [ ] 예쁘게 출력한 결과(실측 +43%)는 로컬에 둔다: 배포나 커밋 전에 /minify로 한 줄로 다시 압축하고, 수 MB를 넘으면 스트리밍으로 전환한다. 자세한 내용은 대용량 JSON 파싱·렌더링·스트리밍을 참고.

FAQ

세 도구가 모두 다른 위치를 보고하는 이유는?

서로 다른 것을 세기 때문입니다. Python의 char N과 Node의 position N은 둘 다 "파서가 멈춘 곳"을 뜻하며, 이 샘플에서는 둘 다 23610으로 일치합니다. jq는 문제 토큰이 시작하는 곳을 보고하며 여기서는 23619로 9칸 더 나아가 있습니다. 누구도 계산을 틀리지 않았고 보정도 필요 없습니다. 같은 도구 안에서만 숫자를 비교하세요.

cut -c와 head -c 중 무엇을 써야 하나?

cut -c를 쓰세요. JSONDecodeError.pos는 Unicode 문자로 세는 오프셋이고 cut -c는 문자 단위로 자르므로 둘이 일치합니다. head -c, tail -c, dd는 바이트 단위로 잘라 CJK 텍스트가 포함된 UTF-8 문서에서 어긋나며, 문자를 반으로 자를 수도 있습니다. 중국어 샘플에서 같은 오프셋으로 문자 뷰는 城市":"杭州"},를, 바이트 뷰는 DD00000","을 줍니다.

중복 키 처리는 정말 어디서나 무방비인가?

그렇습니다. RFC 8259 section 4는 이름이 고유해야 한다(SHOULD)고만 말하며 고유하지 않을 때 동작이 예측 불가능하다고 밝힙니다. 여기서 측정한 Python, Node, jq 모두 경고 없이 마지막 우선 방식을 적용합니다. 잡으려면 직접 object_pairs_hook을 써야 하며, 파싱 시간이 약 3.7배 늘어납니다.

Python은 NaN을 잘 읽는데 Node는 왜 예외를 던지나?

RFC 8259 section 6은 NaN과 Infinity를 숫자 리터럴로 허용하지 않습니다. Python의 json.loads는 Python 자체 float에 그런 값이 있어 기본적으로 받아들이므로, 잘못된 데이터가 Python을 통과해 그대로 다시 쓰입니다. Node의 JSON.parse는 명세를 엄격히 따르며 그 출력에서 SyntaxError를 던집니다. 진입점에 parse_constant를 추가하면 즉시 거부됩니다.

20자리 주문 번호는 JSON을 거치면 항상 정밀도를 잃나?

소비자가 그것을 IEEE 754 배정도로 파싱할 때만입니다. Python의 int는 임의 정밀도라 12345678901234567890을 유지하지만, JavaScript의 Number.MAX_SAFE_INTEGER는 9007199254740991이므로 같은 필드가 12345678901234567000이 됩니다. MDN은 reviver가 파싱 후에 실행된다고 명시하므로 하류에서 복구할 방법이 없습니다. 유일하게 신뢰할 수 있는 수정은 생성자가 문자열로 내보내는 것입니다.