~ / posts / developer
💻 개발자

JSON 파싱 에러 Unexpected token 원인 6가지와 해결법 총정리

SyntaxError: Unexpected token '<', "<!DOCTYPE "... is not valid JSON

이 메시지를 처음 봤을 때 대체 JSON 어디에 < 가 있냐고 한참 들여다본 기억이 있어요. 결론부터 말하면 JSON 파싱 에러의 원인은 거의 여섯 가지 — HTML 응답, 빈 응답, 끝 쉼표·작은따옴표, 파이썬 값(True/None), 이스케이프 안 된 제어 문자, 보이지 않는 문자 — 중 하나예요. 에러 문구만 보고도 어느 쪽인지 대부분 좁힐 수 있습니다.

설정 파일, API 응답, 테스트 픽스처를 몇 년 만지면서 모은 사례를 실제 에러 메시지 기준으로 정리했어요. 이 글의 에러 문구는 전부 Node.js 22.18(V8)과 Python 3.9에서 직접 실행해서 복사한 거라, 검색창에 붙여 넣은 문구와 그대로 대조해 보시면 됩니다.

핵심 요약

Unexpected token '<' 는 JSON이 아니라 HTML 에러 페이지를 받은 것이니 상태 코드부터 보세요.

Expected double-quoted property name 은 끝 쉼표, 작은따옴표, 따옴표 없는 키 중 하나예요.

Unexpected end of JSON input 은 빈 응답이나 중간에 잘린 응답이고, 눈에 안 보이는 BOM·둥근 따옴표·NBSP는 코드 포인트를 찍어 봐야 잡힙니다.

position 숫자는 0부터 세는 문자 오프셋이라, 줄·열로 바꾸는 작은 함수 하나 두면 디버깅이 훨씬 빨라져요.

에러 메시지별 원인 한눈에 보기 (Node·브라우저·Python)

먼저 지도부터 펼쳐 놓을게요. 같은 잘못된 입력이라도 런타임마다 메시지가 다르게 나와서, 검색할 때 헷갈리기 쉬워요.

JSON 파싱 에러 메시지 6종과 각각의 가장 흔한 원인을 화살표로 연결한 표
에러 문구가 보이면 오른쪽 원인부터 확인
잘못된 입력Node 22 / ChromePython json.loads
{"a":1,} (끝 쉼표)Expected double-quoted property name in JSON at position 7Expecting property name enclosed in double quotes: line 1 column 8 (char 7)
{'id': 1} (작은따옴표)Expected property name or '}' in JSON at position 1Expecting property name enclosed in double quotes: line 1 column 2 (char 1)
{"ok": True}Unexpected token 'T', ... is not valid JSONExpecting value: line 1 column 8 (char 7)
빈 문자열Unexpected end of JSON inputExpecting value: line 1 column 1 (char 0)
HTML 페이지Unexpected token '<', "<!DOCTYPE "... is not valid JSONExpecting value: line 1 column 1 (char 0)
BOM으로 시작Unexpected token '', ... is not valid JSONUnexpected UTF-8 BOM (decode using utf-8-sig)
문자열 안 실제 줄바꿈Bad control character in string literal in JSON at position 11Invalid control character at: line 1 column 8 (char 7)
{"a": NaN}Unexpected token 'N'에러 없음 (nan 으로 파싱됨)

참고로 Node 18 이하나 예전 크롬에서는 Unexpected token } in JSON at position 7 처럼 더 짧은 문구가 나왔어요. 검색 결과에 옛 문구가 많이 보이는 이유가 이거예요. 문구는 달라도 원인 분류는 똑같습니다.

마지막 줄이 재미있는데, 파이썬 json 모듈은 기본값으로 NaN, Infinity 를 받아 줘요. 그래서 파이썬 서버가 만든 JSON을 JS 프런트가 못 읽는 일이 생깁니다. 이건 아래에서 다시 다룰게요.

1. Unexpected token '<' — JSON이 아니라 HTML을 받았다

