VideoEndWatchdog DECODE_OVERLOAD 반복 stall 미발화 BUG DESIGN OPEN

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

분석 세션: 아동303 25회기 · 아동049 24회기 · 아동074 2회기 · 2026-06-16 관련 파일: apps/web/shared/lib/video-end-watchdog.ts, apps/web/shared/ui/meet-video.tsx

증상 BUG

full:video 스텝 영상 재생 중 DECODE_OVERLOAD가 반복되는 상황에서 VideoEndWatchdog이 발화하지 않아 자동 전환 실패
영상이 끝까지 재생되지 못하고 stall이 반복되는 동안 Watchdog이 침묵. 결국 호스트(치료사)가 수동으로 스텝을 변경함.

세션별 케이스 요약

아동303 25회기 · 2026-06-16 06:33 KST

로그: 6-019ecf1f-528a-7498-a6f5-5a503e103772_0_06168ae1-…-8fca0dcf3266.json  ·  디바이스: 8GB / 6코어 (정상 기기)

영상durationDECODE_OVERLOAD결과
video_tiki25_movie_01 140.544s 06:35:00+ (반복, 개별 ~1.6s) ❌ 06:35:35 호스트 수동

아동049 24회기 · 2026-06-16 20:00 KST

출처: Grafana ppi-socket Loki 로그 (LogRocket 파일은 부분/혼합 로그로 분석 불가, 소켓 서버 로그로 재조회)  ·  roomId: c53de64f-b555-43f8-981a-bae18acf9a0d_24  ·  디바이스: 8GB / 6코어 (정상 기기)

시각 (UTC)이벤트결과
11:00:32 Guest video play started (1번 영상) ✅ 11:03:57 AI auto-finish 자동 전환
11:03:57 Guest video play started (2번 영상) · DECODE_OVERLOAD 11:04:16~ ❌ 11:04:56 Host changed step
11:06:28 Guest video play started (3번 영상) · DECODE_OVERLOAD 11:07:12~ ✅ 11:06:27 AI auto-finish 자동 전환 (전 스텝)
11:10:38 Guest video play started (4번 영상) · DECODE_OVERLOAD 11:10:40~ ❌ 11:12:10 Host changed step
Guest video ended 소켓 이벤트 4번 모두 없음. 2번·4번 영상에서 DECODE_OVERLOAD 발생 후 Host changed step 확인 — 아동303·아동074과 동일 패턴. LogRocket 파일(6-019ed011-…)은 DECODE_OVERLOAD만 있는 부분/혼합 로그로 게스트 세션 로그가 아닌 것으로 판명됨. 본 분석은 Grafana Loki ppi-socket 로그를 직접 조회해 확인.

아동074 2회기 · 2026-06-16 10:00 KST

로그: 6-uuid-0424_0_c0fa9243-…-c660ac550477.json  ·  디바이스: 4GB / 8코어 (저사양 판정 경계 ≤4GB)

시각영상duration결과
10:00:29 video_tiki01_intro_01 71.573s ✅ near-end fallback 정상
10:05:39 video_tiki01_intro_01 71.573s ❌ 10:06:55 호스트 수동
10:08:27 (다른 영상) - ❌ 10:09:40 호스트 수동
10:11:13 (다른 영상) - ❌ 10:12:59 호스트 수동
10:18:21 (다른 영상) - ❌ 10:18:55 호스트 수동
10:18:57 video_tiki01_movie_03 9.427s ✅ Watchdog backstop 정상 (10:19:07)
Watchdog 성공 케이스 분석 (video_tiki01_movie_03, 9.427s)
arm() → firstDelayMs = 9,400ms → 첫 tick 발화(10:19:07) 시점에 currentTime=9.198s, remaining=0.229s ≤ 0.3s → end threshold 즉시 발화.
짧은 영상은 DECODE_OVERLOAD 누적 지연이 있어도 첫 tick 시점에 remaining이 이미 임계치 이하가 되어 Watchdog이 정상 작동함.
패턴 결론: Watchdog은 영상 duration이 짧을수록 (stall 지연 / duration 비율이 낮을수록) 정상 작동하고, 긴 영상(70s+)에서 DECODE_OVERLOAD가 반복될 때 실패 확률이 높아진다. 3개 세션(아동303 25회기·아동049 24회기·아동074 2회기)에서 총 7회 이상 동일 패턴 확인.

