아동005 27회기 아바타 매칭 오류 — 태양 talking 전환 시 지우 렌더 ROOT CAUSE 원인 확정 수정 적용

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

로그: 아동 6-019eda46-…-119c8629e417 / 진행자 6-019eda43-…-8dee79bc1da8 (LogRocket, ppi-prod) 세션: 2026-06-18 19:24~19:55 KST · roomId 134c035a-…-995807e321f4_28 (활동 라벨 "체크아웃_27") 관련 파일: apps/web/hooks/use-avatar-resources.ts, apps/web/hooks/use-avatar-video.ts

증상 VOC

체크아웃(끝인사) 단계에서 태양(taeyang_v2) 아바타가 말하기(talking) 영상으로 전환될 때, 잠깐 지우(jiwoo_v2) 아바타가 나타남.
아바타 식별자(avatarId)는 태양인데 화면에는 지우 talking 영상(avatar_jiwoo_v2_talking_0.mp4)이 렌더된 뒤 곧 태양으로 복구됨.

아동: 아동005 · 환경: prod · 단말: Linux x86_64 / Chrome · 이번 회기는 지우·태양 두 아바타를 번갈아 사용(체크아웃 단계 페르소나=태양).
게스트(아동) 측에서만 발생, 진행자(모니터) 측에서는 재현되지 않음.

핵심 결론 ROOT CAUSE

useAvatarResources가 avatarId 변경 시 이전 아바타 리소스(avatar state)를 즉시 비우지 않아, "avatarId=신규(태양) + 리소스=이전(지우)"인 stale 윈도우가 생긴다. 그 윈도우에서 avatarState가 idle → talking으로 바뀌며 use-avatar-video의 영상 로드 effect가 재실행되고, stale 상태의 지우 리소스로 talking 영상을 골라 로드해 태양 자리에 지우가 렌더된다. 태양 리소스 fetch가 끝나면 effect가 다시 돌아 정상 복구되므로 "잠깐" 보인다.

발생 메커니즘

  1. act12(체크아웃) 진입 → avatarId가 지우 → 태양으로 전환 (앞선 단계들에서 지우↔태양을 여러 번 오감).
  2. useAvatarResources(taeyang_v2) effect가 돌며 캐시를 지우고(cache.delete, line 72) async로 태양 리소스를 fetch. 이때 avatar state는 여전히 지우 리소스를 들고 있음(초기화 코드 없음).
  3. 체크아웃 인사로 AI가 말하기 시작 → avatarState idle → talkinguse-avatar-video 로드 effect 재실행(의존성에 avatarState 포함).
  4. 이 시점 props.avatarId = 태양인데 avatar = 지우(stale)avatar.resourceItems.find(type === "talking")지우의 talking을 찾아 avatar_jiwoo_v2_talking_0.mp4 로드.
  5. 태양 자리에 지우 talking 영상 렌더 (= VOC 증상).
  6. 태양 fetch 완료 → setAvatar(태양) → effect 재실행 → 태양 영상 로드 → 정상 복구.
10:52:00.072 [HOST] Guest step updated avatarId taeyang_v2 → taeyang → taeyang_v2 (act12/step1, 체크아웃) 10:52:04 [CHILD] MEET_VIDEO loadstart video_v2_05_responsive_bye_0 (끝인사 영상) 10:52:xx avatarId=태양 · avatar=지우(stale) 윈도우에서 talking 전환 → 지우 talking 로드 10:52:45 [CHILD] MEET_VIDEO error (ended·visibilityState=hidden) ← 동일 단계 부수 이슈

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]);

avatarawait 이후에만 갱신되므로, 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_v2talking · idle · eyes_idle · eyes_talking (4종)eyes 모드 보유
지우 jiwoo_v2talking · idle (2종)eyes 없음 → fallback 경로

두 아바타의 리소스 구성이 비대칭이라, 태양(eyes 모드) ↔ 지우(eyes 없음) 전환 시 baseIdleState 변환·fallback이 더해져 effect 재실행이 잦다. avatarState 토글이 stale 윈도우에 걸릴 확률을 높이는 악화 요인.

avatarId 값 자체는 게스트가 로컬에서 결정한다(use-step-sync.tsgetAvatarId(persona, theme)). 호스트는 guest-step-update 소켓 이벤트로 받은 값을 미러링만 한다 → avatarId는 흔들리지 않는다. 흔들리는 것은 "avatarId에 매칭되는 리소스"이며, 그 출처가 비동기 fetch라 타이밍 레이스가 발생한다.

