빌드노트
fetch의 GET에 body를 넣었는데 서버 로그가 없다
fetch의 GET+body는 서버 응답을 받기 전에 요청 생성 단계에서 거절된다. 실제 Chromium과 로컬 수신기로 TypeError·수신 0건을 확인하고, 쿼리 GET·명시한 JSON POST·서버의 HTTP 오류와 비교한다.
fetch()로 GET 검색 요청을 보내면서 body에 JSON을 넣었더니 Request with GET/HEAD method cannot have body라는 TypeError가 나온다. 서버의 검색 처리 로그에는 아무것도 없다. 이 오류는 서버가 JSON을 거절한 응답이 아니라, 브라우저가 요청을 만드는 단계에서 거절한 결과다. GET API가 쿼리를 받는다면 입력을 URL로 옮기고 body를 제거한다. JSON 본문이 필요하다면 서버가 명시적으로 받는 POST API 계약을 사용해야 한다.
HTTP 상태 코드를 받기 전의 실패다
실패한 호출에서 method와 body를 먼저 확인한다. 아래 코드는 브라우저 개발자 도구의 Console에서 합성 검색어 pen&paper로 같은 조합을 확인한다. HTTP 페이지의 /search를 대상으로 하며, 이 실패 자체는 해당 경로가 실제로 응답하기 전에 발생한다.
try {
const response = await fetch("/search", {
method: "GET",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ q: "pen&paper" })
});
console.log("HTTP", response.status);
} catch (error) {
console.log(error.name, error.message);
}
Windows 11에서 새 Chromium 153.0.8010.36의 headless 세션과 Python 3.12.10의 loopback 수신기를 사용해 비교했다. 페이지와 API는 같은 origin에 두었다. 이 호출에서는 HTTP 출력까지 가지 못했고, 잡힌 오류의 이름과 메시지는 다음과 같았다.
TypeError Failed to execute 'fetch' on 'Window': Request with GET/HEAD method cannot have body.
수신기에서도 이 호출로 추가된 API 요청은 0건이었다. 같은 메서드와 본문으로 new Request(...)만 실행했을 때는 생성자가 즉시 TypeError를 던졌다. fetch(...)는 Promise를 반환했고 그 Promise가 거절됐다. 그래서 위 코드에서는 await가 있는 try 안에서 오류를 잡는다. 오류 문구는 브라우저마다 다를 수 있다.
이 순서는 Fetch 표준의 Request 생성 절차 (새 탭에서 열림)에 정해져 있다. GET 또는 HEAD에 null이 아닌 본문이 있으면 TypeError를 던진다. 이어 fetch 메서드 절차 (새 탭에서 열림)는 Request 생성 중 발생한 예외로 Promise를 거절하고 반환한다. body: ""도 null은 아니다. 이번 브라우저에서 빈 문자열 본문 역시 같은 오류와 수신 0건으로 끝났다. 입력을 쿼리로 옮길 때는 body 속성을 빼는 편이 분명하다.
반대로 본문만 빼고 GET /search를 보냈을 때는 수신 1건과 HTTP 400을 얻었다. 이 합성 서버는 쿼리 q가 없으면 400을 보내도록 만들었다. 이때는 fetch가 Response를 반환했고 response.status는 400, response.ok는 false였다. MDN의 fetch 설명 (새 탭에서 열림)처럼 HTTP 오류 상태 자체가 Promise를 거절시키지는 않는다. 자신의 코드가 !response.ok에서 별도 오류를 던지는 경우도 원래의 Fetch 거절과 구별한다.
따라서 TypeError라는 이름이나 서버 로그 부재만으로 원인을 확정하지 않는다. 이 글에서 판별한 조합은 GET에 body를 지정한 코드, GET/HEAD 본문을 지적하는 오류, Request 생성 단계의 실패다. 다른 TypeError라면 URL 오류나 네트워크·CORS 실패도 별도로 살펴야 한다.
GET 검색 입력을 URL로 옮긴다
성공 대조에는 문자열 쿼리 q를 받는 로컬 GET API가 필요하다. 자신의 로컬 앱 페이지를 열고 /search를 실제 검색 경로로 바꾼다. 아래 코드는 같은 검색어를 URL에 붙여 보내고 HTTP 상태와 JSON 응답을 확인한다. 경로와 파라미터 이름은 서버 계약과 맞아야 하며, 응답 모양은 API마다 다르다.
const url = new URL("/search", location.origin);
url.search = new URLSearchParams({ q: "pen&paper" }).toString();
const getResponse = await fetch(url);
console.log(getResponse.status, getResponse.ok);
console.log(await getResponse.json());
body와 요청 본문용 Content-Type을 함께 제거했다. 메서드를 생략한 이 호출은 GET이다. 이번 수신기는 검색어를 그대로 돌려주도록 만들었고, 브라우저에서 확인한 응답 값은 다음과 같았다. 두 번째 줄은 객체의 필드를 JSON으로 적은 것이다.
200 true
{"method":"GET","input":"query","q":"pen&paper"}
실제 수신한 요청 대상은 /search?q=pen%26paper였고 본문은 비어 있었다. &가 파라미터 구분자로 빠져나가지 않고 q 값 안에 보존됐다. URLSearchParams의 인코딩 설명 (새 탭에서 열림)에 맞게 객체에는 원문 값 pen&paper를 넣는다. %26으로 미리 바꾼 값을 넣을 필요가 없다.
자기 API에서 200이 나오지 않는다면 이제 확인할 대상도 달라진다. Network에서 보낸 URL과 HTTP 상태를 보고, 서버가 받은 쿼리의 이름과 값을 대조한다. URL에 q가 있는데도 입력 누락 응답이 온다면 서버의 매핑·파싱 경로를 확인한다. 이번 예제의 400은 입력이 없는 요청이 서버까지 도착한 결과였고, 앞의 GET+body TypeError는 그 이전 단계의 실패였다.
JSON을 유지하려면 POST를 받는 경로가 필요하다
복잡한 검색 조건을 JSON 본문으로 받도록 API를 설계했다면 그 계약에 지정된 메서드와 경로를 사용한다. 기존 GET 경로에서 메서드 글자만 POST로 바꾼다고 서버가 같은 입력을 받아 주지는 않는다. 이 실험에서도 GET 전용 /search에 POST를 보내자 요청은 도착했지만 HTTP 405와 Allow: GET을 받았다.
다음 코드는 POST와 JSON 본문을 받도록 구현한 로컬 /search-json 경로가 있을 때만 실행하는 대조다. 실제 적용에서는 API 문서에 정해진 경로로 바꾸고, 검색어를 본문에 유지한다.
const postResponse = await fetch("/search-json", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ q: "pen&paper" })
});
console.log(postResponse.status, postResponse.ok);
console.log(await postResponse.json());
합성 서버의 이 경로에서는 200 true와 {"method":"POST","input":"json","q":"pen&paper"}를 확인했다. 수신 기록에도 POST와 같은 JSON 본문이 남았다. 쿼리의 문자열 q 한 개를 받는 계약과 JSON 객체를 받는 계약은 각각 서버에서 정의해야 한다. 객체·배열 필터를 보내려는 경우에도 임의로 쿼리 문자열로 바꾸기 전에 API가 정한 표현을 확인한다.
원시 HTTP의 가능성이 Fetch의 허용 범위는 아니다
curl 같은 도구로 GET 본문을 보낸 예제가 있다고 해서 브라우저 Fetch도 같은 조합을 허용하는 것은 아니다. 별도 대조에서 Python의 HTTP 클라이언트로, GET JSON 본문을 받겠다고 정한 합성 /raw-get-json 경로에 직접 보냈다. 이 요청은 GET과 JSON 본문으로 수신됐고 200을 받았다. 브라우저의 거절과 서로 다른 계층의 동작을 확인한 것이다.
RFC 9110의 GET 정의 (새 탭에서 열림)는 HTTP 메시지의 본문 경계와 메서드를 구별하면서도 GET 본문에 일반적으로 정의된 의미가 없다고 설명한다. 용도와 지원을 미리 알린 원 서버에 직접 보내는 조건 없이 GET 본문을 생성하지 말라는 권고도 있다. 원시 클라이언트에서 전송됐다는 관찰을 다른 서버·중간 프록시의 지원 보장이나 Fetch의 우회 방법으로 해석할 수는 없다.
이번 관찰은 새 Chromium 세션과 같은 origin의 loopback HTTP/1.1 수신기에 한정된다. 실제 검색 데이터나 인증정보는 보내지 않았으며 Firefox·Safari, 다른 origin의 CORS 구성과 프록시 경로는 실행하지 않았다. 브라우저에서 받은 결과와 별도 수신 기록을 대조했고, 비교 뒤 수신기와 세션을 닫았다.