Shaka Player의 Video Stall 처리 - 내부 동작과 실전 대응

Video Stall이란 무엇인가

영상 플레이어를 다루다 보면 버퍼링도 아닌데 영상이 멈춰버리는 상황을 만나게 됩니다. 네트워크는 멀쩡하고, video.pausedfalse이며, 충분히 버퍼링된 상태인데도 currentTime이 더 이상 흐르지 않는 경우입니다.

이런 상황을 Video Stall이라고 부릅니다. 일반적인 buffering은 데이터가 부족해서 멈추는 정상적인 동작이지만, stall은 "재생되어야 마땅한 상황에서 재생이 멈춘" 비정상 상태입니다. 사용자 입장에서는 영상이 얼어붙은 것처럼 보이고, 직접 시킹을 해야만 다시 재생되는 답답한 경험이 됩니다.

Shaka Player는 이 stall을 감지하고 자동으로 복구하는 메커니즘을 내장하고 있습니다. 이 글에서는 그 내부 동작을 따라가 보고, 실무에서 어떻게 활용할 수 있는지 정리합니다.

Shaka Player에서 Stall이 발생하는 원인

Stall이 발생하는 원인은 다양하지만, 대표적으로 다음과 같습니다.

  • 하드웨어 디코더 정지: WebOS, Chromecast 같은 임베디드 환경에서 디코더 파이프라인이 일시적으로 응답하지 않는 경우
  • MediaSource Extensions(MSE) 내부 큐 정지: 브라우저가 SourceBuffer에 데이터를 append했지만 디코더가 픽업하지 못한 상태
  • 오디오/비디오 트랙 동기화 실패: 두 트랙의 PTS(Presentation Time Stamp)가 어긋나 한쪽이 진행되지 못함
  • DRM 라이선스 갱신 지연: 라이선스 갱신 중 미디어 파이프라인이 멈추는 경우
  • OS 레벨 오디오 포커스 손실: Android에서 audiofocus가 다른 앱으로 넘어간 상황

이런 상황들은 브라우저나 디바이스가 명확한 에러 이벤트를 발생시키지 않기 때문에, 외부에서 보면 그냥 "영상이 안 나간다"로만 보입니다. Shaka Player는 이런 상태를 wall-clock 기반의 폴링으로 감지합니다.

Shaka Player의 내장 Stall Detector

Shaka Player의 stall 감지/복구 로직은 모두 lib/media/gap_jumping_controller.js 한 파일에 모여 있습니다. 파일명과 달리 StallDetector 클래스도 같은 파일에서 goog.provide됩니다.

StallDetector.poll()의 핵심 로직

StallDetector#poll()이 stall 감지의 심장부입니다. 매 폴링 시점마다 세 가지 값을 읽어서 비교합니다.

poll() { - shouldBeMakingProgress: "지금 재생이 진행되고 있어야 하는가?" - getPresentationSeconds(): video.currentTime - getWallSeconds(): Date.now() / 1000 (실제 벽시계 시간) }

판정 흐름은 다음과 같습니다.

  1. currentTime이 직전 폴링과 동일하고 shouldBeMakingProgress도 동일하면 lastUpdateSeconds_를 갱신하지 않습니다.
  2. wallTime - lastUpdateSeconds_ >= stallThresholdSeconds가 되면, 즉 임계 시간(기본 1초) 이상 currentTime이 멈춰있고 진행되어야 마땅한 상태라면 stall로 판정합니다.
  3. onStall(at, duration) 콜백을 호출하고 didJump_ 플래그로 동일 stall에 콜백이 반복 호출되지 않도록 막습니다.

핵심은 벽시계 시간을 기준으로 currentTime의 정체 여부를 판단한다는 점입니다. 단순히 readyStatewaiting 이벤트를 신뢰하지 않고, 실제 시간이 흐르는데 영상 시간이 멈췄는지를 직접 비교합니다.

"재생되어야 마땅한가" 판단

위 로직에서 가장 중요한 분기는 shouldBeMakingProgress입니다. MediaElementImplementation이 이를 판단하는데, 다음 중 하나라도 해당되면 stall이 아닌 것으로 간주하고 무시합니다.

  • video.paused === true
  • video.playbackRate === 0
  • audioFocusPaused_ (Android의 audiofocuspaused/lost 이벤트로 갱신)
  • video.buffered.length === 0 — 이건 buffering이지 stall이 아닙니다
  • 현재 currentTime이 어떤 버퍼 구간의 내부(시작 -0.1초 ~ 끝 -0.5초)에 있지 않음 — 버퍼 끝 500ms 이내는 buffering으로 간주

버퍼 안쪽에서 멈춘 경우에만 stall로 분류됩니다. 버퍼가 비어있거나 끝자락에서 멈춘 건 데이터 부족 문제이지 디코더 문제가 아니기 때문입니다.

폴링 주기와 트리거

