JSONDecodeError 해결 — 깨진 JSON 파일에서 틀린 곳 정확히 찾아내기 (10가지 유형 실측)

설정 파일이나 API 응답을 json.load()로 읽다가 JSONDecodeError가 나면 메시지에 줄과 칸 번호가 찍힙니다. 그런데 그 위치로 가 봐도 틀린 게 안 보일 때가 많습니다. 직접 깨진 JSON 파일 10종을 만들어 돌려 보니, 쉼표가 빠진 파일은 쉼표가 빠진 6번 줄이 아니라 다음 줄인 7번 줄을 가리켰고, 원인이 다른 세 파일(마지막 쉼표·작은따옴표·주석)은 똑같은 메시지를 냈습니다. 이 글에서는 메시지별로 실제 원인을 짚는 법, 틀린 곳을 앞뒤 문맥과 함께 보여주는 파이썬 스크립트, 31.7MB짜리 한 줄 JSON에서 오류 위치를 찾은 결과까지 정리합니다. 환경은 윈도우 11, Python 3.12.10이고 표준 라이브러리 json만 썼습니다.

JSONDecodeError가 알려주는 네 가지

예외 객체에는 메시지 말고도 위치 정보가 들어 있습니다. e.msg(무엇이 기대와 달랐는지), e.lineno(줄), e.colno(칸), e.pos(파일 처음부터 센 문자 위치)입니다. 줄·칸은 1부터, pos는 0부터 셉니다. 실측에서도 같은 위치가 “칸 23,147,167 / 문자 위치 23,147,166″으로 하나 차이 났습니다.

import json

try:
    with open("settings.json", encoding="utf-8") as f:
        data = json.load(f)
except json.JSONDecodeError as e:
    print(e.msg, "줄", e.lineno, "칸", e.colno, "pos", e.pos)

주의할 점은 이 위치가 “틀린 곳”이 아니라 파서가 더는 못 읽겠다고 멈춘 곳이라는 겁니다. 쉼표가 빠져도 파서는 다음 키가 나올 때까지 이상한 줄 모릅니다. 그래서 위치가 한 줄 뒤로 밀립니다.

깨진 JSON 10종으로 메시지 재현하기

실제로 자주 깨지는 유형을 한 파일에 하나씩 심었습니다. 정상 파일은 작업 목록과 윈도우 로그 경로가 들어간 249바이트짜리 설정 파일입니다. 이 파일을 바탕으로 마지막 쉼표, 작은따옴표, 쉼표 빠짐, 주석, 역슬래시 한 개짜리 윈도우 경로, 중간에 잘린 파일, 빈 파일, JSON Lines, 파이썬식 True, BOM 붙은 파일을 만들었습니다.

$ python reproduce.py
00_good.json               OK
01_trailing_comma.json     JSONDecodeError: Expecting property name enclosed in double quotes (줄 13, 칸 5, pos 213)
02_single_quote.json       JSONDecodeError: Expecting property name enclosed in double quotes (줄 2, 칸 3, pos 4)
03_missing_comma.json      JSONDecodeError: Expecting ',' delimiter (줄 7, 칸 7, pos 110)
04_comment.json            JSONDecodeError: Expecting property name enclosed in double quotes (줄 3, 칸 3, pos 32)
05_backslash.json          JSONDecodeError: Invalid \escape (줄 15, 칸 17, pos 235)
06_truncated.json          JSONDecodeError: Expecting value (줄 7, 칸 18, pos 122)
07_empty.json              JSONDecodeError: Expecting value (줄 1, 칸 1, pos 0)
08_jsonl.json              JSONDecodeError: Extra data (줄 2, 칸 1, pos 63)
09_python_literal.json     JSONDecodeError: Expecting value (줄 7, 칸 18, pos 122)
10_bom.json                JSONDecodeError: Unexpected UTF-8 BOM (decode using utf-8-sig) (줄 1, 칸 1, pos 0)

결과를 원인별로 묶으면 이렇습니다. 메시지만 보고 원인을 바로 단정하면 안 되는 이유가 보입니다.

메시지실제 원인(이번 실측)위치가 가리킨 곳
Expecting property name enclosed in double quotes마지막 항목 뒤 쉼표 / 작은따옴표 키 / 주석쉼표 다음의 닫는 괄호, 따옴표, 주석 시작
Expecting ‘,’ delimiter항목 사이 쉼표 빠짐쉼표가 빠진 줄의 다음 줄
Expecting value파일 잘림 / 빈 파일 / True·False·None값이 시작돼야 할 자리
Invalid \escape윈도우 경로 역슬래시 한 개역슬래시 자리
Extra data한 줄에 객체 하나씩인 JSON Lines두 번째 값의 시작
Unexpected UTF-8 BOM메모장 등이 붙인 BOM파일 맨 앞