실패 타임라인 (아동303 25회기 대표 케이스)

06:33:08.491 Video play event fired → handlePlay → watchdog.arm() firstDelayMs = (140.544 - 0) × 1000 = 140,544ms → 첫 tick 예약 06:35:00+ DECODE_OVERLOAD 반복 시작 (stall ~1.6s → canplay → stall → canplay ...) 06:35:27.841 waiting [DECODE_OVERLOAD] currentTime=102.796s droppedFrames=959/3052 (31%) 06:35:29.525 Video canplay event fired currentTime=102.864s 06:35:29+ 첫 tick 발화: remaining=37.5s > 0.3s → 발화 안 함, progressed=true 세팅 06:35:32.842 waiting [DECODE_OVERLOAD] currentTime=106.129s droppedFrames=1002 06:35:34.460 Video canplay event fired currentTime=106.196s 06:35:35.012 Step changed by host ← 호스트 수동 개입 ← Watchdog 발화 없음

arm() 시점부터 첫 tick까지 140초 대기, 첫 tick 이후 호스트 개입까지 단 6초. 이 6초 동안 maxStall(30s)이 누적될 수 없었음.

종료 경로 발화 여부

경로로그 토큰발화
네이티브 ended 이벤트 [MEET_VIDEO] Video ended event fired ❌ 없음
timeupdate near-end fallback (duration−0.1s) Video onEnd triggered via near-end fallback ❌ 없음
VideoEndWatchdog backstop [MEET_VIDEO] Video onEnd triggered via watchdog backstop ❌ 없음
소켓 종료 통지 [GUEST_SOCKET] Notifying video ended ❌ 없음
소켓 네트워크 문제(업링크 실패)가 아니라 ended 이벤트 자체 미발생. 영상이 currentTime=106s(duration=140s)에서 멈춰 끝까지 도달하지 못한 채 호스트가 수동 개입함.

원인 분석 ROOT CAUSE

1) arm() 첫 tick 스케줄링 방식

VideoEndWatchdog.arm()은 정상 재생 중 불필요한 폴링을 피하려고 첫 tick을 예상 종료 시각에 맞게 단 하나만 예약한다. 그 이후부터 1초 폴링을 시작한다.

// video-end-watchdog.ts  arm()
const firstDelayMs = hasDuration
  ? Math.max(0, (s.duration - s.currentTime) * 1000)  // ← 예상 종료 시점까지 잠들기
  : this.pollIntervalMs;
this.scheduleNext(firstDelayMs);

이 전략은 정상 재생에는 효율적이지만, DECODE_OVERLOAD로 영상이 늦어지는 경우 첫 tick이 발화할 때는 이미 stall이 한참 진행된 상황이다.

2) stall 감지 방식: 연속 stall만 측정

tick()의 maxStall 체크는 마지막 진행 이후 연속 정지 시간만 측정한다.

// video-end-watchdog.ts  tick()
if (s.currentTime > this.lastTime + PROGRESS_EPSILON_SEC) {
  this.lastTime = s.currentTime;
  this.lastProgressAt = this.now();  // ← canplay 후 조금이라도 진행하면 리셋
  this.progressed = true;
} else if (this.progressed) {
  const stalledMs = this.now() - this.lastProgressAt;
  if (remaining <= this.nearEndWindowSec && stalledMs >= this.nearEndStallMs) { fire(); }
  if (stalledMs >= this.maxStallMs) { fire(); }  // 30s 연속 정지
}

3) DECODE_OVERLOAD의 stall-resume 사이클이 측정을 방해

