빌드노트
Fetch 응답을 로그로 읽은 뒤 json()이 실패한다
Fetch 응답을 text()로 로그에 남기면 같은 응답의 json()은 다시 읽을 수 없다. bodyUsed로 소비 지점을 찾고, 한 번 읽은 값의 공유와 소비 전 clone()을 비교한다. 작은 loopback JSON 실험에서 실패 유형과 서버 요청 수를 확인했다.
Fetch 응답을 확인하려고 console.log(await response.text())를 넣었더니, 뒤의 response.json()이 TypeError로 실패할 수 있다. text()가 응답 본문을 이미 읽었기 때문이다. 같은 응답의 본문을 다시 읽는 대신, 한 번 읽은 값을 공유하거나 읽기 전에 clone()으로 분기해야 한다. await로 첫 읽기가 끝나기를 기다려도 본문이 처음 상태로 돌아오지는 않는다.
먼저 fetch()가 반환한 응답에서 json()까지 이어지는 코드를 본다. 중간의 로거, 응답 검사 함수, 공통 래퍼가 text()·json()·blob() 등을 먼저 호출하는지 찾는다. response.status나 헤더 조회만으로는 본문을 소비하지 않는다. MDN의 bodyUsed 설명 (새 탭에서 열림)은 본문을 읽었는지를 이 속성으로 확인할 수 있다고 설명한다.
기존 로그 앞뒤에서 bodyUsed를 확인한다
아래는 기존 async 함수 안에서 읽기 순서를 확인하는 부분이다. response는 방금 fetch()로 받은, 작은 JSON 본문이 있는 개발용 응답이다. 이미 text()로 로그를 남기는 곳의 앞뒤에 상태 출력만 붙인다. 이 진단을 위해 본문 읽기를 하나 더 추가할 필요는 없다.
console.log("before", response.bodyUsed);
const raw = await response.text();
console.log(raw);
console.log("after", response.bodyUsed);
const data = await response.json();
별도 재현에서는 직접 연 loopback 서버가 HTTP 200과 21바이트의 {"ok":true,"count":2}를 반환하게 했다. Windows의 Node.js 22.23.2, 내장 Undici 6.28.0에서 위 순서의 출력과 마지막 호출의 오류는 다음과 같았다.
before false
{"ok":true,"count":2}
after true
TypeError: Body is unusable: Body has already been read
이 응답에서는 JSON 문자열을 정상적으로 받았지만, 로그가 먼저 소비해서 두 번째 읽기가 막혔다. after가 true로 바뀌는 구간을 찾으면 먼저 읽는 함수를 좁힐 수 있다. 로그 직전부터 true라면 그보다 앞선 호출 경로를 확인한다. 오류 문구는 구현마다 달라질 수 있으므로 문구 하나보다 읽기 순서와 상태를 함께 본다.
Fetch 표준의 Body 규칙 (새 탭에서 열림)에서 본문은 스트림이며, 이미 읽기 등의 영향을 받았거나 잠긴 스트림은 본문 소비에 사용할 수 없다. text()와 json()은 각각 그 스트림을 소비하는 메서드다. 한쪽이 보관한 결과를 다른 쪽이 자동으로 재사용하는 구조가 아니다.
로그와 파싱에 같은 문자열을 쓸 수 있다면 한 번만 읽는다
작은 JSON의 텍스트를 로그로 남기고 그 내용을 처리하려면, 기존 두 번째 response.json()을 저장한 문자열의 파싱으로 바꾼다. 다음 코드는 아직 본문을 읽지 않은 응답에서 앞의 진단 코드 대신 실행하는 수정안이다.
const raw = await response.text();
console.log(raw);
const data = JSON.parse(raw);
console.log(data.ok, data.count);
같은 고정 응답에서 JSON 텍스트 다음에 true 2가 출력됐다. 본문 읽기는 한 번이고, 파싱은 이미 메모리에 있는 문자열을 사용한다. 로그에 원래 텍스트가 필요 없고 파싱된 값이면 충분하다면 const data = await response.json()으로 한 번 읽은 뒤 그 data를 로거와 후속 처리에 넘겨도 된다. 그 방식도 같은 데이터로 확인했다.
이 수정은 JSON 문법 오류를 없애 주지는 않는다. JSON.parse(raw)가 SyntaxError를 내면 읽기 횟수 문제를 해결한 뒤에도 본문 내용이 JSON인지 확인해야 한다. MDN의 json() 오류 설명 (새 탭에서 열림)은 본문 사용 상태로 인한 TypeError와 JSON 파싱 실패인 SyntaxError를 구분한다. 다만 TypeError에는 다른 원인도 있으므로 예외 이름만으로 소비 문제라고 단정하지 않는다.
각각 Response를 읽어야 한다면 첫 읽기 전에 복제한다
기존 파서가 Response를 받아 직접 json()을 호출하고, 진단 코드도 따로 text()를 읽어야 한다면 복제할 자리가 필요하다. 다음 코드는 아직 읽거나 잠그지 않은 response를 두 소비자에게 나눈다. 위의 한 번 읽기 수정안과는 별개의 선택이다.
const logCopy = response.clone();
const [raw, data] = await Promise.all([
logCopy.text(),
response.json(),
]);
console.log(raw);
console.log(data.ok, data.count);
실험에서는 복제 직후 원본과 복제본의 bodyUsed가 모두 false였고, 각각 읽은 뒤에는 모두 true였다. 출력은 앞의 수정안처럼 JSON 텍스트와 true 2였다. 원본의 JSON을 먼저 읽고 복제본의 텍스트를 나중에 읽는 순서도 이 작은 본문에서는 성공했다.
반대로 await response.text()를 끝낸 뒤 response.clone()을 호출하면 TypeError: Response.clone: Body has already been consumed.가 즉시 발생했다. clone()은 소비한 본문을 복구하는 메서드가 아니다. Fetch 표준의 Response.clone 규칙 (새 탭에서 열림)도 사용할 수 없는 본문을 먼저 거부한다.
서버에서 경로별 GET 수를 세어 각 조건을 비교했다. 모두 같은 200 JSON을 반환하는 합성 실험이며 실제 서비스의 장애 기록은 아니다.
json()만 호출: 파싱 성공, 요청 1회.- 같은 응답에서
text()뒤json(): 두 번째 읽기 실패, 요청 1회. text()로 읽은 문자열을JSON.parse()에 전달: 파싱 성공, 요청 1회.- 소비 전에 복제해 각각
text()와json()호출: 두 읽기 성공, 요청 1회. text()뒤 복제: 복제 실패, 요청 1회.- 소비한 뒤 새로
fetch()해서 파싱: 새 응답에서 성공, 요청 2회.
clone()은 이 비교에서 재요청을 만들지 않았다. 표준의 본문 복제 절차 (새 탭에서 열림)는 스트림을 두 갈래로 나눈다. 오류를 없애려고 fetch()를 다시 호출하는 코드는 서버에 새 요청을 보내므로 같은 해결로 취급하지 않는다.
bodyUsed와 clone만으로 판단이 끝나지 않는 경우
bodyUsed=false는 다음 읽기가 반드시 성공한다는 보장은 아니다. 추가 비교에서 response.body.getReader()로 reader만 얻고 읽지 않았을 때 bodyUsed=false, response.body.locked=true였다. 그 상태의 json()과 clone()은 모두 TypeError로 실패했다. 읽지 않은 reader의 잠금을 해제한 뒤에는 json()이 성공했다. 자기 코드에서 이 조합이 나오면 스트림 reader를 누가 보유하는지 확인한다. 이미 읽은 본문을 잠금 해제만으로 되돌릴 수 있다는 뜻은 아니다.
크기나 끝을 모르는 응답의 로깅에는 위의 전체 읽기 예제를 그대로 적용하지 않는다. MDN의 clone() 설명 (새 탭에서 열림)에 따르면 빠른 소비자를 따라 데이터가 들어오는 동안 느린 쪽의 미독 데이터는 내부에 상한 없이 쌓일 수 있다. 한 갈래만 읽으면 다른 갈래에 본문 전체가 버퍼링될 수 있다. Promise.all()로 함께 시작해도 두 소비자의 속도가 같아지거나 메모리 상한이 생기지는 않는다.
본문 로그가 꼭 필요한지 먼저 정하고, 큰 응답이나 계속 이어지는 스트림이라면 허용할 로깅 양과 중단 정책을 별도로 설계한다. 이 글의 실행은 21바이트 고정 JSON에 한정했다. 대용량·무한 응답의 메모리 사용량, 브라우저별 오류 문구, 서비스 워커나 라이브러리가 본문을 읽는 경로는 검증하지 않았다. 현재 코드에서 한 번 읽은 값을 전달할 수 있다면 그 방식을 쓰고, 두 소비자가 각자 응답을 읽어야 할 때만 소비 전 복제와 버퍼링 조건을 함께 검토한다.