리소스 다운로드 재시도 실패 처리

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

입장 차단 재접속 복구 fail-fast GUEST_RESOURCE_LOAD_FAILED 모니터 배지 PPI-1004 2026-06-02

TL;DR

이어받기(fetchResumable)로도 끝내 못 받은 리소스가 하나라도 있으면, 기존엔 그 영상이 안 나오는 채로 수업에 그냥 입장됐다. 이를 막기 위해 실패 시 수업 입장을 차단하고 아동·호스트 양쪽에 알린 뒤 재접속(reload)으로 복구하도록 바꿨다.

게스트는 resourcesReady를 켜지 않아 자동 입장을 막고, 아동에게 "다시 시도하기" 화면을 띄운다. 호스트(모니터)에는 신규 소켓 이벤트 GUEST_RESOURCE_LOAD_FAILED로 "리소스 로딩 실패" 배지를 띄운다.

실패가 확정되면 나머지 다운로드를 멈추는 fail-fast로, 진행률이 100%까지 차오른 뒤 에러가 뜨던 오해를 제거했다. (V2 — client-guest / monitor-dashboard 대상)

1. 배경 — 이어받기로도 못 받으면?

이어받기는 네트워크가 잠깐 끊겨도 받은 바이트를 보존해 재시도한다. 그러나 최대 3회 재시도까지 소진하거나, presigned URL 만료(403)·리소스 누락처럼 이어받기로 풀리지 않는 실패가 나면 결국 그 리소스는 못 받는다. 기존 V2 흐름은 이 경우를 흡수하고 그냥 진행했다.

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: 즉시 중단, 진행률도 실패 직전 값에서 멈춤
}

5. 아동 측 — 실패 화면 & 재접속

guest-layout.tsxresourceLoadFailed로딩 화면보다 먼저 검사해, 실패 시 안내 화면을 렌더한다(ResourceLoadingScreenhasError/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) handleResourceLoadFailedresourceLoadFailed=true + 진행률 제거. 새 캐싱 진행률 수신 시 자동 해제(재접속 복구 반영)
UI (monitor-media-display.tsx) 카드뷰/포커스뷰 양쪽에 빨간 "리소스 로딩 실패 · 아동 재접속 대기 중" 배지

자동 복구 표시. 호스트의 resourceLoadFailed는 게스트 정상 disconnect 시 일부러 해제하지 않는다(재접속 갭 동안 배지 유지). 아동이 재접속해 새 캐싱 진행률(current != null)이 흘러 들어오면 그때 해제된다.

7. 전체 흐름

  1. 아동 측 캐싱 중 특정 리소스가 3회 재시도까지 최종 실패 → fail-fast로 중단
  2. 게스트: resourcesReady 미설정 → 입장 차단, 실패 화면 표시 / 호스트: 빨간 배지 표시
  3. 아동이 "다시 시도" 클릭 → 페이지 재접속 → 캐싱 재실행(성공분 캐시 히트, 실패분만 재시도)
  4. 재시도 진행률이 다시 흐르면 호스트 배지 자동 해제 → 성공 시 정상 입장

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.tsxhasError/onRetry prop — 아동용 "다시 시도" 화면
apps/web/entities/monitor-session/model/use-monitor-session.tsresourceLoadFailed 핸들러/상태, 진행률 수신 시 자동 해제
apps/web/shared/ui/monitor-media-display.tsx카드뷰/포커스뷰 실패 배지
apps/web/features/session/ui/session-card.tsx · app/monitor-dashboard/[group]/[roomId]/page.tsxresourceLoadFailed prop 전달
apps/socket/src/sfu-socket/handlers/session-handlers.tsGUEST_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. 관련 문서