마지막 업데이트 2026-07-22
taeyang_v2) 아바타가 말하기(talking) 영상으로 전환될 때, 잠깐 지우(jiwoo_v2) 아바타가 나타남.avatar_jiwoo_v2_talking_0.mp4)이 렌더된 뒤 곧 태양으로 복구됨.
아동: 아동005 · 환경: prod · 단말: Linux x86_64 / Chrome · 이번 회기는 지우·태양 두 아바타를 번갈아 사용(체크아웃 단계 페르소나=태양).
게스트(아동) 측에서만 발생, 진행자(모니터) 측에서는 재현되지 않음.
useAvatarResources가 avatarId 변경 시 이전 아바타 리소스(avatar state)를 즉시 비우지 않아, "avatarId=신규(태양) + 리소스=이전(지우)"인 stale 윈도우가 생긴다. 그 윈도우에서 avatarState가 idle → talking으로 바뀌며 use-avatar-video의 영상 로드 effect가 재실행되고, stale 상태의 지우 리소스로 talking 영상을 골라 로드해 태양 자리에 지우가 렌더된다. 태양 리소스 fetch가 끝나면 effect가 다시 돌아 정상 복구되므로 "잠깐" 보인다.
useAvatarResources(taeyang_v2) effect가 돌며 캐시를 지우고(cache.delete, line 72) async로 태양 리소스를 fetch. 이때 avatar state는 여전히 지우 리소스를 들고 있음(초기화 코드 없음).idle → talking → use-avatar-video 로드 effect 재실행(의존성에 avatarState 포함).props.avatarId = 태양인데 avatar = 지우(stale) → avatar.resourceItems.find(type === "talking")가 지우의 talking을 찾아 avatar_jiwoo_v2_talking_0.mp4 로드.setAvatar(태양) → effect 재실행 → 태양 영상 로드 → 정상 복구.
stepLabel 12 / 1 / 반응_공통_체크아웃_27_0_태양 / 끝인사 — 체크아웃 페르소나는 태양. 직전 단계까지 지우가 떠 있었으므로 stale 리소스가 지우였다.
useAvatarResources: avatarId 변경 시 stale 미초기화// apps/web/hooks/use-avatar-resources.ts:57~93 useEffect(() => { // ... (async () => { setLoading(true); avatarWithResourcesCache.delete(avatarId); // 매 전환마다 캐시 삭제 → 항상 네트워크 fetch const avatarWithResources = await fetchAvatarWithResources(avatarId); // async if (!cancelled) setAvatar(avatarWithResources); // ← 여기서야 새 값으로 교체 })(); // ❗ avatarId 변경 시 setAvatar(null) 등으로 이전 값을 비우는 처리가 없음 }, [avatarId, fetchAvatarWithResources]);
avatar는 await 이후에만 갱신되므로, avatarId가 바뀐 직후부터 fetch 완료 전까지 이전 아바타 리소스가 그대로 노출된다. 게다가 cache.delete(line 72)가 매 전환마다 캐시를 비워 이미 본 아바타도 항상 fetch 왕복이 끼므로 아바타가 바뀌는 모든 구간에서 이 윈도우가 열린다(지우↔태양을 반복하는 이번 회기에서 특히 잘 터짐).
use-avatar-video: stale 리소스로 talking 영상 선택// apps/web/hooks/use-avatar-video.ts:61~74 (effect deps: [avatarId, avatarState, avatar, ...]) const idleItem = avatar.resourceItems?.find(i => i.type === currentState); const talkingItem = avatar.resourceItems?.find(i => i.type === talkingType); // avatar 가 stale(지우)이면 지우의 talking 을 골라 로드 — props.avatarId(태양)와 불일치
| 아바타 | 업로드된 리소스 | 비고 |
|---|---|---|
태양 taeyang_v2 | talking · idle · eyes_idle · eyes_talking (4종) | eyes 모드 보유 |
지우 jiwoo_v2 | talking · idle (2종) | eyes 없음 → fallback 경로 |
두 아바타의 리소스 구성이 비대칭이라, 태양(eyes 모드) ↔ 지우(eyes 없음) 전환 시 baseIdleState 변환·fallback이 더해져 effect 재실행이 잦다. avatarState 토글이 stale 윈도우에 걸릴 확률을 높이는 악화 요인.
use-step-sync.ts → getAvatarId(persona, theme)). 호스트는 guest-step-update 소켓 이벤트로 받은 값을 미러링만 한다 → avatarId는 흔들리지 않는다. 흔들리는 것은 "avatarId에 매칭되는 리소스"이며, 그 출처가 비동기 fetch라 타이밍 레이스가 발생한다.
useAvatarResources/use-avatar-video/MeetAvatar는 게스트·모니터가 공유하는 코드다. 그런데도 모니터에서 재현되지 않는 이유는 렌더 경로에 리소스 게이트가 있느냐의 구조적 차이다.
| 게스트(아동) | 진행자(모니터) | |
|---|---|---|
| 렌더 경로 | GuestLayoutContent → MeetAvatar 직접 | MonitorResourceGate → GuestLayoutContent → MeetAvatar |
| avatarId 변경 시 | 즉시 MeetAvatar 렌더 (게이트 없음) | 로딩 스피너로 막고, 새 아바타 리소스 캐시 후 렌더 |
| 근거 | guest-layout.tsx:278 | monitor-media-display.tsx:172, 313 |
MonitorResourceGate가 stale 윈도우를 원천 차단useAvatarResources(avatarId)로 새 아바타 메타데이터를 가져오고, avatarLoading 동안 자식(MeetAvatar) 대신 "리소스를 불러오는 중..." 스피너만 렌더 (monitor-resource-gate.tsx:157).collectNeededFileNames + Promise.all, :99~130). 주석 :37~38도 "avatarState 변경 시 재해석을 막으려 모든 state 파일을 모은다"고 명시.status="success" 이후에야 MeetAvatar 마운트 → useAvatarVideo가 stale(이전 아바타) 리소스로 도는 윈도우가 애초에 열리지 않음. talking 토글도 그 사이에 끼어들 수 없다.요지: 게이트가 "잘못된 아바타" 대신 "로딩 스피너"로 그 전환 구간을 덮는다. 모니터는 avatarId가 바뀌면 새 리소스가 다 준비되기 전까지 아바타 영역을 렌더하지 않으므로 매칭 오류가 발생할 수 없다.
게스트의 resourcesReady("준비됐나요?" 팝업, guest-layout.tsx:203)는 세션 시작 1회용 게이트일 뿐, 수업 중 스텝/아바타 전환마다 거는 게이트가 아니다. 아동에게 수업 중 로딩 스피너를 안 보이게 하려고 콘텐츠를 즉시 렌더하는데, 바로 그 즉시성이 stale-resource 레이스에 노출되는 대가다.
avatar.id !== avatarId면 로드를 보류해 잘못된 아바타 렌더를 막되, 이전 아바타를 유지하다 새 리소스 도착 시 전환하므로 아동 화면에 로딩 스피너가 끼지 않는다.
use-avatar-video.ts — 리소스 소유자 가드// 영상 로드 effect 진입부: 리소스가 현재 avatarId의 것일 때만 진행 if (!avatar || avatar.id !== props.avatarId || !avatar.resourceItems) { return; }
use-avatar-resources.ts — 캐시 동기 반환// 변경 전: avatarId 변경 시 매번 캐시 삭제 → 항상 네트워크 fetch (stale 윈도우 상시화) - avatarWithResourcesCache.delete(avatarId); // 변경 후: 캐시된 아바타는 동기 반환. 명시적 새로고침은 refresh()가 캐시를 비운 뒤 호출
검증: 변경 파일 tsc --noEmit 타입 통과. 정상 케이스(avatar.id === avatarId)는 기존과 동일 동작, 불일치 시에만 로드 보류 → 이전 아바타 유지 후 새 리소스 도착 시 전환(잘못된 talking 깜빡임 제거).
use-avatar-video 리소스 소유자 가드 — 핵심 수정Avatar.id는 avatarId와 동일(${"{persona}_{theme}"})하므로, 리소스가 현재 avatarId의 것일 때만 사용한다.
// 영상 로드 effect 진입부 if (!avatar || avatar.id !== props.avatarId || !avatar.resourceItems) { return; // stale 리소스로는 영상 로드 안 함 → 잘못된 아바타 렌더 원천 차단 }
useAvatarResources avatarId 변경 시 즉시 초기화 — 보조effect 진입부에서 setAvatar(null) → stale 노출 제거(단독 사용 시 잠깐 blank 발생).
cache.delete(line 72) 제거 — 윈도우 축소이미 본 아바타는 캐시에서 동기 반환되게 해, 재방문 시 fetch·stale 윈도우 자체를 없앤다.
useAvatarResources에서 즉시 setAvatar(null))는 단독 시 blank 깜빡임이 생겨 미적용 — A 가드로 충분.
video_v2_05_responsive_bye_0)에서 DECODE_OVERLOAD·watchdog backstop이 함께 관찰됨(같은 단계의 별개 이슈).