video.js + HLS + MSE의 미묘한 타이밍 충돌 오류 디버깅
웹에서 영상을 재생하는 일은 이제 더 이상 복잡하지 않다.
<video>
태그에 src
만 설정하면, 브라우저가 알아서 영상을 불러오고 재생할 수 있다. 대부분의 경우 이렇게 영상 재생은 브라우저 내에서 “자동”으로 처리되기에 내부 구조를 굳이 들여다볼 필요가 없다.
하지만 실시간 스트리밍이나 세그먼트 기반 스트리밍(HLS 등)으로 넘어가면 이야기는 달라진다. 보다 자연스럽고 안정적인 재생을 위해선 훨씬 더 섬세한 제어가 필요하기 때문이다.
지금까지 브라우저가 "알아서" 해주던 일들을
개발자가 "직접" 컨트롤해야 하는 순간이다.
이를 위해 현대 브라우저는 Media Source Extensions (MSE)라는 Extension을 제공하며, video.js는 바로 이 MSE 위에서 동작하는 대표적인 스트리밍 플레이어 라이브러리다.
MSE를 사용해 개발자는 <video>
태그 위에서 영상 재생을 훨씬 정교하게 제어할 수 있다. 단순히 src
를 설정하는 대신 MediaSource
객체를 직접 만들고, 스트리밍 데이터를 잘게 나눠 SourceBuffer에 하나씩 append()
하는 방식으로 동작한다.
말 그대로 영상을 소화시켜 떠먹여 주는 구조다.
이 복잡한 흐름을 더 쉽게 다루기 위해 등장한 것이 바로 video.js 같은 플레이어 라이브러리인데, video.js는 내부적으로 MSE를 활용해 세그먼트 단위로 영상을 버퍼에 채우고, seek()
같은 사용자 조작에 맞춰 데이터를 유연하게 제어한다.
하지만 이 과정은 순서가 조금만 어긋나도 재생이 실패할 수 있기 때문에
내부 로직을 정교한 흐름과 정확한 시점에 맞춰 구현해야 한다.
- seek?
영상의 특정 위치(time position)로 재생 지점을 이동시키는 행위.
이 과정에서 플레이어는 해당 시점에 대응하는 미디어 데이터를 요청하고, 디코딩하고, 버퍼링하는 작업을 다시 시작함.
성능 개선을 하며 영상 재생 속도를 테스트하던 중, 느린 네트워크 환경에서 간헐적으로 영상이 재생되지 않는 문제를 발견했다.
“아니, 영상 재생이 느린 걸 고쳐놨더니 이번엔 영상이 안 나온다고?
빠르게 만들었더니 망가졌다고?”
머리를 쥐어뜯으며 로그 분석에 들어갔다.
video.js의 이벤트 발생 시점과 세그먼트 응답 시점을 기반으로 문제 상황을 분석했다. 그 결과, 영상 재생이 실패하는 경우에는 ReadyState가 0 또는 1 상태에서 머무는 현상이 반복되는 것을 확인했다. 내부적으로 play() 이벤트가 호출된 뒤에도 buffer가 비어 있으며, 이후 세그먼트 요청도 정상적으로 반영되지 않아 재생이 시작되지 않는 상태임을 확인할 수 있었다.
- ReadyState ?
<video>
요소의 현재 재생 가능 상태를 나타내는 값
일반적으로는 ReadyState >= 3부터가 안정적인 재생이 가능한 상태
첨부된 로그는 실제 로그에서 민감 정보를 제거하고, 이해를 돕기 위해 일부 단순화함.
//[초기화]
09:49:09.231 player.js:228 [event : ready] readyState=0 buffered=[0.00 - 0.00]
09:49:09.273 player.js:228 [event : play] readyState=0 buffered=[0.00 - 0.00]
09:49:09.274 player.js:228 [event : waiting] readyState=0 buffered=[0.00 - 0.00]
//[초기화 이후 반복된 시도와 실패]
09:49:12.868 player.js:228 [http response] readyState=0 buffered=[0.00 - 0.00]
09:49:13.318 player.js:228 [http response] readyState=0 buffered=[0.00 - 0.00]
09:49:13.367 player.js:228 [event : loadedmetadata] readyState=1 buffered=[0.00 - 0.00]
09:49:13.378 player.js:228 [event : waiting] readyState=1 buffered=[0.00 - 0.00]
09:49:14.398 player.js:228 [http response] readyState=1 buffered=[0.00 - 0.00]
09:49:15.073 player.js:228 [http response] readyState=1 buffered=[0.00 - 0.00]
09:49:15.529 player.js:228 [http response] readyState=1 buffered=[0.00 - 0.00]
09:49:16.033 player.js:228 [http response] readyState=1 buffered=[0.00 - 0.00]
이 시점에서 가장 눈에 띄는 건 readyState 값이 안정적인 재생이 가능한 상태(≥ 3)에 도달하지 않았다는 점이다. readyState < 3인 상태에서 play()를 통해 seek이 이루어질 경우, 이후 세그먼트가 적절히 연결되지 않으면 재생 실패 가능성이 매우 높기 때문에 위험하다.
그러나, 이 이유만으로는 문제의 원인을 완전히 설명할 수는 없었다.
동일한 readyState 조건에서 seek을 시도했는데 재생이 정상적으로 이루어지는 경우도 있었기 때문이다. 즉, readyState가 낮은 상태가 항상 재생 실패로 이어지는 건 아니었다.
그렇기에 더 낮은 레벨의 분석이 필요했다. 특히 이 시점에서 확인할 수 있는 buffered() 정보는 video.js 레이어에서 제공하는 버퍼 상태이며, MSE(Media Source Extensions)의 SourceBuffer가 실제로 어떤 상태에 있는지와는 차이가 있을 수 있다.
대체 내부에서 무슨 일이 벌어지고 있길래 재생에 실패하는 걸까?
이를 확인하기 위해 video.js 내부 모듈을 좀 더 파헤쳐 실제 buffer가 어디에서 쌓이고 어디에서 실패하는지를 추적하기로 했다. video.js에서는 이 역할을 VHS(Video.js HTTP Streaming) 모듈이 담당하고 있으며, 해당 모듈의 내부 이벤트 흐름과 버퍼 상태를 집중적으로 추적했다.
버퍼가 존재하는 상태에서 seeking 이벤트 발생
10:29:56.858 player.js:247 [event : waiting] readyState=1 buffered=[2.00 - 2.13]
10:29:56.858 player.js:151 [event : seeking] /*버퍼가 존재하는 상태에서 seek*/
10:29:56.858 player.js:152 [event : seeking] readyState=1
10:29:56.865 player.js:218 [http : request] uri: 'downloadSegmentFile~/segment000089.ts'
10:30:07.325 player.js:247 [event : response] readyState=1 buffered=[0.00 - 0.00]
10:30:07.346 player.js:76 [VHS] appending
10:30:07.347 player.js:80 [VHS] appended
10:30:07.348 player.js:218 [http : request] uri: 'downloadSegmentFile~/segment000090.ts'
10:30:07.350 player.js:247 [event : seeked] readyState=4 buffered=[1654.21 - 1659.18] /*제대로 재생*/
10:30:07.352 player.js:247 [event : canplay] readyState=4 buffered=[1654.21 - 1659.18]
10:30:07.354 player.js:247 [pause] readyState=4 buffered=[1654.21 - 1659.18]
10:30:17.996 player.js:247 [event : response] readyState=4 buffered=[1654.21 - 1659.18] /*이후 요청에서도 버퍼 제대로 쌓임*/
10:30:18.019 player.js:76 [VHS] appending
10:30:18.021 player.js:80 [VHS] appended
버퍼가 존재하지 않는 상태에서 seeking 발생
10:29:54.638 player.js:247 [event : waiting] readyState=1 buffered=[0.00 - 0.00]
10:29:54.638 player.js:151 [event : seeking] /*버퍼가 존재하지 않는 상태에서 seeking*/
10:29:54.646 player.js:218 [http : request] uri: 'downloadSegmentFile~/segment000023.ts'
10:30:02.148 player.js:247 [event : response] readyState=1 buffered=[0.00 - 0.00] /*다른 segement요청을 이어가도 버퍼에 저장 안 됨, 무의미한 서버 통신 이어짐*/
10:30:02.185 player.js:76 [VHS] appending
10:30:02.198 player.js:80 [VHS] appended
10:30:02.199 player.js:218 [http : request] uri: 'downloadSegmentFile~/segment000024.ts'
10:30:12.951 player.js:247 [event : response] readyState=1 buffered=[0.00 - 0.00]
10:30:12.985 player.js:76 [VHS] appending
10:30:12.988 player.js:80 [VHS] appended
10:30:13.026 player.js:218 [http : request] uri: 'downloadSegmentFile~/segment000025.ts'
10:30:30.155 player.js:247 [event : response] readyState=1 buffered=[0.00 - 0.00]
10:30:30.176 player.js:76 [VHS] appending
10:30:30.177 player.js:80 [VHS] appended
VHS 모듈 동작까지 이르러서야 좀 더 원인이 명확해지기 시작했다.
주목할 만한 점은 VHS에서 appending
→ appended
로그가 찍혔다는 점이다.
appending
→ appended
로그가 찍혔다는 것은 SourceBuffer.appendBuffer()
가 호출되었음을 의미한다. 즉, video.js(VHS)는 정상 다운로드된 세그먼트를 문제없이 전달하려 했지만, MSE는 이 데이터를 buffer에 반영하지 못했다.
대체 왜 MSE는 이 데이터를 buffer에 추가하지 못했을까?
버퍼가 존재하지 않는 상태에서 seek을 시도할 경우 영상 재생은 무조건 실패하고, 반대로 readystate와 관계없이 버퍼가 아주 조금이라도 존재하는 상태에서 seek을 시도한 경우 영상 재생은 성공했다.
그렇다면 동일한 조건에서도 재생 성공 여부를 갈라놓은 저 “약간의 버퍼”는 무엇이었을까?
답은 바로 Init segment
에 있었다.
- Init segment?
- Init segment는 영상/오디오 스트리밍을 시작하는 데 필요한 헤더 정보가 포함된 초기 데이터 블록.
- MSE 환경에서 스트리밍 재생을 하려면 브라우저는 먼저 Init segment를 받아야 SourceBuffer에 붙이고 디코딩을 시작할 수 있음.
- TS 세그먼트 중에서도 보통 가장 처음 세그먼트에 포함되며, 만약 이걸 못 받으면, 이후 세그먼트를 아무리 받아도 해석할 수 없어서 버퍼에 추가되지 않음.
그러니까 데이터를 수신하긴 했지만, 해석에 필요한 가이드(Init segment)가 없으니 MSE는 뭔 데이터 쪼가리를 받긴 받았는데 이걸 당최 어떻게 읽으라는거야? 하고 내다버린 거다ㅠㅠ
이 외에도 MSE가 segment를 처리하지 못하는 경우가 더 있지만, 지금같은 초기화 상황에선 Init segment
가 없었던 게 주요한 이유일 것이다.
그렇다.
정말로 '속도가 빨라진 것'이 트리거가 됐다.
성능 개선을 하면서 seek() 호출 타이밍이 기존보다 더 빨라졌고,
아직 재생 준비가 덜 된 상태에서 갑작스러운 seek 요청을 받은 video.js는 Init segment를 로드하지 못한 채로 남아있게 된 거다.
결과적으로 video.js는 재생은 실패한 채, 고장난 상태로 다른 세그먼트를 계속 다운로드하는 상황에 빠졌다. 실제로는 버퍼에 추가하지도 못할 데이터를 서버에서 계속 받아오는 의미 없는 통신을 반복하면서 재생을 하지는 못 한거다.
...video.js 플레이어가 이렇게 섬세한 아이(개복치)다.
원인은 복잡했지만 해결 방법은 의외로 단순했다.
핵심은 seek 요청의 타이밍만 정확히 제어하면 된다.
이를 해결하기 위해 고려할 수 있는 방법은 다음과 같다.
(1) UI를 일시적으로 비활성화해 사용자의 빠른 조작을 제한하기
(2) 아예 seek 동작 자체를 차단하기
(3) seek 요청을 잠시 보류했다가 readyState >= 3인 시점에 재시도하기
결과적으로 UX 관점에서 (3)번을 선택할 수밖에 없었다.
사용자 조작을 막기보다는 시스템 내부에서 타이밍을 조율하는 쪽이 자연스럽기 때문이다.
“정말 내부 구조까지 파헤쳐서 MSE를 알아야 할까?
video.js라는 구현체가 이미 있는데, 굳이 거기까지 알아야 할까?”
지금이야 이렇게 가볍게 정리할 수 있지만 당시엔 MSE가 뭔지도 모른 채 video.js의 내부 구조를 파헤치며 꽤나 머리를 싸맸던 문제였다. 단순한 seek 타이밍 이슈로 보였지만 실제 원인은 Init segment 부재, 버퍼링 구조, readyState 상태 등 브라우저 재생 로직 전반을 이해해야만 파악할 수 있는 복합적인 문제였기 때문에!
문제를 해결해 가는 과정은 즐거웠지만 한편으론 이런 내부 구조까지 익혀야 하나 하는 회의감도 들었다. 미디어 스트리밍이라는 영역이 결국 low-level을 피할 수 없는 도메인이다 보니, “내가 지금 너무 깊이 들어가는 건 아닐까?” 하는 생각을 자주 하게 된다.
하지만 결국, 배움에 과한 건 없다는 걸 다시금 깨달았다.
최근엔 PTT 오디오 스트리밍 라이브러리를 개발하고 있는데, 구조가 단순한 오디오는 오히려 MSE를 직접 다뤄야 한다. 덕분에 이 이슈를 해결하며 익혀둔 개념들이 큰 도움이 되었고 당시엔 낯설기만 했던 MSE도 지금은 제법 익숙해졌다.
역시 사람은 배우고 볼 일이다.