position 0 근처에서 < 를 만났다면 JSON 문법 문제가 아닐 가능성이 99%예요. 서버가 404·500 에러 페이지나 로그인 페이지 HTML을 돌려줬는데, 클라이언트가 확인 없이 res.json() 을 호출한 거죠. 프런트 개발 서버에서 API 경로를 잘못 프록시해서 index.html 이 돌아오는 경우도 정말 흔합니다.

// 나쁜 예: 상태 코드와 타입을 안 보고 바로 파싱
const data = await (await fetch('/api/user')).json();

// 좋은 예: 실패 응답은 본문을 텍스트로 먼저 확인
const res = await fetch('/api/user');
const type = res.headers.get('content-type') || '';
if (!res.ok || !type.includes('application/json')) {
  const body = await res.text();
  throw new Error(`HTTP ${res.status} ${type}: ${body.slice(0, 200)}`);
}
const data = await res.json();

이 패턴으로 바꾸면 Unexpected token '<' 대신 HTTP 404 text/html: <!DOCTYPE html>... 같은 메시지가 남아서, 진짜 원인(경로 오타, 인증 만료, 게이트웨이 장애)이 로그에 바로 보여요. 응답을 한 단계씩 확인하는 습관은 API 응답 디버깅 습관 글에 더 자세히 정리해 뒀어요.

2. Unexpected end of JSON input — 비었거나 잘렸다

입력이 끝났는데 JSON이 아직 안 끝났다는 뜻이에요. 실무에서 보는 원인은 대략 이렇습니다.

빈 본문 대응은 단순해요. 텍스트로 먼저 받고, 비어 있으면 파싱하지 않는 거죠.

const text = await res.text();
const data = text ? JSON.parse(text) : null;

잘림이 의심되면 응답 바이트 수를 찍어 보세요. 매번 정확히 같은 크기(예: 65,536바이트)에서 끊긴다면 코드가 아니라 버퍼나 프록시 설정 문제일 확률이 높아요.

3. 끝 쉼표·작은따옴표·따옴표 없는 키 — Expected property name

가장 흔한 문법 실수 세 가지가 같은 계열의 메시지를 냅니다. 자바스크립트 객체 리터럴, 파이썬 딕셔너리는 다 허용하는 문법이라 손이 그렇게 길들어 있거든요. JSON 표준(RFC 8259)은 셋 다 허용하지 않아요.

{
  "name": "smith",
  "tags": ["a", "b",],
}

위 예시는 배열 안과 객체 끝, 두 군데가 틀렸어요. Node 22에서는 먼저 만나는 배열 쪽에서 Unexpected token ']' 로 멈춥니다. 항목을 복사해 붙이다가 마지막 줄을 지웠는데 쉼표는 남는 경우가 대부분이에요.

작은따옴표 {'id': 1} 는 파이썬에서 딕셔너리를 print() 한 결과를 복사해 올 때 자주 생겨요. 같은 맥락으로 True, None 이 보이면 십중팔구 파이썬 repr을 붙여 넣은 거예요. 파이썬에서 진짜 JSON을 얻으려면 반드시 json.dumps() 를 써야 합니다.

import json
d = {'ok': True, 'v': None, 'name': '한글'}
print(str(d))                              # {'ok': True, 'v': None, 'name': '한글'}  ← JSON 아님
print(json.dumps(d))                       # {"ok": true, "v": null, "name": "\ud55c\uae00"}
print(json.dumps(d, ensure_ascii=False))   # {"ok": true, "v": null, "name": "한글"}

ensure_ascii=False 를 안 주면 한글이 \ud55c 같은 이스케이프로 나와요. 이것도 유효한 JSON이라 파싱은 되지만, 로그에서 읽기 어려우니 사람이 볼 출력에는 꺼 두는 편이 좋아요.

따옴표 없는 키 {id: 1} 는 사람이 손으로 JSON을 쓸 때 잘 나와요. 키는 전부 문자열, 문자열은 전부 큰따옴표. 이 규칙 하나로 2번과 3번이 같이 해결돼요.