특히 잘린 파일과 True를 쓴 파일은 메시지도 같고 위치도 “줄 7, 칸 18″로 같았습니다. 위치의 글자를 직접 봐야 구분이 됩니다(tr로 끝났는지, True인지).

틀린 곳을 앞뒤 줄과 함께 보여주는 스크립트

매번 편집기에서 줄 번호를 찾아가기 번거로워서, 오류 위치의 앞뒤 두 줄과 ^ 표시, 그리고 원인 힌트를 같이 출력하는 스크립트를 만들었습니다. 한글처럼 두 칸을 차지하는 글자가 앞에 있어도 ^가 어긋나지 않게 unicodedata.east_asian_width로 칸 수를 셉니다. 파일 여러 개를 한 번에 받을 수 있고, 하나라도 깨져 있으면 종료 코드 1로 끝나서 다른 스크립트 앞에 검사용으로 끼워 넣을 수 있습니다.

"""python jsonwhere.py <파일...> — 깨진 JSON의 틀린 곳을 앞뒤 문맥과 함께 보여줍니다."""
import json
import sys
import unicodedata

HINTS = [
    ("Unexpected UTF-8 BOM", "파일 맨 앞에 BOM이 있습니다. encoding='utf-8-sig'로 읽거나 BOM 없이 다시 저장하세요."),
    ("Extra data", "값 하나가 끝난 뒤에 내용이 더 있습니다. 한 줄에 하나씩인 JSON Lines라면 줄마다 json.loads 하세요."),
    ("Invalid \\escape", "문자열 안 역슬래시는 \\\\ 두 개로 쓰거나 / 로 바꾸세요(윈도우 경로)."),
    ("Expecting ',' delimiter", "바로 앞 항목 끝에 쉼표가 빠졌을 가능성이 큽니다(표시 위치 바로 앞이나 앞줄을 보세요)."),
    ("Invalid control character", "문자열 안에 줄바꿈·탭이 그대로 들어 있습니다. \\n, \\t 로 바꾸세요."),
]


def width(s):
    """터미널에서 차지하는 칸 수(한글 등 전각 문자는 2칸)."""
    return sum(2 if unicodedata.east_asian_width(c) in "WF" else 1 for c in s)


def hint_for(e, text):
    for key, msg in HINTS:
        if e.msg.startswith(key):
            return msg
    before = text[:e.pos].rstrip()
    here = text[e.pos:e.pos + 10]
    if not text.strip():
        return "파일이 비어 있습니다."
    if not text.rstrip().endswith(("}", "]")):
        return "파일이 중간에 끝났습니다(저장 중 끊김·다운로드 실패 확인)."
    if here.startswith(("//", "/*", "#")):
        return "JSON에는 주석을 쓸 수 없습니다."
    if e.msg.startswith("Expecting property name"):
        if here.startswith("'"):
            return "키를 작은따옴표로 감쌌습니다. JSON은 큰따옴표만 됩니다."
        if before.endswith(","):
            return "닫는 괄호 앞에 쉼표가 남았습니다(마지막 항목 뒤 쉼표)."
    if e.msg.startswith("Expecting value"):
        for word in ("True", "False", "None", "NaN", "'"):
            if here.startswith(word):
                return f"{word} 는 JSON 값이 아닙니다(true/false/null, 큰따옴표)."
    return ""


def show(path, text, e, around=2, span=40):
    print(f"{path}: {e.msg} — 줄 {e.lineno}, 칸 {e.colno:,} (문자 위치 {e.pos:,})")
    lines = text.splitlines() or [""]
    line = lines[e.lineno - 1] if e.lineno <= len(lines) else ""
    if len(line) > 200:  # 한 줄짜리(압축된) JSON은 위치 앞뒤만 잘라서
        start = max(0, e.colno - 1 - span)
        seg = line[start:e.colno - 1 + span]
        print(f"   …{seg}…")
        print("    " + " " * width(line[start:e.colno - 1]) + "^")
    else:
        for n in range(max(1, e.lineno - around), min(len(lines), e.lineno + around) + 1):
            mark = ">" if n == e.lineno else " "
            print(f"{mark}{n:4} | {lines[n - 1]}")
            if n == e.lineno:
                print("       " + " " * width(lines[n - 1][:e.colno - 1]) + "^")
    h = hint_for(e, text)
    if h:
        print(f"   힌트: {h}")


def main(paths):
    bad = 0
    for path in paths:
        with open(path, encoding="utf-8") as f:
            text = f.read()
        try:
            json.loads(text)
            print(f"{path}: OK")
        except json.JSONDecodeError as e:
            bad += 1
            show(path, text, e)
        print()
    return 1 if bad else 0


