리소스 다운로드 HTTP Range 이어받기

마지막 업데이트 2026-07-29

HTTP Range Resumable Fetch 지수 백오프 Cache API S3 presigned URL 2026-06-02
💬 대화로 먼저 이해하기 — "물탱크 채우기 비유" (비개발자·처음 읽는 사람용)
Q"이어받기"가 뭘 해결한 건가요?
A아바타 영상 같은 큰 파일을 받는 건 물탱크(캐시)에 물을 채우는 것과 같아요. 60% 채우다 Wi-Fi가 끊기면, 예전엔 탱크를 비우고 처음부터 다시 받았어요. 이제는 받은 바이트를 보존하고 "이 지점부터만 보내주세요"(HTTP Range 요청)라고 해서 나머지 40%만 이어받습니다.
Q서버가 그 요청을 못 알아들으면요?
ARange를 무시하고 처음부터 전체(200)를 다시 보내는 서버라면, 물이 중복으로 넘치지 않게 탱크를 비우고 처음부터 다시 누적해요 — 자동 폴백이라 어느 쪽이든 안전합니다.
Q실패하면 무조건 다시 시도하나요?
A실패를 두 종류로 구분해요. 호스가 잠깐 빠진 것(네트워크 끊김·미완료)은 0.5초→1초→2초 간격(지수 백오프)으로 최대 3회 재시도하지만, 이용권 만료(403 presigned URL 만료)나 없는 주소(404)는 다시 받아도 소용없어 즉시 실패합니다.
Q다 받았는지는 어떻게 확인해요?
A계량기 검침이에요 — 예고된 총량(Content-Length/Content-Range)과 실제 받은 양(received)이 같아야 완료로 인정해요. 총량을 알 수 없는 드문 경우가 유일한 약점인데, 하단 "알려진 한계"를 보세요.

TL;DR

리소스(아바타 영상·음성 등) 다운로드 중 네트워크가 끊기면 처음부터 전체를 다시 받던 동작을, 이미 받은 바이트를 보존하고 HTTP Range 요청으로 끊긴 지점부터 이어받는 방식으로 바꿨다.

핵심은 신규 fetchResumable() 함수(apps/web/lib/fetch-resumable.ts) 한 곳에 모았고, use-resource-cache.ts의 배치 사전 캐싱(cacheResources)과 단일 온디맨드 조회(getResourceUrl) 두 경로가 동일 로직을 공유한다.

지수 백오프 재시도(최대 3회), Range 미지원 서버 자동 폴백, HTTP 상태 에러 즉시 실패, 다운로드 무결성 검증을 포함한다.

1. 배경 — 왜 바꿨나

아동 수업에 필요한 리소스는 presigned URL을 통해 S3에서 내려받아 Cache API(디스크)에 캐싱된다. 모바일/저사양 회선에서 대용량 영상을 받다가 Wi-Fi가 잠깐 끊기면, 기존 fetch().then(res => res.blob()) 방식은 받던 데이터를 전부 버리고 처음부터 다시 받았다.

2. 해결 — fetchResumable

chunks(받은 조각)와 received(받은 바이트 수)를 재시도 루프 바깥에 두는 것이 전부의 핵심이다. try 안에서 예외가 나도 누적분이 초기화되지 않고 다음 시도로 그대로 이어진다.

const chunks = [];     // ← 루프 밖. catch 후에도 보존
let received = 0;      // ← 루프 밖. 끊긴 지점 = Range 시작 오프셋
let total = 0;
let supportsRange = true;

for (let attempt = 0; attempt <= maxRetries; attempt++) {   // 최초 1 + 재시도 3 = 최대 4회
  try {
    const isResume = received > 0 && supportsRange;
    const headers = isResume ? { Range: `bytes=${received}-` } : {};
    const response = await fetch(url, { headers });
    ...
    const reader = response.body.getReader();
    while (true) {
      const { done, value } = await reader.read();
      if (done) break;
      if (value) { chunks.push(value); received += value.length; }
    }
    if (total > 0 && received !== total) throw new Error("Incomplete download");
    return new Blob(chunks, { type: contentType });
  } catch (error) { ... }
}

