Shaka Player 파헤치기 - Load, Attach
들어가며 — 생성자가 선언한 골격에 콘텐츠를 채우다
지난 Constructor 편에서 우리는 Player의 생성자가 "항상 필요한 인프라만 지금 만들고, 콘텐츠에 의존하는 모든 것은 선언만 해둔 채 load()로 미룬다"는 원칙을 따른다는 것을 확인했습니다. networkingEngine_, drmEngine_, mediaSourceEngine_, streamingEngine_... 핵심 엔진들이 전부 null로 선언만 되어 있었죠.
그렇다면 이 빈 골격은 언제, 어떻게 채워질까요? 그 답이 바로 attach와 load입니다.
attach(mediaElement) — 플레이어를 실제 <video> 엘리먼트에 연결합니다.
load(assetUri) — 매니페스트를 받아 파싱하고, 엔진들을 생성해 실제 재생을 시작합니다.
이 글에서는 두 메서드를 위에서 아래로 따라가며, 생성자가 선언만 해둔 필드들이 어떻게 살아 있는 객체로 채워지는지, 그리고 그 과정을 받치는 동시성 제어와 상태 머신이 어떻게 작동하는지를 정리합니다.
attach — 미디어 엘리먼트와 플레이어를 잇기
attach는 비교적 짧은 메서드입니다. 전체 흐름을 먼저 보겠습니다.
1async attach(mediaElement, initializeMediaSource = true) {
2 // 1. destroy된 플레이어는 사용 불가
3 if (this.loadMode_ == shaka.Player.LoadMode.DESTROYED) {
4 throw this.createAbortLoadError_();
5 }
6
7 const noop = this.video_ && this.video_ == mediaElement;
8
9 // 2. 이미 다른 엘리먼트에 붙어있으면 먼저 떼어낸다
10 if (this.video_ && this.video_ != mediaElement) {
11 await this.detach();
12 }
13
14 // 3. mutex 획득 (중단되었으면 early return)
15 if (await this.atomicOperationAcquireMutex_('attach')) {
16 return;
17 }
18
19 try {
20 if (!noop) {
21 this.makeStateChangeEvent_('attach');
22
23 const onError = (error) => this.onVideoError_(error);
24 this.attachEventManager_.listen(mediaElement, 'error', onError);
25 this.video_ = mediaElement;
26 if (this.cmcdManager_) {
27 this.cmcdManager_.setMediaElement(mediaElement);
28 }
29 }
30
31 // 4. 플랫폼이 지원하면 MediaSource 엔진을 초기화
32 const device = shaka.device.DeviceFactory.getDevice();
33 if (initializeMediaSource && device.supportsMediaSource() &&
34 !this.mediaSourceEngine_) {
35 await this.initializeMediaSourceEngineInner_();
36 }
37 } catch (error) {
38 await this.detach();
39 throw error;
40 } finally {
41 this.mutex_.release();
42 }
43}
핵심 포인트를 짚어보겠습니다.
- DESTROYED 가드: 모든 최상위 작업의 공통 진입 가드입니다.
load에서도 봤던 패턴으로, destroy된 플레이어는 어떤 작업도 받지 않습니다.
- noop 판정: 이미 같은 엘리먼트에 붙어있다면
noop = true로 두고, 실제 연결 작업(이벤트 리스너 등록, video_ 세팅)을 건너뜁니다. 같은 엘리먼트에 두 번 attach해도 안전하도록 만든 멱등성 장치입니다.
- 자동 detach: 다른 엘리먼트에 이미 붙어있으면, 먼저
detach()로 깔끔하게 떼어낸 뒤 새 엘리먼트에 연결합니다.
- error 리스너는
attachEventManager_에: 생성자 편에서 봤던 생명주기별 EventManager가 여기서 쓰입니다. 미디어 엘리먼트의 error 이벤트 리스너는 attach 스코프에 묶이므로, detach할 때 이 매니저만 정리하면 됩니다.
initializeMediaSource 인자가 true이고 플랫폼이 MSE를 지원하면, attach 단계에서 미리 MediaSourceEngine을 초기화해 둘 수도 있습니다. 이는 load 호출 전에 MediaSource를 준비해 두어 로딩 지연을 줄이기 위한 최적화입니다.
TIP
catch 블록에서 에러가 나면 detach()를 호출해 절반만 연결된 상태(half-attached)를 남기지 않고 깨끗이 되돌립니다. 실패 시 상태를 원자적으로 롤백하는 셈입니다.
atomicOperationAcquireMutex_ — 중단 불가능한 작업의 동시성 제어
attach가 load와 다른 점이 하나 있습니다. load는 mutexWrapOperation으로 중간에 중단(interrupt)될 수 있게 설계되어 있었지만, attach는 atomicOperationAcquireMutex_라는 다른 헬퍼를 씁니다.
1async atomicOperationAcquireMutex_(mutexIdentifier) {
2 const operationId = ++this.operationId_;
3 await this.mutex_.acquire(mutexIdentifier);
4 if (operationId != this.operationId_) {
5 this.mutex_.release();
6 return true; // 내가 기다리는 사이 다른 작업이 시작됨 → early return
7 }
8 return false;
9}
동작을 풀어보면 이렇습니다.
operationId_를 증가시켜 "이번 작업"의 고유 번호를 확보합니다.
- mutex를 획득할 때까지 기다립니다.
- 기다리는 동안 다른 최상위 작업(load, detach 등)이 시작되어
operationId_가 또 바뀌었다면, 내가 하려던 작업은 이미 무의미해진 것이므로 mutex를 풀고 true(early end)를 반환합니다.
load와의 차이가 여기에 있습니다. 주석에도 명시되어 있듯, 이 헬퍼는 "중간에 끊을 수 없는 작업(load를 제외한 거의 전부)"을 위한 것입니다. load는 긴 비동기 과정 중간중간 인터럽션을 감지해 빠져나올 수 있어야 하지만, attach·detach·unload 같은 작업은 시작했으면 한 덩어리로 끝내거나, 시작 전에 통째로 포기하는 편이 안전하기 때문입니다.
| 헬퍼 | 사용처 | 중단 처리 |
|---|
mutexWrapOperation | load | 각 단계마다 detectInterruption으로 중간 이탈 가능 |
atomicOperationAcquireMutex_ | attach, detach, unload | 시작 전 한 번만 검사, 시작하면 끝까지 |
detach와unload — attach의 역방향
detach는 attach를 거꾸로 되감는 메서드입니다.
1async detach(keepAdManager = false, isSwitchingContent = false) {
2 if (this.loadMode_ == shaka.Player.LoadMode.DESTROYED) {
3 throw this.createAbortLoadError_();
4 }
5
6 // 먼저 콘텐츠를 unload
7 await this.unload(/* initializeMediaSource= */ false,
8 keepAdManager, isSwitchingContent);
9
10 if (await this.atomicOperationAcquireMutex_('detach')) {
11 return;
12 }
13
14 try {
15 if (this.video_) {
16 this.attachEventManager_.removeAll(); // attach 스코프 리스너 일괄 정리
17 this.video_ = null;
18 }
19 this.makeStateChangeEvent_('detach');
20 if (this.adManager_ && !keepAdManager) {
21 this.adManager_.release();
22 }
23 } finally {
24 this.mutex_.release();
25 }
26}
여기서 this.attachEventManager_.removeAll() 한 줄로 attach 단계에서 등록했던 리스너가 한 번에 정리됩니다. 생성자 편에서 강조했던 "생명주기별 EventManager 분리"의 실익이 이 한 줄에 응축되어 있습니다.
detach가 호출하는 unload는 그보다 먼저 콘텐츠를 정리하는데, 그 시작이 인상적입니다.
1async unload(initializeMediaSource = true, ...) {
2 // load mode를 즉시 NOT_LOADED로 바꿔, 공개 메서드들이
3 // 내부 컴포넌트 사용을 즉시 멈추게 한다.
4 if (this.loadMode_ != shaka.Player.LoadMode.DESTROYED) {
5 this.loadMode_ = shaka.Player.LoadMode.NOT_LOADED;
6 }
7 // ...
8}
loadMode_를 가장 먼저 NOT_LOADED로 바꿉니다. 이렇게 하면 getStats() 같은 공개 API들이 "지금은 로드된 상태가 아니다"를 즉시 인지하고, 해체되는 중인 내부 컴포넌트에 접근하지 않게 됩니다. 상태 플래그를 먼저 내려서 경쟁 상태(race condition)를 막는 방어적 설계입니다.
load의 큰 그림
load는 이 시리즈에서 가장 긴 메서드입니다. 세부는 따로 다룬 적이 있으니, 여기서는 재생 준비 파이프라인이라는 관점에서 큰 줄기만 다시 정리하겠습니다.
load(assetUriOrPreloader)
│
├─ DESTROYED 가드 / 인자 분기(URI vs PreloadManager)
├─ 이미 로드된 콘텐츠가 있으면 unload
├─ operationId 발급 → detectInterruption 준비
│
├─ mimeType 결정 (guessMimeType_)
│
├─ PreloadManager 확보
│ └─ 없으면 preloadInner_()로 내부 생성 → "preload 없는 load"도 같은 경로
│
└─ shouldUseSrcEquals 분기
├─ true → src= 경로 (srcEqualsInner_)
└─ false → MSE 경로 (waitForManifest → ... → loadInner_)
핵심 설계는 모든 로드를 PreloadManager로 통일한다는 점입니다. 사용자가 preload()로 미리 준비한 매니저를 넘기든, 그냥 load(uri)를 호출하든, 내부에서는 preloadInner_가 PreloadManager를 만들어 동일한 코드 경로로 흐르게 합니다.
1async preloadInner_(assetUri, startTime, mimeType, standardLoad = false, config) {
2 if (!mimeType) {
3 mimeType = await this.guessMimeType_(assetUri);
4 }
5 const shouldUseSrcEquals = this.shouldUseSrcEquals_(mimeType);
6 if (shouldUseSrcEquals) {
7 // src= 콘텐츠는 preload할 수 없다
8 return null;
9 }
10 // ... makePreloadManager_로 매니페스트 파싱·프리패치 담당 매니저 생성
11}
흥미롭게도 preloadInner_가 null을 반환하는 경우가 곧 src= 경로입니다. load에서 shouldUseSrcEquals = !preloadManager로 판정하는 이유가 바로 이것입니다. PreloadManager를 만들 수 없는 콘텐츠(네이티브 재생만 가능한 HLS 등)는 자연스럽게 <video src=...> 경로로 빠집니다.
load 내부의 두 갈래
PreloadManager 확보 이후, load는 두 갈래로 갈립니다.
MSE 경로 (DASH/HLS via MediaSource)
1// 1. 매니페스트 파싱을 기다린다
2await preloadManager.waitForManifest();
3this.parser_ = preloadManager.receiveParser();
4this.manifest_ = preloadManager.getManifest();
5
6// 2. MediaSource 엔진 초기화 (아직 없다면)
7if (!this.mediaSourceEngine_) {
8 await this.initializeMediaSourceEngineInner_();
9}
10
11// 3. 프리패치 등 남은 준비 완료를 기다린다
12await preloadManager.waitForFinish();
13
14// 4. DRM 엔진 받아서 video에 연결
15this.drmEngine_ = preloadManager.receiveDrmEngine();
16await this.drmEngine_.attach(this.video_);
17
18// 5. ABR 매니저 준비
19this.abrManager_ = abrFactory();
20this.abrManager_.configure(this.config_.abr);
21
22// 6. 실제 로딩 실행
23await this.loadInner_(startTimeOfLoad, prefetchedVariant, segmentPrefetchById);
생성자에서 null이던 parser_, manifest_, mediaSourceEngine_, drmEngine_, abrManager_가 여기서 차례로 채워집니다. 각 단계가 mutexWrapOperation으로 감싸여 있어, 중간에 다른 load가 끼어들면 즉시 중단됩니다.
src= 경로 (네이티브 재생)
1await this.initializeSrcEqualsDrmInner_(mimeType);
2await this.srcEqualsInner_(startTimeOfLoad, mimeType);
src= 경로는 훨씬 단출합니다. srcEqualsInner_는 SrcEqualsPlayhead를 만들고, PlayRateController·버퍼 관리·자막 디스플레이어를 세팅한 뒤, 브라우저의 네이티브 재생 엔진에 모든 걸 맡깁니다. StreamingEngine도, 매니페스트 파서도 필요 없습니다. 브라우저가 알아서 처리하는 콘텐츠이기 때문입니다.
loadInner_ — 실제 재생을 시작하는 심장부
MSE 경로의 마지막 단계인 loadInner_가 사실상 재생을 점화하는 곳입니다. 약 200줄에 달하지만 핵심 골격은 다음과 같습니다.
1async loadInner_(startTimeOfLoad, prefetchedVariant, segmentPrefetchById) {
2 this.makeStateChangeEvent_('load');
3
4 const mediaElement = this.video_;
5 this.setupPlayRateController_(mediaElement); // 배속 제어기
6 this.addBasicMediaListeners_(mediaElement, startTimeOfLoad); // 미디어 이벤트
7
8 // ABR 매니저 초기화: variant 전환 콜백을 등록
9 this.abrManager_.init((variant, clearBuffer, safeMargin) => {
10 return this.switch_(variant, clearBuffer, safeMargin);
11 }, /* ... */);
12 this.abrManager_.setMediaElement(mediaElement);
13
14 // StreamingEngine 생성
15 this.streamingEngine_ = this.createStreamingEngine();
16 this.streamingEngine_.configure(this.config_.streaming);
17
18 // 여기서 비로소 "로드됨" 상태로 전환
19 this.loadMode_ = shaka.Player.LoadMode.MEDIA_SOURCE;
20
21 this.dispatchEvent(/* 'streaming' 이벤트 */);
22
23 // ... 초기 variant 선택 + lazy-load (아래에서 설명) ...
24
25 // Playhead 생성
26 this.playhead_ = this.createPlayhead(startTime);
27
28 // 초기 variant로 전환
29 this.switchVariant_(initialVariant, /* fromAdaptation= */ true, ...);
30 this.playhead_.ready();
31
32 // 콘텐츠 다운로드 시작 — 데이터가 MediaSource로 흐르기 시작
33 await this.streamingEngine_.start(segmentPrefetchById);
34
35 if (this.config_.abr.enabled) {
36 this.abrManager_.enable();
37 }
38}
여기서 주목할 세 가지가 있습니다.
1. loadMode_ 전환 시점이 절묘하다.
1// 공개 메서드들이 내부 컴포넌트가 다 준비되기 전에 접근하지 못하도록
2// 가능한 한 늦게 MEDIA_SOURCE로 바꾼다. 단, 'streaming' 이벤트보다는
3// 먼저 바꿔야 리스너들이 내부 정보에 접근할 수 있다.
4this.loadMode_ = shaka.Player.LoadMode.MEDIA_SOURCE;
loadMode_를 MEDIA_SOURCE로 올리는 순간부터 getVariantTracks() 같은 공개 API가 "이제 내부 컴포넌트를 써도 된다"고 판단합니다. 그래서 StreamingEngine 생성은 끝났지만 아직 streaming 이벤트는 쏘기 전이라는 정확한 타이밍에 플래그를 올립니다. 너무 일찍 올리면 준비 안 된 컴포넌트에 접근하고, 너무 늦으면 streaming 이벤트 리스너가 정보를 못 읽습니다.
2. 초기 variant 선택과 lazy-load.
1do {
2 activeVariant = this.streamingEngine_.getCurrentVariant();
3 if (!activeVariant) {
4 const chosenVariant = this.chooseVariant_(/* initialSelection= */ true);
5 if (!prefetchedVariant) {
6 initialVariant = chosenVariant;
7 }
8 }
9
10 // playhead를 만들기에 충분한 정보를 얻기 위해 스트림을 lazy-load
11 toLazyLoad = activeVariant || initialVariant;
12 const createSegmentIndexPromises = [];
13 for (const stream of [toLazyLoad.video, toLazyLoad.audio]) {
14 if (stream && !stream.segmentIndex) {
15 createSegmentIndexPromises.push(stream.createSegmentIndex());
16 }
17 }
18 if (createSegmentIndexPromises.length > 0) {
19 await Promise.all(createSegmentIndexPromises);
20 }
21} while (!toLazyLoad || toLazyLoad.disabledUntilTime != 0);
ABR(Adaptive Bitrate) 매니저가 첫 화질을 고르고, 그 variant의 segment index를 그제서야 생성(lazy-load)합니다. 모든 화질의 인덱스를 미리 만들지 않고, 실제 재생할 것만 만드는 것이죠. while 조건은 고른 variant가 일시 비활성화(disabledUntilTime) 상태가 아닐 때까지 반복해, 재생 가능한 variant가 확정될 때까지 돕니다.
3. streamingEngine_.start()가 실제 다운로드의 방아쇠다.
이 호출 이후부터 세그먼트가 네트워크에서 다운로드되어 MediaSource의 SourceBuffer로 흘러 들어갑니다. 그 전까지는 전부 "준비"였고, 여기서 비로소 데이터가 움직입니다.
initializeMediaSourceEngineInner_ — MSE 엔진 준비
attach와 load 양쪽에서 호출되던 이 메서드는 MediaSource 재생의 토대를 놓습니다.
1async initializeMediaSourceEngineInner_() {
2 this.makeStateChangeEvent_('media-source');
3
4 // 이전 src= 모드의 잔재 제거
5 if (this.config_.mediaSource.useSourceElements) {
6 shaka.util.Dom.clearSourceFromVideo(this.video_);
7 }
8
9 this.createAndConfigureTextDisplayer_(); // 자막 디스플레이어 생성
10
11 const mediaSourceEngine = this.createMediaSourceEngine(
12 this.video_,
13 this.textDisplayer_,
14 {
15 getKeySystem: () => this.keySystem(),
16 onMetadata: (...) => this.processTimedMetadataMediaSrc_(...),
17 onEmsg: (emsg) => this.addEmsgToRegionTimeline_(emsg),
18 onEvent: (event) => this.dispatchEvent(event),
19 onManifestUpdate: () => this.onManifestUpdate_(),
20 getDrmInfo: () => this.drmInfo(),
21 },
22 this.lcevcDec_,
23 this.config_.mediaSource);
24
25 // MediaSource가 열릴 때까지 대기 (이 Promise는 절대 reject되지 않음)
26 await mediaSourceEngine.open();
27
28 // 준비가 끝난 뒤에야 참조를 저장
29 this.mediaSourceEngine_ = mediaSourceEngine;
30}
두 가지가 눈에 띕니다. 첫째, 엔진에 넘기는 콜백 묶음은 MediaSourceEngine이 플레이어에게 되돌려 말하는 통로입니다. 메타데이터·emsg·DRM 정보 요청이 이 콜백들을 통해 Player로 전달됩니다. 둘째, await ...open()이 끝난 뒤에야 this.mediaSourceEngine_에 대입합니다. 생성자 편에서 본 "준비가 끝나야 참조를 저장한다"는 패턴이 여기서도 반복됩니다. 절반만 열린 엔진이 필드에 노출되는 일을 막는 것이죠.
상태 머신과 OnStateChange 이벤트
지금까지 코드를 따라오며 makeStateChangeEvent_('...') 호출을 여러 번 마주쳤습니다. 이 호출들이 모이면 Shaka Player의 로딩 상태 머신이 됩니다.
1makeStateChangeEvent_(nodeName) {
2 this.dispatchEvent(shaka.Player.makeEvent_(
3 shaka.util.FakeEvent.EventName.OnStateChange,
4 (new Map()).set('state', nodeName)));
5}
attach → load의 전형적인 MSE 시나리오에서 발생하는 상태 전이는 다음과 같습니다.
attach ← attach() 진입
└ media-source ← initializeMediaSourceEngineInner_()
load ← load() 호출
└ media-source ← (필요 시 MSE 엔진 초기화)
└ load ← loadInner_() 진입
src= 경로라면 media-source/load 대신 src-equals 상태가 등장하고, 정리 시점에는 unload·detach 상태가 디스패치됩니다. 외부에서 OnStateChange 이벤트를 구독하면 플레이어가 지금 로딩 파이프라인의 어느 단계에 있는지를 실시간으로 추적할 수 있습니다. 텔레메트리나 로딩 UI를 만들 때 유용한 관찰 지점입니다.
INFO
이 상태 이름들(attach, media-source, load, src-equals, unload, detach)은 내부적으로 로딩 그래프의 노드 이름과 일치합니다. 즉 상태 머신이 코드 흐름과 일대일로 대응하도록 설계되어 있어, 이벤트만 보고도 내부 동작을 역추적할 수 있습니다.
정리
attach와 load는 생성자가 선언만 해둔 빈 골격을 살아 있는 재생 세션으로 바꾸는 두 단계였습니다. 핵심을 정리하면 다음과 같습니다.
| 단계 | 하는 일 | 채워지는 필드 |
|---|
attach | 미디어 엘리먼트 연결, error 리스너 등록, (선택적) MSE 초기화 | video_, mediaSourceEngine_ |
load (큰 그림) | PreloadManager로 통일, src= vs MSE 분기 | — |
| MSE 경로 | 매니페스트 파싱, DRM·ABR 준비 | parser_, manifest_, drmEngine_, abrManager_ |
loadInner_ | 초기 variant 선택, StreamingEngine 구동, 다운로드 시작 | streamingEngine_, playhead_ |
| src= 경로 | 네이티브 재생에 위임 | playhead_(SrcEquals) |
그 밑을 받치는 두 축도 빼놓을 수 없습니다.
- 동시성 제어:
load는 중단 가능한 mutexWrapOperation을, attach·detach·unload는 중단 불가능한 atomicOperationAcquireMutex_를 씁니다. operationId_가 두 방식 모두의 기준점입니다.
- 상태 머신:
makeStateChangeEvent_가 흩뿌려져 attach → media-source → load로 이어지는 상태 전이를 외부에 노출합니다.
한 줄로 요약하면 다음과 같습니다.
"attach는 플레이어를 화면(엘리먼트)에 잇고, load는 그 위에 매니페스트를 부어 엔진들을 차례로 점화한다. 그리고 그 모든 단계는 mutex와 operationId가 충돌 없이 직렬화한다."
생성자에서 null이던 필드들이 attach와 load를 거치며 하나씩 실체를 갖는 과정을 따라가다 보면, 거대한 플레이어도 결국 "선언 → 연결 → 충전"이라는 단순한 생애주기를 따른다는 것을 알 수 있습니다. 다음 글에서는 이렇게 점화된 StreamingEngine이 실제로 세그먼트를 어떻게 다운로드하고 버퍼에 채워 넣는지를 더 깊이 들여다보겠습니다.