Streams API 부록 1. HTTP 다운로드 진행률은 어떻게 계산될까
작성일:2026.03.19|수정일:2026.07.07|조회수:13

다운로드 화면에서 진행 막대가 조금씩 차오르면 이상하게 안심된다. 반대로 스피너만 계속 돌면 파일이 오는 중인지, 서버가 고민 중인지, 내 인생이 잠깐 멈춘 건지 알 수 없다. 사용자는 둘 다 “다운로드 중”이라고 느끼지만, 내부적으로는 꽤 다른 상황일 수 있다.
진행률 계산 자체는 복잡한 마법이 아니다. 지금까지 받은 바이트와 전체 바이트를 알면 된다. 문제는 HTTP가 언제나 전체 크기를 알려주지는 않는다는 점이다. 이 부록에서는 Content-Length, chunked transfer, stream reader를 연결해서 다운로드 진행률이 언제 계산 가능하고, 언제 그냥 “뭔가 오고 있음” 정도로만 표시할 수 있는지 정리한다.
다운로드 진행률의 기본 공식
다운로드 진행률은 보통 다음과 같은 방식으로 계산된다.
progress = 받은 바이트 / 전체 바이트예를 들어 10MB짜리 파일을 다운로드한다고 가정해 보자. 다운로드가 진행되는 동안 브라우저는 현재까지 받은 데이터 양을 계속 기록한다.
- 전체 크기 = 10MB
- 현재 받은 데이터 = 5MB
이 경우 진행률은 단순히 5 / 10 = 50%로 계산할 수 있다. 이 값이 바로 화면에서 보는 퍼센트다. 즉 다운로드 진행률을 계산하는 데 필요한 정보는 사실 두 가지뿐이다. 지금까지 받은 데이터의 양과, 전체 데이터의 크기다.
여기서 중요한 점이 하나 있다. 이 공식이 성립하려면 반드시 전체 데이터 크기를 알고 있어야 한다. 받은 양만 알아서는 “얼마나 왔는지”는 알 수 있지만, “얼마나 남았는지”는 알 수 없다. 진행률 막대가 필요한 순간에는 이 차이가 꽤 커진다.
HTTP는 전체 크기를 알려줄 수도 있고, 알려주지 않을 수도 있다
HTTP 응답에는 Content-Length라는 헤더가 존재한다. 이 헤더는 응답 본문의 전체 크기를 바이트 단위로 알려준다. 예를 들어 서버가 다음과 같은 응답을 보냈다고 생각해 보자.
Content-Length: 10485760이 값은 응답 본문이 약 10MB라는 뜻이다. 브라우저는 이 값을 기준으로 다운로드가 얼마나 진행되었는지를 계산할 수 있다. 실제로는 네트워크에서 데이터가 조금씩 도착할 때마다 받은 바이트 수를 누적하고, Content-Length 값과 비교해 현재 진행률을 계산한다. 내부적으로는 대략 다음과 같은 계산이 반복되는 셈이다.
progress = receivedBytes / contentLength브라우저는 이 값을 이용해 진행 막대를 업데이트하거나 퍼센트 값을 표시한다. 우리가 보는 다운로드 UI는 결국 이 단순한 계산의 결과라고 볼 수 있다. 단순하지만, 전체 크기라는 기준점이 있어야만 의미가 생긴다.
모든 응답이 Content-Length를 가지는 것은 아니다
문제는 HTTP 응답이 항상 Content-Length 헤더를 포함하는 것은 아니라는 점이다. 서버가 데이터를 스트림 방식으로 전송하는 경우에는 전체 크기를 미리 알 수 없는 상황이 흔하다. 대표적인 예를 들면 이런 것들이다.
- 서버가 데이터를 실시간으로 생성하는 경우
- 로그나 이벤트 스트림
- AI 응답 스트리밍
- chunked transfer encoding
이런 경우 서버는 데이터를 한 번에 모두 만들어 두고 보내는 것이 아니라, 생성되는 즉시 조금씩 전송한다. 데이터는 조각 단위로 계속 흘러오지만, 전체 데이터 크기는 처음부터 알 수 없는 상태가 된다. 그래서 브라우저는 “지금까지 몇 바이트를 받았다”는 값은 알 수 있어도, 그것이 전체의 몇 퍼센트인지는 계산할 수 없다.
이럴 때 화면에 나오는 것은 보통 진행 막대가 아니라 스피너나 “다운로드 중” 같은 상태 표시다. 답답하지만 브라우저가 게으른 것은 아니다. 나눗셈의 분모가 없으니 퍼센트를 만들 수 없는 것이다.
Streams API 관점에서 보면
Streams API 관점에서 보면 이 상황이 조금 더 명확해진다. fetch()가 반환하는 Response 객체의 body는 ReadableStream<Uint8Array> 타입을 가진다. 즉 HTTP 응답은 실제로 바이트 스트림이다. 브라우저는 이 스트림을 한 번에 모두 읽는 것이 아니라, 네트워크에서 도착하는 데이터 조각을 조금씩 읽어 들인다.
예를 들어 다음과 같은 코드가 있을 수 있다.
const response = await fetch(url)
const reader = response.body!.getReader()
let received = 0
while (true) {
const { value, done } = await reader.read()
if (done) break
received += value.length
}이 코드에서 received는 지금까지 받은 바이트 수를 의미한다. 네트워크에서 데이터가 도착할 때마다 reader.read()가 새로운 바이트 청크를 반환하고, 우리는 그 크기를 계속 누적할 수 있다. 여기까지는 Content-Length가 없어도 가능하다.
차이는 그 다음에 생긴다. Content-Length 헤더가 있으면 received / contentLength로 진행률을 만들 수 있다. 반대로 Content-Length가 없으면 received는 계속 증가하지만, 그 값이 전체의 어디쯤인지는 알 수 없다. 결국 Streams API로 바이트를 직접 읽는다고 해서 진행률 문제가 자동으로 해결되는 것은 아니다. 스트림은 “얼마나 받았는가”를 보여 주고, Content-Length는 “전체가 얼마인가”라는 기준을 제공한다.
정리하면 다운로드 진행률은 스트리밍과 반대되는 개념이 아니다. 오히려 스트림을 읽으면서 받은 바이트를 누적하기 때문에 계산할 수 있다. 다만 그 계산이 퍼센트가 되려면 전체 크기라는 기준점이 필요하다. 기준점이 있으면 진행 막대가 되고, 없으면 스피너가 된다. 사용자가 보기에는 작은 UI 차이지만, 내부에서는 꽤 중요한 정보의 차이다.
댓글
댓글을 불러오는 중...