설정 파일이나 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가 나면 표시된 위치의 바로 앞부터 보고, 메시지보다 그 자리의 실제 글자를 확인하세요.
가장 작은 첫걸음은 이렇습니다.
- 지금 쓰는
json.load()를try/except json.JSONDecodeError as e로 감싸고e.lineno,e.colno를 출력하게 합니다. - 위 jsonwhere.py를 저장해 두고, 오류가 나면
python jsonwhere.py 파일.json으로 앞뒤 줄을 봅니다. - 남에게 받는 JSON이라면
encoding="utf-8-sig"로 읽고, 한 줄에 하나씩인 데이터는 줄 단위로 읽어 깨진 줄만 따로 모읍니다.
다음 글에서는 내 사이트의 깨진 링크를 자동으로 점검하는 파이썬 스크립트를 실제로 돌려 보겠습니다.