4. 주석과 NaN — 다른 언어에선 되는데 JSON에선 안 되는 것

설정 파일에 // 운영에서만 바꿀 것 같은 주석을 달고 싶은 마음은 이해합니다. 하지만 표준 JSON에는 주석 문법이 없어요. Node 22에서 {"a":1 // c} 를 파싱하면 Expected ',' or '}' after property value in JSON at position 7 이 나와요.

VS Code 설정(settings.json)이나 tsconfig.json 에 주석이 되는 건 그 도구들이 JSONC(JSON with Comments)를 읽기 때문이에요. 그 파일을 일반 JSON.parse 나 jq 에 넣으면 바로 터집니다. 선택지를 비교하면 이래요.

방법주석끝 쉼표표준 파서 호환쓰는 곳
표준 JSONXXOAPI, 데이터 교환
JSONCO도구마다 다름XVS Code, tsconfig
JSON5OOX일부 빌드 도구 설정
"_comment" 키흉내XO표준을 지켜야 하는 설정
YAMLO해당 없음X사람이 자주 고치는 설정

NaN, Infinity 도 비슷한 함정이에요. JS의 JSON.stringify({a: NaN}) 은 {"a":null} 로 조용히 바꿔 버리고, 파이썬 json.dumps(float('nan')) 는 NaN 이라는 비표준 토큰을 그대로 내보내요. 파이썬 쪽에서 json.dumps(d, allow_nan=False) 로 두면 이런 값이 섞일 때 ValueError 로 미리 알려 줘서, 다른 언어 클라이언트가 깨지는 걸 막을 수 있어요.

5. Bad control character — 문자열 안의 진짜 줄바꿈

JSON 문자열 안에는 U+0000~U+001F 제어 문자(줄바꿈, 탭 포함)를 날것으로 넣을 수 없고, 반드시 \n, \t 처럼 이스케이프해야 해요. 템플릿 문자열로 JSON을 직접 조립하면 이게 자주 터집니다.

const memo = '첫 줄\n둘째 줄';
const bad = `{"memo": "${memo}"}`;     // 실제 줄바꿈이 문자열 안에 들어감
JSON.parse(bad);  // SyntaxError: Bad control character in string literal in JSON at position 13

const good = JSON.stringify({ memo }); // {"memo":"첫 줄\n둘째 줄"}
JSON.parse(good); // 정상

해결책은 하나예요. JSON을 문자열 이어 붙이기로 만들지 말 것. 언제나 JSON.stringify, json.dumps 같은 직렬화 함수를 거치면 따옴표·백슬래시·제어 문자를 알아서 처리해 줘요. 사용자 입력이 들어가는 경우엔 보안 문제(JSON 인젝션)까지 함께 막힙니다.

6. 눈에 안 보이는 문자 — BOM, 둥근 따옴표, NBSP

이게 제일 골치 아파요. 눈으로는 멀쩡한데 파싱이 안 되는 경우요. 범인은 보통 이렇습니다.

이 문자들은 UTF-8로 저장하면 바이트 수가 ASCII 따옴표와 달라서, 헥스 덤프로 보면 바로 티가 나요.

범인 문자별 UTF-8 바이트 수 (ASCII 큰따옴표와 비교)
BOM U+FEFFBOM U+FEFF · 3바이트3바이트둥근따옴표 U+201C둥근따옴표 U+201C · 3바이트3바이트제로폭공백 U+200B제로폭공백 U+200B · 3바이트3바이트줄구분자 U+2028줄구분자 U+2028 · 3바이트3바이트NBSP U+00A0NBSP U+00A0 · 2바이트2바이트큰따옴표 U+0022큰따옴표 U+0022 · 1바이트1바이트

3바이트짜리가 파일 맨 앞(EF BB BF)에 있으면 BOM이에요

단위: 바이트 · 자료: macOS 셸에서 printf 로 각 문자를 출력해 wc -c 로 직접 측정
표로 보기
구분UTF-8 바이트
BOM U+FEFF3
둥근따옴표 U+201C3
제로폭공백 U+200B3
줄구분자 U+20283
NBSP U+00A02
큰따옴표 U+00221

