Unexpected token '<', "<!DOCTYPE "... 오류 원인과 해결
메시지에 JSON 이라는 단어가 들어 있어서 JSON 을 잘못 썼나 싶지만, 이 오류가 날 때 JSON 은 한 번도 등장하지 않았습니다. 서버가 JSON 대신 사람이 볼 HTML 페이지를 보냈고, 파서가 첫 글자 `<` 에서 멈춘 것입니다. 이 글은 따옴표 안에 인용된 열 글자를 읽는 법, 같은 메시지를 내는 원인 다섯 가지를 응답 헤더로 구분하는 법, 그리고 파싱 전에 막는 코드를 다룹니다. 모든 메시지와 수치는 이 PC 에서 직접 확인한 것입니다.
응답 본문을 붙여넣으면 JSON 인지 아닌지, JSON 이라면 어느 줄이 틀렸는지 바로 알려줍니다. 브라우저 안에서만 처리해 데이터가 밖으로 나가지 않습니다.
이 메시지는 JSON 문법 오류가 아닙니다
문구에 JSON 이 들어 있으니 내가 쓴 JSON 이 틀렸나 싶지만, 이 오류가 날 때 JSON 은 아예 등장하지 않았습니다. 서버가 JSON 대신 사람이 볼 HTML 페이지를 보냈고, 파서가 첫 글자 < 를 만난 순간 포기한 것입니다. JSON 에서 값이 시작될 수 있는 글자는 {, [, 따옴표, 숫자, 음수 부호 -, t, f, n 뿐이라 < 는 들어설 자리가 없습니다.
정말 그런지 이 글을 쓰면서 확인했습니다. 이 사이트의 개발 서버(Next.js 16)에 없는 API 경로를, JSON 을 달라고 헤더에 명시해서 요청해 봤습니다.
curl -s -i -H "Accept: application/json" http://localhost:3000/api/nopeHTTP/1.1 404 Not Found
Content-Type: text/html; charset=utf-8
...
<!DOCTYPE html><html lang="ko"> <- 본문 26,283바이트Accept: application/json 을 붙였는데도 Content-Type 은 text/html 이고, 본문은 26,283바이트짜리 404 페이지였습니다. 배포된 운영 서버에 같은 요청을 보내도 20,672바이트 HTML 이 돌아왔습니다. 헤더에 무엇을 원한다고 써 놓든, 없는 경로에는 사람이 볼 HTML 이 옵니다.
res.json() 은 이 HTML 을 그대로 JSON 파서에 넘깁니다. 그래서 오류는 파싱 줄에서 터지지만, 실제로 고칠 곳은 요청 쪽입니다. 파싱 코드를 아무리 고쳐도 달라지지 않습니다.
따옴표 안의 열 글자가 범인을 가리킵니다
이 메시지에서 가장 쓸모 있는 부분은 따옴표 안입니다. V8(크롬·엣지·Node.js)은 파싱에 실패한 문자열의 앞부분을 그대로 인용해 줍니다. 즉 "<!DOCTYPE " 은 파서가 만들어 낸 말이 아니라 서버가 실제로 보낸 첫 열 글자입니다. 응답을 열어 보지 않고도 무엇이 왔는지 알 수 있다는 뜻입니다.
잘리는 기준도 정해져 있습니다. node v24.13.0 에서 입력 길이를 한 글자씩 늘려 가며 확인해 보니, 본문이 20글자 이하면 전체가 인용되고 21글자부터는 앞 10글자만 남고 ... 이 붙었습니다.
JSON.parse('<!DOCTYPE html>')
// Unexpected token '<', "<!DOCTYPE html>" is not valid JSON <- 15글자: 전체 인용
JSON.parse('<!DOCTYPE html>\n<html lang="ko">')
// Unexpected token '<', "<!DOCTYPE "... is not valid JSON <- 32글자: 앞 10글자만그래서 ... 이 붙어 있으면 응답이 20글자보다 길다는 뜻이고, ... 없이 끝나면 따옴표 안에 든 것이 응답 전부입니다. 짧은 응답은 오류 메시지만 봐도 서버가 뭘 보냈는지 다 알 수 있습니다.
정확히 말하면 인용 구간은 파싱이 실패한 자리에서 열 글자 뒤까지, 최대 20글자입니다. HTML 응답은 첫 글자 < 에서 바로 실패하니 그 구간이 곧 앞 열 글자가 되는 것입니다. 실패 자리가 뒤로 밀리면 인용도 그만큼 길어집니다 — 예를 들어 객체 배열을 문자열로 만든 [object Object],[object Object] 는 [ 까지는 배열로 읽히고 1번 자리에서 멈춰서 "[object Obj"... 로 한 글자 더 인용됩니다.
| 인용된 열 글자 | 본문이 이렇게 시작했다는 뜻 | 흔한 출처 |
|---|---|---|
<!DOCTYPE | 보통의 HTML 문서 | 404·500 오류 페이지, 로그인 페이지, SPA 의 index.html |
<script sr | <script src= 로 시작하는 HTML | 개발 서버가 앞에 끼워 넣은 스크립트, 보안 장비·CDN 의 검사 페이지 |
<?xml vers | XML 문서 | 클라우드 스토리지가 돌려주는 <Error><Code> 응답 |
<br /><b> | 경고문이 본문 맨 앞에 찍혔다 | 오류 출력이 켜진 PHP 서버 |
Internal S | 본문이 평문 한 줄 | 프록시·게이트웨이가 만든 500·502 응답 |
undefined | 파싱 대상이 문자열 undefined 였다 | 서버가 아니라 내 코드 — 값이 없는 변수를 넘겼다 |
[object Object] | 객체를 문자열로 바꿔 넘겼다 (15글자라 통째로 인용) | 이미 객체인 값에 JSON.parse 를 한 번 더 했다 |
마지막 두 줄은 앞에 Unexpected token 이 붙지 않고 "undefined" is not valid JSON 처럼 따옴표부터 시작합니다. 이름 붙일 토큰조차 없다는 뜻이라, 그 형태가 보이면 서버가 아니라 내 코드를 보면 됩니다. 표의 왼쪽 두 칸은 각 본문을 실제로 JSON.parse 에 넣어 나온 메시지입니다(node v24.13.0 / V8 13.6). 오른쪽 칸은 그런 본문이 주로 어디서 오는지로, 환경에 따라 다를 수 있습니다.
원인 다섯 가지 — 메시지는 같고 구분은 헤더에서 한다
여기가 이 오류의 성가신 지점입니다. 원인이 다섯 가지쯤 되는데 오류 메시지는 전부 똑같습니다. 메시지만 보고 원인을 좁힐 수 없으니, 응답의 상태 코드와 Content-Type, 그리고 최종 URL 세 가지를 같이 봐야 합니다.
① 앞 슬래시가 빠진 상대 경로
fetch("api/data") 와 fetch("/api/data") 는 다른 주소입니다. 앞 슬래시가 없으면 지금 보고 있는 페이지의 폴더 뒤에 붙습니다. 크롬 152 에서 /guides/json-parse-errors 페이지를 열어 확인해 봤습니다.
// /guides/json-parse-errors 에서 실행
new URL('api/data', location.href).href
// -> 'http://localhost:3000/guides/api/data'요청은 /guides/api/data 로 나가고, 그런 경로는 없으니 HTML 404 가 돌아옵니다. 개발할 때 루트 페이지에서만 눌러 보면 이 실수가 드러나지 않고, 하위 경로로 들어간 뒤에야 터지는 것이 특징입니다.
② 그 라우트가 서버에 없다
주소를 제대로 썼는데도 나는 경우입니다. 오타 한 글자, 대소문자, 끝의 슬래시 유무, 또는 배포에서 빠진 파일이 원인입니다. 확인은 그 주소를 브라우저 주소창에 그대로 붙여넣는 것이 가장 빠릅니다. JSON 이 보이면 서버는 정상이니 코드 문제이고, 404 페이지가 보이면 경로 문제입니다.
③ 세션이 끊겨 로그인 페이지로 갔다
가장 오래 헤매게 되는 경우입니다. fetch 는 리다이렉트를 말없이 따라갑니다. 인증이 풀리면 API 가 로그인 페이지로 보내고, 우리 코드는 그 페이지의 HTML 을 받아 JSON 이라 믿고 파싱합니다. 티가 나는 곳은 두 군데입니다.
const res = await fetch('http://coding-now.com/api/nope');
res.redirected // true
res.url // 'https://www.coding-now.com/api/nope' <- 요청한 주소와 다르다위 예시는 http 에서 https 로 가는 리다이렉트로 재현한 것이지만 원리는 같습니다. res.redirected 가 true 이거나 res.url 이 요청한 주소와 다르면, 그 응답은 내가 부른 곳에서 온 것이 아닙니다. 로그인 페이지로 갔는지 확인하려면 res.url 을 찍어 보면 됩니다.
④ 개발 서버·프록시가 index.html 을 돌려준다
SPA 개발 서버는 모르는 경로를 전부 index.html 로 넘기도록 설정돼 있습니다. API 프록시 설정이 빠지거나 주소를 잘못 적으면 /api/... 요청까지 그 규칙에 걸립니다. 이 경우가 특히 성가신 이유는 상태 코드가 200 이라는 점입니다. res.ok 검사를 넣어 뒀어도 통과합니다. 여기서는 Content-Type 만이 단서입니다.
⑤ 서버가 오류 페이지를 그렸다
서버 코드가 예외를 던지면 프레임워크가 사람이 볼 오류 페이지를 HTML 로 그려 보냅니다. 앞의 네 경우와 달리 이건 서버 로그를 봐야 하는 문제입니다. 상태 코드가 500·502·504 면 클라이언트를 더 손봐도 소용이 없습니다.
fetch 는 404 에 예외를 던지지 않습니다
try { await res.json() } catch 로 감싸 뒀는데 오류가 하필 파싱 줄에서 터지는 이유입니다. fetch 는 서버와 통신이 됐으면 상태 코드가 무엇이든 성공으로 봅니다. 404 도, 500 도 정상적으로 받은 응답입니다. 아래는 같은 코드로 다섯 곳에 요청해 본 결과입니다.
| 요청 | status | res.ok | redirected | Content-Type | res.json() |
|---|---|---|---|---|---|
| 개발 서버의 없는 API | 404 | false | false | text/html | SyntaxError |
| 운영 서버의 없는 API | 404 | false | false | text/html | SyntaxError |
| http → https 리다이렉트 | 404 | false | true | text/html | SyntaxError |
| 정상 JSON API | 200 | true | false | application/json | 성공 |
| 열려 있지 않은 포트 | — | — | — | — | TypeError |
위 네 줄에서 fetch 자체는 한 번도 예외를 던지지 않았습니다. 던진 것은 전부 res.json() 입니다. 그러니 오류 메시지가 JSON 을 가리켜도 진짜 신호는 그보다 먼저 이미 손에 있던 status 와 Content-Type 입니다.
마지막 줄은 성격이 다른 오류입니다. 서버에 닿지도 못하면 res 가 아예 없고 fetch 가 던집니다 — Node 에서는 TypeError: fetch failed, 크롬에서는 TypeError: Failed to fetch 였습니다(둘 다 실측). 이 메시지가 보이면 주소·포트·CORS·서버 실행 여부를 보셔야 합니다.
파싱하기 전에 막는 코드
고치는 방향은 하나입니다. 파싱 전에 이게 JSON 인지 확인하고, 아니면 응답 앞부분을 오류에 담아 던지는 것입니다. 그러면 다음에 같은 일이 생겼을 때 로그만 보고 원인을 알 수 있습니다.
async function getJson(url) {
const res = await fetch(url, { headers: { Accept: 'application/json' } });
const type = res.headers.get('content-type') || '';
const body = await res.text(); // 먼저 문자열로 받는다
if (!type.includes('application/json')) {
throw new Error(
`JSON 이 아닌 응답: ${res.status} ${type}\n` +
`최종 URL: ${res.url}${res.redirected ? ' (리다이렉트됨)' : ''}\n` +
`본문 앞 200자: ${body.slice(0, 200)}`
);
}
if (!res.ok) throw new Error(`${res.status}: ${body.slice(0, 200)}`);
return JSON.parse(body);
}res.json() 대신 res.text() 로 먼저 받는 것이 요점입니다. 응답 본문은 한 번만 읽을 수 있어서, res.json() 이 실패한 뒤에 다시 res.text() 를 부르면 이미 읽었다는 오류가 납니다. 순서를 바꾸면 실패했을 때 본문을 오류 메시지에 담을 수 있고, 그게 다음번 디버깅 시간을 줄여 줍니다.
Content-Type 검사를 넣으면 앞의 ④번 경우(상태 200 + HTML)까지 잡힙니다. res.ok 만 보는 코드는 그걸 놓칩니다.
파이썬은 메시지가 원인을 알려주지 않습니다
같은 상황을 파이썬에서 만나면 메시지가 훨씬 불친절합니다. 성격이 다른 네 가지 본문을 json.loads 에 넣어 봤는데 전부 같은 한 줄이 나왔습니다.
'<!DOCTYPE html>\n<html>' -> Expecting value: line 1 column 1 (char 0)
'' -> Expecting value: line 1 column 1 (char 0)
'Internal Server Error' -> Expecting value: line 1 column 1 (char 0)
'undefined' -> Expecting value: line 1 column 1 (char 0)
(python 3.11.9)char 0 은 첫 글자부터 값이 아니라는 뜻일 뿐, 그 글자가 무엇이었는지는 말해 주지 않습니다. 자바스크립트라면 메시지만 봐도 HTML 인지 평문인지 알 수 있지만 파이썬에서는 알 수 없습니다. 그래서 본문을 직접 찍어 보는 수밖에 없습니다.
import json, requests
r = requests.get(url, headers={"Accept": "application/json"})
try:
data = r.json()
except json.JSONDecodeError:
print(r.status_code, r.headers.get("Content-Type"))
print(r.url) # 리다이렉트됐으면 여기서 드러난다
print(r.text[:200])
raiseurllib 은 fetch 와 반대로 404·500 에서 HTTPError 를 던집니다(실측). 본문을 보려면 except urllib.error.HTTPError as e: 안에서 e.read() 를 부르면 됩니다. 반대로 requests 는 fetch 처럼 상태 코드에 던지지 않으니 r.ok 를 직접 확인해야 합니다.
비슷하지만 다른 메시지들
| 메시지 | 뜻 | 볼 곳 |
|---|---|---|
Unexpected end of JSON input | 읽을 내용이 비었다 | 본문 없는 204 응답, 0바이트 파일 |
"undefined" is not valid JSON | 파싱 대상이 문자열이 아니었다 | 없는 값을 그대로 JSON.parse 에 넘긴 코드 |
TypeError: Failed to fetch | 서버에 닿지도 못했다 | 주소·포트, CORS, 서버가 꺼져 있음 |
Unexpected token 'I', "Internal S"... | 본문이 평문 문장이었다 | 프록시·게이트웨이가 만든 응답 |
반대로 JSON 자체의 문법이 틀렸을 때 나는 메시지들(Expected double-quoted property name, Bad control character, position 26 같은 위치 표시)은 원인이 완전히 다릅니다. 그쪽은 블로그의 다른 글 'JSON 파싱 오류 해결 — Unexpected token 메시지별 원인과 조치'에 메시지별로 정리해 두었습니다.
5분 점검 순서
- 1오류가 난 줄의 요청 주소를 그대로 복사해 브라우저 주소창에 붙여넣습니다. JSON 이 보이면 서버는 정상이니 코드 문제입니다.
- 2개발자 도구 Network 탭에서 그 요청을 찾아 Status 와 Content-Type 을 봅니다.
text/html이면 이 글의 상황으로 확정입니다. - 3Response 탭에서 본문 첫 줄을 봅니다. 404 페이지인지, 로그인 페이지인지,
index.html인지가 여기서 갈립니다. - 4코드에서
res.url을 찍어 요청한 주소와 같은지 봅니다. 다르면 리다이렉트를 따라간 것입니다. - 5상태 코드가 500 대면 서버 로그로 넘어갑니다. 클라이언트에는 고칠 것이 없습니다.
- 6원인을 찾았으면 위의
getJson처럼Content-Type검사를 넣어 둡니다. 다음에는 로그 한 줄로 끝납니다.
자주 묻는 질문
Q. 코드를 안 바꿨는데 갑자기 이 오류가 나기 시작했습니다.
로그인 세션이 끊긴 경우가 가장 많습니다. 인증이 풀리면 API 가 로그인 페이지로 리다이렉트하고, 그 HTML 이 JSON 자리에 들어옵니다. res.url 과 res.redirected 를 찍어 보면 바로 드러납니다. 배포가 실패해 라우트가 빠진 경우도 같은 증상을 냅니다 — 그때는 상태 코드가 404 입니다.
Q. 로컬에서는 되는데 배포하면 이 오류가 납니다.
개발 서버에만 있던 프록시 설정이 배포 환경에 없는 경우가 많습니다. 개발 중에는 /api/... 가 프록시를 타고 백엔드로 갔는데, 배포하면 그런 경로가 없어 404 HTML 을 받습니다. 배포된 주소로 /api/... 를 브라우저에서 직접 열어 보면 바로 확인됩니다.
Q. 따옴표 안에 `<!DOCTYPE ` 대신 `<script sr` 가 보입니다.
응답 본문이 <script src= 로 시작하는 HTML 이라는 뜻입니다. 개발 서버가 페이지 맨 앞에 끼워 넣은 스크립트이거나, CDN·보안 장비가 대신 돌려준 검사 페이지인 경우가 많습니다. 어느 쪽이든 JSON 이 아닌 HTML 이 온 것이라 이 글의 확인 순서가 그대로 적용됩니다.
Q. 따옴표 안에 응답 전체가 보이고 `...` 이 없습니다.
본문이 20글자 이하라서 전체가 인용된 것입니다(node v24.13.0 실측). 짧은 오류 문구 하나만 온 경우이니, 따옴표 안의 내용이 곧 서버가 보낸 전부입니다. 프록시나 게이트웨이가 만든 응답에서 자주 보입니다.
Q. try/catch 로 감쌌는데도 처리가 안 됩니다.
fetch 가 던지지 않는다는 점을 놓친 경우가 많습니다. 404 는 예외가 아니라 정상 응답이므로 catch 에는 파싱 실패만 들어옵니다. await 이 빠져 있으면 예외가 그 try 밖에서 터지니 이것도 확인해 보세요. 상태 코드로 분기하려면 res.ok 를 직접 검사해야 합니다.
응답 본문을 붙여넣으면 JSON 인지 아닌지, JSON 이라면 어느 줄이 틀렸는지 바로 알려줍니다. 브라우저 안에서만 처리해 데이터가 밖으로 나가지 않습니다.