GapJumpingController 생성자에서 두 가지 트리거를 겁니다.

  • shaka.util.Timerconfig.streaming.gapJumpTimerTime 주기마다 onPollGapJump_() 호출
  • videowaiting 이벤트에도 같은 핸들러를 바인딩

코드 주석에는 "We can't trust readyState or 'waiting' events on all platforms"라고 적혀 있습니다. 즉 일부 플랫폼에서 waiting 이벤트가 누락되거나 부정확하게 발생하므로, 타이머 폴링으로 보강하는 구조입니다.

onPollGapJump_()는 매 호출마다 stallDetector_.poll()을 먼저 돌리고, 거기서 액션이 발생하면 같은 틱의 gap-jump 로직은 건너뜁니다. Stall 처리가 gap jump보다 우선합니다.

다만 다음 가드 조건에서는 stall/gap 둘 다 무시됩니다.

  • video.readyState === 0
  • seeking 중인데 'seeking' 이벤트가 아직 도착하지 않음
  • 일시정지 상태 (단, startTime이고 autoplay인 경우만 예외)

StallDetected 이벤트로 Stall 감지하기

Stall이 감지되면 Shaka Player는 외부로 StallDetected 이벤트를 발행합니다. 앱 레벨에서는 이 이벤트를 구독해서 사용자에게 안내하거나 로깅에 활용할 수 있습니다.

1const player = new shaka.Player(videoElement)
2
3player.addEventListener('stalldetected', (event) => {
4  console.warn('Stall detected!', event)
5})

이벤트 발행 직전에 Shaka 내부에서는 this.stallsDetected_++ 카운터가 증가하므로, 누적 횟수는 player.getStats().stallsDetected로 확인할 수 있습니다.

TIP

타입스크립트로 작업한다면 shaka.Player.StallDetectedEvent 타입과 externs/shaka/player.js의 통계 스펙을 참고하세요.

수동 복구 전략 - stallSkip 분기

onStall 콜백이 호출되면 Shaka는 두 가지 복구 전략 중 하나를 자동으로 실행합니다. 선택은 config.streaming.stallSkip 값에 따라 갈립니다.

A. stallSkip > 0 (기본값 약 0.1초): 미세 시킹

video.currentTime += stallSkip

currentTime을 미세하게 앞으로 이동시켜 디코더가 다시 진행하도록 강제합니다. 대부분의 데스크톱 브라우저 환경에서는 이 방식이 가장 빠르고 자연스럽습니다.

B. stallSkip === 0: pause/play 토글

await video.play() video.pause() video.play()

play → pause → play 시퀀스로 디코더 파이프라인을 다시 깨웁니다. 시킹이 느리거나 비싼 플랫폼에서 사용됩니다.

실제로 WebOS와 Chromecast처럼 하드웨어 파이프라인이 긴 플랫폼은 lib/device/webos.js, lib/device/chromecast.jsadjustConfig()에서 stallSkip = 0을 강제하여 이 경로를 타도록 되어 있습니다.

플랫폼권장 전략이유
Desktop Chrome/Safari/FirefoxstallSkip > 0 (시킹)시킹이 빠르고 비용이 낮음
WebOS (LG TV)stallSkip = 0 (pause/play)하드웨어 시킹이 느려 사용자가 끊김을 체감
ChromecaststallSkip = 0 (pause/play)동일한 이유
Android TV케이스별 검증 필요디바이스 편차 큼

따라서 앱에서 직접 stallSkip을 설정하지 않더라도 디바이스별로 적절한 기본값이 적용됩니다. 임베디드 환경에 배포한다면 이 부분을 임의로 덮어쓰지 않는 편이 안전합니다.

실전 예제: React에서 Stall 모니터링 훅 구현

이번에는 Shaka의 stall 메커니즘을 React 환경에서 관찰하는 커스텀 훅을 작성해 봅니다. 자동 복구는 Shaka가 이미 해주므로, 우리가 할 일은 사용자 안내와 텔레메트리 전송입니다.

1import { useEffect, useRef, useState } from 'react'
2import shaka from 'shaka-player'
3
4interface StallState {
5  isStalled: boolean
6  stallCount: number
7  lastStallAt: number | null
8}
9
10export function useShakaStallMonitor(player: shaka.Player | null) {
11  const [state, setState] = useState<StallState>({
12    isStalled: false,
13    stallCount: 0,
14    lastStallAt: null,
15  })
16  const recoveryTimerRef = useRef<number | null>(null)
17
18  useEffect(() => {
19    if (!player) return
20
21    const handleStall = (event: Event) => {
22      setState((prev) => ({
23        isStalled: true,
24        stallCount: prev.stallCount + 1,
25        lastStallAt: Date.now(),
26      }))
27
28      // Shaka의 자동 복구가 끝나는 시점을 추정 (약 stallThreshold + 여유)
29      if (recoveryTimerRef.current) {
30        window.clearTimeout(recoveryTimerRef.current)
31      }
32      recoveryTimerRef.current = window.setTimeout(() => {
33        setState((prev) => ({ ...prev, isStalled: false }))
34      }, 1500)
35    }
36
37    player.addEventListener('stalldetected', handleStall)
38    return () => {
39      player.removeEventListener('stalldetected', handleStall)
40      if (recoveryTimerRef.current) {
41        window.clearTimeout(recoveryTimerRef.current)
42      }
43    }
44  }, [player])
45
46  return state
47}

