마지막 업데이트 2026-07-22
증상(VOC): 수업 중 반복적으로 멈춤·끊김·지연이 발생하고, 양육자가 위치를 옮겨도 매번 재발.
근본 원인: 컨텐츠 활동영상이 저사양 기기(RAM 2GB Android)에서도 480p가 아니라 720p 원본으로 재생됐다. 저사양 게이팅(isLowPerformanceDevice)이 MeetVideo·MeetImage에는 배선돼 있지 않고 아바타에만(그것도 split 모드에서만) 연결돼 있기 때문. 720p@30fps는 H.264 Level 3.1 한계치라 2GB 기기 디코더가 감당 못 해 프레임을 31%까지 버렸다.
두 실패 모드: ① 아바타 없는 긴 영상 = 진행성 과부하(31% 드롭·완주), ② 아바타 있는 영상 = 4~5초 하드 프리즈(숨겨진 아바타 동시 디코딩이 가중). "위치 옮겨도 재발"은 원인이 네트워크가 아니라 기기 디코드라서.
진행자 녹화 멈춤은 별개 원인: 진행자(8GB 정상 기기)의 우주방소개영상 멈춤은 디코드 과부하가 아니라 버퍼 starvation(bufferedAhead 0·dropped 0)이며 게스트 kick보다 먼저 발생 — 게스트 디코드 이슈의 반영이 아닌 모니터 자체 이슈.
| 구분 | 값 |
|---|---|
| 아동 기기 | Android 10 · armv81 · Chrome 150 · deviceMemory 2GB · hardwareConcurrency 8 · isLowPerformanceDevice: true |
| 진행자 기기 | Windows 10 x64 · Chrome 150 · deviceMemory 8GB · hardwareConcurrency 8 |
| 네트워크(아동) | downlink 0.35Mbps · transport RTT 만성 30~70ms · 피크 1984ms |
| 서버/SFU | 정상 (room 스코프 error/warn 0건, 진행자 RTT 시종 <100ms) |
| 세션 시각 | 2026-07-20 16:33~16:54 KST (07:33~07:54 UTC) |
isLowPerformanceDevice는 guest-layout-content.tsx에서 단 한 곳에서만 쓰인다 — 아바타에, 그것도 split 모드에서만.
// guest-layout-content.tsx:203 — 유일한 사용처 <MeetAvatar isLowPerformanceDevice={isSplitMode && isLowPerformanceDevice()} /> // MeetVideo / MeetImage 에는 전달되지 않음 → props.isLowPerformanceDevice = undefined <MeetVideo ... /> // isLowPerformanceDevice 없음 <MeetImage ... /> // isLowPerformanceDevice 없음
그 결과 meet-video.tsx는 getResourceUrl(fileName, undefined)를 호출하고, 서버 /api/resources/urls는 480p 변형 대신 원본(720p)을 반환한다.
| 720p 원본 | 480p 대체본 | |
|---|---|---|
| 해상도 | 1280×720 | 854×480 (픽셀 44.5%) |
| 매크로블록 처리량 @30fps | 108,000 MB/s = Lv3.1 한계 (여유 0) | ≈48,600 MB/s (한계의 45%) |
| 비디오 비트레이트 | ~1.4 Mbps | ~0.8 Mbps |
| 코덱 | H.264 Constrained Baseline / B-frame 0 (양쪽 동일, 이미 디코드 친화적) | |
두 파일의 실질 차이는 해상도(+연동 비트레이트)뿐. 720p는 저사양 디코더에 여유가 0이라 프레임을 버릴 수밖에 없다.
아동측 MEET_VIDEO 전체 타임라인(2400 엔트리 전체 로그)에서 영상별로 두 가지 뚜렷이 다른 실패가 관측됐다.
| 영상 | 활동/아바타 | 결과 | 드롭(프레임) | 패턴 |
|---|---|---|---|---|
| story_01 인트로 | act0 / 아바타 없음 | 완주(0→139.8s) | 738/2398 → 813/2523 (31~32%) | 진행성 과부하 |
| story_02 우주방소개 | act1 / 아바타O | 3.9s에서 정지 | 6→14 (6초간 7프레임=거의 0fps) | 하드 프리즈 |
| bridge_01 브릿지 | act5 / 아바타 없음 | 정상 완주(10s) | — | 정상 |
| tutorial_01 튜토리얼 | act6 / 아바타O | 4.955s에서 정지 | 14→28 | 하드 프리즈 |
| gacha_06 뽑기영상 | act8 / 아바타O | canplay 46.6초 대기 후 완주 | — | 로딩 지연 |
| ending_01 엔딩 | act12 / 아바타O | 3.886s에서 정지 | 15→34 | 하드 프리즈 |
| ending_02 에필로그 | act12 / 아바타O | canplay 79.9초 + 4.75s 정지 | 14→14 (완전 정지) | 로딩지연+프리즈 |
프레임을 31% 버리면서도 영상은 계속 전진(81s→85s→완주). 순수 720p 디코드 스루풋 부족. 아바타가 없는데도 발생 → 720p 컨텐츠 자체가 이 기기엔 과함을 단독 입증.
재생 4~5초 지점에서 완전 정지 — stall 6~7초 동안 디코더가 거의 0프레임만 생산(ending_02는 14→14, 완전 정지). 스루풋 드롭이 아니라 디코더/메인스레드 hang. 하드 프리즈는 전부 아바타 있는 스텝에서 발생 → 컨텐츠 720p + 숨겨진 아바타 idle/talking 동시 디코딩 + avatarState 변경 시 아바타 video 재생성 churn이 메인스레드를 얼린 것으로 추정(상관 강함, 인과는 가설).
// meet-video.tsx:654-677 diagnoseWaitingCase if (document.visibilityState === "hidden") return "TAB_BACKGROUNDED"; if (isRecentSeek) return "SEEK_REBUFFER"; if (corruptedVideoFrames > 0) return "CODEC_CORRUPT"; if (bufferedAheadSec > 1 && droppedVideoFrames > 10) return "DECODE_OVERLOAD"; // 진행성 과부하 if (bufferedAheadSec > 1) return "DECODE_OVERLOAD"; // 데이터 있는데 정지 = hang 포함 if (video.currentTime < 1) return "INITIAL_LOAD"; return isBlob ? "BLOB_READ_STALL" : "NETWORK_STALL";
서버 세션 녹화는 오디오 전용 mp3(아동 마이크 + 핑퐁이 TTS만, recordingManager.ts)라 비디오 트랙이 없다. 따라서 "진행자 녹화 영상"은 서버 녹화물이 아니라 진행자 화면 직접 녹화이고, 모니터는 컨텐츠 mp4를 자기 기기에서 독립 로컬 디코드한다.
시그니처: bufferedAheadSec 0 + dropped 0 + 8GB 정상 기기 → 디코드 과부하가 아니라 데이터가 없어 재생 못 함(buffer/fetch starvation). 재접속 churn으로 모니터가 story_02 blob을 버퍼링하지 못한 것으로 추정.
게스트와 독립: 진행자는 0.1s, 게스트는 3.9s에서 서로 다른 시각·위치에 정지 → 두 플레이어가 독립 로컬 디코드임을 재확인. 진행자 멈춤은 게스트 디코드 이슈의 반영이 아니다.
아동 액세스망 downlink 0.35Mbps, transport RTT 피크 1984ms. 서버·SFU는 정상(진행자 RTT <100ms). 이 저하가 재생 자체를 멈추게 하진 않지만(blob은 재생 전 완전 다운로드) 다음에 기여:
| 우선 | 수정 | 효과 | 상태 |
|---|---|---|---|
| 1 (핵심) | MeetVideo·MeetImage에 isLowPerformanceDevice 배선 → 컨텐츠 720p→480p | 아동측 진행성 과부하 직접 해결. 아바타 유무 무관 모든 영상 스텝 커버 | 구현 · 480p 전환은 2GB Android 미검증 |
| 2 | 아바타 480p 게이트에서 isSplitMode 제거 + 숨김(!isVisible) 아바타 idle/talking pause() + RMS 중단 | 영상 스텝의 아바타 동시 디코딩 제거(하드 프리즈 완화) | 구현·검증완료 (노트북·iPad) |
| 3 | 모니터 리소스 프리캐싱/재접속 시 버퍼 유지 로직 점검 | 진행자 녹화 멈춤(별개 트랙) 해결 | 미착수 |
최종 머지에 남은 코드는 아래 3파일뿐이다(검증용 HUD/오버라이드는 제거 — 아래 표 하단 참조).
| 파일 | 변경 |
|---|---|
| shared/ui/guest-layout-content.tsx | MeetVideo·MeetImage·MeetAvatar에 isLowPerformanceDevice() 전달, 아바타 isSplitMode 게이트 제거, isVisible={isAvatarVisible}(pause 상시 ON) |
| shared/ui/meet-image.tsx | isLowPerformanceDevice prop 추가 → getResourceUrl(fileName, flag) (이미지는 현재 서버상 no-op) |
| shared/ui/meet-avatar.tsx | isVisible prop 추가. 숨김 시 idle/talking pause() + RMS(rmsEnabled) 중단. 표시 복귀 시 idle play(), talking은 RMS 켜진 경우 RMS 루프에 위임(침묵 중 불필요 디코딩 방지) |
최종 커밋 473de048 (브랜치 PPI-1143, 3파일 32줄). 단일 커밋. 코드리뷰 반영(resume 시 talking RMS 위임) 포함.
동일 환경에서 아바타 스텝 → 영상 스텝(아바타 숨김) 이동 후, avatarPause OFF vs ON을 HUD로 실측. 핵심 지표는 전체 디코드 fps 합(= 실제 디코딩 중인 프레임/초).
| 지표 | pause OFF | pause ON | 차이 |
|---|---|---|---|
| 아바타 재생중 · fps합 | 2개 · 59.9 | 0개 · 0.0 | −59.9fps |
| 전체 디코드 fps 합 | 89.9 | 30.0 | −60fps (약 67%↓) |
| 컨텐츠 드롭 | 4/872 (0.5%) | 0/866 (0.0%) | 미세 감소 |
| 지표 | pause OFF | pause ON | 차이 |
|---|---|---|---|
| 아바타 재생중 · fps합 | 2개 · 59.8 | 0개 · 0.0 | −59.8fps |
| 전체 디코드 fps 합 | 89.6 | 30.9 | −58.7fps (약 66%↓) |
| 컨텐츠 드롭 | 0/1071 (0.0%) | 0/1011 (0.0%) | 둘 다 0 |
배포 없이 실기기에서 A/B 하기 위한 런타임 오버라이드 + 화면 내 수치 오버레이. 우선순위: URL 쿼리 > localStorage > 기본값.
// 진입 (mode=test면 세션까지 파라미터 전파) /client-guest?mode=test&debugDecode=1 // 파라미터 debugDecode=1 # HUD 표시 (필수) lowPerf=on|off # 480p 강제 ON / 720p 강제 (없으면 auto 판정) avatarPause=on|off # 숨김 아바타 pause 최적화 (기본 on)
HUD 하단 토글 버튼은 localStorage에 기록 후 현재 URL을 새로고침하므로, 세션 안에서 파라미터 재입력 없이 즉시 A/B 가능하다.