폼이 전송되고 스피너가 나타나며 콘솔은 JSON의 0번 위치에 예기치 않은 < 기호가 있다고 말한다. 페이지 자체는 잘 로드되므로 첫 직감은 파서나 터널을 의심하는 것이다. 보통 둘 다 관여하지 않는다. 전송은 페이지 로드와는 다른 요청이며, JSON이 아닌 무언가를 받고 돌아온 것이다. 브라우저가 실제로 드러내는 순서대로 살펴보자. 네 가지 가능한 답은 각각 다른 수정을 요구한다.

1단계: 실제로 일어난 전송을 붙잡기

전송 전에 네트워크 패널을 열고, 탐색을 넘어 항목이 보존되도록 로그를 설정한 뒤, 사용이 허용된 계정으로 해가 없는 시험 전송을 한 번 한다. 페이지의 문서 요청이 아니라 폼 자체가 시작한 요청을 고른다.

필터를 조심하자. XHR이나 Fetch 필터는 편리하지만, 프레임워크가 아직 숨은 iframe이나 동적으로 만든 폼으로 전송한다면 필터가 유일하게 중요한 요청을 가려 버린다. 먼저 필터 없이 한 번 전송하고 무엇이 나타나는지 보자.

고른 요청에 대해 해석 전에 여섯 가지를 기록한다. 메서드, 쿼리 문자열을 포함한 전체 URL, 상태, 리다이렉트 사슬, 응답의 Content-Type, 본문의 첫 줄들이다. Chrome DevTools의 네트워크 패널 참조는 이것이 있는 Headers, Response, Initiator 보기를 다루며, 다른 브라우저의 동등한 패널도 같은 필드를 보여 준다.

2단계: 생각한 그 요청인지 확인하기

놀랍게도 많은 JSON 오류가 실제로는 라우팅 오류다. 전송 핸들러가 preventDefault를 호출하기 전에 예외를 던지면, 브라우저는 action 속성이 말하는 주소로 네이티브 폼 POST를 수행하고 서버는 완전한 HTML 페이지로 응답한다. 핸들러는 결코 실행되지 않으므로 JSON은 애초에 요청되지 않았다.

패널의 URL을 JavaScript가 호출하는 URL과 비교한다. 다른 곳에서 끝나는 리다이렉트, 빠지거나 남는 끝 슬래시, 핸들러가 GET을 보내는데 엔드포인트가 POST를 받는 메서드 불일치, 옮겨 간 페이지에서 상대 경로로 보내는 폼을 찾아본다. 리다이렉트 사슬을 끝까지 따라가 첫 번째가 아니라 최종 URL을 적는다.

다음으로 시작자를 확인한다. 요청이 스크립트가 아니라 문서 탐색에서 왔다면 문제는 이미 찾은 것이며, 응답 파서를 바꿔도 도움이 되지 않는다.

3단계: 결과를 네 범주로 나누기

모든 결과는 이 중 정확히 하나에 속하고, 범주가 다음에 어디를 볼지 결정한다.

What you seeCategoryWhere to look
Request never finishes, TLS or connection errorNo usable responseEndpoint reachability, certificate, front-end timeout
Request completes, script gets an opaque failureAccess blockedCross-origin policy, credentials mode, missing response header
200 with an HTML body or a login formWrong representationRedirect, expired session, route fallback, error handler
Body begins with a notice then valid JSONWrong representationServer-side warning output before serialisation
Valid JSON, framework rejects itContract mismatchEnvelope shape, field names, value types

가운데 줄이 사람들이 잘못 진단하는 항목이다. 상태가 200이기 때문이다. 리다이렉트 후 도달한 로그인 페이지는 완벽하게 좋은 HTTP 응답이고 아주 나쁜 JSON 문서다. application/json이라는 콘텐츠 타입은 그것을 고치지 않는다. 라벨일 뿐이고, 잘못 라벨이 붙은 본문은 여전히 파싱되지 않는다. 접근 차단은 완료된 요청처럼 보이는 줄이다. 패널은 상태를 보여 주지만 교차 출처 정책이 본문을 붙잡아 두므로, 호출하는 스크립트에게는 타임아웃만큼 쓸모없다.

