JSONDecodeError Expecting value line 1 column 1 (char 0) 해결 가이드
분명히 JSON API를 호출했는데 콘솔에 이렇게 뜹니다.
json.decoder.JSONDecodeError: Expecting value: line 1 column 1 (char 0)"내가 받은 게 JSON인데 왜 첫 글자(char 0)부터 터지지?" 싶죠. 이 에러, 5분이면 끝납니다. 결론부터 말하면 이 메시지는 거의 100% "파서가 받은 텍스트가 사실 JSON이 아니었다"는 뜻입니다. 빈 문자열이거나, HTML 에러 페이지거나, 이미 파싱된 객체였던 거죠. 이 글은 파이썬 개발 가이드 시리즈 4편으로, 진단표 → 복붙 코드 2단 구조로 바로 해결까지 끌고 갑니다.
5초 자가진단표
먼저 본인 상황을 1분 안에 특정하세요. 핵심은 파싱하기 직전에 실제로 받은 값을 찍어보는 것입니다.
| 증상 | 의심 원인 | 1줄 확인법 | 해결 |
|---|---|---|---|
response.text가 '' | 빈 응답 (204, 타임아웃, rate limit) | print(repr(r.text)) → '' | ① 빈 문자열 가드 |
내용이 <!DOCTYPE html>로 시작 | HTML·에러페이지 응답 (404/500/로그인) | r.text[:50]에 <html 포함 | ② raise_for_status + content-type |
맨 앞에 \ufeff | BOM 포함 인코딩 | r.text[:1] == '\ufeff' | ③ BOM 제거 |
| 이미 dict인데 또 파싱 | 이중 인코딩 / loads 두 번 호출 | type(data) 확인 | ④ 이중 파싱 함정 |
Content-Type: text/html | 잘못된 content-type | r.headers['Content-Type'] | ② content-type 확인 |
진단의 절대 1순위 한 줄:
print(response.status_code, repr(response.text[:200]))이 한 줄이면 다섯 가지 중 어떤 상황인지 즉시 보입니다.repr로 찍어야 빈 문자열·공백·BOM이 눈에 보입니다.
원인별 재현 코드 & 복붙 해결 코드
① 빈 응답 — 빈 문자열 가드
가장 흔합니다. 서버가 204 No Content를 주거나, 429(rate limit)·타임아웃으로 본문이 비어버리는 경우입니다.
# ❌ 나쁜 예
import requests
r = requests.get("https://api.example.com/data")
data = r.json() # 본문이 ''이면 char 0에서 터짐# ✅ 고친 예
r = requests.get("https://api.example.com/data", timeout=10)
print(r.status_code, repr(r.text[:200])) # 먼저 눈으로 확인
if not r.text.strip(): # 빈 문자열/공백 가드
data = {} # 안전한 기본값
else:
data = r.json()② HTML·에러페이지 / 잘못된 content-type
404가 예쁜 에러 HTML을 주거나, 인증 만료로 로그인 페이지가 돌아오는 경우입니다. 본문이 <!DOCTYPE html>로 시작하니 JSON 파서가 첫 글자 <에서 멈춥니다.
# ✅ 상태코드와 content-type을 먼저 검증
r = requests.get(url, timeout=10)
r.raise_for_status() # 4xx/5xx면 여기서 예외 발생
ctype = r.headers.get("Content-Type", "")
if "application/json" not in ctype:
raise ValueError(f"JSON이 아님: {ctype} / {r.text[:200]!r}")
data = r.json()③ BOM 포함
일부 공공·금융 OpenAPI는 UTF-8 BOM(\ufeff)을 붙여 보냅니다. 이때는 json 표준이 아니라 utf-8-sig로 디코딩하면 됩니다.
# ✅ requests는 보통 알아서 처리하지만, 직접 bytes를 다룰 땐
import json
raw = r.content # bytes
data = json.loads(raw.decode("utf-8-sig"))인코딩 자체(UnicodeDecodeError, utf-8 디코딩)가 본론이라면 본 시리즈 3편을 참고하세요. 여기서는 BOM 한 줄만 짚고 넘어갑니다.
④ 이중 인코딩 함정
이미 r.json()이나 json.loads()로 파싱이 끝난 객체를 다시 파싱하면 터집니다. dict를 loads에 넣으면 TypeError지만, 이미 파싱된 문자열을 또 넣거나 None을 넣으면 같은 JSONDecodeError가 납니다.
# ❌ 두 번 파싱
data = r.json() # 이미 dict
data = json.loads(data) # 다시 파싱 → 에러
# ✅ 한 번만
data = r.json() # 끝. type(data)로 확인하는 습관json.loads vs json.load 구분
이 둘을 헷갈리면 같은 에러가 납니다. 핵심만 외우세요.
| 함수 | 입력 | 용도 |
|---|---|---|
json.loads(s) | 문자열/bytes | API 응답 텍스트 파싱 |
json.load(f) | 파일 객체 | 열린 파일에서 파싱 |
# ❌ 흔한 실수: 파일 "경로 문자열"을 loads에 넣음
json.loads("data.json") # 경로를 JSON으로 파싱 → char 0 에러
# ✅ 파일은 load
with open("data.json", encoding="utf-8") as f:
data = json.load(f)requests 실전 디버깅 절차
404가 HTML을 반환하거나 인증 리다이렉트로 로그인 페이지가 올 때는 다음 순서로 추적합니다.
print(r.status_code)— 200이 아니면 본문은 십중팔구 에러 페이지.print(r.url)— 리다이렉트로 로그인 URL로 튕겼는지 확인.print(r.headers.get("Content-Type"))—text/html이면 JSON 아님 확정.print(repr(r.text[:300]))—<title>로그인</title>같은 단서 확인.- 토큰 만료면 헤더 갱신, rate limit(429)이면 백오프 후 재시도.
실무 경험 한마디: LLM·OpenAI·국내 AI API를 붙이면서 이 문의가 부쩍 늘었습니다. 429가 JSON이 아닌 HTML로 오거나, 스트리밍 응답을 통째로 json() 돌리거나, 토큰 만료 리다이렉트로 로그인 HTML이 오는 게 대표 3종입니다. 저는 외부 API 호출부에 무조건 status_code + text[:200] 로깅을 깔아두는데, 이거 하나로 디버깅 시간이 절반으로 줄었습니다.
결론: try/except 안전 처리 패턴
마지막은 재발 방지입니다. 2026년 기준 requests 2.x에서는 requests.exceptions.JSONDecodeError가 표준이 되었습니다(내부적으로 표준 json.JSONDecodeError를 상속). 둘 다 잡아두면 안전합니다.
import logging
import requests
from json import JSONDecodeError
def safe_get_json(url, **kwargs):
r = requests.get(url, timeout=10, **kwargs)
try:
r.raise_for_status()
if not r.text.strip():
logging.warning("빈 응답: %s", url)
return None
return r.json()
except (JSONDecodeError, requests.exceptions.JSONDecodeError):
# 원문 일부를 남겨야 나중에 원인이 보인다
logging.error(
"JSON 파싱 실패 | status=%s ctype=%s body=%r",
r.status_code,
r.headers.get("Content-Type"),
r.text[:200],
)
return None최종 체크리스트
- 파싱 직전
print(status_code, repr(text[:200]))로 실제 값 확인 -
raise_for_status()로 4xx/5xx 걸러내기 -
if not text.strip()빈 문자열 가드 - content-type이
application/json인지 확인 - 이미 파싱된 객체를 다시
loads하지 않기 -
try/except로 fallback + 원문 로깅
자주 묻는 질문 (FAQ)
Q. status_code는 200인데 왜 JSONDecodeError가 나나요?
A. 200이라도 본문이 빈 문자열이거나 HTML일 수 있습니다. 200 = JSON 보장이 아닙니다. 반드시 repr(r.text[:200])로 실제 본문을 확인하세요.
Q. requests.exceptions.JSONDecodeError와 json.JSONDecodeError 중 뭘 잡아야 하나요?
A. requests 2.x에서는 전자가 후자를 상속하므로 둘 다 잡으면 안전합니다. requests 응답만 다룬다면 requests.exceptions.JSONDecodeError 하나로 충분합니다.
Q. 파일을 읽는데 같은 에러가 나요.
A. 파일 "경로 문자열"을 json.loads()에 넣었을 가능성이 큽니다. 파일은 open() 후 json.load(f)를 쓰세요.
다음 편에서는 파싱에 성공한 뒤 마주치는 KeyError와 TypeError: 'NoneType' object is not subscriptable 디버깅을 다룹니다.
이 글은 AI 에이전트가 자료 조사와 1차 초안 작성을 담당하고, 사람 편집자가 사실관계·출처·톤과 맥락을 검토한 뒤 발행했습니다. 환경(OS·버전)에 따라 결과가 다를 수 있으니 적용 전 공식 문서를 함께 확인하세요. 오류를 발견하시면 이메일로 제보해 주세요 — 확인 후 신속히 정정합니다.
댓글
첫 번째 댓글을 남겨보세요.