---
title: "fetch 로는 업로드 진행률을 못 그린다, XHR onprogress 로 돌아가기"
description: "업로드가 77% 에서 멈춘다는 제보의 원인은 타이머로 만든 가짜 진행률이었습니다. fetch 가 업로드 진행 이벤트를 주지 않는 이유와 XHR 로 실제 진행률을 그리는 방법입니다."
date: 2026-10-10
updated: 2026-10-10T01:42:19.399Z
tags: [frontend, javascript, fetch, xmlhttprequest]
canonical: https://blog.wooncloud.com/posts/upload-progress-fetch-vs-xhr
---

파일 업로더에서 "업로드가 77% 에서 자주 멈춘다"는 제보가 들어왔습니다. 코드를 열어 보니 진행률 막대가 실제 전송량과 무관하게 타이머로 올라가고 있었습니다.

```ts
// 안티패턴: 시간 기반 가짜 진행률
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% 고착"의 정체였습니다.

![타이머 기반 진행률은 77%에서 멈추지만 XHR의 onprogress는 실제 전송률로 100%까지 올라간다.](/images/posts/upload-progress-fetch-vs-xhr/a80e3da4-2f2e-4c7c-8036-fc1a20fa0af2.webp)

## 왜 처음부터 실제 진행률을 안 썼을까

Fetch API 는 업로드 진행 이벤트를 노출하지 않기 때문입니다. 요청 본문이 얼마나 전송됐는지 알려 주는 콜백이나 이벤트가 없습니다.

요청 본문을 `ReadableStream` 으로 만들어 직접 세는 우회법이 있긴 합니다. 하지만 HTTP/2 를 강제해야 하는 등 브라우저 지원 제약이 있어서 실무에 적용하기가 까다롭습니다.

그래서 그럴듯해 보이는 타이머 위조로 때운 코드가 흔합니다. 빠른 업로드는 느려 보이고 느린 업로드는 멈춘 듯 보이는, 사실 최악의 UX 입니다.

## 업로드만 XHR 로 돌리기

업로드 요청만 `XMLHttpRequest` 로 돌리면 `xhr.upload.onprogress` 가 `loaded / total` 을 정확히 줍니다. 나머지 호출은 `fetch` 그대로 두고, 멀티파트 업로드 한 곳만 얇은 헬퍼로 감쌌습니다.

![fetch 와 타이머로 만든 진행률은 전송량과 무관하게 85% 에서 멈추고, XHR 의 upload.onprogress 는 실제 전송량을 보여 준 뒤 응답이 오면 100% 로 끝낸다.](/images/posts/upload-progress-fetch-vs-xhr/3314390d-06d7-4797-b9d9-73c900efea4b.svg)

```ts
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)`) 해서, 폴백 타이머와 실제 이벤트가 엇갈려 막대가 뒤로 점프하는 일도 막습니다.

```ts
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);             // 응답 도착 = 진짜 완료
```

막대가 정직해지니 "멈췄다"는 제보도 자연스럽게 사라지겠죠. 메모 겸 남겨 둡니다.

참고

- [MDN, XMLHttpRequest progress event](https://developer.mozilla.org/en-US/docs/Web/API/XMLHttpRequest/progress_event)
- [MDN, XMLHttpRequestUpload](https://developer.mozilla.org/en-US/docs/Web/API/XMLHttpRequestUpload)
- [MDN, ProgressEvent.lengthComputable](https://developer.mozilla.org/en-US/docs/Web/API/ProgressEvent/lengthComputable)
- [whatwg/fetch#607, Fetch API 업로드 진행률 논의](https://github.com/whatwg/fetch/issues/607)