진행자(모니터) 측에서 발생하지 않는 원인 — 리소스 게이트 비대칭

useAvatarResources/use-avatar-video/MeetAvatar는 게스트·모니터가 공유하는 코드다. 그런데도 모니터에서 재현되지 않는 이유는 렌더 경로에 리소스 게이트가 있느냐의 구조적 차이다.

게스트(아동)진행자(모니터)
렌더 경로GuestLayoutContentMeetAvatar 직접MonitorResourceGateGuestLayoutContentMeetAvatar
avatarId 변경 시즉시 MeetAvatar 렌더 (게이트 없음)로딩 스피너로 막고, 새 아바타 리소스 캐시 후 렌더
근거guest-layout.tsx:278monitor-media-display.tsx:172, 313

MonitorResourceGate가 stale 윈도우를 원천 차단

요지: 게이트가 "잘못된 아바타" 대신 "로딩 스피너"로 그 전환 구간을 덮는다. 모니터는 avatarId가 바뀌면 새 리소스가 다 준비되기 전까지 아바타 영역을 렌더하지 않으므로 매칭 오류가 발생할 수 없다.

게스트는 왜 게이트가 없나

게스트의 resourcesReady("준비됐나요?" 팝업, guest-layout.tsx:203)는 세션 시작 1회용 게이트일 뿐, 수업 중 스텝/아바타 전환마다 거는 게이트가 아니다. 아동에게 수업 중 로딩 스피너를 안 보이게 하려고 콘텐츠를 즉시 렌더하는데, 바로 그 즉시성이 stale-resource 레이스에 노출되는 대가다.

적용한 수정 A(리소스 소유자 가드)는 게스트가 게이트의 큰 스피너 없이도 같은 보호를 받게 한다 — avatar.id !== avatarId면 로드를 보류해 잘못된 아바타 렌더를 막되, 이전 아바타를 유지하다 새 리소스 도착 시 전환하므로 아동 화면에 로딩 스피너가 끼지 않는다.

수정 적용 DONE

A(리소스 소유자 가드) + C(불필요한 캐시 삭제 제거)를 적용함. 잘못된 아바타 렌더를 원천 차단하고, 아바타 전환 시 네트워크 fetch가 끼던 stale 윈도우를 제거했다.

A. use-avatar-video.ts — 리소스 소유자 가드

// 영상 로드 effect 진입부: 리소스가 현재 avatarId의 것일 때만 진행
if (!avatar || avatar.id !== props.avatarId || !avatar.resourceItems) {
  return;
}

C. use-avatar-resources.ts — 캐시 동기 반환

// 변경 전: avatarId 변경 시 매번 캐시 삭제 → 항상 네트워크 fetch (stale 윈도우 상시화)
- avatarWithResourcesCache.delete(avatarId);
// 변경 후: 캐시된 아바타는 동기 반환. 명시적 새로고침은 refresh()가 캐시를 비운 뒤 호출

검증: 변경 파일 tsc --noEmit 타입 통과. 정상 케이스(avatar.id === avatarId)는 기존과 동일 동작, 불일치 시에만 로드 보류 → 이전 아바타 유지 후 새 리소스 도착 시 전환(잘못된 talking 깜빡임 제거).

수정안 — 검토 옵션 (적용: A+C)

A. use-avatar-video 리소스 소유자 가드 — 핵심 수정

Avatar.id는 avatarId와 동일(${"{persona}_{theme}"})하므로, 리소스가 현재 avatarId의 것일 때만 사용한다.

// 영상 로드 effect 진입부
if (!avatar || avatar.id !== props.avatarId || !avatar.resourceItems) {
  return;  // stale 리소스로는 영상 로드 안 함 → 잘못된 아바타 렌더 원천 차단
}

B. useAvatarResources avatarId 변경 시 즉시 초기화 — 보조

effect 진입부에서 setAvatar(null) → stale 노출 제거(단독 사용 시 잠깐 blank 발생).

C. cache.delete(line 72) 제거 — 윈도우 축소

이미 본 아바타는 캐시에서 동기 반환되게 해, 재방문 시 fetch·stale 윈도우 자체를 없앤다.

적용: A + C. A가 잘못된 아바타 렌더를 원천 차단하고, C가 지우↔태양 반복 전환에서 레이스 빈도·깜빡임을 줄인다. B(useAvatarResources에서 즉시 setAvatar(null))는 단독 시 blank 깜빡임이 생겨 미적용 — A 가드로 충분.

영향 범위

관련 문서