Shaka Player 파헤치기 - DrmEngine
들어가며 — 매니페스트가 넘긴 drmInfos에서 시작한다
매니페스트 파서 2부작(DASH, HLS)에서 우리는 MPD와 M3U8이 서로 다른 길을 거쳐 똑같은 manifest_ 모델로 수렴한다는 것을 봤습니다. 그 모델의 각 variant 안에는 drmInfos라는 배열이 들어 있었습니다. DASH라면 ContentProtection 요소에서, HLS라면 EXT-X-KEY 태그에서 파싱된, "이 트랙이 어떤 키 시스템으로 보호되는가"에 대한 정보입니다.
그리고 PreloadManager 편에서는 initializeDrm()이 다음과 같이 DrmEngine의 시동을 거는 것을 봤습니다.
1// preload_manager.js — initializeDrm (발췌)
2const playableVariants = shaka.util.StreamUtils.getPlayableVariants(
3 this.manifest_.variants);
4await this.drmEngine_.initForPlayback(
5 playableVariants, this.manifest_.offlineSessionIds, isDynamic);
이번 편은 그 initForPlayback 안으로 들어가, 매니페스트 파싱 단계에서 취합된 manifest_를 입력으로 DrmEngine이 어떤 처리들을 실행하는지를 추적합니다. 파서가 "무엇이 암호화됐는지"를 알려주면, DrmEngine은 거기에 앱 설정을 더해 실제 복호화를 준비합니다. 그 전 과정을 lib/drm/drm_engine.js를 따라 한 단계씩 열어 보겠습니다.
입구 — initForPlayback
시작점인 initForPlayback은 세 가지를 받습니다. 재생할 variant 목록, 오프라인 세션 ID, 라이브 여부입니다.
1// drm_engine.js — initForPlayback
2initForPlayback(variants, offlineSessionIds, isLive = true) {
3 this.storedPersistentSessions_ = new Map();
4
5 // 오프라인 세션 ID와 설정상의 지속 세션 메타데이터를 복원 후보로 등록
6 for (const sessionId of offlineSessionIds) {
7 this.storedPersistentSessions_.set(
8 sessionId, {initData: null, initDataType: null});
9 }
10 for (const metadata of this.config_.persistentSessionsMetadata) {
11 this.storedPersistentSessions_.set(metadata.sessionId,
12 {initData: metadata.initData, initDataType: metadata.initDataType});
13 }
14
15 this.usePersistentLicenses_ = this.storedPersistentSessions_.size > 0;
16
17 return this.init_(variants, isLive);
18}
오프라인 다운로드나 지속 라이선스(persistent license)로 이미 받아 둔 세션이 있으면 그 목록을 미리 챙겨 둔 뒤, 본격적인 작업인 init_에 variants와 isLive를 그대로 넘깁니다. 이 variants가 바로 manifest_에서 온 값이라는 점이 이 글 내내 중심입니다.
init_ — 매니페스트의 DRM 정보를 정규화한다
init_은 매니페스트에 흩어진 DRM 정보를 모아 실제로 협상 가능한 형태로 다듬는 단계입니다. 핵심만 추리면 이렇습니다.
1// drm_engine.js — init_ (핵심 골격)
2async init_(variants, isLive) {
3 // 1) variant마다 video·audio의 drmInfos를 긁어 본다
4 const hadDrmInfo = variants.some((variant) => {
5 return (variant.video && variant.video.drmInfos.length) ||
6 (variant.audio && variant.audio.drmInfos.length);
7 });
8
9 const servers = shaka.util.MapUtils.asMap(this.config_.servers);
10
11 // 2) 라이브인데 지금은 평문이면, 미래의 암호화 구간을 위해 미리 세팅
12 if (!hadDrmInfo && isLive) {
13 shaka.drm.DrmEngine.replaceDrmInfo_(variants, servers);
14 }
15
16 // 3) 각 drmInfo에 앱 설정(라이선스 서버·robustness 등)을 채워 넣는다
17 for (const variant of variants) {
18 for (const info of this.getVariantDrmInfos_(variant)) {
19 shaka.drm.DrmEngine.fillInDrmInfoDefaults_(
20 info, servers, advanced, this.config_.keySystemsMapping);
21 }
22 }
23 // ... expandRobustness로 robustness 조합 확장 ...
24
25 // 4) mediaCapabilities로 각 variant의 decodingInfos를 채운다 (협상의 토대)
26 await shaka.util.StreamUtils.getDecodingInfosForVariants(variants,
27 this.usePersistentLicenses_, this.srcEquals_,
28 this.config_.preferredKeySystems);
29
30 // 5) 정말 평문이면 여기서 끝, 아니면 키 시스템 협상으로
31 if (!hasDrmInfo) {
32 this.initialized_ = true;
33 return Promise.resolve();
34 }
35 const p = this.queryMediaKeys_(variants);
36 return hadDrmInfo ? p : p.catch(() => {});
37}
manifest_와 DrmEngine이 만나는 정확한 지점은 getVariantDrmInfos_입니다.
1// drm_engine.js — getVariantDrmInfos_
2getVariantDrmInfos_(variant) {
3 const videoDrmInfos = variant.video ? variant.video.drmInfos : [];
4 const audioDrmInfos = variant.audio ? variant.audio.drmInfos : [];
5 return videoDrmInfos.concat(audioDrmInfos);
6}
단 세 줄입니다. 하지만 이 video.drmInfos와 audio.drmInfos가 바로 DASH 파서가 ContentProtection에서, HLS 파서가 EXT-X-KEY에서 채워 넣은 그 값입니다. 파서 2부작에서 만든 결과물이 여기서 DRM 처리의 입력이 되는 것이죠.
이 단계에서 일어나는 일을 곱씹어 볼 만한 점이 몇 가지 있습니다.
매니페스트 정보와 앱 설정의 결합. 매니페스트는 keySystem, initData(PSSH), encryptionScheme 같은 "콘텐츠 쪽 사실"을 알려줍니다. 하지만 정작 라이선스 서버 주소는 매니페스트에 없는 경우가 많습니다. 그래서 fillInDrmInfoDefaults_가 앱 설정(config.servers, config.advanced)에서 licenseServerUri와 robustness 같은 "운영 쪽 정보"를 채워 넣습니다. 콘텐츠가 말하는 사실과 앱이 정한 정책이 합쳐져야 비로소 하나의 완성된 drmInfo가 됩니다.
라이브의 미래 대비. 지금 받은 매니페스트가 평문이라도, 라이브라면 나중에 광고나 특정 구간이 암호화되어 들어올 수 있습니다. 그래서 !hadDrmInfo && isLive인 경우 replaceDrmInfo_로 설정에 등록된 모든 키 시스템을 미리 세팅해 둡니다. 파서 편에서 본 "라이브는 동적으로 갱신된다"는 성질이 DRM 레이어에서도 똑같이 반복됩니다.
INFO
getDecodingInfosForVariants는 navigator.mediaCapabilities.decodingInfo를 호출해 각 variant의 decodingInfos를 채웁니다. 여기에는 코덱 지원 여부뿐 아니라 **keySystemAccess**까지 함께 담깁니다. Shaka가 requestMediaKeySystemAccess를 직접 부르지 않고 이 경로를 쓰는 이유는, 코덱 디코딩 가능성과 키 시스템 협상을 한 번에 처리하기 위해서입니다. 그래서 이 호출은 반드시 queryMediaKeys_보다 먼저 와야 합니다.
queryMediaKeys_ — 키 시스템 협상과 MediaKeys 생성
정규화된 drmInfos와 decodingInfos를 손에 쥐었으니, 이제 실제로 어떤 키 시스템을 쓸지 결정하고 CDM을 생성할 차례입니다.
1// drm_engine.js — queryMediaKeys_ (핵심 골격)
2async queryMediaKeys_(variants) {
3 const drmInfosByKeySystem = new Map();
4 const mediaKeySystemAccess =
5 this.getKeySystemAccessFromVariants_(variants, drmInfosByKeySystem);
6
7 if (!mediaKeySystemAccess) {
8 throw new shaka.util.Error(/* MISSING_EME_SUPPORT 또는
9 REQUESTED_KEY_SYSTEM_CONFIG_UNAVAILABLE */);
10 }
11
12 const keySystem = /* 매핑 적용된 keySystem */;
13 this.currentDrmInfo_ = this.createDrmInfoByInfos_(
14 keySystem, drmInfosByKeySystem.get(keySystem));
15 if (!this.currentDrmInfo_.licenseServerUri) {
16 throw new shaka.util.Error(/* NO_LICENSE_SERVER_GIVEN */);
17 }
18
19 // 실제 CDM(MediaKeys) 생성
20 const mediaKeys = await mediaKeySystemAccess.createMediaKeys();
21 this.mediaKeys_ = mediaKeys;
22
23 // HDCP 정책 확인 (설정된 경우)
24 if (this.config_.minHdcpVersion != '' && 'getStatusForPolicy' in this.mediaKeys_) {
25 const status = await this.mediaKeys_.getStatusForPolicy(
26 {minHdcpVersion: this.config_.minHdcpVersion});
27 if (status != 'usable') {
28 throw new shaka.util.Error(/* MIN_HDCP_VERSION_NOT_MATCH */);
29 }
30 }
31
32 this.initialized_ = true;
33 this.expirationTimer_.tickEvery(this.config_.updateExpirationTime);
34 await this.setServerCertificate();
35}
키 시스템 선택은 getKeySystemAccessFromVariants_가 맡습니다. variant들의 drmInfos를 키 시스템별로 모은 뒤, 앞서 채워 둔 decodingInfos 중 supported이면서 keySystemAccess가 있는 것을 고릅니다. 선택에는 우선순위가 있습니다.
preferredKeySystems 설정이 있으면 그것을 먼저 시도합니다.
- 설정이 없고 라이선스 서버가 하나뿐이면, 그 키 시스템을 우선합니다.
- 그다음 라이선스 서버가 설정된 키 시스템을 먼저(
shouldHaveLicenseServer가 true인 것부터) 시도합니다. 서버가 없는 키 시스템까지 훑는 것은, "지원되는 키 시스템이 아예 없는 경우"와 "지원되지만 설정이 빠진 경우"를 구분해 진단하기 위함입니다.
인식되는 키 시스템이 하나도 없으면 NO_RECOGNIZED_KEY_SYSTEMS로 끝납니다.
키 시스템이 정해지면 createDrmInfoByInfos_로 currentDrmInfo_(앞으로 이 재생 세션이 사용할 단일 DRM 정보)를 확정합니다. 이때 licenseServerUri가 비어 있으면 곧장 NO_LICENSE_SERVER_GIVEN입니다. 앞서 강조한 "매니페스트 + 앱 설정" 결합이 여기서 검증되는 셈입니다.
그 뒤 createMediaKeys()로 실제 CDM 객체를 만들고, HDCP 정책을 확인하고, 서버 인증서를 미리 설정합니다.
TIP
이 메서드에서도 시리즈 내내 반복된 패턴이 보입니다. this.mediaKeys_와 this.initialized_는 모든 준비가 끝난 뒤에야 세팅됩니다. 생성 도중 실패하면 currentDrmInfo_를 null로 되돌리고 예외를 던지므로, "절반만 준비된 엔진"이 외부에 노출되는 일이 없습니다.
attach — video에 MediaKeys 연결
CDM은 만들어졌지만, 아직 <video> 엘리먼트와 이어지지 않았습니다. 그 연결을 attach가 담당합니다.
1// drm_engine.js — attach (발췌)
2async attach(video) {
3 if (!this.mediaKeys_) {
4 // 매니페스트는 평문이라 했는데 실제로 암호화면 경고하기 위해 감시
5 this.eventManager_.listenOnce(video, 'encrypted', (event) => {
6 this.onError_(new shaka.util.Error(/* ENCRYPTED_CONTENT_WITHOUT_DRM_INFO */));
7 });
8 return;
9 }
10
11 this.video_ = video;
12
13 // 매니페스트에서 온 initData가 있는지 확인
14 this.manifestInitData_ = this.currentDrmInfo_.initData.find(
15 (o) => o.initData.length > 0) || null;
16
17 const keySystem = this.currentDrmInfo_.keySystem;
18 const needWaitForEncryptedEvent = device.needWaitForEncryptedEvent(keySystem);
19
20 // FairPlay Modern EME는 'encrypted' 이벤트를 기다려야 한다
21 if (!needWaitForEncryptedEvent &&
22 (this.manifestInitData_ ||
23 this.currentDrmInfo_.keySystem !== 'com.apple.fps' ||
24 this.storedPersistentSessions_.size)) {
25 await this.attachMediaKeys_(); // 내부에서 video.setMediaKeys
26 }
27
28 this.createOrLoad();
29}
두 갈래가 있습니다. 평문이라면(mediaKeys_가 없으면) MediaKeys를 붙일 게 없으니, 대신 encrypted 이벤트를 한 번 감시합니다. 매니페스트가 평문이라 판단했는데 실제 스트림이 암호화되어 있으면 이 이벤트가 발생하고, Shaka는 ENCRYPTED_CONTENT_WITHOUT_DRM_INFO로 그 모순을 알립니다.
암호화 콘텐츠라면 attachMediaKeys_로 video.setMediaKeys를 호출해 CDM을 엘리먼트에 묶습니다. 다만 애플 FairPlay Modern EME(com.apple.fps)는 init data가 encrypted 이벤트로만 도착하므로, 매니페스트에 init data가 미리 있거나 FairPlay가 아니거나 오프라인 세션이 있을 때만 미리 attach하고, 그 외에는 이벤트를 기다립니다. 마지막으로 createOrLoad()를 호출해 세션 개설로 넘어갑니다.
createOrLoad → createSession — 세션과 라이선스 교환
이제 실제 키를 받아 올 차례입니다. createOrLoad는 지속 세션을 복원하거나, 매니페스트에서 온 init data마다 새 세션을 만듭니다.
1// drm_engine.js — createOrLoad (발췌)
2async createOrLoad() {
3 // 지속 세션이 있으면 먼저 복원하고, 필요한 키가 다 있으면 새 라이선스는 생략
4 if (this.storedPersistentSessions_.size) {
5 this.storedPersistentSessions_.forEach((metadata, sessionId) => {
6 this.loadOfflineSession_(sessionId, metadata);
7 });
8 await this.allSessionsLoaded_.promise;
9 if (keyIds.size > 0 && this.areAllKeysUsable_()) {
10 return this.allSessionsLoaded_.promise;
11 }
12 }
13
14 // 매니페스트에서 온 initData(PSSH 등)마다 세션 생성
15 const initDatas = (this.currentDrmInfo_ ? this.currentDrmInfo_.initData : []) || [];
16 for (const initDataOverride of initDatas) {
17 this.newInitData(initDataOverride.initDataType, initDataOverride.initData);
18 }
19 return this.allSessionsLoaded_.promise;
20}
newInitData를 거쳐 도달하는 createSession이 EME 세션을 실제로 여는 곳입니다.
1// drm_engine.js — createSession (발췌)
2createSession(initDataType, initData, sessionType, isRenewal = false) {
3 const session = this.mediaKeys_.createSession(sessionType);
4
5 // 두 이벤트가 라이선스 교환의 양 끝이다
6 this.eventManager_.listen(session, 'message',
7 (event) => this.onSessionMessage_(event)); // 라이선스 요청 메시지
8 this.eventManager_.listen(session, 'keystatuseschange',
9 (event) => this.onKeyStatusesChange_(event)); // 키 상태 변화
10
11 this.activeSessions_.set(session, /* metadata */);
12
13 // CDM에게 "이 init data로 라이선스 요청 메시지를 만들어 달라"
14 session.generateRequest(initDataType, initData).catch(/* ... */);
15}
generateRequest가 성공하면 CDM이 message 이벤트를 쏘고, 그것이 onSessionMessage_를 거쳐 sendLicenseRequest_로 이어집니다.
1// drm_engine.js — sendLicenseRequest_ (발췌)
2async sendLicenseRequest_(event) {
3 const session = event.target;
4 const url = this.currentDrmInfo_.licenseServerUri;
5
6 const requestType = shaka.net.NetworkingEngine.RequestType.LICENSE;
7 const request = shaka.net.NetworkingEngine.makeRequest(
8 [url], this.config_.retryParameters);
9 request.body = event.message; // CDM이 만든 라이선스 요청 본문
10 request.method = 'POST';
11 request.drmInfo = this.currentDrmInfo_;
12 // advanced.headers 추가, PlayReady 언패킹, ClearKey 보정 ...
13
14 const response = await this.makeNetworkRequest_(request, requestType);
15 // 받은 라이선스를 세션에 적용 → 키가 usable해지고 복호화 시작
16 await session.update(response.data);
17}
여기서 중요한 설계 하나. 라이선스 요청은 RequestType.LICENSE로 NetworkingEngine을 거칩니다. 즉 앱이 등록한 요청·응답 필터가 그대로 적용됩니다. 라이선스 서버에 인증 토큰 헤더를 끼우거나, 서버가 감싸 보낸 응답을 풀어내는 작업을 전부 이 지점에서 가로채 처리할 수 있습니다. DRM이 Shaka의 네트워크 계층과 따로 놀지 않고 한 몸으로 묶여 있는 것이죠.
마지막 session.update(response.data)가 라이선스를 세션에 적용하는 순간이며, 이때부터 키가 usable 상태가 되어 세그먼트 복호화가 가능해집니다.
재생 중에도 멈추지 않는다
DRM은 재생을 시작했다고 끝나는 일이 아닙니다. 라이선스는 만료되고, 라이브 콘텐츠는 키를 주기적으로 교체(키 로테이션)합니다. DrmEngine은 이를 위해 몇 가지 상시 동작을 유지합니다.
키 로테이션 — newInitData. 재생 도중 새로운 init data를 만나면 그에 대한 새 세션을 엽니다. 매니페스트가 갱신되며 새 키 정보가 오는 경우(PreloadManager 편에서 본 manifestPlayerInterface.newDrmInfo 콜백이 이 길로 흘려보냅니다), 혹은 parseInbandPssh로 세그먼트 안의 PSSH를 직접 추출하는 경우 모두 여기로 모입니다.
만료 폴링 — pollExpiration_. EME는 라이선스 만료를 이벤트로 알려 주지 않습니다. 그래서 DrmEngine은 타이머로 session.expiration을 주기적으로 들여다보고, 값이 바뀌면 onExpirationUpdated로 통지합니다. renewalIntervalSec가 지나면 triggerRenewal_로 라이선스를 미리 갱신합니다.
키 상태 추적 — onKeyStatusesChange_. 키가 usable, expired, output-restricted 등으로 바뀌면 이 핸들러가 받아, 재생 가능한 트랙을 거르는 데 반영합니다.
라이브에서 키가 끊임없이 갱신되는 상황을, "미리 모든 키 시스템을 세팅하고 새 키가 오면 세션을 추가한다"는 같은 원리로 대응하는 것입니다.
정리
DrmEngine은 매니페스트가 넘긴 variant.drmInfos를 입력으로, 그것에 앱 설정을 결합해 실제 복호화를 준비하는 엔진입니다. 단계별로 정리하면 다음과 같습니다.
| 단계 | 메서드 | 입력 (manifest / config) | 산출물 |
|---|
| 정규화 | init_ | variant.drmInfos + config.servers | 완성된 drmInfo 목록 |
| 협상 | queryMediaKeys_ | decodingInfos | mediaKeys_, currentDrmInfo_ |
| 연결 | attach | <video> element | setMediaKeys |
| 세션 | createSession | initData (PSSH) | MediaKeySession |
| 라이선스 | sendLicenseRequest_ | licenseServerUri | session.update |
핵심을 다시 모으면 다음과 같습니다.
- 매니페스트를 소비한다: 출발점은 언제나
getVariantDrmInfos_가 꺼내는 variant.drmInfos입니다. DASH의 ContentProtection과 HLS의 EXT-X-KEY가 채운 값이 그대로 DRM 처리의 입력이 됩니다.
- 매니페스트 + 앱 설정: 콘텐츠가 주는
keySystem·initData에, 앱이 정한 licenseServerUri·robustness가 더해져야 currentDrmInfo_가 완성됩니다.
- 현대 EME 경로:
mediaCapabilities.decodingInfo가 채운 keySystemAccess를 재사용해, 코덱 지원성과 키 시스템 협상을 한 번에 처리합니다.
- 네트워크 계층과 한 몸: 라이선스 요청이
NetworkingEngine을 거치므로, 앱의 요청·응답 필터가 그대로 적용됩니다.
- 라이브 대비와 직렬화: 평문이어도 라이브면 키 시스템을 미리 세팅하고, 준비가 끝난 뒤에야
mediaKeys_를 노출하며, 재생 중에는 키 로테이션·만료·갱신을 상시 처리합니다.
한 줄로 요약하면 다음과 같습니다.
"매니페스트가 '무엇이 어떤 키로 잠겼는지'를 알려주면, DrmEngine은 앱 설정으로 자물쇠 주소를 채우고 CDM과 협상해 세션을 열고 라이선스를 받아, 잠긴 세그먼트를 풀 준비를 마친다."
이제 매니페스트를 만들고(파서), 보호를 준비하는(DrmEngine) 단계까지 모두 따라왔습니다. 다음 편부터는 드디어 이 모든 준비를 실제 재생으로 바꾸는 구동 단계로 넘어가겠습니다. 파싱된 variant 중 하나를 골라 세그먼트를 내려받아 MediaSource로 흘려보내는 StreamingEngine, 그리고 대역폭을 추정해 화질을 갈아 끼우는 AbrManager입니다.