핵심. 재진입 시 received > 0이면 Range: bytes={received}-로 끊긴 지점부터만 요청하고, 서버의 206 Partial Content 응답분을 기존 chunks에 이어붙인다. received === 0이면 자연히 처음부터 전체 재다운로드가 된다.

3. 재시도 메커니즘

catch 블록은 두 갈래로 나뉜다.

① 즉시 중단 (재시도 없음)

② 지수 백오프 후 재시도

네트워크 오류(TypeError)나 미완료 다운로드만 재시도 대상. 대기 시간은 baseDelayMs × 2^attempt:

attempt계산대기
0500 × 2⁰500ms
1500 × 2¹1,000ms
2500 × 2²2,000ms

4. 엣지 케이스 처리

Range 미지원 서버

이어받기 요청을 보냈는데 서버가 Range를 무시하고 200으로 전체를 다시 주면, 중복 누적을 막기 위해 chunks=[], received=0, supportsRange=false로 리셋하고 처음부터 다시 누적한다.

메타데이터 추출

첫 응답에서만 Content-Type과 전체 크기를 파악한다. 206이면 Content-Range/{total}을, 200이면 Content-Length를 사용.

스트리밍 미지원 폴백

response.body(ReadableStream)가 없는 환경에서는 response.blob()로 폴백한다.

무결성 검증

전체 크기를 알 수 있을 때(total > 0) received === total을 확인해, 잘린 응답을 throw → 다음 시도에서 이어받기.

5. 적용 경로 — use-resource-cache

두 캐싱 경로 모두 캐시 미스일 때만 fetchResumable을 호출하고, 받은 blob을 Response로 감싸 버전 키(?v={updatedAt})로 Cache API에 저장한다.

경로설명
cacheResources() 수업 시작 전 배치 사전 캐싱. /api/resources/urls로 URL+버전을 한 번에 조회 후, 미캐시 항목만 네트워크 혼잡 방지를 위해 순차 다운로드.
getResourceUrl() 단일 온디맨드 조회. 메모리 blobURL → 메모리 Response → 디스크 Cache API 순으로 확인하고 전부 미스면 다운로드.

6. 변경 파일

파일변경
apps/web/lib/fetch-resumable.ts (신규) Range 기반 이어받기 + 지수 백오프 재시도 + Range 미지원 폴백 + 무결성 검증을 담은 fetchResumable(fileName, url, options). maxRetries(기본 3), baseDelayMs(기본 500) 옵션.
apps/web/hooks/use-resource-cache.ts 배치·단일 두 경로의 다운로드를 fetchResumable로 통합. 불필요한 메타데이터 전달 제거(status=200, headers 단순화).

7. 전체 시나리오

대용량 영상 60% 받다가 Wi-Fi 끊김:

  1. attempt=0 — 60%에서 reader.read() 끊김 → catch (chunks/received 보존) → 500ms 대기
  2. attempt=1Range: bytes={60%}- 전송 → 서버 206 → 나머지 40% 이어받기 → received === total 통과 → blob 반환 ✅

presigned URL 만료:

  1. attempt=0 — 서버 403throw "HTTP 403 Forbidden" → 재시도 없이 즉시 실패
알려진 한계: 서버가 Content-Length·Content-Range를 모두 주지 않아 total === 0인 경우 무결성 검증이 건너뛰어진다. 이때 스트림이 중간에 done=true로 정상 종료되면 잘린 데이터를 "완료"로 오인할 수 있다. S3 presigned URL은 항상 Content-Length를 주므로 실사용에선 거의 발생하지 않지만, 무결성 보장 측면의 약점으로 남는다.