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

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

HTTP Range Resumable Fetch 지수 백오프 Cache API S3 presigned URL 2026-06-02

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를 주므로 실사용에선 거의 발생하지 않지만, 무결성 보장 측면의 약점으로 남는다.