찾는 방법은 코드 포인트를 찍어 보는 거예요. 아래 함수는 ASCII 범위를 벗어나거나 허용되지 않는 제어 문자를 위치와 함께 출력합니다.

function findSuspicious(s) {
  for (const [i, ch] of [...s].entries()) {
    const cp = ch.codePointAt(0);
    if (cp > 0x7e || (cp < 0x20 && !'\n\r\t'.includes(ch))) {
      console.log(i, 'U+' + cp.toString(16).toUpperCase().padStart(4, '0'));
    }
  }
}
findSuspicious('\uFEFF{\u201cid\u201d:\u00a01}');
// 0 U+FEFF / 2 U+201C / 5 U+201D / 7 U+00A0

한글 값이 들어 있는 JSON이면 한글도 같이 찍히니, U+FEFF, U+201C~201D, U+00A0, U+200B 같은 익숙한 번호만 골라 보면 돼요. 정리는 이렇게 합니다.

import json
# BOM은 utf-8-sig 로 열면 자동 제거
data = json.load(open('config.json', encoding='utf-8-sig'))
const clean = text
  .replace(/^\uFEFF/, '')
  .replace(/[\u201C\u201D]/g, '"')
  .replace(/\u00A0/g, ' ');

다만 둥근 따옴표 치환은 문자열 값 안의 정상적인 둥근 따옴표까지 바꿔 버리니, 복붙 사고를 고치는 일회성 용도로만 쓰세요. 근본 해결은 원본 출처에서 코드 블록으로 복사하는 거예요. UTF-8 바이트 구조가 궁금하면 UTF-8과 EUC-KR 한글 바이트 수 글을 참고하세요.

position 숫자로 에러 위치 빨리 찾기

at position 34 의 숫자는 0부터 세는 문자(UTF-16 코드 유닛) 오프셋이에요. Node 20 이후로는 (line 4 column 1) 처럼 줄·열을 같이 알려 주지만, 브라우저나 구버전에서는 숫자만 나오는 경우가 많아요. 한 줄로 압축된 응답이라면 숫자를 세는 건 고역이라, 저는 이런 래퍼를 디버그용으로 둡니다.

function parseWithContext(text) {
  try {
    return JSON.parse(text);
  } catch (e) {
    const m = /position (\d+)/.exec(e.message);
    if (m) {
      const pos = Number(m[1]);
      const before = text.slice(0, pos);
      const line = before.split('\n').length;
      const col = pos - before.lastIndexOf('\n');
      const around = text.slice(Math.max(0, pos - 20), pos + 20);
      console.error(`${e.message}\n→ ${line}행 ${col}열 근처: ${JSON.stringify(around)}`);
    }
    throw e;
  }
}
// Expected double-quoted property name in JSON at position 34 (line 4 column 1)
// → 4행 1열 근처: "mith\",\n  \"age\": 30,\n}"

주변 40자를 JSON.stringify 로 감싸 찍는 게 포인트예요. 줄바꿈, 탭, 보이지 않는 문자가 \n, \u00a0 처럼 드러나거든요. 파이썬은 json.JSONDecodeError 객체가 e.lineno, e.colno, e.pos 를 속성으로 갖고 있어서 따로 계산할 필요가 없어요.

코드를 쓰기 귀찮을 때는 그냥 JSON 포맷터에 붙여 넣고 몇 번째 줄인지부터 확인합니다. 정렬되면서 어디서 깨지는지 바로 보이니까 숫자 세는 것보다 훨씬 빨라요. 문법 검증만 필요하면 JSON 검증기가 더 간단하고요.

