홈/개발/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) 원인과 해결
목차
  1. 5초 자가진단표
  2. 원인별 재현 코드 & 복붙 해결 코드
  3. requests 실전 디버깅 절차
  4. 결론: try/except 안전 처리 패턴
  5. 자주 묻는 질문 (FAQ)

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.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 디버깅을 다룹니다.

✦ ✦ ✦
출처·검증 안내

글마다 출처와 확인 범위가 다릅니다. 아래에 확인일·확인 범위·출처가 기록된 글만 그 범위에서 확인된 것입니다. 기록이 없는 글은 적용 전 사용 중인 버전의 공식 문서로 직접 확인하고, 오류는 이메일로 알려주세요. 편집 정책 · 이메일로 제보

이 글에는 사람이 확인한 날짜·범위 기록이 아직 없습니다.

발행 ·
주제 관련 공식 문서(자동 연결 · 문장별 근거 아님)Python 공식 문서 ↗

댓글

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