← 전체 글

JSON 위치 0의 Unexpected Token <: 받은 건 HTML

"Unexpected token <" 오류는 JSON.parse가 JSON이 아닌 HTML 페이지(404, 로그인 리다이렉트, 잘못된 URL)를 받았다는 뜻. 그 이유를 깨진/고친 fetch 예시와 함께 설명.

SyntaxError: Unexpected token '<', "<!DOCTYPE "... is not valid JSON —— 거의 항상 한 가지를 의미합니다: 코드가 JSON이 아니라 HTML 페이지 에 대해 JSON.parse()(또는 res.json())을 호출했다는 뜻. 위치 0의 <<!DOCTYPE html> 또는 <html> 의 시작입니다. 정확히 왜 발생하고 어떻게 고치는지 설명합니다.

에러의 모습

// V8 (Chrome / Node / Edge)
SyntaxError: Unexpected token '<', "<!DOCTYPE "... is not valid JSON
// 더 오래된 V8
SyntaxError: Unexpected token < in JSON at position 0

// Firefox
SyntaxError: JSON.parse: unexpected character at line 1 column 1 of the JSON data

// Safari
SyntaxError: JSON Parse error: Unexpected identifier "<"

위치 0 이 핵심 단서: 첫 바이트부터 이미 틀렸음 —— 응답은 처음부터 JSON이 아니었습니다.

왜 발생하는가: HTML을 받았다

fetch 나 AJAX 호출이 기대한 JSON 대신 HTML 문서를 돌려줬습니다. 흔한 원인:

  • 프레임워크나 웹 서버가 HTML로 제공하는 404 / 500 에러 페이지
  • HTML 로그인 페이지로 가는 로그인/인증 리다이렉트(세션 만료)
  • 잘못된 URL —— API 엔드포인트 대신 app/SPA 라우트에 도달
  • 프록시, CDN, 캡티브 포털 이 자체 HTML 페이지를 반환

깨진 예

const res  = await fetch('/api/user');   // 서버가 404 HTML 페이지를 돌려줌
const data = await res.json();           // ❌ Unexpected token '<' …
// res.json() 이 "<!DOCTYPE html>…" 을 JSON으로 파싱하려 함

수정된 예

const res = await fetch('/api/user');

// 1) 파싱 전에 상태 확인
if (!res.ok) {
  throw new Error(`HTTP ${res.status} ${res.statusText}`);
}

// 2) 선택: 정말 JSON인지 확인
const type = res.headers.get('content-type') ?? '';
if (!type.includes('application/json')) {
  const text = await res.text();
  throw new Error(`Expected JSON, got: ${text.slice(0, 80)}…`);
}

const data = await res.json();           // ✅ 안전

알아 둘 만한, 덜 분명한 두 가지 원인

  • WAF 차단/요율 제한 응답. WAF, CDN 또는 DDoS 보호 계층이 호출을 가로채 자체 HTML 챌린지/차단 페이지를 돌려줄 수 있습니다 —— Cloudflare 인터스티셜, AWS WAF 거부 페이지, 공공 Wi-Fi의 캡티브 포털. 상태는 종종 403 / 429 / 503 에 HTML 본문. Network 탭에서 응답을 확인하고 CF-Ray X-Amz-Cf-Id 같은 헤더로 식별하세요.
  • SPA 히스토리 폴백이 index.html 을 돌려줌. 정적 호스트와 개발 서버는 클라이언트 라우터가 처리하도록 알 수 없는 경로를 모두 /index.html 로 재작성하는 경우가 흔합니다. 프런트엔드가 실수로 /api/user 를 상대 경로로 fetch하고 프록시가 설정되지 않았다면, 서버는 SPA의 HTML 셸을 200 으로 조용히 돌려줍니다 —— '200 OK인데 HTML' 인 혼란스러운 버전의 같은 에러가 됩니다.

고치는 단계

  1. 원시 본문 로깅:console.log(await res.clone().text()). <!DOCTYPE<html> 로 시작하면 HTML.
  2. res.json() 호출 전에 res.ok / res.status 확인 —— 4xx/5xx 는 보통 HTML 에러 페이지를 돌려줍니다.
  3. Network 탭에서 URL 확인. 상대 경로가 API가 아니라 SPA의 index.html 로 풀릴 수 있음.
  4. 인증 확인 —— 로그인 페이지로의 리다이렉트는 토큰/세션 만료를 의미.
  5. Content-Type 검사 —— 이에 따라 분기해 비-JSON 응답을 맹목적으로 파싱하지 마세요.

자주 묻는 질문

'Unexpected token < in JSON at position 0' 이 무슨 뜻?

파서가 < 를 첫 문자로 발견. 이는 어떤 합법한 JSON에서도 불가능. 응답은 코드가 기대한 JSON이 아니라 HTML(<!DOCTYPE> 또는 <html> 페이지)이었습니다.

왜 내 fetch 가 JSON 대신 HTML 을 돌려주나요?

흔한 원인: 404/500 에러 페이지, 로그인 페이지로의 인증 리다이렉트, 또는 API가 아니라 프런트엔드로 풀리는 잘못된 URL. res.ok 를 확인하고 await res.text() 를 로그해 무엇이 돌아왔는지 보세요.

내 앱이 죽지 않게 하려면?

파싱 전에 가드: res.okContent-Type 헤더를 검사하고 JSON.parse()/res.json() 을 try/catch 로 감싸 HTML 응답을 예외가 아닌 처리 가능한 에러로 만드세요.

지금 고치기

확신이 없는 응답 본문이 있다면 JSON Fix 에 붙여 넣으세요 —— 그게 유효한 JSON인지 다른 것(예: HTML)인지 즉시 알려 줍니다. 모든 처리는 브라우저에서.