네 번째 줄은 오래된 PHP와 프레임워크 스택에서 똑같이 흔하다. 인코딩된 페이로드 앞에 출력된 사용 중단 경고는 어떤 JSON 파서도 받아들이지 않는 본문을 만들며, 종종 그 경고가 설명의 전부다.

4단계: 파서 오류를 문자 그대로 읽기

오류 문구는 파싱을 깨뜨린 첫 문자를 지목하며, 이것이 보통 추측 없이 범주를 알려 준다.

Error text (Chrome/V8)What it means
Unexpected token < in JSON at position 0The body starts with markup: an HTML page or an error document
Unexpected end of JSON inputEmpty body, or the response was truncated
Unexpected token o in JSON at position 1You parsed something already parsed, typically an object rather than text
Unexpected token uThe body starts with the bare word undefined, usually an empty response rendered as a string

200과 빈 본문은 별개의 사례다. 엔드포인트는 실행되었고 아무것도 만들지 않았는데, 하류의 무언가가 그것을 성공으로 받아들였다. 이것은 전송이 아니라 애플리케이션 계약의 실패이며, 자리는 프록시가 아니라 다음 단계다. Firefox와 Safari는 같은 네 가지 실패를 다르게 표현하므로 정확한 문자열이 아니라 문자나 빈 본문으로 맞춘다.

5단계: 증거가 가리키는 계층을 고치기

라우팅 문제라면 요청을 고친다. action을 바로잡고, 핸들러가 빠져나가지 않게 하고, 엔드포인트와 메서드를 맞춘 뒤 다시 시험한다. 표현 문제라면 본문을 만든 것을 고치고, 데이터를 기대한 곳에서 오류 문서가 왜 돌아오는지 밝힌다. 계약 문제라면 실행 중인 버전에 대해 프레임워크가 실제로 기대하는 구조를 읽고, 클라이언트를 느슨하게 하는 대신 응답을 그에 맞춰 만든다.

접근이 차단된 응답은 별도의 절차가 필요하다. 요청 자체는 올바를 수 있고 본문이 코드에 닿지 않을 수 있기 때문이다. 응답의 Access-Control-Allow-Origin을 브라우저가 보낸 정확한 Origin과 비교하고, 와일드카드가 자격 증명이 있는 요청을 만족하지 않는다는 점을 기억한다. 클라이언트의 credentials: 'include'를 서버의 Access-Control-Allow-Credentials: true와 짝지어 보고, 호출이 no-cors 모드를 썼는지 확인한다. 거기서 불투명한 본문은 결함이 아니라 문서화된 결과다.

파서를 더 관대한 것으로 바꾸는 것은 피해야 할 유일한 수정이다. 응답을 코드로 평가하거나 파싱될 때까지 텍스트를 벗겨 내는 것은 계약 불일치를 숨기고 침묵을 얻는 대가로 주입 표면을 더한다. 마찬가지로 상태는 본문과 별도로 확인해야 한다. 애플리케이션 수준의 오류 객체를 담은 200은 많은 API에서 정상이며 페이로드를 읽어 처리해야 한다.

다른 사람의 도움이 필요하면 원시 내보내기 대신 최소한의 기록을 공유한다. 메서드, URL, 상태, 리다이렉트 후 최종 URL, 응답의 콘텐츠 타입, 본문의 모양, 기대하는 계약이다. 네트워크 아카이브에는 쿠키, 토큰, 쿼리 문자열, 폼에 있던 개인 데이터가 들어 있고 자동 정리기는 검토가 아니다.

  • 어떤 코드를 바꾸기 전에 필터 없이 요청을 붙잡는다.
  • 시작자가 자신의 스크립트였고 최종 URL이 자신의 엔드포인트인지 확인한다.
  • 파서를 건드리기 전에 응답을 분류한다.
  • 라우팅, 접근, 표현, 계약을 고치고, 파서를 느슨하게 해서 해결하지 않는다.

관련 글