디버깅 체크리스트 — 이 순서대로 보면 빠르다

  1. 상태 코드와 Content-Type — 200이 아니거나 text/html 이면 JSON 문제가 아니에요.
  2. 본문 길이 — 0이면 빈 응답 처리, 매번 같은 크기에서 끊기면 프록시·버퍼 의심.
  3. 첫 글자 — { 나 [ 가 아니면 BOM, HTML, 혹은 앞에 붙은 로그 출력(console.log 가 stdout에 섞인 CLI)을 의심.
  4. position 주변 40자 — 위 래퍼로 실제 문자를 확인.
  5. 이미 객체인지 — "[object Object]" is not valid JSON 이면 axios처럼 자동 파싱하는 라이브러리 결과를 한 번 더 JSON.parse 한 거예요. "undefined" is not valid JSON 이면 값 자체가 없는 거고요.
  6. 생성 쪽 확인 — 문자열 이어 붙이기로 JSON을 만들고 있다면 직렬화 함수로 바꾸기.

여섯 단계를 다 봤는데도 안 되면 그때부터 인코딩(EUC-KR로 내려온 응답을 UTF-8로 읽음)이나 압축 해제 실패를 의심할 차례예요.

자주 묻는 질문

Unexpected token in JSON at position 0 은 무슨 뜻인가요?

첫 글자부터 JSON이 아니라는 뜻이에요. 토큰이 < 면 HTML 응답, 보이지 않는 문자면 BOM, u 면 undefined 를 문자열로 넘긴 경우가 대부분이에요. 응답 본문을 res.text() 로 받아 앞 100자를 찍어 보면 바로 판별됩니다.

JSON에서 마지막 쉼표(trailing comma)를 허용하는 방법이 있나요?

표준 JSON 파서에서는 없어요. JSON5나 JSONC 파서를 쓰면 허용되지만, 그 파일은 다른 도구와 호환되지 않아요. 데이터 교환용이면 쉼표를 지우는 게 맞고, 설정 파일이면 JSONC를 지원하는 도구인지 먼저 확인하세요.

파이썬 JSONDecodeError Expecting value 는 왜 나나요?

값이 와야 할 자리에 JSON 값이 아닌 게 있다는 뜻이에요. line 1 column 1 (char 0) 이면 빈 문자열이나 HTML 응답이고, 중간 위치라면 True, None, 작은따옴표 문자열 같은 파이썬 표기가 섞인 경우가 많아요. requests 라면 r.status_code 와 r.text[:200] 을 먼저 확인하세요.

JSON 파일에 주석을 넣으려면 어떻게 하나요?

표준 JSON은 주석을 지원하지 않아요. 표준을 지켜야 하면 "_comment": "설명" 같은 키를 두고, 사람이 자주 고치는 설정이라면 YAML이나 TOML을 고려하세요. VS Code·TypeScript 설정처럼 도구가 JSONC를 공식 지원하는 경우에만 // 주석이 안전해요.

화면에서는 똑같아 보이는데 파싱이 안 되면 무엇을 봐야 하나요?

보이지 않는 문자를 의심하세요. 코드 포인트를 출력해 U+FEFF(BOM), U+201C·U+201D(둥근 따옴표), U+00A0(NBSP), U+200B(제로폭 공백)가 있는지 확인하면 됩니다. 헥스 덤프에서 파일 맨 앞이 EF BB BF 면 BOM이에요.

정리

JSON 파싱 에러는 메시지만 제대로 읽으면 원인이 금방 좁혀져요. < 는 HTML, end of input 은 빈 응답·잘림, property name 은 쉼표·따옴표, T·N 은 파이썬 값, control character 는 이스케이프 누락, 그리고 멀쩡해 보이면 보이지 않는 문자. 이 여섯 갈래를 기억해 두고, position을 줄·열로 바꾸는 래퍼 하나만 챙겨 두면 대부분 5분 안에 끝납니다.

손으로 확인하고 싶을 땐 JSON 포맷터나 JSON 검증기에 붙여 넣어 보세요. 포맷 선택 자체가 고민이라면 JSON·YAML·XML 차이 글도 같이 보면 좋아요.

#JSON 파싱 에러#Unexpected token#JSON.parse#Unexpected end of JSON input#trailing comma#JSONDecodeError#JSON 디버깅
다음 글 →JWT 구조와 서명 원리 — 헤더·페이로드·서명, HS256과 RS256 차이