리소스 다운로드 재시도 실패 처리
마지막 업데이트 2026-07-22
TL;DR
이어받기(fetchResumable)로도 끝내 못 받은 리소스가 하나라도 있으면, 기존엔 그 영상이 안 나오는 채로 수업에 그냥 입장됐다. 이를 막기 위해 실패 시 수업 입장을 차단하고 아동·호스트 양쪽에 알린 뒤 재접속(reload)으로 복구하도록 바꿨다.
게스트는 resourcesReady를 켜지 않아 자동 입장을 막고, 아동에게 "다시 시도하기" 화면을 띄운다. 호스트(모니터)에는 신규 소켓 이벤트 GUEST_RESOURCE_LOAD_FAILED로 "리소스 로딩 실패" 배지를 띄운다.
실패가 확정되면 나머지 다운로드를 멈추는 fail-fast로, 진행률이 100%까지 차오른 뒤 에러가 뜨던 오해를 제거했다. (V2 — client-guest / monitor-dashboard 대상)
1. 배경 — 이어받기로도 못 받으면?
이어받기는 네트워크가 잠깐 끊겨도 받은 바이트를 보존해 재시도한다. 그러나 최대 3회 재시도까지 소진하거나, presigned URL 만료(403)·리소스 누락처럼 이어받기로 풀리지 않는 실패가 나면 결국 그 리소스는 못 받는다. 기존 V2 흐름은 이 경우를 흡수하고 그냥 진행했다.
cacheResources가 개별 파일 실패를catch로 삼키고 다음 파일로 계속 → 항상resourcesReady=true- 결과적으로 아바타 영상·음성이 빠진 채로 수업이 시작 → 아동에게 영상이 그냥 안 나오는 치명적 경험
- 실패가 조용히 묻혀, 진행자도 사유를 알 수 없었음
2. 정책 결정
대상
V2 시스템(entities/guest-page-session + monitor-dashboard). 이미 GUEST_CACHING_PROGRESS로 게스트→호스트 진행률을 전송 중인 경로를 그대로 확장.
차단 기준
리소스 1개라도 최종 실패하면 입장 차단. "영상이 안 나오는 것"이 치명적이라는 판단에 따른 가장 안전한 정책.
복구 방식
아동용 "다시 시도하기" 버튼 → window.location.reload()로 페이지 재접속. 이미 받은 리소스는 캐시 히트로 스킵되고 실패분만 재시도.
3. 게스트 — 입장 차단 메커니즘
입장은 resourcesReady===true가 되어야만 자동으로 트리거된다(requestEntry). 따라서 실패 시 resourcesReady를 켜지 않는 것만으로 입장이 자연 차단된다 — 별도 가드가 필요 없다.
이를 위해 cacheResources가 실패 파일 목록을 반환하도록 시그니처를 바꿨다.
// use-resource-cache.ts
async (fileNames, isLowPerformanceDevice, onProgress)
: Promise<{ failed: string[] }> => { ... return { failed }; }
// use-guest-page-session.ts
const result = await cacheResources(loadedResourceFileNames, isLowPerformance, ...);
let failedFileNames = result.failed;
...
if (failedFileNames.length > 0) {
socket.emit(SOCKET_EVENTS.GUEST_RESOURCE_LOAD_FAILED, { roomId, failedCount });
setCachingResources(false);
setResourceLoadFailed(true); // ← resourcesReady는 켜지 않음 = 입장 차단
return;
}
전체 실패도 커버. failedFileNames의 초기값을 전체 목록으로 두어, 메타데이터 조회(/api/resources/urls)나 caches.open 자체가 throw하면 catch에서 기본값이 유지돼 모든 리소스를 실패로 간주한다.
4. fail-fast — 진행률 왜곡 제거
기존엔 진행률 증가(completed++)가 finally에 있어 실패한 파일도 카운트됐다. 18/21에서 끊기면 19·20·21이 각각 재시도(약 3.5초)하며 21/21까지 차오른 뒤 에러가 떠, "거의 다 됐네 → 갑자기 실패"라는 혼란을 줬다.
정책이 "1개라도 실패하면 차단"이므로, 한 파일이 최종 실패하면 나머지를 받지 않고 즉시 중단한다.
// 성공 경로에서만 진행률 증가
const blob = await fetchResumable(resource.fileName, resource.url);
... cache.put ...
completed++;
onProgress?.(completed, total);
} catch (error) {
failed.push(resource.fileName);
break; // fail-fast: 즉시 중단, 진행률도 실패 직전 값에서 멈춤
}
- 18/21에서 멈추고 곧바로 실패 화면 → 오해 소지 제거
- 남은 파일의 무의미한 재시도(수 초) 절약
- 메타데이터 누락 분기도 동일하게
break+ 조기return으로 일관 처리
5. 아동 측 — 실패 화면 & 재접속
guest-layout.tsx는 resourceLoadFailed를 로딩 화면보다 먼저 검사해, 실패 시 안내 화면을 렌더한다(ResourceLoadingScreen의 hasError/onRetry prop).
if (session.resourceLoadFailed) {
return (
<ResourceLoadingScreen progress={{current:0,total:0}} hasError
onRetry={() => window.location.reload()} />
);
}
화면 문구는 아동 친화적으로: 🔌 "준비물을 다 받지 못했어요 / 인터넷이 잠깐 불안정했나 봐요. 아래 버튼을 눌러 다시 시도해 주세요!" + 큰 "다시 시도하기" 버튼. 클릭 시 페이지가 재접속되며 캐싱이 처음부터 다시 돌되, 성공분은 Cache API 히트로 스킵된다.
6. 호스트(모니터) 측 — 실패 알림
아동 본인 화면은 로컬 상태로 전환되지만, 호스트는 소켓으로 알려야 한다. 진행률과 동일한 릴레이 패턴을 따른다.
| 레이어 | 처리 |
|---|---|
상수 (@ppi/shared) |
신규 이벤트 GUEST_RESOURCE_LOAD_FAILED: "guest-resource-load-failed" |
소켓 서버 (session-handlers.ts) |
socket.to(roomId).emit(...)로 방 안 호스트/모니터에 릴레이 (게스트 sender 제외) |
모니터 훅 (use-monitor-session.ts) |
handleResourceLoadFailed → resourceLoadFailed=true + 진행률 제거. 새 캐싱 진행률 수신 시 자동 해제(재접속 복구 반영) |
UI (monitor-media-display.tsx) |
카드뷰/포커스뷰 양쪽에 빨간 "리소스 로딩 실패 · 아동 재접속 대기 중" 배지 |
자동 복구 표시. 호스트의 resourceLoadFailed는 게스트 정상 disconnect 시 일부러 해제하지 않는다(재접속 갭 동안 배지 유지). 아동이 재접속해 새 캐싱 진행률(current != null)이 흘러 들어오면 그때 해제된다.
7. 전체 흐름
- 아동 측 캐싱 중 특정 리소스가 3회 재시도까지 최종 실패 → fail-fast로 중단
- 게스트:
resourcesReady미설정 → 입장 차단, 실패 화면 표시 / 호스트: 빨간 배지 표시 - 아동이 "다시 시도" 클릭 → 페이지 재접속 → 캐싱 재실행(성공분 캐시 히트, 실패분만 재시도)
- 재시도 진행률이 다시 흐르면 호스트 배지 자동 해제 → 성공 시 정상 입장
8. 변경 파일
| 파일 | 변경 |
|---|---|
apps/web/hooks/use-resource-cache.ts | { failed } 반환, fail-fast break, 진행률 증가를 성공 경로로 이동, 메타데이터 누락 조기 종료 |
apps/web/entities/guest-page-session/model/use-guest-page-session.ts | 실패 시 입장 차단 + resourceLoadFailed 상태 + GUEST_RESOURCE_LOAD_FAILED emit |
apps/web/widgets/guest/guest-layout/ui/guest-layout.tsx | 실패 화면 분기 추가(로딩 화면보다 먼저) |
apps/web/widgets/guest/resource-loading-screen/ui/resource-loading-screen.tsx | hasError/onRetry prop — 아동용 "다시 시도" 화면 |
apps/web/entities/monitor-session/model/use-monitor-session.ts | resourceLoadFailed 핸들러/상태, 진행률 수신 시 자동 해제 |
apps/web/shared/ui/monitor-media-display.tsx | 카드뷰/포커스뷰 실패 배지 |
apps/web/features/session/ui/session-card.tsx · app/monitor-dashboard/[group]/[roomId]/page.tsx | resourceLoadFailed prop 전달 |
apps/socket/src/sfu-socket/handlers/session-handlers.ts | GUEST_RESOURCE_LOAD_FAILED 릴레이 |
packages/shared/src/utils/constants.ts | 신규 소켓 이벤트 상수 |
9. 알려진 한계
① 전체 네트워크 단절(DevTools Offline 등)에서는 소켓도 함께 끊기므로 GUEST_RESOURCE_LOAD_FAILED emit이 호스트에 도달하지 못한다. 호스트는 socket.io ping timeout(~20초) 후 일반 DISCONNECTED로만 보인다. 소켓이 살아있는 부분 실패(특정 리소스 HTTP 실패, 403 등)에서는 배지가 정상 표시된다.
② 영구 실패(서버에 리소스 없음 등)면 "다시 시도"가 매번 같은 실패로 돌아와 아동이 빠져나오기 어렵다. 재시도 횟수 제한/안내 fallback은 후속 과제.
③ 네트워크 복구 후 재연결 시 실패 이벤트 재전송은 미구현 — 아동 네트워크가 돌아와도 (재시도 전이라면) 호스트가 DISCONNECTED에 머물 수 있다.
10. 관련 문서
- 리소스 다운로드 HTTP Range 이어받기 — 이 문서의 전제가 되는
fetchResumable재시도/이어받기 구현 - 카드뷰 세션 변천사 — 호스트 배지가 표시되는 모니터 카드/포커스뷰