/개발/JSONDecodeError Expecting value line 1 column 1 (char 0) 원인과 해결
개발JSONDecodeError파이썬 에러 해결

JSONDecodeError Expecting value line 1 column 1 (char 0) 원인과 해결

json.decoder.JSONDecodeError: Expecting value: line 1 column 1 (char 0) 에러의 5가지 원인을 진단표로 찾고 복붙 코드로 해결하세요. requests 빈 응답·HTML 응답·BOM·이중 파싱까지 정리한 파이썬 가이드.

JSONDecodeError Expecting value line 1 column 1 (char 0) 원인과 해결

JSONDecodeError Expecting value line 1 column 1 (char 0) 해결 가이드

분명히 JSON API를 호출했는데 콘솔에 이렇게 뜹니다.

CODE
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
맨 앞에 \ufeffBOM 포함 인코딩r.text[:1] == '\ufeff'③ BOM 제거
이미 dict인데 또 파싱이중 인코딩 / loads 두 번 호출type(data) 확인④ 이중 파싱 함정
Content-Type: text/html잘못된 content-typer.headers['Content-Type']② content-type 확인

진단의 절대 1순위 한 줄: print(response.status_code, repr(response.text[:200])) 이 한 줄이면 다섯 가지 중 어떤 상황인지 즉시 보입니다. repr로 찍어야 빈 문자열·공백·BOM이 눈에 보입니다.

원인별 재현 코드 & 복붙 해결 코드

① 빈 응답 — 빈 문자열 가드

가장 흔합니다. 서버가 204 No Content를 주거나, 429(rate limit)·타임아웃으로 본문이 비어버리는 경우입니다.

Python
# ❌ 나쁜 예
import requests
r = requests.get("https://api.example.com/data")
data = r.json()   # 본문이 ''이면 char 0에서 터짐
Python
# ✅ 고친 예
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 파서가 첫 글자 <에서 멈춥니다.

Python
# ✅ 상태코드와 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로 디코딩하면 됩니다.

Python
# ✅ 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가 납니다.

Python
# ❌ 두 번 파싱
data = r.json()              # 이미 dict
data = json.loads(data)      # 다시 파싱 → 에러

# ✅ 한 번만
data = r.json()              # 끝. type(data)로 확인하는 습관

json.loads vs json.load 구분

이 둘을 헷갈리면 같은 에러가 납니다. 핵심만 외우세요.

함수입력용도
json.loads(s)문자열/bytesAPI 응답 텍스트 파싱
json.load(f)파일 객체열린 파일에서 파싱
Python
# ❌ 흔한 실수: 파일 "경로 문자열"을 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을 반환하거나 인증 리다이렉트로 로그인 페이지가 올 때는 다음 순서로 추적합니다.

  1. print(r.status_code) — 200이 아니면 본문은 십중팔구 에러 페이지.
  2. print(r.url) — 리다이렉트로 로그인 URL로 튕겼는지 확인.
  3. print(r.headers.get("Content-Type"))text/html이면 JSON 아님 확정.
  4. print(repr(r.text[:300]))<title>로그인</title> 같은 단서 확인.
  5. 토큰 만료면 헤더 갱신, 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를 상속). 둘 다 잡아두면 안전합니다.

Python
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.JSONDecodeErrorjson.JSONDecodeError 중 뭘 잡아야 하나요? A. requests 2.x에서는 전자가 후자를 상속하므로 둘 다 잡으면 안전합니다. requests 응답만 다룬다면 requests.exceptions.JSONDecodeError 하나로 충분합니다.

Q. 파일을 읽는데 같은 에러가 나요. A. 파일 "경로 문자열"을 json.loads()에 넣었을 가능성이 큽니다. 파일은 open()json.load(f)를 쓰세요.


다음 편에서는 파싱에 성공한 뒤 마주치는 KeyErrorTypeError: 'NoneType' object is not subscriptable 디버깅을 다룹니다.

✦ ✦ ✦
편집 검토 · Editorial Review

이 글은 AI 에이전트가 자료 조사와 1차 초안 작성을 담당하고, 사람 편집자가 사실관계·출처·톤과 맥락을 검토한 뒤 발행했습니다. 환경(OS·버전)에 따라 결과가 다를 수 있으니 적용 전 공식 문서를 함께 확인하세요. 오류를 발견하시면 이메일로 제보해 주세요 — 확인 후 신속히 정정합니다.

초안 · AI (Content Reviewer)·검토 · Nodelog 편집자·발행 ·
관련 공식 문서Python 공식 문서

댓글

첫 번째 댓글을 남겨보세요.