체인지로그의 한 줄은 대개 짧습니다. "Fix a MediaSource leak when unload() is interrupted"라는 문장만으로는 어떤 상황에서 문제가 생겼고, 내 서비스에도 해당하는지 판단하기 어렵습니다.
이 글에서는 Shaka Player 5.3.3에서 5.3.4 사이의 변경 사항을 GitHub의 PR과 이슈, 실제 변경 코드를 따라가며 분석합니다. 각 항목에는 근거가 되는 PR을 함께 표기했습니다.
v5.3.4는 2026년 11월 6일에 배포되었습니다. v5.3.3과 비교하면 커밋은 38개이고, 그중 21건이 체인지로그에 올랐습니다. 버그 수정이 20건, 성능 개선이 1건입니다. 새 기능과 Breaking Change는 없는 안정화 릴리스입니다.
| 영역 | 건수 | PR |
|---|---|---|
| 플레이어 생명주기 | 5 | #10928, #10886, #10885, #10927, #10913 |
| 재시도와 프리로드 | 2 | #10902, #10827 |
| DASH | 3 | #10881, #10919, #10895 |
| 광고 | 2 | #10925, #10926 |
| 구형 기기 호환성 | 2 | #10868, #10851 |
| UI와 접근성 | 4 | #10880, #10924, #10906, #10904 |
| 미디어 포맷과 성능 | 3 | #10857, #10898, #10916 |
분류는 이 글에서 내용에 따라 나눈 것입니다.
Shaka Player의 load(), attach(), unload(), destroy()는 모두 비동기로 동작합니다. 어떤 작업이 끝나기를 기다리는 사이에 다른 작업이 시작될 수 있습니다. 5.3.4의 수정 중 가장 많은 수가 이 작업이 겹치는 순간을 다룹니다.
#10928 Fix leaks and hangs when destroy() interrupts load() or attach()
destroy()는 자신의 unload를 위해 새로운 operation ID를 받고 Player를 파괴 상태로 표시합니다. 진행 중인 작업이 중단되었는지는 이 ID로 판단합니다.
문제는 load()와 attach()가 파괴 여부를 시작할 때만 확인했다는 점입니다.
load()가 시작되어 앞선 작업을 기다립니다.destroy()가 시작되어 operation ID를 받습니다.load()가 더 새로운 operation ID를 받습니다.destroy()의 unload는 자신이 중단되었다고 판단해 정리를 전부 건너뜁니다.그 결과 MediaSourceEngine과 MediaSource가 해제되지 않았고, 다른 미디어 요소로의 attach()는 끝나지 않았습니다. 수정은 operation ID를 받기 직전에 파괴 여부를 한 번 더 확인하는 것입니다.
#10886 Fix a MediaSource leak when unload() is interrupted
unload()는 마지막에 새로운 MediaSourceEngine을 만듭니다. 그런데 이 작업이 뮤텍스를 해제한 뒤에 실행되었고, MediaSource가 열린 다음에야 Player에 저장되었습니다. 그사이 시작된 attach()나 load()는 엔진이 없다고 판단해 하나를 더 만들거나 파괴할 대상을 찾지 못했습니다. 결국 한쪽 엔진은 파괴되지 않고 남았습니다.
수정은 해당 코드를 finally의 뮤텍스 해제 앞으로 옮긴 것입니다.
PR에 따르면 누수된 MediaSource는 버퍼링한 미디어를 계속 붙잡고 있으며, 이 문제는 테스트 중 Chromecast Hub가 응답하지 않는 원인을 추적하다가 발견되었습니다.
#10885 Fix TypeError when the segment index is released during a switch
#10927 Avoid a TypeError when a stream fails during unload
두 수정 모두 비동기 작업 도중 객체가 해제되어 null을 읽던 문제입니다.
makeAbortDecision_()이 createSegmentIndex()를 기다리는 사이 unload로 세그먼트 인덱스가 해제될 수 있습니다. 이 경우를 막는 코드는 assert뿐이었는데, assert는 릴리스 빌드에서 제거됩니다. PR 작성자는 라이브 HLS 재생 직후 다른 DASH 콘텐츠를 load()하면 약 3번 중 1번 load()가 TypeError로 실패했다고 보고했습니다.StreamingEngine이 Player에 스트림 비활성화를 요청합니다. unload 중에는 StreamingEngine이 이미 사라졌는데 매니페스트와 AbrManager는 남아 있어서 switchVariant_()에서 null을 읽었습니다. 이 에러는 앱이 잡을 수 없는 unhandled rejection으로 나타났습니다.두 에러는 shaka.util.Error가 아닌 네이티브 TypeError였습니다. 에러 처리 글에서 다룬 Severity 분기로는 다룰 수 없던 에러들이 정리된 것입니다.
#10913 Avoid an unhandled rejection when a preload fails
player.preload()는 PreloadManager.start()를 호출하지만 그 결과를 관찰하지 않습니다. 앱이 다음 콘텐츠를 미리 로드해 놓고 끝내 재생하지 않으면, 매니페스트 404 같은 실패가 unhandled rejection이 됩니다.
waitForFinish()는 여전히 에러를 전달하므로 동작은 바뀌지 않습니다. PR 작성자는 5.2.12 운영 환경에서 7주 동안 사용자 430명에게서 약 1,100건이 수집되었다고 보고했습니다.
#10902 Don't reuse failed segment prefetches on retry
SegmentPrefetch는 프리페치 작업을 맵에 저장해 둡니다. 그런데 실패한 작업도 맵에 남아 있었습니다. 그래서 player.retryStreaming()을 호출하면 세그먼트를 다시 받지 않고 실패한 작업을 재사용해 곧바로 다시 실패했습니다. 프리페치는 기본 설정(segmentPrefetchLimit: 1)에서 켜져 있으므로, PR 설명대로 세그먼트 실패가 있을 때마다 일어날 수 있었습니다.
retryStreaming()은 에러 처리 글에서 본 라이브 자동 재시도와 online 이벤트 복구에서도 쓰이는 경로입니다. 이 경로에서 재시도하는 앱이라면 5.3.4의 영향을 직접 받습니다.
#10827 Choose the initial variant from the player's bandwidth estimate (이슈 #10825)
PreloadManager는 자신만의 AbrManager를 새로 만듭니다. 이 매니저에는 측정 샘플이 없어 abr.defaultBandwidthEstimate(1Mbps)나 navigator.connection.downlink에 의존합니다. PR에 따르면 Chrome은 downlink 값을 10Mbps로 제한합니다.
그 결과 빠른 네트워크에서도 낮은 화질을 프리페치했고, load() 시점에 Player의 ABR이 높은 화질로 바꾸면서 프리페치한 세그먼트를 버리고 다시 받았습니다. 수정 후에는 Player가 이미 측정한 값을 초기 추정치로 사용합니다.
PR 작성자가 Chrome 155, 80Mbps 제한 환경에서 측정한 결과입니다.
| 빌드 | 프리페치 화질 | load()부터 첫 프레임까지 |
|---|---|---|
| 수정 전, 프리로드 없음 | - | 1.49초 |
| 수정 전, 프리로드 | 360p | 1.27 / 1.29 / 1.29초 |
| 수정 후, 프리로드 | 1080p | 0.04 / 0.03 / 0.03초 |
수정 전에도 첫 프레임은 1080p였습니다. 즉 이 수정은 첫 화질이 아니라 프리로드가 실제로 시작 시간을 줄이게 만든 것입니다. 측정치는 특정 환경의 결과이며, 재생 전 측정값이 없으면 기존 설정을 그대로 사용합니다.
매니페스트 갱신 중에 PTO(presentationTimeOffset)가 바뀌면, Shaka는 타임라인을 이전 PTO 기준으로 옮깁니다. 이때 unscaledStart에 delta를 더한 뒤, 초 단위 값을 계산하면서 delta를 다시 더하고 있었습니다.
연결된 이슈는 MediaTailor를 사용하는 라이브 SSAI 스트림에서, 광고 구간이 끝날 때 약 2분 전 세그먼트부터 다시 받는 증상을 보고했습니다.
#10919 Avoid continuous manifest updates when minimumUpdatePeriod is 0 (이슈 #10912)
minimumUpdatePeriod="PT0S"는 MPD가 언제든 바뀔 수 있다는 의미이지, 쉬지 않고 받아야 한다는 의미가 아닙니다. 그런데 이 값이 0이면 갱신 지연을 제한하는 값은 평균 매니페스트 요청 시간만 남습니다. 빠른 CDN 엣지에서는 이 값이 매우 작습니다.
이슈 작성자는 5.3.2에서 90.1초 동안 매니페스트 요청 5,906회(중앙값 간격 10ms)를 측정했습니다. 시청자 한 명이 초당 약 66회 요청한 셈입니다. 수정 후에는 dash.js와 같이 1초를 하한으로 사용합니다.
양수인 minimumUpdatePeriod와 manifest.updatePeriod 설정의 동작은 바뀌지 않습니다.
#10895 Keep the text timestamp offset when video is in another Period (이슈 #10894)
자막이 없는 광고 Period가 있는 멀티 Period DASH에서는 자막 스트림이 비디오보다 다음 Period를 먼저 받을 수 있습니다. 그런데 5.1.0부터(#9676) 비디오를 추가할 때마다 TextEngine의 타임스탬프 오프셋을 덮어썼습니다. 다음 Period의 자막이 현재 Period의 오프셋으로 파싱되어 append window 밖으로 밀려났고, 해당 Period는 탐색하기 전까지 자막 없이 재생되었습니다.
이제 자막 스트림에 자체 오프셋이 없는 HLS와 sequence mode에서만 비디오 오프셋을 전달합니다.
#10925 Detect mid-rolls when no video frames are presented
#10926 Exclude embedded slots from the interstitial break count
#10925는 미드롤 탐지 방식의 빈틈을 메웠습니다. 스마트 TV가 아닌 환경에서 미드롤 인터스티셜은 requestVideoFrameCallback에서만 확인했는데, 이 콜백은 백그라운드 탭이나 오디오 전용 콘텐츠처럼 프레임이 표시되지 않으면 실행되지 않습니다. 그래서 미드롤이 조용히 건너뛰어졌습니다. 이제 timeupdate 핸들러에서도 확인합니다.
#10926은 광고 순번 표시를 고쳤습니다. 같은 시각에 시작하는 인터스티셜을 셀 때 SVTA 임베디드 슬롯까지 포함해서, 광고 4개와 슬롯 4개가 있는 구간이 "1 of 8"로 표시되었습니다. 임베디드 슬롯은 광고 구간이 아니라 본 콘텐츠의 일부이므로 집계에서 제외합니다.
#10868 Make codec rejection in the MediaSource polyfill work
Shaka는 기기별로 지원이 불안정한 코덱을 rejectCodecs()로 거부합니다. 구형 Tizen과 1세대 Chromecast의 Opus가 그 대상입니다. 그런데 패치된 isTypeSupported()는 audio/webm; codecs="opus"와 같은 전체 MIME 타입을, opus처럼 코덱 문자열을 기대하는 getCodecBase()에 넘기고 있었습니다.
비교 결과가 일치할 수 없으니 아무 코덱도 거부되지 않았습니다. 1세대 Chromecast에서는 "Rejecting opus." 로그가 찍히면서도 Opus를 지원한다고 응답했습니다. 수정된 hasCodec_은 MIME 타입에서 코덱을 먼저 꺼내고, MP4의 Opus처럼 대소문자가 다른 경우도 함께 처리합니다.
#10851 Align wrapped lines correctly on older browsers
white-space: pre-wrap을 쓰면 Chrome 90 이하 브라우저는 자동 줄바꿈 지점의 공백을 줄의 일부로 계산합니다. 가운데 정렬은 공백 절반만큼, 끝 정렬은 공백 하나만큼 밀립니다. 1세대, 2세대 Chromecast와 Chromecast Ultra가 해당합니다.
이제 pre-line이 공백을 뭉개지 않는 텍스트에는 pre-line을 사용합니다.
| PR | 문제 | 수정 |
|---|---|---|
| #10880 | 재생 위치 갱신이 휠로 조작한 시크바 값을 덮어씀 (이슈 #10879) | 키보드 시크와 같은 경로로 처리 |
| #10924 | 라이브에서 포인터가 멈춰 있어도 seek range가 움직여 프리뷰가 어긋남 | seek range가 바뀌면 프리뷰를 다시 계산 |
| #10906 | 1px 포커스 표시가 밝은 영상 위에서 잘 보이지 않음 | 2px 링으로 변경, 흰색과 검은색 모두에 3:1 대비 유지 |
| #10904 | CSS 전용 툴팁을 Escape로 닫을 수 없고, 포인터를 옮기면 사라짐 | Escape로 닫기, 포인터가 벗어나도 300ms 유지 |
뒤의 두 건은 UI의 웹 접근성 기준 점검을 추적하는 이슈 #3146의 일환입니다. 특히 #10904는 호버로 나타나는 콘텐츠를 닫고 유지할 수 있어야 한다는 WCAG 1.4.13을 근거로 합니다. #10906은 TV 리모컨처럼 Tab 이외의 키로 포커스를 옮겨도 키보드 탐색으로 인식하고, 컨트롤이 사라질 때는 포커스를 플레이어로 돌려줍니다.
#10906의 포커스 링은 --shaka-focus-ring-color, --shaka-focus-ring-width CSS 변수로 제공되므로, UI 테마를 직접 구성했다면 대비를 확인해 보는 것이 좋습니다.
load()는 약 0.3초 대신 2.3초가 걸렸습니다.BufferUtils.equal이 한 바이트씩 비교하던 것을 DataView로 4바이트씩 비교하도록 바꾸고, 남은 바이트만 기존 방식으로 비교합니다.load(), unload(), destroy()가 겹치는 순간의 누수, hang, TypeError입니다. 콘텐츠를 자주 전환하거나 플레이어를 자주 파괴하는 앱, 스마트 TV처럼 메모리가 제한된 환경일수록 의미가 큽니다.retryStreaming()을 사용하거나 라이브 자동 재시도에 의존한다면 #10902 수정의 대상입니다.preload()를 사용한다면 #10827과 #10913으로 시작 시간과 에러 수집이 함께 개선됩니다.minimumUpdatePeriod="PT0S"를 쓰거나, 자막이 없는 광고 Period가 있는 콘텐츠를 운영한다면 #10919와 #10895를 확인해 볼 만합니다.