사용하는 쪽 예시입니다.

1function VideoPlayer({ manifestUrl }: { manifestUrl: string }) {
2  const videoRef = useRef<HTMLVideoElement>(null)
3  const [player, setPlayer] = useState<shaka.Player | null>(null)
4  const { isStalled, stallCount } = useShakaStallMonitor(player)
5
6  useEffect(() => {
7    if (!videoRef.current) return
8    const p = new shaka.Player(videoRef.current)
9    p.load(manifestUrl)
10    setPlayer(p)
11    return () => {
12      p.destroy()
13    }
14  }, [manifestUrl])
15
16  return (
17    <div>
18      <video ref={videoRef} controls />
19      {isStalled && (
20        <div role="status">재생을 복구하는 중입니다... (누적 {stallCount}회)</div>
21      )}
22    </div>
23  )
24}

이 훅은 Shaka의 복구 로직을 대체하지 않습니다. Shaka가 자동으로 시킹이나 pause/play 토글을 시도하는 동안, UI 레이어에서는 사용자에게 상태를 안내할 뿐입니다. 둘을 명확히 분리하는 것이 중요합니다.

주의사항과 디버깅 팁

1. stallEnabled를 함부로 끄지 말 것

config.streaming.stallEnabled = false로 두면 createStallDetector_()null을 반환하여 감지기 자체가 생성되지 않습니다. 일부 환경에서 오작동한다는 이유로 끄는 경우가 있지만, 이 경우 stall 발생 시 어떤 자동 복구도 일어나지 않으므로 사용자 경험이 더 나빠질 수 있습니다.

2. 관련 설정 한눈에 보기

의미
stallEnabledfalsecreateStallDetector_()null 반환 — 감지기 자체가 안 만들어짐
stallThresholdstall로 판정할 정지 임계 시간(초). 기본 1초
stallSkip0이면 pause/play, 양수면 그만큼 시킹
gapJumpTimerTime폴링 주기

3. 진단에 유용한 API

  • player.getStats().stallsDetected: 누적 stall 횟수
  • player.getBufferedInfo(): 현재 버퍼 구간 ({ total, audio, video, text })
  • videoElement.buffered: 브라우저가 직접 보고하는 버퍼 범위

이 세 값을 함께 로깅하면 "버퍼는 충분한데 stall이 났는지" vs "버퍼가 부족해서 멈춘 건지"를 구분할 수 있습니다. 버퍼가 충분한데도 stall이 잦다면 디코더 이슈, 버퍼가 자주 비어있다면 네트워크/ABR 이슈로 1차 분류가 가능합니다.

4. 같은 stall에서 콜백이 한 번만 호출됨

내부적으로 didJump_ 플래그가 있어 동일 stall 구간에서는 onStall이 한 번만 호출됩니다. 따라서 외부에서 StallDetected 이벤트를 카운트할 때는 "stall 구간 수"이지 "정지된 프레임 수"가 아니라는 점을 기억해야 합니다.

정리

Shaka Player의 stall 처리는 결국 다음 흐름으로 요약됩니다.

  1. 타이머 또는 waiting 이벤트 → GapJumpingController.onPollGapJump_()
  2. StallDetector.poll(): 버퍼 안에서 currentTime이 멈췄는지 wall-clock 기준으로 확인
  3. → 임계 시간 초과 시 onStall 콜백: stallSkip만큼 시킹 또는 pause/play 토글
  4. stallsDetected_++, StallDetected 이벤트 디스패치
  5. stall이 처리되지 않은 일반 폴링이면 buffered ranges 사이의 진짜 gap을 찾아 seek_()로 점프

핵심을 한 줄로 정리하면 다음과 같습니다.

"Shaka는 readyState나 waiting 이벤트를 신뢰하지 않고, 벽시계 시간을 기준으로 직접 stall 여부를 판단한다."

이런 설계 덕분에 플랫폼별 편차가 큰 임베디드 환경에서도 안정적으로 stall을 감지하고 복구할 수 있습니다. 앱에서는 자동 복구 로직을 굳이 다시 만들기보다, StallDetected 이벤트와 getStats().stallsDetected를 활용해 사용자 안내와 텔레메트리에 집중하는 편이 훨씬 효과적입니다.

다음에 영상이 멈춘다는 사용자 제보를 받으면, 단순한 buffering으로 단정 짓기 전에 player.getStats().stallsDetected부터 확인해 보세요. 그 숫자 하나가 문제의 본질이 디코더에 있는지 네트워크에 있는지를 가르는 결정적인 단서가 됩니다.