shaka-player처럼 수만 줄에 달하는 라이브러리를 처음 마주하면, 어디서부터 읽어야 할지 막막합니다. 저는 이런 경우 클래스의 생성자(Constructor)부터 읽는 것을 좋아합니다. 생성자는 그 객체가 살아가는 동안 손에 쥐고 있을 모든 부품의 목록이기 때문입니다.
lib/player.js의 Player 생성자는 약 400줄에 달합니다. 언뜻 보면 그냥 수십 개의 필드를 null로 채우는 지루한 코드처럼 보이지만, 자세히 들여다보면 이 플레이어가 어떤 책임들을 가지고 있고, 그 책임들을 어떤 생명주기로 관리하는지가 고스란히 드러납니다. DRM, 네트워킹, 미디어 소스, ABR, 광고, 자막... 생성자만 제대로 읽어도 Shaka Player의 전체 지형도를 그릴 수 있습니다.
이 글에서는 Player의 생성자를 위에서 아래로 따라가며, 각 블록이 왜 그 자리에 그런 모습으로 존재하는지를 정리합니다.
생성자의 시그니처는 다음과 같습니다.
세 개의 인자가 있습니다.
mediaElement: 재생에 사용할 <video>(또는 <audio>) 엘리먼트입니다. 다만 뒤에서 보겠지만, 이 인자로 직접 넘기는 방식은 이미 deprecated 되었습니다.videoContainer: 비디오를 감싸는 컨테이너 엘리먼트로, 기본값은 null입니다.dependencyInjector: 생성된 Player 인스턴스를 받아 내부 의존성을 바꿔치기할 수 있는 함수입니다. 주로 테스트에서 활용됩니다.super() 호출로 시작하는 것은 Player가 shaka.util.FakeEventTarget을 상속해 이벤트를 디스패치할 수 있는 객체이기 때문입니다. 생성자 전체가 사실상 이 이벤트 타겟 위에 각종 부품을 장착하는 과정입니다.
null로 — "선언과 생성의 분리"생성자 본문의 첫인상은 끝없이 이어지는 필드 초기화입니다.
networkingEngine_, drmEngine_, mediaSourceEngine_, playhead_, streamingEngine_, parser_, manifest_, abrManager_... 핵심 엔진들이 모두 일단 null로 선언됩니다. 이것은 Shaka Player의 중요한 설계 결정을 보여줍니다.
핵심은 "객체 생성"과 "콘텐츠 로딩"을 철저히 분리한다는 점입니다. 생성자 시점에는 아직 무엇을 재생할지 모릅니다. 어떤 매니페스트인지, DASH인지 HLS인지, DRM이 걸려있는지 알 수 없죠. 그래서 실제 엔진들은 load()가 호출되는 시점에 가서야 생성됩니다. 생성자는 그저 "이런 필드가 존재한다"는 선언만 해두는 역할입니다.
이렇게 모든 필드를 생성자에서 미리 선언해두면, Closure Compiler 기반의 타입 체크가 가능해지고(@private {Type}주석), 인스턴스의 hidden class가 안정되어 V8 엔진의 최적화에도 유리합니다. "쓸지 안 쓸지 모르지만 일단 선언해둔다"는 패턴은 대규모 JS 라이브러리에서 흔히 볼 수 있습니다.
loadMode_만 예외적으로 NOT_LOADED라는 의미 있는 초기값을 가집니다. 이 값은 생성자 맨 처음에 등장하는데, 플레이어의 상태 머신을 대표하는 필드이기 때문입니다. 앞서 load() 분석에서 보았던 DESTROYED 가드도 바로 이 필드를 검사하는 것이었습니다.
필드 초기화 중에서 특히 눈여겨볼 부분은 EventManager가 다섯 개나 만들어진다는 점입니다.
왜 하나로 합치지 않고 다섯 개로 나눴을까요? 답은 각 EventManager가 담당하는 리스너들의 "수명"이 다르기 때문입니다.
globalEventManager_: 플레이어가 destroy될 때까지 살아있어야 하는 리스너 (예: window의 online 이벤트)attachEventManager_: 미디어 엘리먼트가 attach되어 있는 동안만 유효한 리스너. detach하면 정리되어야 함loadEventManager_: 콘텐츠가 로드되어 있는 동안만 유효. unload하면 정리되어야 함trickPlayEventManager_: 배속/되감기 같은 trick play가 활성화된 동안만adManagerEventManager_: 광고 매니저와 연결된 동안만이렇게 스코프별로 EventManager를 분리해두면, 특정 생명주기가 끝날 때 해당 매니저만 removeAll() 하면 관련 리스너가 한 번에 정리됩니다. unload할 때 attach 리스너까지 실수로 날려버리는 일이 없는 거죠. 리스너 누수를 구조적으로 방지하는 깔끔한 설계입니다.
operationId_와Mutex생성자 중반에는 앞선 load() 분석에서 만났던 동시성 제어 장치들의 "출생지"가 있습니다.
operationId_는 0에서 출발하는 단순한 카운터입니다. 하지만 이 카운터가 load(), unload(), attach() 같은 최상위 작업마다 증가하면서, "내가 비동기 작업을 기다리는 동안 다른 작업이 끼어들었는가"를 감지하는 기준점이 됩니다.
mutex_는 한 번에 하나의 최상위 작업만 진행되도록 보장하는 잠금 장치입니다. load() 안에서 보았던 mutexWrapOperation이 바로 이 mutex_를 잡고 푸는 패턴이었습니다.
생성자에서는 이 둘을 그저 초기값으로 세팅할 뿐이지만, 이 두 줄이 Shaka Player의 모든 비동기 작업이 서로 충돌하지 않게 만드는 토대입니다. 생성자를 읽으면 "이 라이브러리가 동시성 문제를 진지하게 다루는구나"를 미리 알 수 있습니다.
필드 선언이 끝나갈 무렵, 단순 null 할당이 아니라 실제 값을 계산해 채우는 블록이 등장합니다.
config_는 defaultConfig_()로 기본 설정을 채웁니다. 사용자가 configure()로 덮어쓰기 전까지 사용할 기본값입니다. 이 config_는 곧바로 manifestFilterer_ 같은 다른 객체의 생성 인자로도 쓰입니다. 설정이 먼저 준비되어야 그 설정에 의존하는 객체를 만들 수 있다는 순서가 코드에 그대로 드러납니다.
이어서 현재 재생 품질 선택 기준인 currentAdaptationSetCriteria_를 구성합니다.
AdaptationSetCriteria는 "여러 화질·음성 트랙 중 무엇을 고를 것인가"의 기준을 담는 객체입니다. 선호 오디오/비디오, 언어, 채널 수, HDR 레벨 같은 값들로 초기화됩니다. 생성자 시점에는 아직 매니페스트가 없으니 language, role 등은 빈 문자열로 두고, 사용자 설정에서 가져올 수 있는 값들만 채워둡니다.
이 블록 바로 뒤에서는 자막 관련 초기 상태도 설정에서 끌어옵니다.
dependencyInjector — 테스트와 확장을 위한 주입 지점필드 초기화가 끝난 직후, 핵심 엔진을 만들기 바로 직전에 의존성 주입자가 호출됩니다.
호출 시점이 절묘합니다. 모든 필드가 선언된 직후, 그러나 실제 엔진이 만들어지기 직전입니다. 왜 하필 이 위치일까요?
dependencyInjector(this)는 인스턴스를 통째로 넘겨받아 내부 메서드(createNetworkingEngine 등)를 가짜 구현으로 바꿔치기할 수 있습니다. 만약 의존성 주입이 엔진 생성 이후에 호출된다면 이미 진짜 엔진이 만들어진 뒤이므로 의미가 없습니다. "빈 껍데기는 다 준비됐지만 아직 부품은 안 끼운" 바로 그 순간에 주입자를 호출해야, 테스트 코드가 원하는 가짜 부품으로 갈아끼울 기회를 얻습니다.
이 패턴은 의존성 주입(DI)의 교과서적인 활용입니다. 생성자가 직접 new로 의존성을 만들면 테스트에서 갈아끼우기 어렵지만, createXxx_()메서드로 한 단계 감싸고 그 직전에 주입 훅을 두면, 프로덕션에서는 진짜 구현을, 테스트에서는 mock을 쉽게 꽂을 수 있습니다.
대부분의 엔진이 load()로 미뤄지는 와중에도, 생성자에서 곧바로 만들어지는 예외적인 엔진들이 있습니다.
cmcdManager_ (CMCD: Common Media Client Data): 클라이언트 측 데이터를 모든 네트워크 요청에 실어 보내기 위한 매니저입니다. 주석에도 "client data can be attached to all requests"라고 적혀 있습니다.cmsdManager_ (CMSD: Common Media Server Data): 서버가 응답에 실어 보내는 데이터를 처리합니다.networkingEngine_: 모든 HTTP 요청의 관문입니다.이들이 왜 미뤄지지 않고 즉시 생성될까요? 콘텐츠 로딩 자체가 네트워킹에 의존하기 때문입니다. 매니페스트를 받아오는 것부터가 네트워크 요청이므로, networkingEngine_은 load() 이전에 이미 존재해야 합니다. CMCD/CMSD 역시 그 네트워킹에 데이터를 붙이는 역할이라 함께 준비됩니다. 즉, "콘텐츠와 무관하게 항상 필요한" 인프라성 엔진들만 생성자에서 미리 만든다는 기준이 보입니다.
생성자 후반부는 광고 처리에 상당한 분량을 할애합니다. 먼저 광고 콘텐츠를 끼워넣기 위한 프리로드 타이머를 만듭니다.
그리고 광고 매니저 팩토리가 등록되어 있을 때만 adManager_를 만들고, 광고 관련 이벤트 세 가지를 구독합니다.
여기서 흐름이 잘 드러납니다.
ad-content-pause-requested: 광고를 틀어야 하니, 본편 콘텐츠를 detachAndSavePreload로 떼어내 preloadDueAdManager_에 보관합니다. 본편의 위치(position)도 함께 저장합니다.ad-content-resume-requested: 광고가 끝났으니 본편을 복구할 차례입니다. 저장해둔 오프셋을 설정하고 preloadDueAdManagerTimer_를 0.1초 뒤 발화하도록 겁니다.preloadDueAdManager_를 다시 attach + load하여 광고로 끊겼던 본편을 빠르게 이어 재생합니다.광고 때문에 본편을 통째로 다시 로드하면 느리니까, 본편을 프리로드 상태로 "냉동 보관"했다가 광고가 끝나면 빠르게 "해동"하는 구조입니다. 주석을 보면 옵셔널 모듈 의존을 피하려고 일부러 이벤트 상수 대신 문자열을 직접 쓴다는 점도 확인할 수 있습니다.
마지막으로 큐 매니저도 팩토리가 있을 때만 조건부로 생성됩니다.
attach생성자 끝자락에는 플레이어 전체 생명주기에 걸친 글로벌 리스너가 등록됩니다.
online 이벤트를 globalEventManager_에 등록한 점에 주목하세요. 이 리스너는 플레이어가 살아있는 내내 유효해야 하므로, attach나 load 스코프가 아닌 global 스코프에 묶이는 것이 맞습니다. 앞서 EventManager를 다섯 개로 나눈 설계가 여기서 실제로 활용되는 모습입니다.
그리고 생성자의 가장 마지막, deprecated된 mediaElement 인자 처리가 등장합니다.
이 블록이 왜 맨 마지막에 있는지에 대한 주석이 핵심입니다. attach()는 비동기로 동작하지만, 개념적으로는 player의 모든 필드와 엔진이 준비된 뒤에야 호출되어야 합니다. 만약 생성자 중간에서 attach를 호출하면, 아직 초기화되지 않은 필드를 attach 내부 로직이 참조할 위험이 있습니다.
그래서 "혹시 호출되더라도 모든 준비가 끝난 뒤"라는 안전을 위해 생성자의 제일 끝에 배치한 것입니다. 동시에 alwaysWarn으로 "이 방식은 deprecated이니 attach 메서드를 직접 쓰라"고 경고합니다. 즉 이 코드는 하위 호환을 위해 남겨둔 마지막 안전장치인 셈입니다.
Shaka Player의 생성자는 단순한 필드 초기화의 나열이 아니라, 이 라이브러리의 설계 철학이 압축된 청사진이었습니다. 핵심을 정리하면 다음과 같습니다.
| 블록 | 핵심 |
|---|---|
필드 null 초기화 | 객체 생성과 콘텐츠 로딩을 분리, 엔진은 load()에서 생성 |
| 5개의 EventManager | 생명주기(global/attach/load/trickPlay/ad)별로 리스너를 분리해 누수 방지 |
operationId_ + Mutex | 모든 최상위 비동기 작업의 동시성 제어 토대 |
config_ 우선 구성 | 설정에 의존하는 객체보다 설정을 먼저 준비 |
dependencyInjector | 필드 선언 직후·엔진 생성 직전에 호출해 테스트 주입 허용 |
| 즉시 생성 엔진 | 콘텐츠와 무관한 인프라(CMCD/CMSD/Networking)만 미리 생성 |
| AdManager | 본편을 프리로드로 냉동 보관했다가 광고 후 빠르게 복구 |
마지막 attach | 모든 초기화가 끝난 뒤 호출되도록 생성자 맨 끝에 배치 |
생성자를 한 줄로 요약하면 다음과 같습니다.
"항상 필요한 인프라만 지금 만들고, 콘텐츠에 의존하는 모든 것은 선언만 해둔 채
load()로 미룬다."
이 원칙 덕분에 Shaka Player는 어떤 콘텐츠(DASH/HLS, DRM 유무, 광고 유무)가 들어와도 동일한 빈 골격에서 출발할 수 있습니다. 다음 글에서는 이 골격 위에서 실제로 콘텐츠를 끼워넣는 attach와 load의 내부를 더 깊이 따라가 보겠습니다. 거대한 라이브러리도 결국 생성자에서 선언한 필드들을 하나씩 채워나가는 과정일 뿐입니다.