DECODE_OVERLOAD는 완전 정지가 아니라 짧은 stall(~1.6s)과 resume이 반복되는 패턴이다. 매 resume 직후 currentTime이 미세하게라도 진행하면 lastProgressAt이 리셋되어 누적 stall이 쌓이지 않는다.

wall clock │──play──stall(1.6s)──canplay+진행──stall(1.6s)──canplay+진행──stall... stalledMs │─────────0→1.6s────────RESET(0)───────0→1.6s──────────RESET(0)───────... maxStall(30s) 도달 불가능 ↑

4) 케이스별 수치 비교

지표아동303 25회기아동074 2회기 (10:05)
영상 duration140.544s71.573s
arm() → 첫 tick140s 대기71.5s 대기
첫 tick 시점 currentTime102.864s (remaining=37.7s)~30s 추정 (remaining ~40s)
wall clock 누적 지연36s미측정
개별 stall 지속 시간~1.6s~0.4–2s
첫 tick → 호스트 개입까지6초64초 (but maxStall 미달)
결과호스트 수동호스트 수동 × 4회

설계 한계 DESIGN

VideoEndWatchdog은 두 가지 케이스를 커버한다:

커버되지 않는 케이스: 영상이 짧은 stall과 resume을 반복하며 예상보다 훨씬 늦게 끝나는 상황.
wall clock 기준으로는 영상이 이미 "끝났어야" 할 시간이 지났지만, currentTime은 아직 끝에 도달하지 않았고, 연속 stall도 30s에 달하지 않아 두 경로 모두 발화하지 않는다.

개선 방안

방안 1 (권장) — wall clock drift 감지 추가

arm() 시점의 wall clock과 currentTime을 기록해두고, tick마다 "지금쯤이면 영상이 끝났어야 한다"를 체크한다.

// arm() 에 추가
this.armWallClockMs = this.now();
this.armVideoTimeSec = s.currentTime;

// tick() 에 추가 (endThreshold 체크 직후)
const wallElapsedSec = (this.now() - this.armWallClockMs) / 1000;
const expectedElapsedSec = s.duration - this.armVideoTimeSec;
const DRIFT_MARGIN_SEC = 30;  // 예상 종료 시각 + 30s 초과 시 발화

if (this.progressed && wallElapsedSec > expectedElapsedSec + DRIFT_MARGIN_SEC) {
  this.fire();  // "wall clock 기준 오버듀" 발화
  return;
}
이번 케이스 적용 시: arm(06:33:08) + duration(140.5s) + margin(30s) = 06:35:59에 Watchdog 발화.
호스트 수동 개입(06:35:35) 이전에 자동 처리 가능. (현재보다 ~24초 단축)

방안 2 — 누적 stall 시간 측정

연속 stall이 아닌 총 stall 누적 시간을 별도로 추적하여 임계치 초과 시 발화.

// tick() — stall 구간마다 누적
if (currentlyStalled) {
  this.totalStalledMs += tickElapsedMs;
}
if (this.totalStalledMs >= CUMULATIVE_STALL_THRESHOLD_MS) {
  this.fire();
}
임계치 설정이 까다롭다. 정상 재생 중에도 일부 stall이 발생할 수 있어 오발화 가능성이 있다. 방안 1이 더 직관적이고 안전하다.

방안 3 — DECODE_OVERLOAD 카운터 기반 조기 경보

meet-video.tsx에서 DECODE_OVERLOAD 횟수를 카운팅하여 일정 횟수 초과 시 Watchdog의 polling 간격을 단축하거나 별도 타이머를 시작. 근본 해결책은 아니지만 기존 구조를 유지하면서 부분 보완 가능.

로그 보강 (진단 목적)

Watchdog tick이 발화했으나 아무 조건도 충족 못 해 통과된 경우, 현재는 로그가 전혀 없다. tick 발화 로그 추가 시 "Watchdog이 동작은 했는데 왜 발화 안 했냐" 원인 추적이 용이해진다.

// tick() 마지막에 추가
logger.debug("Watchdog tick: no fire", {
  remaining, stalledMs, progressed: this.progressed, wallElapsedSec
});

관련 문서