리소스 다운로드 HTTP Range 이어받기
마지막 업데이트 2026-07-22
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()) 방식은 받던 데이터를 전부 버리고 처음부터 다시 받았다.
- 네트워크가 불안정할수록 재시도 비용이 누적 → 수업 시작 전 캐싱이 느려지거나 실패
TypeError: Failed to fetch(연결 끊김)와403(presigned URL 만료)이 구분 없이 똑같이 실패 처리됨- 중간에 잘린(truncate) 응답을 정상 완료로 오인할 여지
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 블록은 두 갈래로 나뉜다.
① 즉시 중단 (재시도 없음)
- HTTP 상태 에러 —
!response.ok && status !== 206이면throw new Error("HTTP ..."). 메시지가"HTTP "로 시작하면break. 403 만료·404·5xx는 이어받기로 해결되지 않으므로 백오프 없이 즉시 실패. - 마지막 시도 —
attempt === maxRetries면break후lastErrorthrow.
② 지수 백오프 후 재시도
네트워크 오류(TypeError)나 미완료 다운로드만 재시도 대상. 대기 시간은 baseDelayMs × 2^attempt:
| attempt | 계산 | 대기 |
|---|---|---|
| 0 | 500 × 2⁰ | 500ms |
| 1 | 500 × 2¹ | 1,000ms |
| 2 | 500 × 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 끊김:
attempt=0— 60%에서reader.read()끊김 →catch(chunks/received 보존) → 500ms 대기attempt=1—Range: bytes={60%}-전송 → 서버206→ 나머지 40% 이어받기 →received === total통과 → blob 반환 ✅
presigned URL 만료:
attempt=0— 서버403→throw "HTTP 403 Forbidden"→ 재시도 없이 즉시 실패
Content-Length·Content-Range를 모두 주지 않아 total === 0인 경우 무결성 검증이 건너뛰어진다. 이때 스트림이 중간에 done=true로 정상 종료되면 잘린 데이터를 "완료"로 오인할 수 있다. S3 presigned URL은 항상 Content-Length를 주므로 실사용에선 거의 발생하지 않지만, 무결성 보장 측면의 약점으로 남는다.