Shaka Player 파헤치기 - PreloadManager
들어가며 — load가 위임한 일을 누가 하는가
지난 Load, Attach 편에서 우리는 load가 정작 직접 일을 하지 않는다는 사실을 확인했습니다. load는 매니페스트를 파싱하지도, DRM을 초기화하지도 않았습니다. 대신 결과를 수령(receive)하기만 했죠.
1// load() 내부 — MSE 경로
2await preloadManager.waitForManifest(); // 매니페스트 파싱 완료를 기다리고
3this.parser_ = preloadManager.receiveParser(); // 파서를 넘겨받고
4this.manifest_ = preloadManager.getManifest(); // 매니페스트 모델을 넘겨받고
5await preloadManager.waitForFinish(); // 남은 준비 완료를 기다린 뒤
6this.drmEngine_ = preloadManager.receiveDrmEngine(); // DRM 엔진을 넘겨받는다
load의 전체 본문은 사실상 이 "기다리고, 넘겨받는" 동작의 연속입니다. 그렇다면 실제로 매니페스트를 내려받아 파싱하고, DRM 엔진을 만들어 라이선스를 준비하고, 세그먼트를 미리 받아두는 일은 어디서 벌어질까요?
그 답이 바로 PreloadManager입니다. 이 글에서는 load가 위임만 했던 준비 작업의 실제 생산자인 PreloadManager의 내부로 한 단계 내려가, start()가 어떤 순서로 무엇을 채우는지 따라가 보겠습니다.
PreloadManager는 어디서 만들어지는가
load는 preloadInner_를 통해 PreloadManager를 손에 넣습니다. 사용자가 preload()로 미리 만들어 넘겼든, 그냥 load(uri)를 호출했든, 결국 같은 자리로 모입니다.
1// player.js — preloadInner_
2async preloadInner_(assetUri, startTime, mimeType, standardLoad = false, config) {
3 if (!mimeType) {
4 mimeType = await this.guessMimeType_(assetUri);
5 }
6 const shouldUseSrcEquals = this.shouldUseSrcEquals_(mimeType);
7 if (shouldUseSrcEquals) {
8 return null; // src= 콘텐츠는 preload할 수 없다
9 }
10 // ...
11 let preloadManagerPromise = this.makePreloadManager_(
12 assetUri, startTime, mimeType || null, preloadConfig,
13 /* allowPrefetch= */ !standardLoad, disableVideo);
14 // ...
15}
여기서 눈여겨볼 인자가 allowPrefetch입니다. standardLoad(즉 일반 load)이면 !standardLoad가 false가 되어 프리패치를 끄고, 사용자가 명시적으로 preload()한 경우에만 true가 되어 프리패치를 켭니다. preload와 load의 동작 차이가 사실상 이 플래그 하나로 갈린다는 점은 뒤에서 다시 다루겠습니다.
실제 인스턴스를 조립하는 곳은 makePreloadManager_입니다. 이 메서드의 절반 이상은 두 개의 거대한 콜백 묶음을 만드는 데 쓰입니다.
1// player.js — makePreloadManager_ (발췌)
2const manifestPlayerInterface = {
3 networkingEngine: this.networkingEngine_,
4 filter: async (manifest) => { /* 필터 후 trackschanged 이벤트 */ },
5 onTimelineRegionAdded: (region) => { /* ... */ },
6 onEvent: (event) => preloadManager.dispatchEvent(event),
7 onError: (error) => preloadManager.onError(error),
8 getBandwidthEstimate: () => this.abrManager_.getBandwidthEstimate(),
9 // ...
10};
11
12const drmPlayerInterface = {
13 netEngine: this.networkingEngine_,
14 onError: (e) => preloadManager.onError(e),
15 onKeyStatus: (map) => { /* ... */ },
16 onExpirationUpdated: (id, expiration) => { /* ... */ },
17 onEvent: (e) => { /* DRM 세션 이벤트 → 통계 기록 */ },
18};
manifestPlayerInterface는 매니페스트 파서가 플레이어에게 되돌려 말하는 통로이고, drmPlayerInterface는 DRM 엔진이 플레이어에게 되돌려 말하는 통로입니다. 두 인터페이스와 네트워킹 엔진, ABR 설정 등을 묶어 PreloadManager 생성자에 넘기면 준비가 끝납니다.
1preloadManager = new shaka.media.PreloadManager(
2 assetUri, mimeType, startTime, playerInterface);
3return preloadManager;
여기까지는 객체를 조립만 했을 뿐, 아직 네트워크 요청 한 번 나가지 않았습니다. 실제 작업은 load가 preloadManager.start()를 호출하는 순간 시작됩니다.
start() — 준비 파이프라인의 전모
PreloadManager의 심장은 start()입니다. 이름은 단순하지만, 이 안에 매니페스트 파싱부터 DRM 초기화, 프리패치까지의 전 과정이 순서대로 담겨 있습니다.
1// preload_manager.js — start()
2start() {
3 (async () => {
4 // 플레이어가 이벤트를 연결할 틈을 주기 위해 한 번 컨텍스트 스위치
5 await Promise.resolve();
6
7 try {
8 await this.parseManifestInner_(); // ① 매니페스트 파싱
9 this.throwIfDestroyed_();
10
11 if (!shaka.drm.DrmUtils.isMediaKeysPolyfilled('webkit')) {
12 await this.initializeDrm(); // ② DRM 초기화
13 this.throwIfDestroyed_();
14 }
15
16 await this.validatePrefetchedVariant_(); // ③ 골라둔 variant 재검증
17 this.throwIfDestroyed_();
18
19 if (this.allowPrefetch_) {
20 await this.prefetchInner_(); // ④ 세그먼트 프리패치
21 this.throwIfDestroyed_();
22 }
23
24 if (this.allowPrefetch_ && this.drmEngine_) {
25 await this.drmEngine_.waitForActiveRequests(); // ⑤ DRM 요청 마무리
26 this.throwIfDestroyed_();
27 }
28
29 this.successPromise_.resolve(); // 전체 완료 신호
30 } catch (error) {
31 // OPERATION_ABORTED / OBJECT_DESTROYED는 무시
32 if (/* ... 정상 중단이 아니면 */) {
33 this.successPromise_.reject(error);
34 }
35 }
36 })();
37}
전체가 즉시 실행 비동기 함수(IIFE)로 감싸여 있다는 점이 중요합니다. start() 자체는 아무것도 반환하지 않고 곧바로 끝나며, 파이프라인은 백그라운드에서 굴러갑니다. 호출자는 그 결과를 뒤에서 살펴볼 waitForFinish()로 관찰하죠.
또 한 가지, 각 단계 사이마다 throwIfDestroyed_()가 끼어 있습니다. load가 도중에 다른 load로 교체되어 이 PreloadManager가 파괴되면, 다음 단계로 넘어가기 직전에 즉시 예외를 던져 빠져나옵니다. 이전 편에서 본 mutexWrapOperation의 detectInterruption과 같은 철학이 PreloadManager 내부에서도 반복되는 셈입니다.
이제 각 단계를 차례로 열어 봅시다.
parseManifestInner_ — 매니페스트가 모델이 되기까지
① 단계입니다. URI와 mimeType만 알고 있는 상태에서, 재생에 쓸 매니페스트 모델 객체를 만들어 내는 곳입니다.
1// preload_manager.js — parseManifestInner_ (발췌)
2async parseManifestInner_() {
3 this.makeStateChangeEvent_('manifest-parser');
4
5 if (!this.parser_) {
6 // URI/mimeType을 보고 DASH·HLS 등 알맞은 파서 팩토리를 고른다
7 this.parserFactory_ = shaka.media.ManifestParser.getFactory(
8 this.assetUri_, this.mimeType_);
9 this.parser_ = this.parserFactory_();
10 this.parser_.configure(this.config_.manifest, () => this.isPreload_);
11 }
12
13 const startTime = Date.now() / 1000;
14 this.makeStateChangeEvent_('manifest');
15
16 if (!this.manifest_) {
17 // 실제 매니페스트를 내려받아 파싱 → 내부 모델 생성
18 this.manifest_ = await this.parser_.start(
19 this.assetUri_, this.manifestPlayerInterface_);
20
21 shaka.media.PreloadManager.filterForAVVariants_(this.manifest_);
22 await this.chooseInitialVariant_();
23 }
24
25 this.manifestPromise_.resolve(); // ← 파싱 완료 신호
26
27 // ManifestParsed 이벤트 디스패치 (필터링 전 시점)
28 // variant가 하나도 없으면 NO_VARIANTS 에러
29 // applyRestrictions로 1차 필터링
30 // stats_.setManifestTime(delta) 로 파싱 소요 시간 기록
31}
흐름을 정리하면 이렇습니다.
- 파서 선택 —
ManifestParser.getFactory(uri, mimeType)가 DASH면 DASH 파서, HLS면 HLS 파서를 고릅니다. 여기서 앞서 조립한 manifestPlayerInterface_가 파서에게 건네집니다.
- 파싱 —
parser_.start()가 매니페스트를 내려받아 manifest_ 모델(variants, textStreams, presentationTimeline 등)을 만듭니다.
- 정규화 —
filterForAVVariants_로 모든 variant를 audio-only·video-only·audio-video 중 하나로 정리하고, applyRestrictions로 설정상 제약(해상도·대역폭 등)을 1차 적용합니다.
- 초기 화질 미리 결정 —
chooseInitialVariant_()가 ABR을 동원해 첫 variant 후보를 미리 골라 둡니다.
특히 manifestPromise_.resolve()가 호출되는 시점을 기억해 두세요. 매니페스트 파싱이 끝나는 바로 그 순간입니다. load가 receiveParser()/getManifest()로 파서와 매니페스트를 안전하게 넘겨받을 수 있는 시점이 정확히 여기서 열립니다.
initializeDrm — DRM 엔진과 2단계 필터링
② 단계입니다. 매니페스트를 손에 넣었으니, 이제 그 안의 보호 정보를 보고 DRM 엔진을 세웁니다.
1// preload_manager.js — initializeDrm (발췌)
2async initializeDrm(media) {
3 if (!this.manifest_ || this.drmEngine_) {
4 return;
5 }
6 this.makeStateChangeEvent_('drm-engine');
7 this.startTimeOfDrm_ = Date.now() / 1000;
8
9 this.drmEngine_ = this.createDrmEngine_();
10 this.manifestFilterer_.setDrmEngine(this.drmEngine_);
11 this.drmEngine_.configure(this.config_.drm, () => this.isPreload_);
12
13 const playableVariants = shaka.util.StreamUtils.getPlayableVariants(
14 this.manifest_.variants);
15 await this.drmEngine_.initForPlayback(
16 playableVariants, this.manifest_.offlineSessionIds, isDynamic);
17
18 // DRM 정보를 알게 되었으니, keySystem 기준으로 매니페스트를 "다시" 필터링
19 const tracksChangedAfter = await this.manifestFilterer_.filterManifest(
20 this.manifest_);
21 // ...
22}
여기서 가장 중요한 설계는 매니페스트 필터링이 두 번 일어난다는 점입니다.
- 1차 (파싱 직후) —
applyRestrictions. 사용자가 설정한 해상도·대역폭 제약을 적용합니다. 아직 어떤 DRM 키 시스템을 쓸지는 모릅니다.
- 2차 (DRM 초기화 후) —
filterManifest. initForPlayback으로 실제 사용할 keySystem이 정해진 뒤, 그 키 시스템으로 재생 가능한 variant만 남깁니다.
DRM은 "어떤 키 시스템을 쓸 수 있는가"를 협상한 다음에야 최종 후보가 확정되기 때문에, 한 번에 거를 수 없고 이렇게 두 단계로 나뉩니다.
이 순서가 HLS에서 묘하게 꼬이는 지점이 하나 있습니다. chooseInitialVariant_가 DRM 초기화보다 먼저 첫 화질을 고르는데, 다음 조건을 보세요.
1// preload_manager.js — shouldCreateSegmentIndexBeforeDrmEngineInitialization_
2shouldCreateSegmentIndexBeforeDrmEngineInitialization_() {
3 if (this.prefetchedVariant_) {
4 return false;
5 }
6 if (!this.manifest_.variants.length) {
7 return false;
8 }
9 // variant가 하나뿐이면 어차피 그것만 쓰므로 미리 준비하는 게 이득
10 if (this.manifest_.variants.length == 1) {
11 return true;
12 }
13 // HLS는 DRM 정보가 보통 미디어 플레이리스트 안에 있어,
14 // 그것을 내려받아야(= 세그먼트 인덱스를 만들어야) 진짜 정보를 알 수 있다
15 if (this.manifest_.type == shaka.media.ManifestParser.HLS) {
16 return true;
17 }
18 return false;
19}
HLS는 DRM 정보가 마스터 플레이리스트가 아니라 미디어 플레이리스트(=실제 세그먼트 목록) 안에 들어있는 경우가 많습니다. 그래서 DRM을 초기화하기 전에 미리 그 variant의 세그먼트 인덱스를 만들어 미디어 플레이리스트를 내려받아 둬야, 이후 DRM 단계에서 올바른 정보로 협상할 수 있습니다. 컨테이너 포맷의 구조적 차이가 코드의 실행 순서까지 비트는, 흥미로운 사례입니다.
validatePrefetchedVariant_와 prefetchInner_
③, ④ 단계입니다. 앞서 골라둔 variant가 2차 필터링(DRM)을 통과했는지 검증하고, 통과했다면(그리고 프리패치가 허용됐다면) 세그먼트를 미리 받아 둡니다.
1// preload_manager.js — validatePrefetchedVariant_
2async validatePrefetchedVariant_() {
3 // DRM 필터링으로 골라둔 variant가 목록에서 빠졌다면, 다시 고른다
4 if (this.prefetchedVariant_ &&
5 !this.manifest_.variants.includes(this.prefetchedVariant_)) {
6 await this.closeSegmentIndexFromVariant_(this.prefetchedVariant_);
7 this.prefetchedVariant_ = this.configureAbrManagerAndChooseVariant_();
8 if (this.prefetchedVariant_) {
9 await this.createSegmentIndexFromVariant_(this.prefetchedVariant_);
10 }
11 }
12}
chooseInitialVariant_가 DRM 전에 후보를 골랐기 때문에, 2차 필터링에서 그 후보가 탈락할 수도 있습니다. validatePrefetchedVariant_는 그 가능성을 메우는 안전장치입니다. 골라둔 variant가 여전히 살아있으면 그대로 두고, 빠졌으면 새로 고릅니다.
그다음이 프리패치입니다.
1// preload_manager.js — prefetchInner_ (발췌)
2async prefetchInner_() {
3 if (!this.prefetchedVariant_) {
4 const variant = this.configureAbrManagerAndChooseVariant_();
5 if (variant) {
6 this.prefetchedVariant_ = variant;
7 }
8 }
9 if (this.prefetchedVariant_) {
10 const promises = [];
11 const variant = this.prefetchedVariant_;
12 if (variant.video) promises.push(this.prefetchStream_(variant.video, isLive));
13 if (variant.audio) promises.push(this.prefetchStream_(variant.audio, isLive));
14 const textStream = this.chooseTextStream_(variant);
15 if (textStream) {
16 promises.push(this.prefetchStream_(textStream, isLive));
17 this.prefetchedTextStream_ = textStream;
18 }
19 await Promise.all(promises);
20 }
21}
여기서 다시 start()의 두 곳을 떠올려 봅시다.
1if (this.allowPrefetch_) {
2 await this.prefetchInner_();
3}
4// ...
5if (this.allowPrefetch_ && this.drmEngine_) {
6 await this.drmEngine_.waitForActiveRequests();
7}
allowPrefetch_가 false인 일반 load에서는 이 두 단계가 통째로 건너뛰어집니다. 즉 일반 load의 PreloadManager는 "매니페스트 파싱 + DRM 초기화 + 초기 variant 선택"까지만 하고 끝나며, 실제 세그먼트 다운로드는 이후 loadInner_의 StreamingEngine에 맡깁니다. 반면 사용자가 명시적으로 preload()한 경우에는 세그먼트와 DRM 라이선스까지 미리 확보해, 나중에 load()가 호출되는 순간 곧바로 재생을 시작할 수 있게 됩니다.
INFO
즉 preload와 load는 별개의 코드 경로가 아닙니다. 둘 다 같은 PreloadManager.start()를 타고, 차이는 오직 allowPrefetch_플래그 하나입니다. 이전 편에서 "모든 로드를 PreloadManager로 통일한다"고 했던 설계가, 프리패치 여부라는 단 하나의 분기로 깔끔하게 흡수되어 있는 셈입니다.
두 개의 Promise 신호등
지금까지 manifestPromise_와 successPromise_가 여러 번 등장했습니다. 이 둘이 PreloadManager와 load를 잇는 신호등입니다. 생성자에서 Promise.withResolvers()로 만들어 둡니다.
1// preload_manager.js — constructor
2this.manifestPromise_ = Promise.withResolvers(); // 매니페스트 파싱 완료
3this.successPromise_ = Promise.withResolvers(); // 전체 준비 완료
manifestPromise_는 parseManifestInner_ 끝에서 resolve됩니다 — 파싱이 끝난 순간.
successPromise_는 start() 파이프라인 전체가 끝나야 resolve되고, 도중에 실패하면 reject됩니다 — 전체 준비가 끝난 순간.
load 쪽은 이 둘을 서로 다른 시점에 기다립니다.
1// preload_manager.js
2waitForFinish() {
3 return this.successPromise_.promise;
4}
5
6waitForManifest() {
7 return Promise.race([
8 this.manifestPromise_.promise,
9 this.successPromise_.promise,
10 ]);
11}
waitForManifest()가 Promise.race를 쓰는 이유가 절묘합니다. 정상 흐름에서는 manifestPromise_가 먼저 풀려 파싱만 끝나면 곧장 다음 단계로 넘어갈 수 있습니다. 하지만 파싱 도중 치명적 에러가 나면 successPromise_가 reject되는데, race가 이것도 함께 지켜보고 있으므로 에러가 즉시 전파되어 매니페스트를 영영 기다리며 멈추는 일을 막습니다.
이전 편에서 본 load의 두 대기 지점이 정확히 이 두 신호등에 대응합니다.
1// load() — MSE 경로
2await preloadManager.waitForManifest(); // ← manifestPromise_ 신호를 기다림
3// ... 파서·매니페스트 수령, MSE 엔진 초기화 ...
4await preloadManager.waitForFinish(); // ← successPromise_ 신호를 기다림
매니페스트가 준비되면 곧바로 MediaSource 엔진 초기화 같은 후속 작업을 시작하고, 나머지(DRM·프리패치) 완료는 그 뒤에 따로 기다리는 2단계 대기 구조입니다.
receive* — 소유권을 넘기고 떠나는 패턴
마지막으로, load가 PreloadManager에게서 결과를 가져갈 때 쓰는 receive* 메서드들을 짚고 넘어가겠습니다. 이름이 get*이 아니라 receive*인 데에는 이유가 있습니다.
1// preload_manager.js
2receiveParser() {
3 this.parserEntrusted_ = true; // "이 파서는 이제 내 것이 아니다"라고 표시
4 return this.parser_;
5}
6
7receiveDrmEngine() {
8 this.drmEngineEntrusted_ = true;
9 return this.drmEngine_;
10}
receive*는 단순한 getter가 아니라 소유권 이전(entrust)입니다. 호출되는 순간 parserEntrusted_·drmEngineEntrusted_ 플래그가 켜집니다. 이 플래그는 destroy()에서 결정적인 역할을 합니다.
1// preload_manager.js — destroy (발췌)
2async destroy() {
3 this.destroyed_ = true;
4 if (this.parser_ && !this.parserEntrusted_) {
5 // 아직 넘기지 않은 파서만 파괴한다
6 // ...
7 }
8 // drmEngine_ 등도 동일하게 entrusted가 아닐 때만 정리
9}
load의 마지막은 항상 preloadManager.destroy()로 끝납니다.
1// load() — finally
2} finally {
3 if (preloadManager) {
4 await preloadManager.destroy();
5 }
6 this.preloadNextUrl_ = null;
7}
만약 receive*가 없었다면, load가 파서와 DRM 엔진을 넘겨받아 한창 재생에 쓰고 있는데 destroy()가 그것들을 파괴해 버리는 참사가 났을 겁니다. 이미 넘긴 것은 파괴하지 않고, 넘기지 못한 채 남은 것(생성됐지만 끝내 쓰이지 않은 리소스)만 정리한다 — entrusted_ 플래그가 이 경계를 그어 줍니다.
생성자 편에서 본 "준비가 끝나야 참조를 저장한다"는 원칙과 짝을 이루는, 넘긴 것의 책임은 따라간다는 라이프사이클 규약인 셈입니다.
정리
load가 결과만 수령하던 매니페스트 파싱·DRM·프리패치는, 전부 PreloadManager.start()라는 하나의 파이프라인에서 생산됩니다.
| 단계 | 메서드 | 하는 일 | 신호 |
|---|
| ① | parseManifestInner_ | 파서 선택 → 매니페스트 파싱 → 정규화·1차 필터·초기 화질 선택 | manifestPromise_.resolve() |
| ② | initializeDrm | DRM 엔진 생성·초기화 → keySystem 기준 2차 필터 | — |
| ③ | validatePrefetchedVariant_ | 2차 필터 후 골라둔 variant 재검증 | — |
| ④ | prefetchInner_ | (preload일 때만) 세그먼트 선다운로드 | — |
| ⑤ | waitForActiveRequests | DRM 요청 마무리 | successPromise_.resolve() |
핵심 설계를 다시 모으면 다음과 같습니다.
- 두 개의 Promise 신호등:
manifestPromise_(파싱)와 successPromise_(전체)가 load의 2단계 대기(waitForManifest → waitForFinish)와 일대일로 맞물립니다. waitForManifest의 Promise.race는 에러를 즉시 전파하는 안전장치입니다.
- 소유권 이전:
receive*가 entrusted_ 플래그를 세워, destroy()가 넘긴 리소스를 건드리지 않게 합니다.
- 2단계 매니페스트 필터링: 제약 기반(파싱 직후)과 keySystem 기반(DRM 후)으로 나뉘며, HLS는 DRM 정보 위치 때문에 세그먼트 인덱스를 DRM보다 먼저 만듭니다.
- preload = load: 둘은 같은
start()를 타고, 차이는 allowPrefetch_ 하나뿐입니다.
한 줄로 요약하면 다음과 같습니다.
"load가 '기다리고 넘겨받는' 소비자라면, PreloadManager는 매니페스트·DRM·세그먼트를 실제로 만들어 내는 생산자다. 둘은 두 개의 Promise 신호등으로 연결되고, 소유권 이전 규약으로 안전하게 갈라선다."
다음 편에서는 PreloadManager가 준비를 끝낸 뒤, loadInner_가 점화하는 구동 단계 — StreamingEngine이 세그먼트를 실제로 다운로드해 MediaSource로 흘려보내고, AbrManager가 대역폭을 추정해 화질을 갈아 끼우는 과정을 따라가 보겠습니다.