fetch 로는 업로드 진행률을 못 그린다, XHR onprogress 로 돌아가기
업로드가 77% 에서 멈춘다는 제보의 원인은 타이머로 만든 가짜 진행률이었습니다. fetch 가 업로드 진행 이벤트를 주지 않는 이유와 XHR 로 실제 진행률을 그리는 방법입니다.
파일 업로더에서 "업로드가 77% 에서 자주 멈춘다"는 제보가 들어왔습니다. 코드를 열어 보니 진행률 막대가 실제 전송량과 무관하게 타이머로 올라가고 있었습니다.
// 안티패턴: 시간 기반 가짜 진행률
setUploadProgress(0);
const timer = setInterval(() => {
setUploadProgress((p) => Math.min(85, p + 7)); // 180ms마다 +7%, 85%에서 멈춤
}, 180);
await fetch("/files", { method: "POST", body: formData });
clearInterval(timer);
setUploadProgress(100);여기에 파일별 표시 오프셋(progress + (index*4 - 8))까지 겹치면, 단일 파일 업로드는 타이머 천장(85%)에 닿는 순간 85 - 8 = 77% 에 고정된 채 실제 전송이 끝날 때까지 머뭅니다. "77% 고착"의 정체였습니다.
왜 처음부터 실제 진행률을 안 썼을까
Fetch API 는 업로드 진행 이벤트를 노출하지 않기 때문입니다. 요청 본문이 얼마나 전송됐는지 알려 주는 콜백이나 이벤트가 없습니다.
요청 본문을 ReadableStream 으로 만들어 직접 세는 우회법이 있긴 합니다. 하지만 HTTP/2 를 강제해야 하는 등 브라우저 지원 제약이 있어서 실무에 적용하기가 까다롭습니다.
그래서 그럴듯해 보이는 타이머 위조로 때운 코드가 흔합니다. 빠른 업로드는 느려 보이고 느린 업로드는 멈춘 듯 보이는, 사실 최악의 UX 입니다.
업로드만 XHR 로 돌리기
업로드 요청만 XMLHttpRequest 로 돌리면 xhr.upload.onprogress 가 loaded / total 을 정확히 줍니다. 나머지 호출은 fetch 그대로 두고, 멀티파트 업로드 한 곳만 얇은 헬퍼로 감쌌습니다.
function uploadWithProgress<T>(
url: string,
body: FormData,
onProgress?: (percent: number) => void,
): Promise<T> {
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.open("POST", url);
// FormData 는 Content-Type 이 boundary 와 함께 자동 설정된다. 직접 세팅 금지.
xhr.upload.onprogress = (e) => {
if (!e.lengthComputable || e.total <= 0) return; // total 미상이면 폴백에 맡김
onProgress?.(Math.round((e.loaded / e.total) * 100));
};
xhr.onload = () => {
if (xhr.status >= 200 && xhr.status < 300) {
resolve(xhr.responseText ? JSON.parse(xhr.responseText) : (null as T));
} else {
reject(new Error(`${xhr.status} ${xhr.statusText}`));
}
};
xhr.onerror = () => reject(new Error("network error"));
xhr.send(body);
});
}처음 쓰면 헷갈리는 점이 몇 가지 있습니다.
- 진행 이벤트는
xhr.upload.onprogress입니다.xhr.onprogress는 다운로드 방향입니다. lengthComputable이false면total을 믿을 수 없으니 콜백을 건너뛰고 폴백(보조 타이머)에 맡깁니다. 프록시나 구형 환경 방어입니다.FormData를 보낼 때는Content-Type을 직접 세팅하지 않습니다. 브라우저가 multipart boundary 와 함께 채워 줍니다.
100% 가 곧 완료는 아니다
바이트를 다 보냈다고 끝난 게 아닙니다. 서버가 저장하고 후처리하는 중이거든요. 이 구간을 100% 로 올려 버리면 이번에는 "100% 에서 멈춤"이 됩니다.
그래서 전송은 99% 까지만 표시하고, 응답이 도착했을 때만 100% 로 올립니다. 그 사이는 "처리 중"으로 따로 보여 줍니다. 진행률은 단조 증가하게(Math.max(prev, next)) 해서, 폴백 타이머와 실제 이벤트가 엇갈려 막대가 뒤로 점프하는 일도 막습니다.
let sawReal = false;
const onProgress = (percent: number) => {
sawReal = true;
if (percent >= 100) {
setProgress(99); // 전송 끝, 서버 처리 대기
setProcessing(true); // "처리 중" 표시
} else {
setProgress((p) => Math.max(p, percent)); // 단조 증가
}
};
const res = await uploadWithProgress("/files", formData, onProgress);
setProcessing(false);
setProgress(100); // 응답 도착 = 진짜 완료막대가 정직해지니 "멈췄다"는 제보도 자연스럽게 사라지겠죠. 메모 겸 남겨 둡니다.
참고