if __name__ == "__main__":
    sys.stdout.reconfigure(encoding="utf-8")
    sys.exit(main(sys.argv[1:]))

쉼표가 빠진 파일에 돌린 결과입니다. 7번 줄을 가리키지만 실제로 고칠 곳은 바로 위 6번 줄 끝이라는 게 한눈에 보입니다.

samples/03_missing_comma.json: Expecting ',' delimiter — 줄 7, 칸 7 (문자 위치 110)
    5 |       "id": "codetipbox-publish",
    6 |       "time": "10:00"
>   7 |       "enabled": true
             ^
    8 |     },
    9 |     {
   힌트: 바로 앞 항목 끝에 쉼표가 빠졌을 가능성이 큽니다(표시 위치 바로 앞이나 앞줄을 보세요).

처음 돌렸을 때 틀린 힌트 두 개

첫 버전을 10종 샘플에 돌려 보니 힌트 두 개가 틀렸습니다. 하나는 주석 파일에 “마지막 쉼표” 힌트가 붙은 것입니다.

samples/04_comment.json: Expecting property name enclosed in double quotes — 줄 3, 칸 3 (문자 위치 32)
    2 |   "site": "codetipbox.com",
>   3 |   // 매일 도는 작업
         ^
    4 |   "jobs": [
   힌트: 닫는 괄호 앞에 쉼표가 남았습니다(마지막 항목 뒤 쉼표).

“오류 위치 바로 앞 글자가 쉼표면 마지막 쉼표”를 먼저 검사했는데, 주석 바로 앞 줄도 쉼표로 끝나서 그 조건에 걸린 것이었습니다. 주석(//, /*, #) 검사를 쉼표 검사보다 앞으로 옮겨 고쳤습니다.

다른 하나는 잘린 파일에 힌트가 아예 안 나온 것입니다. “오류 위치가 파일 끝이면 잘림”으로 판단했는데, 잘린 자리에 tr 두 글자가 남아 있어서 오류 위치(122)가 파일 끝(124)과 달랐습니다. “파일이 }나 ]로 끝나지 않으면 잘림”으로 바꾸니 맞게 나왔습니다. 고친 뒤에는 10종 모두 맞는 힌트가 나왔고, 11개 파일을 검사하는 데 0.05초가 걸렸습니다.

samples/04_comment.json: Expecting property name enclosed in double quotes — 줄 3, 칸 3 (문자 위치 32)
>   3 |   // 매일 도는 작업
         ^
   힌트: JSON에는 주석을 쓸 수 없습니다.

samples/06_truncated.json: Expecting value — 줄 7, 칸 18 (문자 위치 122)
    6 |       "time": "10:00",
>   7 |       "enabled": tr
                        ^
   힌트: 파일이 중간에 끝났습니다(저장 중 끊김·다운로드 실패 확인).

samples/05_backslash.json: Invalid \escape — 줄 15, 칸 17 (문자 위치 235)
>  15 |   "log_dir": "C:\logs\hub"
                       ^
   힌트: 문자열 안 역슬래시는 \\ 두 개로 쓰거나 / 로 바꾸세요(윈도우 경로).
[exit 1, 0.05초]

31.7MB 한 줄짜리 JSON에서 오류 찾기

API 응답이나 내보낸 데이터는 줄바꿈 없이 한 줄로 압축돼 있는 경우가 많습니다. 이럴 때 “줄 1, 칸 23,147,167″이라는 정보는 거의 쓸모가 없습니다. 편집기가 그 한 줄을 여는 것부터 버겁기 때문입니다. 한글 제목이 들어간 레코드 30만 건을 한 줄 JSON(31.7MB)으로 만들고, 90% 지점의 쉼표 하나를 지워서 시험했습니다.

$ python bigfile.py
big_bad.json: 31.7MB, 줄 수 1
big_good.json: OK, 0.41초
big_bad.json: Expecting ',' delimiter (줄 1, 칸 23,147,167), 0.42초

$ python jsonwhere.py samples/big_bad.json
samples/big_bad.json: Expecting ',' delimiter — 줄 1, 칸 23,147,167 (문자 위치 23,147,166)
   …n","json"],"views":1893059},{"id":270438"title":"글 제목 270438번 — 자동화 실측","tags":[…
                                            ^

정상 파일을 읽는 데 0.41초, 깨진 파일이 오류를 내기까지 0.42초였습니다. 오류를 찾는 데 따로 시간이 더 들지 않는다는 뜻입니다. 스크립트는 줄이 200자를 넘으면 오류 위치 앞뒤 40자만 잘라 보여주게 해 두었는데, 덕분에 "id":270438"title" 사이에 쉼표가 빠진 게 바로 보였습니다.

JSON Lines는 줄 단위로 읽어 깨진 줄만 골라내기

로그나 수집 데이터는 한 줄에 객체 하나씩 쓰는 JSON Lines(.jsonl) 형식이 많습니다. 이걸 json.load()로 통째로 읽으면 앞의 재현처럼 Extra data가 납니다. 줄마다 따로 읽으면 깨진 줄만 건너뛸 수 있습니다. 30만 줄 중 123,457번째 줄을 앞 30글자만 남기고 잘라서 시험했습니다.

import json

good, broken = [], []
with open("big.jsonl", encoding="utf-8") as f:
    for n, line in enumerate(f, 1):
        if not line.strip():
            continue
        try:
            good.append(json.loads(line))
        except json.JSONDecodeError as e:
            broken.append((n, e.msg, line[:40]))
print(f"정상 {len(good):,}줄, 깨진 줄 {broken}")
big.jsonl: 정상 299,999줄, 깨진 줄 [(123457, 'Invalid control character at', '{"id": 123456, "title": "글 제목 \n')], 1.21초

깨진 줄 1개만 정확히 골라냈고 나머지 299,999줄은 살렸습니다. 1.21초 걸렸습니다. 재미있는 건 메시지였습니다. 잘린 파일인데 “Expecting value”가 아니라 Invalid control character at이 나왔습니다. 문자열 도중에 잘려서, 파서 입장에서는 문자열 안에 줄바꿈 문자가 그대로 들어온 것으로 보였기 때문입니다.

JSONDecodeError가 아니라 UnicodeDecodeError가 날 때

윈도우에서는 open()에 encoding을 안 적으면 다른 오류가 납니다. 이 노트북의 기본 인코딩은 cp949였고, BOM이 붙은 파일을 encoding 없이 읽으니 JSON 오류가 아니라 인코딩 오류로 터졌습니다.

$ python encoding_check.py
기본 인코딩: cp949
00_good.json encoding 생략: OK, log_dir=C:\logs\hub
10_bom.json encoding 생략: UnicodeDecodeError: 'cp949' codec can't decode byte 0xbf in position 2: illegal multibyte sequence
00_good.json utf-8-sig: OK, site=codetipbox.com
10_bom.json utf-8-sig: OK, site=codetipbox.com

정리하면 BOM 파일은 encoding 생략 시 UnicodeDecodeError, encoding="utf-8"이면 Unexpected UTF-8 BOM이 납니다. encoding="utf-8-sig"로 읽으면 BOM이 있든 없든 둘 다 정상으로 읽혔습니다. 남이 만든 JSON 파일을 받는 스크립트라면 utf-8-sig로 읽는 편이 안전합니다.

주의할 점

  • 오류 위치는 틀린 곳이 아니라 파서가 멈춘 곳입니다. 쉼표 빠짐은 다음 줄, 마지막 쉼표는 닫는 괄호를 가리켰습니다. 표시된 위치의 바로 앞부터 보세요.
  • 같은 메시지에 원인이 여러 개입니다. “Expecting property name enclosed in double quotes”는 마지막 쉼표·작은따옴표·주석 세 경우 모두에서 나왔습니다.
  • 윈도우 경로는 역슬래시를 두 번 쓰거나 슬래시로 바꾸세요. "C:\logs"는 Invalid \escape가 납니다.
  • 오류를 숨기려고 문자열을 고쳐서 다시 읽는 방식은 피하세요. 예를 들어 작은따옴표를 큰따옴표로 일괄 치환하면 값 안의 작은따옴표까지 바뀝니다. 원인을 찾아 원본을 고치는 게 낫습니다.
  • 힌트 규칙은 직접 만든 샘플 10종 기준입니다. 처음 버전에서 두 개가 틀렸던 것처럼, 새로운 유형이 나오면 샘플을 추가해서 다시 확인하세요.

마무리

JSONDecodeError가 나면 표시된 위치의 바로 앞부터 보고, 메시지보다 그 자리의 실제 글자를 확인하세요.

가장 작은 첫걸음은 이렇습니다.

  1. 지금 쓰는 json.load()를 try/except json.JSONDecodeError as e로 감싸고 e.lineno, e.colno를 출력하게 합니다.
  2. 위 jsonwhere.py를 저장해 두고, 오류가 나면 python jsonwhere.py 파일.json으로 앞뒤 줄을 봅니다.
  3. 남에게 받는 JSON이라면 encoding="utf-8-sig"로 읽고, 한 줄에 하나씩인 데이터는 줄 단위로 읽어 깨진 줄만 따로 모읍니다.

다음 글에서는 내 사이트의 깨진 링크를 자동으로 점검하는 파이썬 스크립트를 실제로 돌려 보겠습니다.

댓글 남기기