본문 바로가기
빌드노트 검색

빌드노트

204 삭제 응답은 성공인데 response.json()이 실패한다

204는 본문 없는 성공 응답이다. loopback 서버의 204·200 JSON·200 빈 본문·200 잘못된 JSON을 실제 브라우저 fetch로 비교하고, 204만 파싱을 생략해 HTTP 성공과 본문 오류를 구별하는 코드를 확인했다.

삭제 API가 204를 반환했는데 화면에서는 실패로 표시된다. fetch() 다음에 무조건 response.json()을 호출하면 이런 일이 생긴다. 204는 본문 없는 성공 응답이므로 JSON 파싱을 건너뛰어야 한다. catch에 들어왔다는 사실만으로 삭제 요청 자체가 실패했다고 볼 수 없다.

RFC 9110 §15.3.5 (새 탭에서 열림)는 204를 요청을 성공적으로 수행했으며 추가로 보낼 본문이 없는 상태로 정의한다. 이 응답에는 본문을 넣을 수 없다. 서버가 204를 보내면서 클라이언트가 읽을 {}도 함께 보내는 식으로 해결할 문제는 아니다.

204에서는 파싱 전에 끝낸다

성공 응답이 ‘204 또는 200의 JSON 본문’인 API라면, 기존의 await response.json()을 다음 함수 호출로 바꿀 수 있다. response는 await fetch(...)로 이미 받은 응답이다.

async function readApiResponse(response) {
  if (!response.ok) throw new Error(`HTTP ${response.status}`);
  if (response.status === 204) return;
  return response.json();
}

const data = await readApiResponse(response);

이 함수는 HTTP 실패를 먼저 구별하고, 204에서는 undefined로 정상 완료한다. 200이면 JSON을 파싱해 돌려준다. 호출하는 쪽도 정상 완료를 곧바로 data.deleted 같은 속성의 존재와 연결하지 않아야 한다. 204에서는 반환 데이터 없이 성공 화면 처리를 이어간다.

이 분기는 위 API의 응답 계약에 맞춘 것이다. 성공할 때 다른 형식이나 다른 상태도 돌려주는 API에 그대로 공통 적용할 코드는 아니다. Response.ok 문서 (새 탭에서 열림)에서 ok가 뜻하는 것은 상태가 200부터 299 사이인지이며, JSON을 읽을 수 있는지까지 보장하지 않는다.

같은 ok=true에서도 결과가 달랐다

이를 따로 확인하려고 Windows x64에서 Node.js 22.23.2로 127.0.0.1에 합성 서버를 열었다. 전용 Headless Chrome 153 세션에서 같은 출처의 응답 네 종류를 실제 fetch()로 받았다. 메서드는 DELETE지만 서버는 정해진 응답만 반환하며 저장하거나 삭제하는 데이터는 없다. 실제 업무의 삭제 결과를 재현한 실험은 아니다.

  • 204, 본문 없음: ok=true, 수신 본문은 빈 문자열 "", 0바이트였다. json()은 SyntaxError로 거부됐다.
  • 200, 유효한 JSON: ok=true, 본문 {"deleted":true}는 16바이트였다. json()은 { deleted: true } 객체로 완료됐다.
  • 200, 빈 본문: ok=true, 본문 ""는 0바이트였다. json()은 SyntaxError로 거부됐다.
  • 200, 잘못된 JSON: ok=true, 본문 {"deleted":는 11바이트였다. json()은 SyntaxError로 거부됐다.

200 응답 세 개에는 모두 Content-Type: application/json을 넣었다. 204에서는 Content-Type과 Content-Length를 생략했다. 본문 크기는 길이 헤더만 읽은 값이 아니라 브라우저가 받은 텍스트를 UTF-8로 센 값이며, 이 실험의 본문은 모두 ASCII였다.

파싱에 실패한 세 경우는 오류 메시지까지 같았다. 아래는 이 브라우저에서 관찰한 원문이다.

SyntaxError: Failed to execute 'json' on 'Response': Unexpected end of JSON input

따라서 오류 문구만으로는 정상적인 204인지, 200의 빈 본문인지, 작성하다 끝난 형태의 JSON인지 구별할 수 없었다. 브라우저마다 메시지 문구도 달라질 수 있으므로 상태와 실제 본문을 함께 확인해야 한다.

Fetch 표준의 본문 소비 절차 (새 탭에서 열림)는 본문이 없을 때도 빈 바이트를 변환 절차에 넘긴다. json()은 그 바이트를 JSON으로 파싱한다. 빈 입력을 성공 값으로 바꿔 주는 메서드가 아니다. Response.json() 문서 (새 탭에서 열림)도 JSON으로 파싱할 수 없는 본문에서 SyntaxError가 발생한다고 설명한다.

앞의 수정 함수를 실제 응답에 적용하자 204는 정상 완료됐고 json() 호출 횟수는 0이었다. 200의 유효한 JSON은 한 번 파싱됐고, 빈 본문과 잘못된 JSON은 여전히 오류로 남았다. 추가 비교에서 500은 HTTP 500 오류로 분리됐으며, 본문이 네 글자 null인 200은 한 번 파싱해 JavaScript의 null을 반환했다. 본문 없음과 JSON 값 null도 다른 결과였다.

200의 빈 본문까지 성공으로 덮지 않는다

response.json().catch(() => null)로 바꾸면 204의 오류는 사라진다. 하지만 위 실험의 200 빈 본문과 잘못된 JSON도 똑같이 숨겨진다. JSON을 받기로 한 API에서 응답 생성이 잘못됐을 때 발견할 기회를 잃는다. 예외를 삼키는 대신, 본문이 없기로 약속한 상태에서 파싱을 생략한다.

200의 빈 본문 자체를 모두 HTTP 규격 위반으로 볼 수는 없다. RFC 9110의 200 설명 (새 탭에서 열림)은 길이가 0임을 명시한 응답도 다룬다. 다만 JSON을 반환한다고 약속한 엔드포인트라면 빈 본문은 그 약속과 맞지 않는다. 200에서도 빈 응답을 허용하는 API라면 그 조건을 별도 계약으로 정해야 한다. Content-Type이 JSON이라는 이유만으로 본문까지 유효해지지는 않는다.

자기 코드에서는 개발자 도구의 Network에서 응답 상태와 본문을 먼저 보고, JSON을 읽는 코드 위치를 확인한다. 작은 응답의 원문을 코드로 확인하려면 본문을 소비하기 전에 const text = await response.clone().text();를 넣어 response.status, response.ok, text를 함께 살펴볼 수 있다. 이 실험도 복제본으로 원문을 읽고 원래 응답으로 수정 함수를 실행했다. Response 복제 절차 (새 탭에서 열림)에 따라 이미 읽었거나 잠긴 응답은 복제할 수 없으므로, 파싱 실패 뒤 같은 본문을 다시 읽는 순서로 사용하지 않는다.

상태가 204이고 API 계약도 본문 없는 성공이라면 소비 코드의 204 분기를 확인한다. 상태가 200이면 받은 본문과 서버의 JSON 응답 계약을 대조한다. TypeError가 난다면 이미 소비한 본문이나 디코딩 오류 등 다른 조건도 확인해야 한다. 이번 실험에서 확인한 것은 한 브라우저의 같은 출처 합성 응답과 위 소비 함수까지다. 실제 서버의 삭제 수행 여부, 교차 출처 접근, 통신 중단 조건은 검증하지 않았다.

← 목록으로 돌아가기