지난 글에서 Shaka Player가 stall을 어떻게 감지하고 복구하는지 따라가 봤습니다. 핵심은 벽시계 시간 기준 폴링과 두 가지 복구 전략(미세 시킹 / pause·play 토글)이었습니다.
같은 문제를 풀어야 하는 hls.js는 어떨까요? hls.js는 MSE 위에서 동작하는 HLS 전용 라이브러리지만, "버퍼는 있는데 currentTime이 안 움직인다"는 문제를 다뤄야 한다는 점은 동일합니다. 다만 접근 방식과 복구 전략의 폭이 Shaka와 흥미롭게 갈립니다.
이 글에서는 hls.js v1.x의 master 브랜치를 기준으로 src/controller/gap-controller.ts 한 파일을 따라가며 hls.js의 stall 처리를 정리하고, 마지막에 Shaka Player와의 차이를 비교합니다.
hls.js의 stall 감지/복구 로직은 src/controller/gap-controller.ts 단일 파일에 모여 있습니다. Shaka가 gap_jumping_controller.js에 StallDetector와 GapJumpingController를 함께 두는 것과 구조적으로 비슷합니다.
진입은 GapController 클래스가 담당합니다.
TICK_INTERVAL = 100(ms) 간격으로 tick()이 호출되고, 미디어 엘리먼트의 playing / waiting / ended 이벤트도 함께 구독합니다. waiting이 들어오면 즉시 this.waiting = performance.now()를 저장한 뒤 tick()을 강제로 한 번 더 돌립니다.
tick()은 readyState와 buffered 유무를 확인한 뒤 poll(currentTime, lastCurrentTime)로 본격적인 판정에 들어갑니다.
poll() — 분기 따라가기poll()이 hls.js stall 처리의 본체입니다. 분기 순서를 정리하면 다음과 같습니다.
currentTime이 직전 폴링과 다르면 재생이 정상 진행 중입니다. skipRetry/nudgeRetry 카운터를 0으로 리셋하고, 필요하다면 stallResolved()로 stall 해제 이벤트를 발행한 뒤 종료합니다. 이때 video buffered range 경계를 막 넘어간 상황이라면 nudgeOnVideoHole() 이라는 특수 처리가 끼어듭니다. 이건 뒤에서 다시 다룹니다.
시킹 자체가 일시적으로 currentTime 진행을 멈추기 때문에, 시킹 노이즈로 stall을 오판하지 않도록 빠져나갑니다.
pausedEndedOrHalted)paused, ended, playbackRate === 0인 경우는 "원래 안 움직여야 정상"이므로 stall 아님으로 처리합니다.
시킹 중이고 점프할 만한 적당한 거리의 홀이 없거나 in-flight fragment가 있다면 종료합니다. 시킹이 마무리되기를 기다리는 단계입니다.
아직 한 번도 움직인 적 없는데(!this.moved) stall이 잡혔다면, 영상이 시작 위치 직전의 작은 갭에 걸린 경우일 수 있습니다. 이때는 MAX_START_GAP_JUMP = 2.0초(라이브는 targetduration * 2) 이내라면 _trySkipBufferHole()로 점프합니다.
여기까지 도달했다는 건 "재생이 멈춰있고, 시킹/정지 상태도 아니다"라는 뜻입니다. stall 시작 시각을 this.stalled에 기록합니다. 만약 직전에 waiting 이벤트가 detectStallWithCurrentTimeMs(기본 1250ms) 내에 발생했다면 그 시점을, 아니면 현재 시각을 기준으로 잡습니다.
stalledDuration이 임계치를 넘어서거나 이미 waiting 이벤트가 들어와 있고 한 번이라도 재생한 적이 있다면 _reportStall()로 BUFFER_STALLED_ERROR를 1회 발행합니다.
마지막으로 _tryFixBufferStall()을 호출해 실제 복구를 시도합니다.
_trySkipBufferHole()_trySkipBufferHole()은 partial fragment로 인해 생긴 작은 홀을 점프해서 해소하는 전략입니다.
핵심 동작:
maxBufferHole(기본 0.1초)보다 크다면 함부로 점프하지 않고, 그 구간 전체가 partial/appended fragment로 채워질 예정인지를 walk 하면서 확인합니다.skipBufferHolePadding(기본 0.1초)만큼 여유를 더한 위치로 currentTime을 이동시키고, BUFFER_SEEK_OVER_HOLE 에러를 fatal: false로 발행합니다.skipRetry가 nudgeMaxRetry(기본 3)를 초과하면 fatal로 승격됩니다._tryNudgeBuffer()_tryNudgeBuffer()는 같은 버퍼 안인데 재생이 안 되는 경우를 위한 미세 시킹입니다.
핵심 포인트:
(nudgeRetry + 1) * nudgeOffset 으로 누진됩니다. 기본값은 nudgeOffset = 0.1이므로 0.1초 → 0.2초 → 0.3초 식으로 점점 크게 흔듭니다.nudgeMaxRetry(기본 3)를 초과하면 그제야 BUFFER_STALLED_ERROR를 fatal: true로 발행합니다. 즉 그 전까지는 모든 stall이 recoverable로 분류됩니다._tryFixBufferStall()은 partial fragment 홀이 있으면 _trySkipBufferHole()을 먼저 시도하고, 그게 통하지 않거나 해당 상황이 아니면 stalledDuration > highBufferWatchdogPeriod(기본 2초) 조건이 성립할 때 _tryNudgeBuffer()로 넘어갑니다.
nudgeOnVideoHole()의 0.000001 트릭세 번째 전략은 hls.js만의 독특한 처방입니다. 이미 재생은 진행되고 있는데 video buffered range 경계를 막 넘는 순간, 디코더가 다음 range를 무시하고 멈춰버리는 Chrome 버그를 회피하기 위한 코드입니다.
압권은 this.media.currentTime += 0.000001 한 줄입니다. 사람 눈에는 절대 보이지 않을 마이크로 시킹으로 비디오 디코더 파이프라인을 강제 flush합니다. 주석에는 "Magic number to flush the pipeline without interuption to audio playback"이라고 적혀 있습니다.
발동 조건도 굉장히 까다롭습니다.
이런 좁은 조건에서만 미세 시킹을 트리거합니다. 무차별 nudge가 audio 끊김을 유발할 수 있기 때문에 굉장히 보수적으로 가드된 셈입니다.
본체 분기 외에도 코드 곳곳에 흥미로운 보조 로직이 숨어있습니다.
MAX_START_GAP_JUMP와 라이브 보정상수 MAX_START_GAP_JUMP = 2.0은 VOD 환경의 시작 갭 점프 한도입니다. 라이브 스트림은 targetduration * 2 까지 허용되는데, 라이브 윈도우 슬라이딩으로 audio-stream-controller가 video보다 약간 뒤쪽 프래그먼트를 append하면서 발생하는 시작 갭을 흡수하기 위한 보정입니다.
adjacentTraversal의sn 가드nudge 시도 전, 현재 프래그먼트와 다음 프래그먼트의 시퀀스 번호(sn) 차이가 2 미만인 경우에만 nudge를 허용합니다. 멀리 떨어진 segment로 점프해버리지 않도록 가드하는 셈입니다.
stallReported 단일 발행 +STALL_RESOLVEDstallReported 플래그로 한 stall 구간당 BUFFER_STALLED_ERROR는 한 번만 발행되고, 해소 시 STALL_RESOLVED가 정확히 한 번 짝맞춰 발행됩니다. 외부에서 카운트할 때는 "정지된 프레임 수"가 아니라 "stall 구간 수"라는 점을 기억해야 합니다.
hls.js는 stall 관련 신호를 모두 Events.ERROR와 Events.STALL_RESOLVED 두 가지 채널로 노출합니다.
| 이벤트/에러 | 발행 시점 | fatal |
|---|---|---|
STALL_RESOLVED | 보고된 stall이 해제될 때 | — |
ERROR: BUFFER_STALLED_ERROR | stall이 임계 시간 초과 시 1회 보고, 또는 nudge 한도 초과 시 | false / true |
ERROR: BUFFER_NUDGE_ON_STALL | _tryNudgeBuffer()가 미세 시킹할 때 | false |
ERROR: BUFFER_SEEK_OVER_HOLE | _trySkipBufferHole() 또는 nudgeOnVideoHole()이 점프할 때 | false / true |
특이한 건 fatal: false인 ERROR가 굉장히 자주 발행될 수 있다는 점입니다. nudge 한 번에 한 번씩 발행되므로, 외부에서 무조건 ERROR를 사용자에게 노출하면 안 됩니다. fatal 플래그를 반드시 확인해야 합니다.
같은 문제를 두 라이브러리가 어떻게 다르게 푸는지를 정리하면 차이가 명확해집니다.
Date.now() 기반 벽시계 시간으로 정지 여부를 판단. "현재 진행되어야 마땅한가(shouldBeMakingProgress)"라는 별도 판단이 핵심.performance.now() + waiting 이벤트 시점을 함께 사용. paused / ended / playbackRate === 0을 직접 확인해 진행 여부를 판단.판정 임계치도 다릅니다. Shaka는 stallThreshold 기본 1초, hls.js는 detectStallWithCurrentTimeMs 기본 1250ms입니다.
| 항목 | Shaka Player | hls.js |
|---|---|---|
| 복구 전략 수 | 2가지 | 3가지 |
| 전략 1 | stallSkip > 0: currentTime += stallSkip | _trySkipBufferHole(): partial fragment 홀 점프 |
| 전략 2 | stallSkip === 0: play → pause → play 토글 | _tryNudgeBuffer(): 누진 미세 시킹 |
| 전략 3 | — | nudgeOnVideoHole(): 0.000001 마이크로 시킹 |
Shaka는 "플랫폼별로 어울리는 단일 전략 하나를 고른다"는 접근입니다. 데스크톱은 시킹, WebOS/Chromecast 같은 임베디드는 pause/play 토글로 자동 갈립니다.
hls.js는 "상황별로 다른 전략을 순차 시도한다"는 접근입니다. partial fragment 홀이면 skip, 같은 버퍼 안이면 nudge, video range 경계 통과면 마이크로 시킹. 그리고 각각이 자체 retry 카운터를 갖습니다.
GapJumpingController가 buffered ranges 사이의 진짜 gap을 별도로 점프. stall 처리와 gap jump가 같은 onPollGapJump_() 안에서 동작하지만 책임은 분리._trySkipBufferHole() 한 메서드가 담당. MAX_START_GAP_JUMP와 라이브 targetduration * 2 보정으로 구간을 다르게 적용.lib/device/webos.js, lib/device/chromecast.js의 adjustConfig()에서 디바이스별로 stallSkip = 0을 강제. 즉 디바이스 인지 기반 정책.nudgeOnVideoHole은 사실상 Chrome 버그를 겨냥하지만, 코드는 디바이스가 아니라 buffered ranges 형태를 보고 판단합니다.한 줄로 요약하면 다음과 같습니다.
Shaka는 "플랫폼별로 어울리는 단일 처방"을 선택한다. hls.js는 "상황별로 다른 처방을 누진 적용"한다.
Shaka가 디바이스 다양성을 우선시한다면, hls.js는 동일 디바이스 안에서도 매니페스트·세그먼트 구조에서 비롯되는 다양한 갭 패턴을 우선시합니다. 두 접근 모두 해당 라이브러리가 주로 사용되는 환경(Shaka는 OTT/CTV, hls.js는 브라우저)에 잘 맞춰져 있습니다.
자동 복구는 hls.js가 알아서 해주므로, 앱 레벨에서 할 일은 사용자 안내와 텔레메트리입니다. ERROR 이벤트에서 stall 관련 detail만 필터링하고, STALL_RESOLVED로 해제 시점을 잡는 훅을 예시로 작성해 봅니다.
이 훅은 hls.js의 복구 로직을 대체하지 않습니다. BUFFER_STALLED_ERROR와 BUFFER_SEEK_OVER_HOLE은 stall 보고/스킵 시점을, BUFFER_NUDGE_ON_STALL은 미세 시킹 횟수를, STALL_RESOLVED는 해제 시점을 알려줍니다. 사용자 UI 표시와 텔레메트리 전송에 그대로 활용할 수 있습니다.
fatal: false인 ERROR는 정상 복구 시도이므로 사용자에게 노출하면 안 됩니다. fatal 플래그를 분기 조건의 최우선 가드로 두세요.
hls.js의 stall 처리를 한 흐름으로 요약하면 다음과 같습니다.
MEDIA_ATTACHED → 100ms 폴링 + waiting 이벤트 트리거poll(): playhead 이동, seeking, paused·ended, 시작 갭, stall 누적을 단계별로 분기_tryFixBufferStall(): 상황에 따라 _trySkipBufferHole() → _tryNudgeBuffer() 순서로 시도nudgeOnVideoHole()이 Chrome video hole 버그를 마이크로 시킹으로 회피fatal: false 위주) + STALL_RESOLVED 이벤트로 외부에 신호Shaka Player와 hls.js는 결국 다른 길로 같은 곳에 도착합니다. Shaka는 플랫폼별로 처방을 골라 적용하고, hls.js는 상황별로 처방을 누진 적용합니다. 만약 라이브러리 선택지가 둘 다 열려 있다면, 임베디드 디바이스 비중이 크고 디바이스별 정책 분기를 줄이고 싶다면 Shaka가, 브라우저 위주이고 partial fragment·라이브 윈도우 같은 매니페스트 변동성을 정밀하게 다뤄야 한다면 hls.js가 더 어울리는 선택이 될 수 있습니다.
다음에 영상이 멈춘다는 사용자 제보를 받고 라이브러리 소스로 내려가야 한다면, hls.js의 경우 가장 먼저 살펴볼 곳은 gap-controller.ts 한 파일이라는 점만 기억해두면 됩니다. 거기에 답이 거의 다 들어 있습니다.