원문: https://jakearchibald.com/2025/fetch-streams-not-for-progress/

Mozilla에서 제 역할 중 하나는 우리가 올바른 기능에 집중하고 있는지 확인하는 것인데, 이번에는 fetch 업로드 스트림 주제를 다루게 됐습니다. Chrome이 한동안 지원해 온 기능이지만, Firefox나 Safari에서는 아직 지원되지 않습니다.
저는 다양한 소셜 플랫폼에서 이 기능을 어떻게 생각하는지, 무엇에 활용할 것인지를 물어봤습니다. 단연 가장 많은 답변은 "업로드 진행 상황을 측정하기 위해"였습니다. 하지만 그 목적으로 쓰면 부정확한 결과가 나오고, 심지어 브라우저에서 잘못된 구현으로 이어질 수도 있습니다.
자세히 살펴보겠습니다…
응답 스트림
스트리밍 응답은 수년 전부터 모든 브라우저에서 쓸 수 있었습니다.
const response = await fetch(url);
const reader = response.body.getReader();
while (true) {
const { done, value } = await reader.read();
if (done) break;
console.log(value);
}
console.log('Done!');
이렇게 하면 응답을 청크 단위로 받아, 수신하는 즉시 처리를 시작할 수 있습니다.
async 이터레이터(async iterator)를 쓰면 더욱 간단해집니다.
const response = await fetch(url);
for await (const chunk of response.body) {
console.log(chunk);
}
console.log('Done!');
…하지만 Safari에서는 지원되지 않습니다. 저는 interop 2026을 위해 이를 제안했습니다.
청크는 Uint8Array이지만, TextDecoderStream을 쓰면 텍스트로 청크를 받을 수 있습니다.
const response = await fetch(url);
const textStream = response.body.pipeThrough(new TextDecoderStream());
하지만 다운로드 진행 상황 측정에는 이상적이지 않습니다
이런 식으로 다운로드 진행 상황을 측정하려 할 수 있습니다.
// 항상 동작하는 것은 아닙니다!
const response = await fetch(url);
const contentLength = Number(response.headers.get('Content-Length')) || 0;
let downloaded = 0;
for await (const chunk of response.body) {
downloaded += chunk.length;
if (contentLength) {
console.log(
`Downloaded ${((downloaded / contentLength) * 100).toFixed(2)}%`,
);
}
}
데이터를 확보하는 시점을 측정한다는 점은 좋습니다. 다운로드에서는 이 시점이 중요하니까요. 우편으로 소포 세 개를 받는다고 해봅시다. 각 소포가 손에 들어오는 시점을 측정하는 것이니 문제없습니다.
하지만 응답에 Content-Encoding(brotli나 gzip 같은)이 있다면 이 방식은 무너집니다. 이때 Content-Length는 인코딩된 크기를 나타내지만, 청크는 디코딩된 청크이기 때문입니다. 그래서 downloaded 값이 contentLength 값을 넘어서기 십상입니다.
이런 실수를 했더라도 혼자가 아닙니다. 몇 년 전 저는 브라우저가 압축된 리소스의 다운로드 진행 상황을 어떻게 처리하는지 조사했는데, 결과는… 엉망이었습니다.
압축을 풀지 않고 원시 본문을 가져올 수 있는 기능 요청이 있습니다.
요청 스트림
요청 스트림은 응답 스트림과 기본적으로 같지만, 다운로드가 아닌 업로드를 위한 것입니다. 업로드 전에 일부 트랜스코딩(transcoding)이나 처리를 수행하는 비디오 업로드 앱을 떠올려 보세요.
// 디스크 또는 카메라에서 비디오 가져오기
const videoStream = getVideoStreamSomehow();
// 편집이나 트랜스코딩 등의 방식으로 비디오 처리하기
const processedVideo = videoStream.pipeThrough(new SomeVideoProcessor());
// 스트림 업로드하기
await fetch(url, {
method: 'POST',
body: processedVideo,
duplex: 'half',
headers: {
'Content-Type': 'video/mp4',
},
});
이 모델에서는 비디오 처리가 업로드와 나란히 진행될 수 있습니다. 더 전통적인 방법에서는 업로드가 시작되기 전에 전체 비디오를 처리해야 하므로, 그만큼 훨씬 느려질 수 있습니다.
제약 사항
duplex: 'half'는 요청이 완료되어야만 응답을 쓸 수 있다는 뜻입니다. 브라우저가 요청과 응답을 병렬로 처리하는 기능을 나중에 지원할 수 있도록 '예약'해 둔 것입니다.
요청과 응답을 병렬로 처리하고 싶다면 fetch를 두 번 하면 됩니다. 하나는 요청 데이터를 스트리밍하고, 다른 하나는 응답 데이터를 스트리밍합니다.
또한 요청의 Content-Length를 알 수 없으므로, 이 기능은 요청 데이터를 청크 단위로 처리하는 데 이미 익숙한 전송 방식, 즉 HTTP/2 이상으로 제한됩니다.
하지만 업로드 진행 상황 측정에는 좋지 않습니다
너무 못 미더워서 코드를 보여드리지도 않겠습니다.
요청 스트림을 쓰면 결국 스트림에서 데이터를 가져가는 시점을 측정하게 됩니다. 우편 배달에 빗대면, 배달원이 각 소포를 수거하는 시점을 측정하는 셈인데, 소포가 실제로 배달됐다는 뜻은 아닙니다.
Fetch는 배달원입니다. 청크 일부를 가져가서 네트워크 처리를 시작합니다. 그 청크들이 실제로 전송되는 것은 나중의 일이며, 어쩌면 실패할 수도 있습니다.
이런 방식은 스트림의 일반적인 동작으로, 파이프의 어느 부분이 다른 부분보다 빠르게 흐르더라도 모든 것이 최대한 원활하게 굴러가도록 도와줍니다. 각 스트림에는 "하이 워터 마크(high water mark)"가 있으며, 이는 버퍼링할 이상적인 양을 의미합니다.
스트림으로 업로드 진행 상황을 측정하면 미래에 문제가 생길 수 있습니다
스트림으로 업로드 진행 상황을 측정하려 하면 결과가 부정확할 뿐만 아니라, 미래에 이 기능의 품질을 제한할 수도 있습니다.
브라우저가 이 기능을 출시하면서 꽤 적은 양을 버퍼링한다고 해봅시다. 개발자들이 이를 업로드 진행 상황에 쓰면 정확하지는 않아도 어느 정도 가까울 수 있습니다.
그러다 한 브라우저가 fetch 내부에서 더 많이 버퍼링하면 더 나은 성능을 얻을 수 있다는 것을 발견했다고 해봅시다. 웹의 많은 부분이 업로드 진행 상황을 위해 이 기능을 쓰고 있다면, 그 브라우저는 두 갈래 길 앞에 놓입니다. 변경 사항을 출시해 이 사이트들의 업로드 진행 상황 측정을 훨씬 더 부정확하게 만들거나, 전반적인 개선 사항 출시를 포기하거나입니다. 저는 브라우저들이 이런 타협을 해야 하는 처지에 놓이는 걸 원하지 않습니다.
업로드 진행 상황 측정 방법
안타깝게도 업로드 및 다운로드 진행 상황 측정은 fetch에서 빠진 기능입니다. 지금 당장 진행 이벤트가 필요하다면, 가장 좋은 방법은 XHR을 쓰는 것입니다.
const xhr = new XMLHttpRequest();
// 업로드 진행 상황
xhr.upload.onprogress = (event) => {
if (event.lengthComputable) {
console.log(
`Uploaded ${((event.loaded / event.total) * 100).toFixed(2)}%`,
);
}
};
// 다운로드 진행 상황
xhr.onprogress = (event) => {
if (event.lengthComputable) {
console.log(
`Downloaded ${((event.loaded / event.total) * 100).toFixed(2)}%`,
);
}
};
xhr.open('POST', url);
xhr.send(blobOrWhatever);
하지만 앞으로는…
Igalia의 Luke Warlow가 지금 업로드와 다운로드 모두에 진행 이벤트를 추가하기 위한 fetch API를 작업하고 있습니다.
API가 출시 전에 바뀔 수 있지만, 현재는 다음과 같은 모습입니다.
const response = await fetch(url, {
observer(requestObserver, responseObserver) {
requestObserver.onprogress = (event) => {
if (event.lengthComputable) {
console.log(
`Uploaded ${((event.loaded / event.total) * 100).toFixed(2)}%`,
);
}
};
responseObserver.onprogress = (event) => {
if (event.lengthComputable) {
console.log(
`Downloaded ${((event.loaded / event.total) * 100).toFixed(2)}%`,
);
}
};
},
});
이렇게 하면 fetch의 모든 현대적인 이점을 누리면서 진행 상황 기능의 공백을 채울 수 있을 것입니다!
진행 이벤트 외에 다른 목적으로 요청 스트림을 쓰는 데 관심이 있다면, Interop 2026 제안이 있습니다. 또한 이 설문조사나 interop GitHub 스레드로 개발자의 관심도를 파악하고 싶습니다.
'Web Frontend Developer' 카테고리의 다른 글
| [번역] 프로그레시브 이미지 렌더링의 현재와 잠재적 미래 (1) | 2026.08.09 |
|---|---|
| [번역] 컴포넌트가 WCAG를 준수할 수 있을까요? (0) | 2026.06.05 |
| [번역] 타입스크립트 성능 문제 해결하기 — 사례 연구 (0) | 2026.06.05 |
| [번역] 리액트는 기본값으로 승리했습니다 – 그리고 프런트엔드 혁신을 죽이고 있습니다 (0) | 2026.05.08 |
| [번역] 자바스크립트 Date 계산은 얼마나 잘못될 수 있을까요? (0) | 2026.05.08 |