본문으로 건너뛰기
Previous
Next
DRM 완벽 가이드 | Widevine·FairPlay·PlayReady·AES-128 총정리

DRM 완벽 가이드 | Widevine·FairPlay·PlayReady·AES-128 총정리

DRM 완벽 가이드 | Widevine·FairPlay·PlayReady·AES-128 총정리

이 글의 핵심

DRM(Digital Rights Management) 기술의 모든 것. Widevine, FairPlay, PlayReady, AES-128 암호화, EME, CENC 표준부터 Netflix, YouTube, Spotify의 DRM 구현까지 실전 예제로 완벽 이해.

들어가며: DRM이 필요한 이유

Netflix, Disney+, Spotify 같은 스트리밍 서비스는 어떻게 콘텐츠를 보호할까요? 답은 DRM(Digital Rights Management)입니다. DRM은 디지털 콘텐츠의 불법 복제를 방지하고, 승인된 사용자만 재생할 수 있게 하는 기술입니다. 이 글에서 다룰 내용:

  • 주요 DRM 시스템 (Widevine, FairPlay, PlayReady)
  • DRM 동작 원리와 아키텍처
  • EME (Encrypted Media Extensions)
  • CENC (Common Encryption) 표준
  • 실전 구현 예제

실전 경험에서 배운 교훈

이 기술을 실무 프로젝트에 처음 도입했을 때, 공식 문서만으로는 알 수 없었던 많은 함정들이 있었습니다. 특히 프로덕션 환경에서 발생하는 엣지 케이스들은 로컬 개발 환경에서는 재현조차 되지 않았죠.

가장 어려웠던 점은 성능 최적화였습니다. 처음엔 “동작만 하면 되겠지”라고 생각했지만, 실제 사용자 트래픽이 몰리면서 병목 지점들이 하나씩 드러났습니다. 특히 데이터베이스 쿼리 최적화, 캐싱 전략, 에러 핸들링 구조 등은 여러 번의 장애를 겪으면서 개선해 나갔습니다.

이 글에서는 그런 시행착오를 통해 얻은 실전 노하우와, “이렇게 하면 안 된다”는 교훈들을 함께 정리했습니다. 특히 트러블슈팅 섹션은 실제 장애 대응 경험을 바탕으로 작성했으니, 비슷한 문제를 마주했을 때 참고하시면 도움이 될 것입니다.

1. DRM 기본 개념

DRM이란?

DRM(Digital Rights Management)은 디지털 콘텐츠의 암호화, 라이선스 관리, 접근 제어를 통해 저작권을 보호하는 기술입니다.

DRM 구성 요소

flowchart TB
    subgraph Content[콘텐츠 제공자]
        Video[원본 비디오]
        Encoder[인코더/패키저]
        KeyServer[키 서버]
    end
    
    subgraph CDN[CDN]
        Encrypted["암호화된\n콘텐츠"]
    end
    
    subgraph Client[클라이언트]
        Player[비디오 플레이어]
        CDM["CDM\nContent Decryption Module"]
        TEE["TEE/Hardware\n보안 영역"]
    end
    
    subgraph License[라이선스 서버]
        Auth[인증]
        LicenseDB[라이선스 DB]
    end
    
    Video --> Encoder
    Encoder -->|암호화| Encrypted
    Encoder -->|키 저장| KeyServer
    
    Encrypted --> Player
    Player -->|라이선스 요청| Auth
    Auth -->|검증| LicenseDB
    LicenseDB -->|키 전달| CDM
    CDM -->|복호화| TEE
    TEE -->|재생| Player

DRM 동작 흐름

sequenceDiagram
    participant User as 사용자
    participant Player as 플레이어
    participant CDN
    participant License as 라이선스 서버
    participant CDM as CDM (보안 모듈)
    
    User->>Player: 비디오 재생 요청
    Player->>CDN: 암호화된 콘텐츠 요청
    CDN->>Player: 암호화된 세그먼트 전달
    
    Player->>License: 라이선스 요청\n(사용자 인증 토큰)
    License->>License: 권한 확인\n(구독, 지역, 기기)
    License->>Player: 라이선스 + 복호화 키
    
    Player->>CDM: 복호화 요청
    CDM->>CDM: 하드웨어 보안 영역에서\n복호화
    CDM->>Player: 복호화된 프레임
    Player->>User: 비디오 재생

2. 주요 DRM 시스템

DRM 생태계

flowchart TB
    subgraph Google
        Widevine[Widevine]
        Chrome[Chrome]
        Android[Android]
    end
    
    subgraph Apple
        FairPlay[FairPlay]
        Safari[Safari]
        iOS[iOS/tvOS]
    end
    
    subgraph Microsoft
        PlayReady[PlayReady]
        Edge[Edge]
        Xbox[Xbox]
    end
    
    subgraph Open
        ClearKey[ClearKey]
        AES128[HLS AES-128]
    end
    
    Widevine --> Chrome
    Widevine --> Android
    
    FairPlay --> Safari
    FairPlay --> iOS
    
    PlayReady --> Edge
    PlayReady --> Xbox

플랫폼별 DRM 지원

플랫폼WidevineFairPlayPlayReadyClearKey
Chrome✅ L1/L3
Safari
Edge
Firefox✅ L3
Android✅ L1/L3
iOS
Xbox

3. Widevine (Google)

Widevine이란?

Widevine은 Google이 개발한 DRM으로, Android와 Chrome에서 널리 사용됩니다. Netflix, YouTube Premium, Disney+가 사용합니다.

Widevine 보안 레벨

flowchart TB
    subgraph L1["Widevine L1\nHardware-backed"]
        L1_Desc["✅ TEE/Secure Processor\n✅ 4K/HDR 지원\n✅ 최고 보안"]
    end
    
    subgraph L2["Widevine L2\nSoftware-backed"]
        L2_Desc["⚠️ 소프트웨어 보안\n⚠️ 1080p 제한\n⚠️ 중간 보안"]
    end
    
    subgraph L3["Widevine L3\nSoftware only"]
        L3_Desc["❌ 기본 보안\n❌ 480p/SD 제한\n❌ 낮은 보안"]
    end
    
    L1 --> Best[최고 화질]
    L2 --> Medium[중간 화질]
    L3 --> Low[낮은 화질]

Widevine 아키텍처

flowchart TB
    subgraph App[애플리케이션]
        Player[Video Player]
        EME[EME API]
    end
    
    subgraph Browser[브라우저]
        CDM[Widevine CDM]
    end
    
    subgraph Hardware["하드웨어 (L1)"]
        TEE["Trusted Execution\nEnvironment"]
        SecureVideo[Secure Video Path]
    end
    
    subgraph Backend[백엔드]
        License["Widevine\nLicense Server"]
        Content["암호화된\n콘텐츠"]
    end
    
    Player --> EME
    EME --> CDM
    CDM --> TEE
    
    Player --> Content
    CDM --> License
    
    TEE --> SecureVideo
    SecureVideo --> Display[디스플레이]

Widevine 내부 동작 원리

Widevine은 어떻게 콘텐츠를 보호하나요?

Widevine은 3계층 보안 모델을 사용합니다:

  1. 암호화된 콘텐츠: AES-128/AES-256으로 암호화
  2. CDM (Content Decryption Module): 브라우저/OS에 내장된 보안 모듈
  3. TEE (Trusted Execution Environment): 하드웨어 보안 영역 (L1만 해당)

보안 레벨별 차이점:

레벨복호화 위치비디오 파이프라인최대 화질사용 예시
L1Hardware TEESecure Video Path4K/HDR프리미엄 구독 (Netflix 4K)
L2Software (메모리)일반 파이프라인1080p일반 구독
L3Software일반 파이프라인480p/SD무료 티어, 구형 기기

왜 L1이 4K를 지원하나요?

  • L1은 복호화된 비디오가 CPU/메모리에 노출되지 않음
  • 하드웨어에서 직접 디스플레이로 전송 (Secure Video Path)
  • 화면 캡처, 메모리 덤프로도 추출 불가능
  • 콘텐츠 제공자가 고화질 제공에 동의

L3는 왜 480p만?

  • 복호화된 비디오가 메모리에 있어 캡처 가능
  • 콘텐츠 제공자가 화질을 제한하여 리스크 감소

Widevine L1/L2/L3 보안 레벨 내부 구조 (심화)

앞의 표는 사업·제품 관점의 결과(화질·파이프라인)에 가깝습니다. 여기서는 엔지니어링 관점에서 각 레벨이 실제로 어떤 신뢰 경계(trust boundary)를 갖는지 정리합니다. 세부 구현은 기기·SoC·OEM·브라우저 빌드마다 달라지며, Google·제조사·라이선스 어댑터 간 계약에 따라 비공개인 부분이 많습니다. 다만 공개 문서와 업계 관행을 바탕으로 한 합리적인 내부 모델은 다음과 같습니다.

L1: 하드웨어에 고정된 신뢰 루트와 OEMCrypto

L1복호화·디코딩·표시 경로가 가능한 한 애플리케이션 프로세스와 분리되는 구성을 뜻합니다. Android 쪽 용어로는 종종 Widevine L1 = 하드웨어 보안 + OEMCrypto가 TEE/보안 프로세서 계열에서 동작하는 조합으로 이해됩니다.

  • OEMCrypto(또는 동급 TA): 기기 제조 단계에서 주입된 기기 고유 비밀인증서 체인을 바탕으로, 라이선스에 포함된 콘텐츠 키를 파생·보관·사용합니다. 평문 콘텐츠 키가 일반 앱 메모리에 노출되지 않도록 설계하는 것이 목표입니다.
  • TEE/보안 월드: 일반 OS와 다른 실행 환경에서 암호 연산을 수행합니다. 루팅·프리다 수준의 공격에도 키 재사용 비용을 크게 올리는 역할을 합니다(불가능하다는 뜻은 아님).
  • Secure Video Path / 하드웨어 컴포지터 경로: 복호화된 압축 스트림 또는 디코딩 결과가 GPU·디스플레이 엔진으로 이어지는 경로가 OS가 임의로 읽기 어려운 형태로 유지됩니다. 이와 결합해 HDCP·디지털 출력 제한 정책이 실효성을 갖습니다.
  • 프로비저닝(Provisioning): 공장 또는 초기 부팅 시 기기에 키박스·인증서를 심고, 이후 CDM이 라이선스 서버와 “이 기기는 정품 하드웨어”라는 주장을 암호학적으로 뒷받침합니다.

실무에서 L1이 핵심인 이유는, 스튜디오·라이선서가 UHD·HDR·짧은 윈도우 같은 정책을 허용할 최소 보안 바닥으로 L1을 요구하기 때문입니다.

L2: 소프트웨어 중심이지만 “완전 개방 메모리”는 아닌 중간 단계

L2는 구현체에 따라 이름이 혼동되기 쉽습니다. 일반적인 설명은 “일부 연산은 보호된 환경, 일부는 일반 디코더”처럼 L1과 L3 사이에 놓인 형태로 요약됩니다.

  • 복호화는 여전히 제한된 보안 실행 환경에서 수행되지만, 디코딩 이후 프레임 버퍼가 완전한 SVP로 이어지지 않을 수 있습니다.
  • 그 결과 화면 캡처·메모리 스캐폴딩에 대한 저항은 L1보다 낮고, 콘텐츠 규칙(해상도·동시 스트림)이 L1보다 보수적으로 잡히는 경우가 많습니다.
  • 일부 단말·브라우저 조합에서는 L2가 아예 노출되지 않고 L1/L3만 표시되기도 하여, 서비스는 실제 MediaKeySystemAccess 협상 결과로만 신뢰해야 합니다.

L3: 소프트웨어 CDM과 가장 넓은 공격 표면

L3Widevine CDM이 사용자 공간에서 동작하고, 암호 연산이 CPU·메모리 보호에 더 의존하는 구성입니다.

  • 라이선스는 동일하게 암호화되어 전달되지만, 구현·디버깅·메모리 덤프에 대한 방어가 L1 대비 약합니다.
  • 가상화·에뮬레이터·구형 PC 등에서 흔히 L3로 떨어지며, 스트리밍 서비스는 SD·스테레오·낮은 비트레이트로 정책을 제한합니다.
  • 루팅·부트로더 언락이 감지되면 L1이 L3로 다운그레이드되어 화질이 바뀌는 것은, 바로 이 신뢰 모델이 깨졌다고 판단하기 때문입니다.

엔지니어가 꼭 기억할 것

  • 보안 레벨은 “기기 속성”이며, 프런트엔드 JS만으로 100% 신뢰할 수 없습니다. 서버·분석 파이프라인에서 기기 보고값과 재생 실패 로그를 함께 봐야 합니다.
  • robustness(예: HW_SECURE_ALL, SW_SECURE_CRYPTO)는 “원하는 레벨”이 아니라 “협상 가능한 조합”입니다. 높은 robustness를 요청하면 NotSupportedError가 나는 것이 정상입니다.
  • L1/L2/L3와 해상도 정책의 매핑스튜디오 계약·플랫폼 가이드에 따르며, 기술적으로 L1이라도 비트레이트·HDR 메타는 별도 제한될 수 있습니다.

Widevine 구현 완벽 가이드

1단계: MediaKeySystemAccess 요청

// HTML5 Video + EME (Encrypted Media Extensions)
const video = document.querySelector('video');

// Widevine 설정
const config = [{
  // initDataTypes: 초기화 데이터 형식
  // 'cenc' = Common Encryption (ISO BMFF)
  // 'webm' = WebM 컨테이너용
  initDataTypes: ['cenc'],
  
  // 비디오 능력 설정
  videoCapabilities: [{
    // contentType: MIME 타입 + 코덱
    // avc1.42E01E = H.264 Baseline Profile Level 3.0
    contentType: 'video/mp4; codecs="avc1.42E01E"',
    
    // robustness: 보안 수준
    // SW_SECURE_CRYPTO = L3 (소프트웨어)
    // SW_SECURE_DECODE = L2 (소프트웨어 디코딩)
    // HW_SECURE_CRYPTO = L1 (하드웨어 암호화)
    // HW_SECURE_DECODE = L1 (하드웨어 디코딩)
    // HW_SECURE_ALL = L1 (모든 파이프라인 하드웨어)
    robustness: 'SW_SECURE_CRYPTO'  // L3
    // robustness: 'HW_SECURE_ALL'   // L1 (4K용)
  }],
  
  // 오디오 능력 설정
  audioCapabilities: [{
    // mp4a.40.2 = AAC-LC (Low Complexity)
    contentType: 'audio/mp4; codecs="mp4a.40.2"',
    robustness: 'SW_SECURE_CRYPTO'
  }]
}];

// Widevine 지원 확인 및 초기화
navigator.requestMediaKeySystemAccess('com.widevine.alpha', config)
  .then(keySystemAccess => {
    // keySystemAccess: Widevine 접근 권한 객체
    console.log('✅ Widevine supported');
    console.log('Configuration:', keySystemAccess.getConfiguration());
    
    // MediaKeys 생성 (CDM 인스턴스)
    return keySystemAccess.createMediaKeys();
  })
  .then(mediaKeys => {
    // MediaKeys를 video 요소에 연결
    return video.setMediaKeys(mediaKeys);
  })
  .then(() => {
    console.log('✅ Widevine initialized');
    
    // 이제 암호화된 비디오 로드 가능
    video.src = 'https://cdn.example.com/encrypted-video.mp4';
  })
  .catch(err => {
    console.error('❌ DRM initialization failed:', err);
    
    // 에러 타입별 처리
    if (err.name === 'NotSupportedError') {
      console.error('이 브라우저/기기는 Widevine을 지원하지 않습니다');
    } else if (err.name === 'InvalidStateError') {
      console.error('DRM 설정이 올바르지 않습니다');
    }
  });

각 단계의 내부 동작:

1. navigator.requestMediaKeySystemAccess()
   → 브라우저에 "Widevine 지원하나요?" 질문
   → config와 기기 능력 비교
   → L1 요청했는데 기기가 L3만 지원하면 거부

2. keySystemAccess.createMediaKeys()
   → CDM (Content Decryption Module) 초기화
   → 메모리에 보안 컨텍스트 생성
   → 라이선스 요청 준비

3. video.setMediaKeys(mediaKeys)
   → video 요소와 CDM 연결
   → encrypted 이벤트 리스너 활성화
   → 이제 암호화된 콘텐츠 재생 가능

2단계: 라이선스 요청 처리

// 'encrypted' 이벤트: 암호화된 세그먼트를 만났을 때 발생
video.addEventListener('encrypted', (event) => {
  console.log('🔒 Encrypted content detected');
  
  // event.initDataType: 'cenc', 'webm', 'keyids' 등
  // event.initData: PSSH (Protection System Specific Header) 박스
  //   - 콘텐츠 ID, 키 ID, 라이선스 서버 URL 등 포함
  const initData = event.initData;
  const initDataType = event.initDataType;
  
  // MediaKeySession 생성
  // Session: 라이선스 요청/수신을 관리하는 객체
  const session = video.mediaKeys.createSession();
  
  // 'message' 이벤트: 라이선스 요청이 필요할 때 발생
  session.addEventListener('message', async (event) => {
    // event.message: 라이선스 요청 페이로드
    // - 기기 ID, 키 요청, 보안 정보 포함
    // - Widevine이 자동 생성
    const licenseRequest = event.message;
    
    console.log('📤 License request generated');
    console.log('Request size:', licenseRequest.byteLength, 'bytes');
    
    try {
      // 라이선스 서버에 요청
      const response = await fetch('https://license.example.com/widevine', {
        method: 'POST',
        headers: {
          'Content-Type': 'application/octet-stream',
          'Authorization': `Bearer ${userToken}`  // 사용자 인증
        },
        body: licenseRequest
      });
      
      if (!response.ok) {
        throw new Error(`License server error: ${response.status}`);
      }
      
      // 라이선스 받기 (복호화 키 포함)
      const license = await response.arrayBuffer();
      
      console.log('📥 License received');
      console.log('License size:', license.byteLength, 'bytes');
      
      // CDM에 라이선스 전달
      await session.update(license);
      
      console.log('✅ License applied, video can now play');
      
    } catch (error) {
      console.error('❌ License request failed:', error);
      
      // 에러 처리
      session.close();
    }
  });
  
  // Session 초기화 (라이선스 요청 트리거)
  session.generateRequest(initDataType, initData)
    .catch(err => {
      console.error('❌ Failed to generate license request:', err);
    });
});

// 키 상태 변경 모니터링
video.addEventListener('waitingforkey', () => {
  console.log('⏳ Waiting for decryption key...');
});

// 재생 가능 상태
video.addEventListener('canplay', () => {
  console.log('▶️ Video ready to play');
});

라이선스 요청/응답 구조:

라이선스 요청 (licenseRequest):
┌─────────────────────────────────┐
│ Widevine CDM이 생성              │
├─────────────────────────────────┤
│ - 기기 ID (Device ID)           │
│ - 키 요청 (Key Request)         │
│ - PSSH 데이터                   │
│ - 보안 레벨 (L1/L2/L3)          │
│ - 클라이언트 능력 정보          │
└─────────────────────────────────┘
         ↓ POST
라이선스 서버
         ↓ 검증
  - 사용자 권한 확인
  - 구독 상태 확인
  - 지역 제한 확인
  - 기기 제한 확인

라이선스 응답 (license):
┌─────────────────────────────────┐
│ - 복호화 키 (암호화됨)          │
│ - 키 유효기간                   │
│ - 재생 규칙 (횟수 제한 등)     │
│ - 출력 제한 (HDCP 필요 여부)   │
└─────────────────────────────────┘

PSSH (Protection System Specific Header)란?

PSSH는 암호화된 콘텐츠에 포함된 메타데이터입니다:

MP4 파일 구조:
├─ moov (메타데이터)
│  ├─ pssh (Widevine)
│  │  ├─ system_id: edef8ba9-79d6-4ace-a3c8-27dcd51d21ed
│  │  ├─ key_ids: [abc123, def456]
│  │  └─ data: 라이선스 서버 URL, 콘텐츠 ID 등
│  ├─ pssh (PlayReady)
│  └─ pssh (FairPlay)
├─ moof (암호화된 비디오 프레임)
└─ mdat (암호화된 데이터)

initData (event.initData):
- PSSH 박스의 바이너리 데이터
- CDM이 이를 읽어서 라이선스 요청 생성

3단계: Widevine 레벨 확인 및 폴백 전략

왜 레벨 확인이 필요한가요?

사용자 기기마다 지원하는 Widevine 레벨이 다릅니다:

  • 최신 Android 폰: L1 지원 → 4K 제공
  • 구형 Android/PC: L3만 지원 → SD로 제한
  • 루팅된 기기: L3로 다운그레이드됨 → 보안 위험

서비스는 최고 화질부터 시도하고, 실패하면 낮은 화질로 폴백(fallback)해야 합니다.

// 브라우저/기기의 Widevine 레벨 확인
async function checkWidevineLevel() {
  const configs = [
    {
      label: 'L1 (Hardware)',
      maxResolution: '4K',
      config: [{
        initDataTypes: ['cenc'],
        videoCapabilities: [{
          contentType: 'video/mp4; codecs="avc1.42E01E"',
          // HW_SECURE_ALL: 모든 파이프라인이 하드웨어 보안
          // - 복호화: TEE에서 실행
          // - 디코딩: 보안 비디오 디코더
          // - 출력: Secure Video Path
          robustness: 'HW_SECURE_ALL'
        }]
      }]
    },
    {
      label: 'L2 (Software Decode)',
      maxResolution: '1080p',
      config: [{
        initDataTypes: ['cenc'],
        videoCapabilities: [{
          contentType: 'video/mp4; codecs="avc1.42E01E"',
          // SW_SECURE_DECODE: 소프트웨어 디코딩
          // 복호화는 보안 영역, 디코딩은 일반 메모리
          robustness: 'SW_SECURE_DECODE'
        }]
      }]
    },
    {
      label: 'L3 (Software)',
      maxResolution: '480p',
      config: [{
        initDataTypes: ['cenc'],
        videoCapabilities: [{
          contentType: 'video/mp4; codecs="avc1.42E01E"',
          // SW_SECURE_CRYPTO: 모두 소프트웨어
          // 복호화/디코딩 모두 일반 메모리에서 실행
          robustness: 'SW_SECURE_CRYPTO'
        }]
      }]
    }
  ];
  
  // 높은 레벨부터 시도
  for (const {label, maxResolution, config} of configs) {
    try {
      const access = await navigator.requestMediaKeySystemAccess(
        'com.widevine.alpha', 
        config
      );
      
      console.log(`✅ ${label} supported (최대 ${maxResolution})`);
      
      // 실제 설정 확인
      const actualConfig = access.getConfiguration();
      console.log('Actual config:', actualConfig);
      
      return { 
        level: label, 
        maxResolution,
        access 
      };
      
    } catch (e) {
      console.log(`❌ ${label} not supported`);
      // 다음 레벨 시도
    }
  }
  
  throw new Error('Widevine not supported on this device');
}

// 사용 예시
async function initDRM() {
  try {
    const result = await checkWidevineLevel();
    console.log(`기기 지원: ${result.level} (${result.maxResolution})`);
    
    // 서버에 레벨 정보 전달
    const videoQuality = getQualityForLevel(result.level);
    const manifestURL = `https://cdn.example.com/manifest_${videoQuality}.mpd`;
    
    console.log(`로딩: ${manifestURL}`);
    
  } catch (error) {
    console.error('DRM 초기화 실패:', error);
    // 폴백: DRM 없는 저화질 제공 or 에러 메시지
  }
}

function getQualityForLevel(level) {
  const qualityMap = {
    'L1 (Hardware)': '4k',
    'L2 (Software Decode)': '1080p',
    'L3 (Software)': '480p'
  };
  return qualityMap[level] || '480p';
}
checkWidevineLevel();

4. FairPlay (Apple)

FairPlay란?

FairPlay는 Apple이 개발한 DRM으로, iOS, macOS, tvOS, Safari에서 사용됩니다. HLS (HTTP Live Streaming)와 긴밀하게 통합되어 있습니다.

Widevine vs FairPlay 핵심 차이:

항목WidevineFairPlay
프로토콜DASH (MPEG-DASH)HLS (HTTP Live Streaming)
컨테이너MP4 (fMP4)MPEG-TS (Transport Stream)
암호화 표준CENC (Common Encryption)Apple 독자 방식 (SAMPLE-AES)
인증서불필요FPS Certificate 필수
URL 스킴https://skd:// (Streaming Key Delivery)
APIEME (웹 표준)AVFoundation (Apple 독자)
라이선스 요청Challenge/ResponseSPC/CKC
플랫폼크로스 플랫폼Apple 전용

FairPlay의 특수한 점:

  1. FPS Certificate (인증서) 필수

    • Apple에 사업자 등록 후 발급 (Apple Developer Program 필요)
    • 인증서 없이는 테스트조차 불가능
    • 유효기간 1년 (갱신 필요)
  2. skd:// URL 스킴

    • HLS m3u8에서 skd:// 로 시작하는 특수 URL 사용
    • 예: skd://fps.example.com?contentId=movie123
    • AVPlayer가 이를 감지하면 FairPlay 활성화
  3. SPC/CKC 구조

    • SPC (Server Playback Context): 라이선스 요청
    • CKC (Content Key Context): 라이선스 응답
    • Widevine의 Challenge/Response와 유사하지만 형식이 다름

FairPlay 동작 흐름

sequenceDiagram
    participant Player as AVPlayer
    participant App as 앱
    participant KSM as Key Server Module
    participant License as FairPlay License Server
    
    Player->>App: 암호화된 콘텐츠 감지
    App->>KSM: SPC 요청\n(Server Playback Context)
    KSM->>App: SPC 생성
    
    App->>License: SPC + 인증 토큰
    License->>License: 권한 검증
    License->>App: CKC\n(Content Key Context)
    
    App->>KSM: CKC 전달
    KSM->>Player: 복호화 키
    Player->>Player: 콘텐츠 재생

FairPlay 구현 완벽 가이드 (iOS/Swift)

FairPlay 구현 전 준비사항

1. Apple Developer Program 가입

2. FPS (FairPlay Streaming) 인증서 발급

# 1. Key 생성 (privatekey.pem)
openssl genrsa -aes256 -out privatekey.pem 1024

# 2. CSR (Certificate Signing Request) 생성
openssl req -new -sha1 -key privatekey.pem -out certreq.csr \
  -subj "/CN=FPS Certificate/O=YourCompany/C=KR"

# 3. Apple Developer Portal에서:
#    - Certificates → FairPlay Streaming Certificate
#    - certreq.csr 업로드
#    - fairplay.cer 다운로드

# 4. PEM 형식으로 변환
openssl x509 -inform der -in fairplay.cer -out fairplay.pem

# 결과물:
# - privatekey.pem (서버에 보관, 절대 노출 금지)
# - fairplay.pem (클라이언트에 전달)

3. HLS 스트림 준비

master.m3u8:
#EXTM3U
#EXT-X-STREAM-INF:BANDWIDTH=2000000,RESOLUTION=1920x1080
1080p.m3u8

1080p.m3u8:
#EXTM3U
#EXT-X-VERSION:6
#EXT-X-TARGETDURATION:10
#EXT-X-KEY:METHOD=SAMPLE-AES,URI="skd://fps.example.com?contentId=movie123",KEYFORMAT="com.apple.streamingkeydelivery"
#EXTINF:10.0,
segment_0.ts
#EXTINF:10.0,
segment_1.ts

핵심: #EXT-X-KEY 태그가 FairPlay를 트리거합니다.

1단계: FairPlay 매니저 구현

import AVFoundation

class FairPlayManager: NSObject {
    var asset: AVURLAsset?
    var resourceLoaderDelegate: FairPlayResourceLoaderDelegate?
    
    func playVideo(url: URL, certificateURL: URL, licenseURL: URL) {
        // AVURLAsset 생성
        // - url: HLS 마스터 플레이리스트 (master.m3u8)
        asset = AVURLAsset(url: url)
        
        // Resource Loader Delegate 설정
        // 이 델리게이트가 FairPlay 라이선스 획득을 담당
        resourceLoaderDelegate = FairPlayResourceLoaderDelegate(
            certificateURL: certificateURL,
            licenseURL: licenseURL
        )
        
        // Asset의 resourceLoader에 델리게이트 연결
        // AVPlayer가 skd:// URL을 만나면 이 델리게이트 호출
        asset?.resourceLoader.setDelegate(
            resourceLoaderDelegate,
            queue: DispatchQueue.main
        )
        
        // AVPlayer로 재생
        let playerItem = AVPlayerItem(asset: asset!)
        let player = AVPlayer(playerItem: playerItem)
        player.play()
        
        // 에러 모니터링
        NotificationCenter.default.addObserver(
            forName: .AVPlayerItemFailedToPlayToEndTime,
            object: playerItem,
            queue: .main
        ) { notification in
            if let error = notification.userInfo?[AVPlayerItemFailedToPlayToEndTimeErrorKey] as? Error {
                print("❌ Playback failed:", error)
            }
        }
    }
}
#### 2단계: Resource Loader Delegate 구현

```swift
class FairPlayResourceLoaderDelegate: NSObject, AVAssetResourceLoaderDelegate {
    let certificateURL: URL
    let licenseURL: URL
    var certificate: Data?
    
    init(certificateURL: URL, licenseURL: URL) {
        self.certificateURL = certificateURL
        self.licenseURL = licenseURL
        super.init()
        
        // FPS 인증서 다운로드
        loadCertificate()
    }
    
    func loadCertificate() {
        // 인증서는 미리 다운로드해서 캐싱 권장
        // 실전에서는 Bundle에 포함하거나 Keychain에 저장
        URLSession.shared.dataTask(with: certificateURL) { [weak self] data, response, error in
            if let error = error {
                print("❌ Certificate download failed:", error)
                return
            }
            
            if let data = data {
                self?.certificate = data
                print("✅ FairPlay certificate loaded (\(data.count) bytes)")
            }
        }.resume()
    }
    
    // AVPlayer가 skd:// URL을 만나면 이 메서드 호출
    func resourceLoader(
        _ resourceLoader: AVAssetResourceLoader,
        shouldWaitForLoadingOfRequestedResource loadingRequest: AVAssetResourceLoadingRequest
    ) -> Bool {
        
        // 1. URL 검증
        guard let url = loadingRequest.request.url else {
            print("❌ No URL in request")
            return false
        }
        
        print("📥 Resource request:", url.absoluteString)
        
        // 2. skd:// 스킴 확인
        guard url.scheme == "skd" else {
            print("❌ Not a FairPlay request (scheme: \(url.scheme ?? "nil"))")
            return false
        }
        
        // 3. 인증서 확인
        guard let certificate = certificate else {
            print("❌ Certificate not loaded yet")
            loadingRequest.finishLoading(with: NSError(
                domain: "FairPlay",
                code: -2,
                userInfo: [NSLocalizedDescriptionKey: "Certificate not available"]
            ))
            return false
        }
        
        // 4. Content ID 추출
        // skd://fps.example.com?contentId=movie123 → "movie123"
        let contentId = extractContentId(from: url)
        guard let contentIdData = contentId.data(using: .utf8) else {
            print("❌ Invalid content ID")
            return false
        }
        
        print("📝 Content ID:", contentId)
        
        // 5. SPC (Server Playback Context) 생성
        do {
            // streamingContentKeyRequestData:
            // - FairPlay 라이선스 요청 페이로드 생성
            // - 기기 정보, 콘텐츠 ID, 인증서를 조합하여 암호화
            let spcData = try loadingRequest.streamingContentKeyRequestData(
                forApp: certificate,              // FPS 인증서
                contentIdentifier: contentIdData, // 콘텐츠 식별자
                options: nil                      // 추가 옵션 (보통 nil)
            )
            
            print("✅ SPC generated (\(spcData.count) bytes)")
            
            // 6. 라이선스 서버에 SPC 전송 → CKC 수신
            requestLicense(spc: spcData, contentId: contentId) { [weak self] ckcData in
                guard let self = self else { return }
                
                if let ckc = ckcData {
                    print("✅ CKC received (\(ckc.count) bytes)")
                    
                    // 7. CKC (Content Key Context)를 AVPlayer에 전달
                    loadingRequest.dataRequest?.respond(with: ckc)
                    loadingRequest.finishLoading()
                    
                    print("✅ FairPlay license applied")
                    
                } else {
                    print("❌ CKC not received")
                    loadingRequest.finishLoading(with: NSError(
                        domain: "FairPlay",
                        code: -1,
                        userInfo: [NSLocalizedDescriptionKey: "License acquisition failed"]
                    ))
                }
            }
            
            return true
            
        } catch {
            print("❌ SPC generation failed:", error)
            
            // 상세 에러 정보
            let nsError = error as NSError
            print("Error domain:", nsError.domain)
            print("Error code:", nsError.code)
            print("Error info:", nsError.userInfo)
            
            return false
        }
    }
    
    // URL에서 Content ID 추출
    func extractContentId(from url: URL) -> String {
        // skd://fps.example.com?contentId=movie123
        // → "movie123"
        
        if let queryItems = URLComponents(url: url, resolvingAgainstBaseURL: false)?.queryItems {
            if let contentId = queryItems.first(where: { $0.name == "contentId" })?.value {
                return contentId
            }
        }
        
        // 쿼리 파라미터 없으면 호스트를 Content ID로 사용
        // skd://movie123 → "movie123"
        return url.host ?? ""
    }
    
    // 3단계: 라이선스 서버와 통신
    func requestLicense(spc: Data, contentId: String, completion: @escaping (Data?) -> Void) {
        var request = URLRequest(url: licenseURL)
        request.httpMethod = "POST"
        
        // 헤더 설정
        request.setValue("application/octet-stream", forHTTPHeaderField: "Content-Type")
        
        // 실전에서는 사용자 인증 토큰 추가
        // request.setValue("Bearer \(authToken)", forHTTPHeaderField: "Authorization")
        
        // 콘텐츠 ID를 커스텀 헤더로 전달 (선택사항)
        request.setValue(contentId, forHTTPHeaderField: "X-Content-ID")
        
        // SPC (바이너리 데이터) 전송
        request.httpBody = spc
        
        print("📤 Sending SPC to license server...")
        
        let task = URLSession.shared.dataTask(with: request) { data, response, error in
            // 에러 처리
            if let error = error {
                print("❌ License request failed:", error)
                completion(nil)
                return
            }
            
            // HTTP 응답 코드 확인
            if let httpResponse = response as? HTTPURLResponse {
                print("📥 License server response: \(httpResponse.statusCode)")
                
                if httpResponse.statusCode != 200 {
                    print("❌ License server error:", httpResponse.statusCode)
                    completion(nil)
                    return
                }
            }
            
            // CKC 데이터 반환
            if let data = data {
                print("✅ CKC received (\(data.count) bytes)")
                completion(data)
            } else {
                print("❌ No data in response")
                completion(nil)
            }
        }
        
        task.resume()
    }
}

SPC/CKC 데이터 구조:

SPC (Server Playback Context) - 클라이언트 → 서버:
┌────────────────────────────────────────────┐
│ 암호화된 바이너리 데이터 (수백~수천 bytes) │
├────────────────────────────────────────────┤
│ 포함 정보 (암호화되어 있어 직접 읽기 불가):│
│ - Device ID (기기 고유 ID)                 │
│ - Content ID                               │
│ - 인증서 정보                               │
│ - 기기 능력 (하드웨어 DRM 지원 여부)       │
│ - Nonce (재사용 공격 방지)                 │
└────────────────────────────────────────────┘

서버 측 검증:
1. SPC 복호화 (Apple 제공 라이브러리 사용)
2. Content ID 추출
3. 사용자 권한 확인 (구독, 지역 제한 등)
4. 복호화 키 생성
5. CKC 생성

CKC (Content Key Context) - 서버 → 클라이언트:
┌────────────────────────────────────────────┐
│ 암호화된 바이너리 데이터                    │
├────────────────────────────────────────────┤
│ 포함 정보 (AVPlayer만 복호화 가능):        │
│ - 콘텐츠 복호화 키                          │
│ - 키 유효기간                               │
│ - 재생 규칙 (오프라인 허용, 횟수 제한 등)  │
│ - 출력 제한 (AirPlay 허용 여부)            │
└────────────────────────────────────────────┘

보안 특징:
- SPC/CKC는 모두 암호화되어 있어 중간자 공격 불가
- 기기별로 고유한 Device ID 사용
- Nonce로 재사용 공격(Replay Attack) 방지
- 인증서 기반 신뢰 체인

FairPlay HLS 매니페스트

#EXTM3U
#EXT-X-VERSION:6
#EXT-X-TARGETDURATION:10
#EXT-X-MEDIA-SEQUENCE:0
# FairPlay 키 정보
#EXT-X-KEY:METHOD=SAMPLE-AES,URI="skd://content-id-12345",KEYFORMAT="com.apple.streamingkeydelivery",KEYFORMATVERSIONS="1"
# 세그먼트
#EXTINF:10.0,
segment-0.m4s
#EXTINF:10.0,
segment-1.m4s
#EXTINF:10.0,
segment-2.m4s

5. PlayReady (Microsoft)

PlayReady란?

PlayReady는 Microsoft가 개발한 DRM으로, Windows, Xbox, Edge에서 사용됩니다. 기업용 스트리밍과 게임 콘솔에 강점이 있습니다.

PlayReady의 특징:

특징설명
플랫폼Windows 10/11, Xbox, Edge, 일부 Smart TV
프로토콜DASH, Smooth Streaming
보안 수준SL150 (소프트웨어) ~ SL3000 (하드웨어)
라이선스 타입영구, 임시, 구독, 도메인 바인딩
독특한 기능도메인 라이선스 (가족 내 기기 공유 가능)

PlayReady vs Widevine:

항목PlayReadyWidevine
주요 생태계Windows, Xbox, 기업Android, Chrome, TV
보안 레벨SL150/2000/3000L3/L2/L1
시장 점유율기업/게임 콘솔 강세모바일/웹 강세
구현 난이도Windows API (복잡)EME (표준, 상대적 쉬움)
라이선스 체인지원 (Domain License)미지원

도메인 라이선스란?

  • PlayReady만의 고유 기능
  • 예: 사용자가 5대의 기기를 “도메인”으로 등록
  • 한 번 라이선스를 받으면 도메인 내 모든 기기에서 재생 가능
  • 가족 공유, 기업 환경에 유리

PlayReady 아키텍처

flowchart TB
    subgraph Client[클라이언트]
        App[애플리케이션]
        PRT[PlayReady Runtime]
        TEE["Trusted Execution\nEnvironment"]
    end
    
    subgraph Server[서버]
        Content[암호화된 콘텐츠]
        License["PlayReady\nLicense Server"]
    end
    
    App --> PRT
    PRT --> TEE
    
    App --> Content
    PRT --> License
    
    License -->|라이선스| PRT
    Content -->|암호화된 데이터| PRT
    PRT -->|복호화| TEE

PlayReady 구현 완벽 가이드 (C#/UWP)

PlayReady 구현 전 알아야 할 것:

  1. Windows 플랫폼 전용: UWP (Universal Windows Platform) 또는 Win32 앱
  2. MediaProtectionManager: DRM 관리 핵심 클래스
  3. 두 가지 서비스 요청:
    • Individualization (개별화): 기기 등록 (처음 1회)
    • License Acquisition: 라이선스 획득 (콘텐츠마다)

1단계: MediaProtectionManager 초기화

using Windows.Media.Protection;
using Windows.Media.Protection.PlayReady;
using System;

public class PlayReadyManager
{
    private MediaProtectionManager protectionManager;
    
    // PlayReady System ID (GUID)
    // 모든 PlayReady 구현에서 동일한 값 사용
    private const string PlayReadySystemId = "{F4637010-03C3-42CD-B932-B48ADF3A6A54}";
    
    public void Initialize()
    {
        Console.WriteLine("🔧 Initializing PlayReady...");
        
        // MediaProtectionManager 생성
        // 이 객체가 DRM 전체를 관리
        protectionManager = new MediaProtectionManager();
        
        // 이벤트 핸들러 등록
        
        // 1. 서비스 요청 핸들러 (라이선스, 개별화)
        protectionManager.ServiceRequested += OnServiceRequested;
        
        // 2. 재활성화 핸들러 (라이선스 만료 시)
        protectionManager.RebootNeeded += OnRebootNeeded;
        
        // 3. 컴포넌트 로드 실패 핸들러
        protectionManager.ComponentLoadFailed += OnComponentLoadFailed;
        
        // PlayReady 시스템 ID 등록
        var props = new Windows.Foundation.Collections.PropertySet();
        
        // System ID와 런타임 매핑
        props.Add(
            PlayReadySystemId,
            "Windows.Media.Protection.PlayReady.PlayReadyWinRTTrustedInput"
        );
        
        // MediaProtectionManager 속성 설정
        protectionManager.Properties.Add(
            "Windows.Media.Protection.MediaProtectionSystemId", 
            PlayReadySystemId
        );
        
        protectionManager.Properties.Add(
            "Windows.Media.Protection.MediaProtectionSystemIdMapping", 
            props
        );
        
        Console.WriteLine("✅ PlayReady initialized");
    }
    
    // MediaElement에 적용
    public MediaProtectionManager GetProtectionManager()
    {
        return protectionManager;
    }
}

// XAML에서 사용:
// <MediaElement 
//     Source="https://cdn.example.com/video.mp4"
//     ProtectionManager="{Binding PlayReadyManager.GetProtectionManager()}" />

2단계: 서비스 요청 처리

private async void OnServiceRequested(
    MediaProtectionManager sender,
    ServiceRequestedEventArgs args)
{
    Console.WriteLine($"📥 Service requested: {args.Request.GetType().Name}");
    
    // 1. Individualization (개별화) 요청
    // 기기를 PlayReady 시스템에 등록 (처음 1회만 발생)
    if (args.Request is PlayReadyIndividualizationServiceRequest)
    {
        Console.WriteLine("🔑 Individualization requested (first-time setup)");
        
        var request = args.Request as PlayReadyIndividualizationServiceRequest;
        
        try
        {
            // PlayReady 서버와 통신하여 기기 인증서 획득
            // 이 과정에서 기기 고유 ID 생성
            await request.BeginServiceRequest();
            
            Console.WriteLine("✅ Device individualized");
            
        }
        catch (Exception ex)
        {
            Console.WriteLine($"❌ Individualization failed: {ex.Message}");
            args.Completion.Complete(false);
            return;
        }
    }
    
    // 2. License Acquisition (라이선스 획득) 요청
    // 실제 콘텐츠 재생을 위한 키 획득
    else if (args.Request is PlayReadyLicenseAcquisitionServiceRequest)
    {
        Console.WriteLine("📜 License acquisition requested");
        
        var request = args.Request as PlayReadyLicenseAcquisitionServiceRequest;
        
        try
        {
            // 커스텀 데이터 추가 (인증 토큰, 사용자 정보 등)
            // 이 데이터는 라이선스 서버에 전달됨
            string customData = GetAuthToken();
            request.ChallengeCustomData = customData;
            
            Console.WriteLine($"🔐 Custom data: {customData.Substring(0, 20)}...");
            
            // 라이선스 서버 URL 확인
            if (request.Uri != null)
            {
                Console.WriteLine($"📍 License server: {request.Uri}");
            }
            
            // 라이선스 요청 시작
            await request.BeginServiceRequest();
            
            Console.WriteLine("✅ License acquired");
            
            // 라이선스 정보 확인
            var licenseIterable = new PlayReadyLicenseIterable(
                request.ContentHeader, 
                true  // includeExpired
            );
            
            foreach (var license in licenseIterable)
            {
                Console.WriteLine($"📅 License expires: {license.ExpirationDate}");
                Console.WriteLine($"🔢 Chain depth: {license.ChainDepth}");
            }
            
        }
        catch (Exception ex)
        {
            Console.WriteLine($"❌ License acquisition failed: {ex.Message}");
            Console.WriteLine($"   HRESULT: {ex.HResult:X}");
            
            args.Completion.Complete(false);
            return;
        }
    }
    
    // 3. Metering (사용량 측정) 요청
    else if (args.Request is PlayReadyMeteringReportServiceRequest)
    {
        Console.WriteLine("📊 Metering report requested");
        
        var request = args.Request as PlayReadyMeteringReportServiceRequest;
        await request.BeginServiceRequest();
    }
    
    // 서비스 요청 완료
    args.Completion.Complete(true);
}

private void OnRebootNeeded(MediaProtectionManager sender, RebootNeededEventArgs args)
{
    Console.WriteLine("🔄 Reboot needed (license renewal required)");
    // 라이선스가 만료되어 앱 재시작 필요
}

private void OnComponentLoadFailed(
    MediaProtectionManager sender,
    ComponentLoadFailedEventArgs args)
{
    Console.WriteLine($"❌ Component load failed");
    Console.WriteLine($"   Information: {args.Information.Items}");
    Console.WriteLine($"   Renewal: {args.Completion.RenewalNeedsRetry}");
    
    // 디버깅용: 어떤 컴포넌트가 실패했는지 확인
    foreach (var item in args.Information.Items)
    {
        Console.WriteLine($"   - Name: {item.Name}");
        Console.WriteLine($"   - Reasons: {item.Reasons}");
        Console.WriteLine($"   - Renewal ID: {item.RenewalId}");
    }
}

private string GetAuthToken()
{
    // 실전에서는 사용자 인증 후 JWT 토큰 생성
    // 라이선스 서버가 이 토큰으로 사용자 권한 검증
    
    // 예시 JWT 토큰 (실제로는 서버에서 발급)
    return "Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJ1c2VyMTIzIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c";
}

PlayReady 서비스 요청 흐름:

초기 재생 시도:
1. MediaElement가 암호화된 콘텐츠 감지
2. PlayReady PSSH 박스 확인
3. Individualization 필요 여부 확인
   ├─ 처음: Individualization 요청 발생
   │  → 기기를 PlayReady에 등록
   │  → 기기 인증서 발급
   └─ 이미 등록: 건너뛰기

4. License Acquisition 요청 발생
   → Challenge (라이선스 요청) 생성
   → 서버에 POST (ChallengeCustomData 포함)
   → 서버 검증:
      - 사용자 인증 (토큰 확인)
      - 구독 상태
      - 기기 제한 (최대 5대 등)
      - 지역 제한
   → Response (라이선스) 수신
   → PlayReady가 라이선스 저장

5. 비디오 재생 시작

6. EME와 CENC

EME (Encrypted Media Extensions)

EMEW3C 웹 표준으로, 브라우저에서 DRM 콘텐츠를 재생하기 위한 JavaScript API입니다.

EME의 핵심 개념:

EME는 브라우저와 CDM(Content Decryption Module) 사이의 "중개자"입니다.

┌──────────────────────┐
│   웹 애플리케이션     │
│  (HTML5 Video Player) │
└──────────┬───────────┘
           │ JavaScript

┌──────────────────────┐
│    EME API           │ ← W3C 표준 (브라우저마다 동일)
│  navigator.request   │
│  MediaKeySystemAccess│
└──────────┬───────────┘
           │ 추상화

┌──────────────────────┐
│    CDM               │ ← DRM 시스템별로 다름
│  (Widevine, FairPlay,│    (브라우저가 제공)
│   PlayReady)         │
└──────────────────────┘

EME의 장점:
1. 플러그인 불필요 (Flash, Silverlight 시대는 종료)
2. 표준 API (브라우저마다 다른 코드 작성 불필요)
3. 샌드박스 보안 (브라우저 격리 환경)

CDM(Content Decryption Module) 아키텍처

CDM은 W3C EME에서 말하는 “콘텐츠 복호화를 담당하는 사용자 에이전트 구성 요소”입니다. 중요한 점은 CDM이 웹 개발자가 설치하는 npm 패키지가 아니라, 브라우저·OS 벤더가 배포·서명하는 바이너리(또는 OS 프레임워크)라는 것입니다.

역할 분리: 웹 앱 · 브라우저 · CDM · 미디어 파이프라인

경계책임비고
JavaScriptrequestMediaKeySystemAccess, 세션 생성, 라이선스 바이트를 네트워크로 전달평문 콘텐츠 키·복호화된 샘플에 직접 접근 불가
브라우저(UA)CDM 프로세스 생성, IPC, 미디어 파이프라인과 CDM 연결구현마다 샌드박스·프로세스 모델 상이
CDM라이선스 파싱, 키 캐시, 복호화 연산 요청 처리, 키 상태 보고Widevine·PlayReady·FairPlay 등 키 시스템별 구현
OS/하드웨어TEE, 디코더, 디스플레이 경로, HDCPL1에서 특히 중요

Chromium 계열에서는 흔히 CDM이 별도 프로세스로 떠서 브라우저 탭 JS와 메모리 공간을 공유하지 않습니다. 그 이유는 라이선스·키 재료가 렌더러에서 관측되지 않게 하기 위함입니다.

CDM이 제공하지 않는 것(웹 표준 관점)

  • URL 결정권: 라이선스 HTTP 엔드포인트는 보통 PSSH·매니페스트 메타데이터·앱 설정에서 옵니다. CDM은 Challenge(라이선스 요청 페이로드)를 만들 뿐, 비즈니스 로직(구독·지역)은 서버가 판단합니다.
  • 토큰 대체: 인증 헤더·쿠키·세션은 애플리케이션이 fetch에 붙입니다. CDM은 바이너리 메시지만 넘깁니다.
  • 동일 키의 JS 노출: 표준 EME에서는 콘텐츠 키를 ArrayBuffer로 꺼내는 API가 없습니다. ClearKey는 예외적으로 키를 알 수 있게 설계되었으나, 상용 DRM과 목적이 다릅니다.

업데이트·개별화(Individualization)

상용 CDM은 자체 업데이트·기기 등록 절차를 가질 수 있으며, 브라우저는 이를 허용된 벤더 채널로만 받습니다. 일부 흐름에서는 message 이벤트의 messageType이 개별화·갱신을 뜻하며, 앱은 이를 라이선스 서버 또는 벤더가 지정한 엔드포인트로 중계해야 합니다.

EME “프로토콜”: W3C API와 CDM 간 메시지 계약

EME 자체는 라이선스 바이트의 의미를 표준화하지 않습니다. Widevine·PlayReady·FairPlay는 각각 자체 이진 포맷을 사용합니다. 따라서 “EME 프로토콜”은 정확히는 브라우저가 JS와 CDM 사이에서 보장하는 동작·이벤트 계약을 가리킵니다.

MediaKeyMessageEvent.messageType의 실무적 의미

스펙과 구현에서 자주 등장하는 유형은 다음과 같습니다(브라우저·CDM 버전에 따라 문자열·지원 여부가 다름).

  • license-request: 최초 Challenge가 생성됨. 앱은 이를 라이선스 서버에 POST하고 응답을 session.update()로 되돌려보냄.
  • license-renewal: 세션·라이선스 갱신이 필요할 때 추가 메시지. 장시간 VOD·라이브에서 끊김 없이 재생하려면 이 경로를 서버가 처리해야 함.
  • individualization-request: CDM 개별화·증명서 교환용. 앱은 DRM 벤더 문서에 따라 별도 URL로 보내거나, 동일 라이선스 서버가 흡수하기도 함.

앱 코드에서는 event.messageType을 분기해 로깅·라우팅하는 것이 좋습니다.

MediaKeySession.update() 이후: keystatuses 변화

update()가 성공하면 MediaKeySessionkeyStatuses이 갱신됩니다. 각 키 ID는 대략 다음 같은 상태를 가질 수 있습니다(스펙 용어).

  • usable: 재생에 사용 가능.
  • expired: 만료. 갱신 실패 시 재생 중단으로 이어질 수 있음.
  • output-restricted, internal-error 등: HDCP·출력 경로 또는 CDM 내부 오류. 하드웨어·케이블·외부 모니터 문제와 연관되는 경우가 많음.

플레이어는 waitingforkey·encrypted와 함께 keystatuseschange 이벤트를 구독해 사용자 메시지(“외부 디스플레이는 HD 지원이 제한됩니다”)로 연결할 수 있습니다.

네트워크 계층: CORS·자격 증명·바디

  • 라이선스 요청은 보통 POST, Content-Type: application/octet-stream입니다. JSON이 아닙니다.
  • 동일 출처 정책 때문에, 라이선스 서버가 다른 오리진이면 CORS 헤더가 필요합니다. 쿠키·HTTP-only 세션을 쓰면 fetchcredentials: 'include'와 서버측 Access-Control-Allow-Credentials가 맞물려야 합니다.
  • 많은 프로덕션 서비스는 앱 도메인의 /license 프록시를 두어 CORS·인증 복잡도를 줄입니다(아래 프로덕션 DRM 패턴 참고).

EME API 내부 통신 메커니즘:

EME API 실행 흐름 (브라우저 내부):

1. requestMediaKeySystemAccess():
   
   JavaScript:
   navigator.requestMediaKeySystemAccess('com.widevine.alpha', configs)
   
   브라우저 내부:
   ├─ 1) keySystem 검증
   │    - 'com.widevine.alpha' 문자열 확인
   │    - 브라우저에 Widevine CDM 설치 확인
   │    - CDM 버전 확인 (4.10.2557.0 등)

   ├─ 2) configurations 검증
   │    - initDataTypes 지원 확인 (['cenc', 'webm'])
   │    - videoCapabilities 확인:
   │      * contentType: 'video/mp4; codecs="avc1.42E01E"'
   │      * robustness: 'SW_SECURE_CRYPTO' / 'HW_SECURE_ALL'
   │    - audioCapabilities 확인
   │    - distinctiveIdentifier: 기기 식별자 필요 여부
   │    - persistentState: 오프라인 라이선스 필요 여부

   ├─ 3) CDM에 지원 여부 질의
   │    Browser → CDM (IPC):
   │    "Can you handle H.264 with SW_SECURE_CRYPTO?"

   │    CDM → Browser:
   │    "Yes" or "No"

   └─ 4) MediaKeySystemAccess 반환
        - 지원됨: Promise<MediaKeySystemAccess>
        - 미지원: Promise<reject>

2. createMediaKeys():
   
   JavaScript:
   const mediaKeys = await access.createMediaKeys()
   
   브라우저 내부:
   ├─ 1) CDM 프로세스 시작
   │    - Chrome: Widevine CDM (별도 프로세스)
   │    - 프로세스 격리 (Sandbox)
   │    - IPC 채널 생성 (Mojo)

   ├─ 2) CDM 초기화
   │    - CDM이 내부 상태 초기화
   │    - 키 저장소 준비
   │    - TEE 연결 (L1의 경우)

   └─ 3) MediaKeys 객체 반환
        - JavaScript에서 CDM 제어 가능

3. setMediaKeys():
   
   JavaScript:
   await video.setMediaKeys(mediaKeys)
   
   브라우저 내부:
   ├─ 1) <video> 엘리먼트와 CDM 연결
   │    HTMLVideoElement → MediaKeys → CDM

   └─ 2) 복호화 파이프라인 연결
        Demuxer → CDM → Decoder → Renderer

4. encrypted 이벤트:
   
   비디오 재생 중:
   video.src = 'https://cdn.example.com/encrypted.mp4'
   video.play()
   
   브라우저 내부:
   ├─ 1) Demuxer가 초기화 데이터 발견
   │    - PSSH (Protection System Specific Header) 박스
   │    - MP4: moov/trak/mdia/minf/stbl/stsd/encv/sinf/schi/tenc

   ├─ 2) initData 추출
   │    PSSH 박스 구조:
   │    ┌───────────────────────────┐
   │    │ Version: 0 or 1           │
   │    │ Flags: 0x000000           │
   │    │ SystemID: Widevine UUID   │
   │    │ DataSize: N bytes         │
   │    │ Data: [키 ID, PSSH data]  │
   │    └───────────────────────────┘

   └─ 3) 'encrypted' 이벤트 발생
        event.initDataType = 'cenc'
        event.initData = PSSH 박스 (ArrayBuffer)

5. generateRequest():
   
   JavaScript:
   const session = mediaKeys.createSession()
   await session.generateRequest('cenc', initData)
   
   브라우저 내부:
   ├─ 1) CDM에 initData 전달
   │    Browser → CDM (IPC):
   │    "Generate license request for these key IDs"
   │    - initData에서 Key ID 추출
   │    - PSSH 데이터 파싱

   ├─ 2) CDM이 Challenge 생성
   │    - Device 정보 포함
   │    - Key ID 목록
   │    - Nonce (재전송 공격 방지)
   │    - 서명 (CDM 인증서)

   └─ 3) 'message' 이벤트 발생
        event.message = Challenge (ArrayBuffer)
        event.messageType = 'license-request'

6. License 서버 통신:
   
   JavaScript:
   session.addEventListener('message', async (e) => {
     const response = await fetch(licenseUrl, {
       method: 'POST',
       body: e.message  // Challenge
     })
     const license = await response.arrayBuffer()
     await session.update(license)
   })
   
   네트워크 레벨:
   POST /license/widevine HTTP/2
   Content-Type: application/octet-stream
   Authorization: Bearer <token>
   
   [Challenge: ~2KB binary data]
   

   HTTP/2 200 OK
   Content-Type: application/octet-stream
   
   [License: ~5KB binary data]

7. session.update(license):
   
   JavaScript:
   await session.update(license)
   
   브라우저 내부:
   ├─ 1) CDM에 라이선스 전달
   │    Browser → CDM (IPC):
   │    "Here is the license from server"

   ├─ 2) CDM이 라이선스 검증
   │    - 서명 확인 (라이선스 서버 인증)
   │    - 유효기간 확인
   │    - 권한 확인 (해상도, 오프라인 등)
   │    - 콘텐츠 키 추출 (암호화되어 있음)

   ├─ 3) 콘텐츠 키 저장
   │    L1 (하드웨어):
   │    - TEE (Trusted Execution Environment)에 저장
   │    - ARM TrustZone, Intel SGX
   │    - 메인 OS에서 접근 불가

   │    L3 (소프트웨어):
   │    - 프로세스 메모리에 저장
   │    - 암호화되어 저장되지만 덤프 가능

   └─ 4) 복호화 파이프라인 준비
        키 매핑: Key ID → Content Key

8. 비디오 복호화 (재생 중):
   
   프레임 처리 흐름:
   
   Demuxer:
   [암호화된 MP4] → PSSH 확인 → Key ID 추출
                   → [암호화된 Sample]
   
   CDM으로 전달:
   Browser → CDM (IPC):
   - Sample data (암호화)
   - Key ID
   - IV (Initialization Vector)
   
   CDM 복호화:
   L1 (하드웨어):
   ├─ 1) Sample을 TEE로 전송
   ├─ 2) TEE에서 AES-128 CTR 복호화
   ├─ 3) 복호화된 데이터를 Secure Path로 전송
   └─ 4) GPU로 직접 전달 (메인 메모리 우회)
   
   L3 (소프트웨어):
   ├─ 1) Sample을 CDM 프로세스로 전송
   ├─ 2) AES-128 CTR 복호화 (소프트웨어)
   ├─ 3) 복호화된 데이터를 Browser로 반환
   └─ 4) 일반 Decoder로 전달
   
   Decoder:
   [복호화된 H.264] → libavcodec → [YUV Frame]
   
   Renderer:
   [YUV Frame] → GPU → Display

전체 지연시간:
- L1: ~5ms (하드웨어)
- L3: ~10ms (소프트웨어)

EME API 구조 (계층):

// 레벨 1: MediaKeySystemAccess - DRM 시스템 지원 확인
navigator.requestMediaKeySystemAccess(keySystem, configurations)
Promise<MediaKeySystemAccess>
  
  내부 동작:
  - CDM 존재 여부 확인
  - Configuration 검증
  - 보안 레벨 확인

// 레벨 2: MediaKeys - CDM 인스턴스
MediaKeySystemAccess.createMediaKeys()
Promise<MediaKeys>
  
  내부 동작:
  - CDM 프로세스 시작 (별도 프로세스)
  - IPC 채널 생성
  - TEE 연결 (L1)

// 레벨 3: MediaKeySession - 라이선스 세션
MediaKeys.createSession(sessionType)
  → MediaKeySession
  
  sessionType:
  - 'temporary': 일회성 (기본)
  - 'persistent-license': 오프라인 재생
  
  내부 동작:
  - Session ID 생성
  - CDM 세션 생성

// 4. video에 연결
video.setMediaKeys(MediaKeys)

// 흐름:
// MediaKeySystemAccess → MediaKeys → MediaKeySession
//                                  ↓
//                              video 요소

EME 지원 확인 및 DRM 탐지

// 1. 기본 EME 지원 확인
if ('mediaKeys' in HTMLMediaElement.prototype) {
  console.log('✅ EME API supported');
  console.log('   - requestMediaKeySystemAccess: ', 
    'requestMediaKeySystemAccess' in navigator);
  console.log('   - MediaKeys: ', 'MediaKeys' in window);
} else {
  console.log('❌ EME not supported');
  console.log('   브라우저를 최신 버전으로 업데이트하세요.');
}

// 2. 지원되는 DRM 시스템 탐지
const drmSystems = [
  {
    name: 'Widevine',
    keySystem: 'com.widevine.alpha',
    platforms: ['Chrome', 'Edge', 'Firefox', 'Android']
  },
  {
    name: 'FairPlay',
    keySystem: 'com.apple.fps.1_0',
    platforms: ['Safari', 'iOS', 'tvOS']
  },
  {
    name: 'PlayReady',
    keySystem: 'com.microsoft.playready',
    platforms: ['Edge', 'Windows', 'Xbox']
  },
  {
    name: 'ClearKey',
    keySystem: 'org.w3.clearkey',
    platforms: ['All (테스트용)']
  }
];

async function detectDRMSupport() {
  console.log('🔍 Detecting DRM support...\n');
  
  const supportedDRMs = [];
  
  for (const drm of drmSystems) {
    try {
      // 기본 config로 지원 확인
      const config = [{
        initDataTypes: ['cenc'],
        videoCapabilities: [{
          contentType: 'video/mp4; codecs="avc1.42E01E"'
        }]
      }];
      
      const access = await navigator.requestMediaKeySystemAccess(
        drm.keySystem, 
        config
      );
      
      console.log(`✅ ${drm.name} (${drm.keySystem})`);
      console.log(`   Platforms: ${drm.platforms.join(', ')}`);
      
      // 실제 설정 확인
      const actualConfig = access.getConfiguration();
      console.log(`   Persistent state: ${actualConfig.persistentState}`);
      console.log(`   Distinctive ID: ${actualConfig.distinctiveIdentifier}`);
      console.log('');
      
      supportedDRMs.push(drm.name);
      
    } catch (e) {
      console.log(`❌ ${drm.name} not supported`);
      console.log('');
    }
  }
  
  return supportedDRMs;
}

// 사용
detectDRMSupport().then(supported => {
  console.log('Supported DRMs:', supported);
  
  if (supported.length === 0) {
    console.error('⚠️ 이 브라우저는 DRM을 지원하지 않습니다.');
  }
});

// 3. 상세 능력 확인
async function checkDetailedCapabilities() {
  const configs = [
    {
      label: 'HD (720p)',
      config: [{
        initDataTypes: ['cenc'],
        videoCapabilities: [{
          contentType: 'video/mp4; codecs="avc1.42E01E"',
          robustness: 'SW_SECURE_CRYPTO'
        }]
      }]
    },
    {
      label: '4K (2160p)',
      config: [{
        initDataTypes: ['cenc'],
        videoCapabilities: [{
          contentType: 'video/mp4; codecs="avc1.640028"',  // H.264 High Profile
          robustness: 'HW_SECURE_ALL'
        }]
      }]
    },
    {
      label: 'HDR10',
      config: [{
        initDataTypes: ['cenc'],
        videoCapabilities: [{
          contentType: 'video/mp4; codecs="hev1.2.4.L153.B0"',  // HEVC HDR10
          robustness: 'HW_SECURE_ALL'
        }]
      }]
    }
  ];
  
  for (const {label, config} of configs) {
    try {
      await navigator.requestMediaKeySystemAccess('com.widevine.alpha', config);
      console.log(`✅ ${label} supported`);
    } catch (e) {
      console.log(`❌ ${label} not supported`);
    }
  }
}

실전 팁:

// 브라우저별 폴백 전략
async function selectBestDRM() {
  // 우선순위: FairPlay (Safari) > Widevine (Chrome) > PlayReady (Edge)
  
  const isApple = /Safari|iOS|Mac/.test(navigator.userAgent) && 
                  !/Chrome/.test(navigator.userAgent);
  
  if (isApple) {
    // Safari는 FairPlay만 지원
    return {
      keySystem: 'com.apple.fps.1_0',
      manifestURL: '/hls/master.m3u8',  // HLS
      licenseURL: '/fairplay/license'
    };
  }
  
  // Chrome/Edge는 Widevine 우선
  try {
    await navigator.requestMediaKeySystemAccess('com.widevine.alpha', [{}]);
    return {
      keySystem: 'com.widevine.alpha',
      manifestURL: '/dash/manifest.mpd',  // DASH
      licenseURL: '/widevine/license'
    };
  } catch (e) {
    // Edge의 경우 PlayReady 폴백
    return {
      keySystem: 'com.microsoft.playready',
      manifestURL: '/dash/manifest.mpd',
      licenseURL: '/playready/license'
    };
  }
}

CENC (Common Encryption)

CENC하나의 암호화된 파일을 여러 DRM 시스템에서 사용할 수 있게 하는 표준입니다.

왜 CENC가 필요한가요?

CENC 이전:

각 DRM마다 따로 암호화 → 스토리지 3배, 인코딩 3배

원본 비디오
├─ Widevine용 암호화 → video_widevine.mp4 (1GB)
├─ FairPlay용 암호화 → video_fairplay.m4s (1GB)
└─ PlayReady용 암호화 → video_playready.mp4 (1GB)
   총 3GB 스토리지 필요

CENC 적용 후:

한 번 암호화 → 모든 DRM에서 사용 가능

원본 비디오
└─ CENC 암호화 → video_cenc.mp4 (1GB)
   ├─ Chrome → Widevine CDM으로 복호화
   ├─ Safari → FairPlay CDM으로 복호화
   └─ Edge → PlayReady CDM으로 복호화
   총 1GB만 필요 (67% 절약)

CENC의 동작 원리:

1. 암호화는 동일 (AES-128 CTR 모드)
   - 모든 DRM이 같은 암호화 알고리즘 사용
   - 콘텐츠 키(Content Key)는 동일

2. PSSH 박스만 다름 (Protection System Specific Header)
   - 하나의 파일에 여러 PSSH 박스 포함
   - 각 PSSH는 해당 DRM의 메타데이터 포함

MP4 파일 구조:
moov
├─ pssh (Widevine)
│  └─ system_id: edef8ba9-79d6-4ace-a3c8-27dcd51d21ed
├─ pssh (PlayReady)
│  └─ system_id: 9a04f079-9840-4286-ab92-e65be0885f95
├─ pssh (FairPlay)
│  └─ system_id: 94ce86fb-07ff-4f43-adb8-93d2fa968ca2
└─ trak (암호화된 비디오 트랙)
   └─ mdat (암호화된 데이터) ← 모든 DRM이 공유
flowchart TB
    Original[원본 비디오] --> Encoder[인코더 + Packager]
    
    Encoder --> CENC["CENC 암호화\n단일 파일\n(PSSH: Widevine + FairPlay + PlayReady)"]
    
    CENC --> Chrome["Chrome\nWidevine PSSH 읽기"]
    CENC --> Safari["Safari\nFairPlay PSSH 읽기"]
    CENC --> Edge["Edge\nPlayReady PSSH 읽기"]
    
    Chrome --> Play1[재생]
    Safari --> Play2[재생]
    Edge --> Play3[재생]

CENC vs 개별 암호화:

항목CENC개별 암호화
스토리지1배3배 (DRM 개수만큼)
인코딩 시간1회3회
CDN 비용낮음높음 (3배)
관리 복잡도낮음높음
호환성모든 최신 DRM특정 DRM만
표준ISO/IEC 23001-7각 DRM 독자 방식

CENC 암호화 실전 가이드

도구 선택

CENC 암호화를 위한 주요 도구:

도구개발사장점단점
Shaka PackagerGoogle무료, DASH/HLS 지원, 문서 우수CLI만 지원
Bento4Axiomatic무료, 유연한 CLI문서 부족
FFmpeg커뮤니티만능, 무료DRM 기능 제한적
AWS MediaConvertAWS클라우드, 자동화유료
BitmovinBitmovin엔터프라이즈급고가

추천: Shaka Packager (무료, 오픈소스, 구글 지원)

1단계: Shaka Packager 설치

# macOS (Homebrew)
brew install shaka-packager

# Linux (바이너리 다운로드)
wget https://github.com/shaka-project/shaka-packager/releases/download/v2.6.1/packager-linux-x64
chmod +x packager-linux-x64
sudo mv packager-linux-x64 /usr/local/bin/packager

# Windows (바이너리 다운로드)
# https://github.com/shaka-project/shaka-packager/releases

# 설치 확인
packager --version
# Shaka Packager version v2.6.1-...

2단계: 기본 CENC 암호화

# 1. 콘텐츠 키 생성 (16바이트 = 128비트)
# Key ID와 Key는 32자 hex 문자열 (16바이트)
KEY_ID=$(openssl rand -hex 16)
KEY=$(openssl rand -hex 16)

echo "Generated Key ID: $KEY_ID"
echo "Generated Key: $KEY"

# 예시:
# KEY_ID: 1234567890abcdef1234567890abcdef
# KEY: fedcba0987654321fedcba0987654321

# 2. CENC 암호화 (기본)
packager \
  in=input.mp4,stream=video,output=video_encrypted.mp4 \
  in=input.mp4,stream=audio,output=audio_encrypted.mp4 \
  --enable_raw_key_encryption \
  --keys label=:key_id=$KEY_ID:key=$KEY \
  --protection_scheme cenc \
  --clear_lead 3 \
  --mpd_output manifest.mpd

# 옵션 설명:
# - in=input.mp4: 입력 파일
# - stream=video: 비디오 스트림만 추출
# - output=video_encrypted.mp4: 출력 파일명
# - --enable_raw_key_encryption: 원시 키 암호화 활성화
# - --keys: 암호화 키 지정
#   - label=: 키 레이블 (비어있으면 모든 스트림에 적용)
#   - key_id=: 키 식별자 (PSSH에 포함)
#   - key=: 실제 암호화 키 (AES-128)
# - --protection_scheme cenc: CENC 표준 사용
#   - cenc: AES-128 CTR 모드
#   - cbcs: AES-128 CBC 모드 (FairPlay 호환)
# - --clear_lead 3: 처음 3초는 암호화하지 않음
#   - 플레이어가 코덱/해상도 감지 용이
#   - 미리보기 썸네일 추출 가능
# - --mpd_output: DASH 매니페스트 생성

# 생성된 파일:
# - video_encrypted.mp4 (암호화된 비디오)
# - audio_encrypted.mp4 (암호화된 오디오)
# - manifest.mpd (DASH 매니페스트)

# 3. 키를 안전하게 저장 (데이터베이스나 Key Management Service)
echo "$KEY_ID:$KEY" >> keys.txt

3단계: 멀티 DRM 지원 (Widevine + PlayReady + FairPlay)

# 프로덕션 환경: 3개 DRM 모두 지원
packager \
  # 입력/출력
  in=input.mp4,stream=video,output=video_cenc.mp4 \
  in=input.mp4,stream=audio,output=audio_cenc.mp4 \
  \
  # Widevine 설정
  --enable_widevine_encryption \
  --key_server_url "https://license.widevine.com/cenc/getcontentkey/widevine_test" \
  --content_id "content-$RANDOM" \
  --signer "widevine_test" \
  --aes_signing_key "1ae8ccd0e7985cc0b6203a55855a1034afc252980e970ca90e5202689f947ab9" \
  --aes_signing_iv "d58ce954203b7c9a9a9d467f59839249" \
  \
  # PlayReady 설정
  --enable_playready_encryption \
  --playready_server_url "https://playready.example.com/rightsmanager.asmx" \
  --playready_key_id "$KEY_ID" \
  --playready_key "$KEY" \
  \
  # FairPlay 설정 (HLS 전용)
  --hls_master_playlist_output "master.m3u8" \
  --hls_key_uri "skd://fps.example.com?contentId=$CONTENT_ID" \
  \
  # 출력 형식
  --mpd_output manifest.mpd \
  --protection_scheme cenc

# 결과:
# 1. manifest.mpd - DASH 매니페스트 (Widevine + PlayReady)
#    ├─ <ContentProtection schemeIdUri="urn:uuid:edef8ba9..."> Widevine
#    └─ <ContentProtection schemeIdUri="urn:uuid:9a04f079..."> PlayReady
#
# 2. master.m3u8 - HLS 매니페스트 (FairPlay)
#    └─ #EXT-X-KEY:METHOD=SAMPLE-AES,URI="skd://..."

4단계: 적응형 비트레이트 (ABR) + CENC

# 여러 화질(480p, 720p, 1080p, 4K)을 CENC로 암호화

# 1. 멀티 비트레이트 인코딩 (FFmpeg)
ffmpeg -i input.mp4 \
  -filter_complex "[0:v]split=4[v1][v2][v3][v4]; \
    [v1]scale=854:480[v480]; \
    [v2]scale=1280:720[v720]; \
    [v3]scale=1920:1080[v1080]; \
    [v4]scale=3840:2160[v4k]" \
  -map "[v480]" -c:v libx264 -b:v 1000k -maxrate 1200k -bufsize 2000k video_480p.mp4 \
  -map "[v720]" -c:v libx264 -b:v 2500k -maxrate 3000k -bufsize 5000k video_720p.mp4 \
  -map "[v1080]" -c:v libx264 -b:v 5000k -maxrate 6000k -bufsize 10000k video_1080p.mp4 \
  -map "[v4k]" -c:v libx265 -b:v 15000k -maxrate 18000k -bufsize 30000k video_4k.mp4 \
  -map 0:a -c:a aac -b:a 128k audio.mp4

# 2. 모든 화질을 CENC로 암호화
packager \
  in=video_480p.mp4,stream=video,output=video_480p_enc.mp4,drm_label=SD \
  in=video_720p.mp4,stream=video,output=video_720p_enc.mp4,drm_label=HD \
  in=video_1080p.mp4,stream=video,output=video_1080p_enc.mp4,drm_label=FULL_HD \
  in=video_4k.mp4,stream=video,output=video_4k_enc.mp4,drm_label=UHD \
  in=audio.mp4,stream=audio,output=audio_enc.mp4 \
  --enable_raw_key_encryption \
  --keys \
    label=SD:key_id=$KEY_ID_SD:key=$KEY_SD \
    label=HD:key_id=$KEY_ID_HD:key=$KEY_HD \
    label=FULL_HD:key_id=$KEY_ID_1080:key=$KEY_1080 \
    label=UHD:key_id=$KEY_ID_4K:key=$KEY_4K \
  --mpd_output manifest_abr.mpd

# 결과 manifest_abr.mpd:
# - 4개의 비디오 Adaptation Set (480p, 720p, 1080p, 4K)
# - 1개의 오디오 Adaptation Set
# - 클라이언트가 네트워크 속도에 따라 자동 선택

키 관리 전략:

화질별 다른 키:
- SD (480p): Key A (구독 없어도 재생 가능)
- HD (720p): Key B (기본 구독 필요)
- Full HD (1080p): Key C (프리미엄 구독)
- 4K: Key D (최상위 구독)

라이선스 서버가 사용자 구독 등급에 따라 해당 키만 전달:
- 무료 사용자: Key A만 받음 → SD만 재생
- 프리미엄 사용자: Key A,B,C,D 모두 받음 → 4K까지 재생

7. HLS AES-128 암호화

HLS AES-128이란?

HLS AES-128은 Apple의 HTTP Live Streaming에서 사용하는 간단한 암호화 방식입니다.

DRM vs AES-128 비교:

항목DRM (Widevine/FairPlay)HLS AES-128
보안 수준매우 높음 (하드웨어 보안)낮음 (키 노출 가능)
라이선스 서버필수선택사항
하드웨어 보호TEE/Secure Path없음
복호화 위치CDM (격리됨)플레이어 메모리 (노출됨)
구현 난이도높음낮음
비용높음 (라이선스 필요)무료
적합한 콘텐츠프리미엄 (영화, 드라마)일반 (교육, 웹캐스트)
키 관리동적 (사용자별)정적 (콘텐츠별)

언제 AES-128을 사용하나요?

사용해도 괜찮은 경우:

  • 교육 콘텐츠 (무료/저가)
  • 라이브 웨비나, 회의
  • 사내 교육 영상
  • URL 유출만 방지하면 되는 경우
  • 예산이 제한적인 경우

사용하면 안 되는 경우:

  • 프리미엄 영화/드라마
  • 고가의 독점 콘텐츠
  • 법적 보호가 필요한 콘텐츠
  • 전문적인 해커의 타겟이 될 수 있는 콘텐츠

AES-128의 한계:

공격 시나리오:
1. 개발자 도구에서 네트워크 탭 열기
2. key.bin URL 확인
3. curl https://example.com/key.bin > key.bin
4. FFmpeg로 복호화:
   ffmpeg -allowed_extensions ALL -i playlist.m3u8 -c copy output.mp4
   
소요 시간: 5분 이내 (기술 지식 있으면)

AES-128 보안 강화 방법:

  1. 키 URL에 인증 토큰 추가

    #EXT-X-KEY:METHOD=AES-128,URI="https://example.com/key.bin?token=JWT_TOKEN"
  2. 키를 동적으로 생성 (사용자별)

    • 같은 콘텐츠라도 사용자마다 다른 키 제공
    • 키 유출 시 해당 사용자만 차단
  3. 짧은 키 유효기간

    • 1시간 ~ 24시간마다 키 교체
    • 세션 기반 키 생성
  4. IP/Referer 검증

    • 키 요청 시 IP, Referer 확인
    • 웹사이트 내에서만 재생 가능

HLS 암호화 매니페스트

#EXTM3U
#EXT-X-VERSION:3
#EXT-X-TARGETDURATION:10
#EXT-X-MEDIA-SEQUENCE:0
# AES-128 암호화 키 정보
#EXT-X-KEY:METHOD=AES-128,URI="https://example.com/key.bin",IV=0x12345678901234567890123456789012
# 세그먼트
#EXTINF:10.0,
segment-0.ts
#EXTINF:10.0,
segment-1.ts
#EXTINF:10.0,
segment-2.ts

HLS AES-128 암호화 실전 가이드

1단계: 키 생성 및 관리

# 1. 암호화 키 생성 (16바이트 = 128비트)
openssl rand 16 > enc.key

# 생성된 키 확인
hexdump -C enc.key
# 00000000  a3 7f 2c 89 4e 1d 6b 9a  f2 83 4c 0e 5d 6f 8a b1  |..,�N.k��.L.]o��|

# 2. IV (Initialization Vector) 생성
# IV는 암호화 무작위성을 위한 초기값
openssl rand -hex 16 > enc.iv

# IV 확인 (32자 hex 문자열)
cat enc.iv
# 1234567890abcdef1234567890abcdef

# 3. 키 정보 파일 생성 (enc.keyinfo)
# 이 파일은 FFmpeg가 읽어서 HLS에 포함
# 형식:
# 1줄: 키 URL (클라이언트가 요청할 주소)
# 2줄: 키 파일 경로 (로컬 경로, FFmpeg가 읽음)
# 3줄: IV (hex 문자열)

echo "https://example.com/api/keys/$(uuidgen).bin" > enc.keyinfo
echo "$(pwd)/enc.key" >> enc.keyinfo
cat enc.iv >> enc.keyinfo

# enc.keyinfo 내용 확인
cat enc.keyinfo
# https://example.com/api/keys/550e8400-e29b-41d4-a716-446655440000.bin
# /home/user/project/enc.key
# 1234567890abcdef1234567890abcdef

# 보안 강화: 토큰 기반 키 URL
# JWT 토큰을 쿼리 파라미터로 추가
TOKEN=$(generate_jwt_token)  # 사용자 인증 토큰 생성 함수
echo "https://example.com/api/keys/$(uuidgen).bin?token=$TOKEN" > enc.keyinfo
echo "$(pwd)/enc.key" >> enc.keyinfo
cat enc.iv >> enc.keyinfo

2단계: FFmpeg로 HLS 암호화

# 기본 HLS AES-128 암호화
ffmpeg -i input.mp4 \
  # 코덱 설정
  -c:v libx264 \          # H.264 비디오 인코딩
  -preset medium \        # 인코딩 속도/품질 균형
  -crf 23 \               # 화질 (18=거의 무손실, 28=낮은 품질)
  -c:a aac \              # AAC 오디오 인코딩
  -b:a 128k \             # 오디오 비트레이트
  # HLS 설정
  -hls_time 10 \          # 세그먼트 길이 (10초)
  -hls_list_size 0 \      # m3u8에 모든 세그먼트 포함 (0=전부)
  -hls_segment_type mpegts \  # 세그먼트 형식 (mpegts 또는 fmp4)
  -hls_playlist_type vod \    # VOD (vod) 또는 라이브 (event)
  -hls_segment_filename "segment_%03d.ts" \  # 세그먼트 파일명 패턴
  # 암호화 설정
  -hls_key_info_file enc.keyinfo \  # 키 정보 파일
  -hls_flags independent_segments \ # 세그먼트를 독립적으로 복호화 가능
  # 출력
  playlist.m3u8

# 생성된 파일:
# - playlist.m3u8 (HLS 매니페스트)
# - segment_000.ts ~ segment_XXX.ts (암호화된 세그먼트)
# - enc.key (암호화 키, 서버에 업로드해야 함)

# playlist.m3u8 내용:
cat playlist.m3u8
# #EXTM3U
# #EXT-X-VERSION:3
# #EXT-X-TARGETDURATION:10
# #EXT-X-MEDIA-SEQUENCE:0
# #EXT-X-KEY:METHOD=AES-128,URI="https://example.com/api/keys/550e8400.bin",IV=0x1234567890abcdef1234567890abcdef
# #EXTINF:10.0,
# segment_000.ts
# #EXTINF:10.0,
# segment_001.ts
# ...
# #EXT-X-ENDLIST

3단계: 멀티 비트레이트 + 암호화

# 480p, 720p, 1080p를 각각 다른 키로 암호화

# 1. 각 화질별로 키 생성
for quality in 480p 720p 1080p; do
  openssl rand 16 > enc_${quality}.key
  openssl rand -hex 16 > enc_${quality}.iv
  
  echo "https://example.com/keys/${quality}_$(uuidgen).bin" > enc_${quality}.keyinfo
  echo "$(pwd)/enc_${quality}.key" >> enc_${quality}.keyinfo
  cat enc_${quality}.iv >> enc_${quality}.keyinfo
done

# 2. 멀티 비트레이트 인코딩 + 암호화
ffmpeg -i input.mp4 \
  # 480p
  -map 0:v:0 -map 0:a:0 \
  -s:v:0 854x480 -c:v:0 libx264 -b:v:0 1000k \
  -c:a:0 aac -b:a:0 96k \
  -hls_time 10 \
  -hls_key_info_file enc_480p.keyinfo \
  -hls_segment_filename "480p/segment_%03d.ts" \
  480p/playlist.m3u8 \
  \
  # 720p
  -map 0:v:0 -map 0:a:0 \
  -s:v:1 1280x720 -c:v:1 libx264 -b:v:1 2500k \
  -c:a:1 aac -b:a:1 128k \
  -hls_time 10 \
  -hls_key_info_file enc_720p.keyinfo \
  -hls_segment_filename "720p/segment_%03d.ts" \
  720p/playlist.m3u8 \
  \
  # 1080p
  -map 0:v:0 -map 0:a:0 \
  -s:v:2 1920x1080 -c:v:2 libx264 -b:v:2 5000k \
  -c:a:2 aac -b:a:2 128k \
  -hls_time 10 \
  -hls_key_info_file enc_1080p.keyinfo \
  -hls_segment_filename "1080p/segment_%03d.ts" \
  1080p/playlist.m3u8

# 3. 마스터 플레이리스트 생성 (master.m3u8)
cat > master.m3u8 << 'EOF'
#EXTM3U
#EXT-X-VERSION:3
#EXT-X-STREAM-INF:BANDWIDTH=1200000,RESOLUTION=854x480,CODECS="avc1.42001e,mp4a.40.2"
480p/playlist.m3u8
#EXT-X-STREAM-INF:BANDWIDTH=3000000,RESOLUTION=1280x720,CODECS="avc1.42001f,mp4a.40.2"
720p/playlist.m3u8
#EXT-X-STREAM-INF:BANDWIDTH=6000000,RESOLUTION=1920x1080,CODECS="avc1.640028,mp4a.40.2"
1080p/playlist.m3u8
EOF

# 구조:
# master.m3u8 (마스터 플레이리스트)
# ├─ 480p/
# │  ├─ playlist.m3u8 (키: enc_480p.key)
# │  └─ segment_000.ts ~ segment_XXX.ts
# ├─ 720p/
# │  ├─ playlist.m3u8 (키: enc_720p.key)
# │  └─ segment_000.ts ~ segment_XXX.ts
# └─ 1080p/
#    ├─ playlist.m3u8 (키: enc_1080p.key)
#    └─ segment_000.ts ~ segment_XXX.ts

# 사용자 구독 등급별로 다른 키 제공:
# - 무료: 480p 키만 제공
# - 기본: 480p, 720p 키 제공
# - 프리미엄: 모든 키 제공

4단계: 키 서버 구현 (Node.js + Express)

const express = require('express');
const jwt = require('jsonwebtoken');
const fs = require('fs');

const app = express();
const SECRET = 'your-jwt-secret';

// 키 저장소 (실전에서는 데이터베이스 사용)
const keys = {
  '480p': fs.readFileSync('./enc_480p.key'),
  '720p': fs.readFileSync('./enc_720p.key'),
  '1080p': fs.readFileSync('./enc_1080p.key')
};

// 사용자 구독 등급
const subscriptions = {
  'user_free': ['480p'],
  'user_basic': ['480p', '720p'],
  'user_premium': ['480p', '720p', '1080p']
};

// 키 제공 엔드포인트
app.get('/api/keys/:keyId.bin', (req, res) => {
  const { keyId } = req.params;
  const token = req.query.token;
  
  // 1. JWT 토큰 검증
  try {
    const decoded = jwt.verify(token, SECRET);
    const userId = decoded.userId;
    
    console.log(`Key request: ${keyId} from ${userId}`);
    
    // 2. 키 ID에서 화질 추출
    // 예: 480p_550e8400-e29b-41d4-a716.bin → "480p"
    const quality = keyId.split('_')[0];
    
    // 3. 사용자 구독 등급 확인
    const allowedQualities = subscriptions[userId] || [];
    
    if (!allowedQualities.includes(quality)) {
      console.log(`❌ ${userId} is not subscribed to ${quality}`);
      return res.status(403).json({ error: 'Subscription required' });
    }
    
    // 4. 키 제공
    const key = keys[quality];
    if (!key) {
      return res.status(404).json({ error: 'Key not found' });
    }
    
    console.log(`✅ Key provided: ${quality} to ${userId}`);
    
    // 5. 바이너리로 응답
    res.setHeader('Content-Type', 'application/octet-stream');
    res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate');
    res.send(key);
    
  } catch (error) {
    console.error('❌ Token verification failed:', error.message);
    return res.status(401).json({ error: 'Invalid token' });
  }
});

// JWT 토큰 생성 (로그인 시)
app.post('/api/login', (req, res) => {
  const { userId, subscription } = req.body;
  
  const token = jwt.sign(
    { userId, subscription },
    SECRET,
    { expiresIn: '24h' }  // 24시간 유효
  );
  
  res.json({ token });
});

app.listen(3000, () => {
  console.log('🔑 Key server running on :3000');
});

// 사용:
// 1. 로그인: POST /api/login { userId: "user_premium", subscription: "premium" }
//    → 응답: { token: "eyJhbGc..." }
// 2. 키 요청: GET /api/keys/1080p_550e8400.bin?token=eyJhbGc...
//    → 응답: 16바이트 바이너리 키

HLS 복호화 이해 (교육 목적)

⚠️ 주의: 이 섹션은 AES-128 암호화가 어떻게 작동하는지 이해하기 위한 교육 목적입니다. 불법적인 콘텐츠 다운로드/복호화는 저작권법 위반이며, 법적 책임을 질 수 있습니다.

HLS AES-128 복호화 원리

HLS 플레이어가 세그먼트를 재생하는 과정:

1. master.m3u8 다운로드
2. 적절한 화질의 playlist.m3u8 선택
3. playlist.m3u8 파싱:
   #EXT-X-KEY:METHOD=AES-128,URI="...",IV=0x...
   → 키 URL과 IV 추출
4. 키 다운로드 (key.bin)
5. 각 세그먼트(segment_XXX.ts)를:
   a. 다운로드
   b. AES-128 CBC 모드로 복호화
   c. 비디오/오디오 디코딩
   d. 재생

복호화 코드 (Python)

from Crypto.Cipher import AES
import requests
import os

def decrypt_hls_segment(segment_url, key_url, iv_hex, output_file):
    """
    HLS 세그먼트 복호화
    
    Args:
        segment_url: 암호화된 세그먼트 URL
        key_url: 암호화 키 URL
        iv_hex: IV (16진수 문자열, 32자)
        output_file: 복호화된 파일 저장 경로
    """
    
    # 1. 암호화 키 다운로드
    print(f"📥 Downloading key: {key_url}")
    key_response = requests.get(key_url)
    
    if key_response.status_code != 200:
        raise Exception(f"Failed to download key: {key_response.status_code}")
    
    key = key_response.content
    print(f"✅ Key downloaded: {len(key)} bytes")
    
    if len(key) != 16:
        raise Exception(f"Invalid key size: {len(key)} (expected 16)")
    
    # 2. 암호화된 세그먼트 다운로드
    print(f"📥 Downloading segment: {segment_url}")
    segment_response = requests.get(segment_url)
    
    if segment_response.status_code != 200:
        raise Exception(f"Failed to download segment: {segment_response.status_code}")
    
    encrypted_data = segment_response.content
    print(f"✅ Segment downloaded: {len(encrypted_data)} bytes")
    
    # 3. IV (Initialization Vector) 준비
    # IV는 32자 hex 문자열 (예: "1234567890abcdef1234567890abcdef")
    # 앞의 "0x" 제거
    if iv_hex.startswith('0x'):
        iv_hex = iv_hex[2:]
    
    iv = bytes.fromhex(iv_hex)
    print(f"✅ IV: {iv_hex}")
    
    if len(iv) != 16:
        raise Exception(f"Invalid IV size: {len(iv)} (expected 16)")
    
    # 4. AES-128 CBC 모드로 복호화
    # - AES-128: 키 크기 128비트 (16바이트)
    # - CBC: Cipher Block Chaining (블록 단위 체인 암호화)
    # - IV: 첫 번째 블록의 XOR 입력
    print("🔓 Decrypting...")
    cipher = AES.new(key, AES.MODE_CBC, iv)
    decrypted_data = cipher.decrypt(encrypted_data)
    
    # 5. PKCS7 패딩 제거
    # AES는 16바이트 블록 단위로 암호화하므로
    # 마지막 블록이 16의 배수가 아니면 패딩 추가됨
    # 
    # 예: 실제 데이터 14바이트 → 2바이트 패딩 (0x02 0x02)
    #     실제 데이터 16바이트 → 16바이트 패딩 (0x10 * 16)
    padding_length = decrypted_data[-1]
    
    # 패딩 검증
    if padding_length > 16:
        print(f"⚠️ Warning: Invalid padding length {padding_length}")
    else:
        # 패딩 제거
        decrypted_data = decrypted_data[:-padding_length]
        print(f"✅ Padding removed: {padding_length} bytes")
    
    # 6. 복호화된 데이터 저장
    with open(output_file, 'wb') as f:
        f.write(decrypted_data)
    
    print(f"✅ Decrypted file saved: {output_file} ({len(decrypted_data)} bytes)")
    return decrypted_data

# 사용 예시
if __name__ == "__main__":
    # m3u8에서 추출한 정보
    segment_url = 'https://cdn.example.com/segment_000.ts'
    key_url = 'https://example.com/key.bin'
    iv = '1234567890abcdef1234567890abcdef'
    
    # 복호화
    decrypt_hls_segment(
        segment_url=segment_url,
        key_url=key_url,
        iv_hex=iv,
        output_file='decrypted_segment_000.ts'
    )
    
    # 모든 세그먼트 복호화 및 병합
    # ffmpeg -i "concat:seg0.ts|seg1.ts|seg2.ts" -c copy output.mp4

HLS 다운로더 (전체 영상 복호화)

import re
import subprocess

def download_and_decrypt_hls(m3u8_url, output_file):
    """
    HLS 스트림 전체를 다운로드 및 복호화
    """
    
    # 1. m3u8 다운로드
    print(f"📥 Downloading m3u8: {m3u8_url}")
    response = requests.get(m3u8_url)
    m3u8_content = response.text
    
    # 2. 키 URL과 IV 추출
    key_match = re.search(r'#EXT-X-KEY:METHOD=AES-128,URI="([^"]+)"(?:,IV=0x([0-9a-fA-F]+))?', m3u8_content)
    
    if not key_match:
        raise Exception("No encryption key found in m3u8")
    
    key_url = key_match.group(1)
    iv = key_match.group(2) or '00000000000000000000000000000000'
    
    print(f"🔑 Key URL: {key_url}")
    print(f"🔢 IV: {iv}")
    
    # 3. 세그먼트 URL 추출
    base_url = m3u8_url.rsplit('/', 1)[0]
    segments = re.findall(r'^[^#\n][^\n]*\.ts', m3u8_content, re.MULTILINE)
    
    print(f"📦 Found {len(segments)} segments")
    
    # 4. 각 세그먼트 다운로드 및 복호화
    decrypted_segments = []
    
    for i, segment in enumerate(segments):
        segment_url = f"{base_url}/{segment}" if not segment.startswith('http') else segment
        output = f"temp_segment_{i:03d}.ts"
        
        print(f"\n[{i+1}/{len(segments)}] Processing {segment}")
        decrypt_hls_segment(segment_url, key_url, iv, output)
        decrypted_segments.append(output)
    
    # 5. FFmpeg로 세그먼트 병합
    print("\n📹 Merging segments with FFmpeg...")
    concat_file = 'concat_list.txt'
    
    with open(concat_file, 'w') as f:
        for seg in decrypted_segments:
            f.write(f"file '{seg}'\n")
    
    subprocess.run([
        'ffmpeg',
        '-f', 'concat',
        '-safe', '0',
        '-i', concat_file,
        '-c', 'copy',
        output_file
    ], check=True)
    
    # 6. 임시 파일 삭제
    for seg in decrypted_segments:
        os.remove(seg)
    os.remove(concat_file)
    
    print(f"\n✅ Complete: {output_file}")

# 사용
download_and_decrypt_hls(
    'https://example.com/playlist.m3u8',
    'output.mp4'
)

왜 이렇게 쉽게 복호화가 가능한가요?

AES-128의 문제점:
1. 키가 HTTP로 전송됨 (HTTPS라도 클라이언트에 도착)
2. 클라이언트 메모리에 평문 키 존재
3. 브라우저 개발자 도구로 키 URL 확인 가능
4. 키가 정적 (콘텐츠별로 고정)

DRM의 차이점:
1. 키가 암호화되어 전송됨
2. CDM만 키를 복호화 가능 (JavaScript 접근 불가)
3. 하드웨어에서 복호화 (메모리에 평문 노출 안 됨)
4. 키가 동적 (사용자/세션별로 다름)

보안 강화 방법:

  1. 토큰 기반 키 URL (앞서 설명한 Node.js 예시)
  2. 키 로테이션: 세그먼트마다 다른 키 사용
    #EXT-X-KEY:METHOD=AES-128,URI="https://example.com/key1.bin"
    segment_000.ts
    segment_001.ts
    #EXT-X-KEY:METHOD=AES-128,URI="https://example.com/key2.bin"
    segment_002.ts
    segment_003.ts
  3. 지역 제한 + 시간 제한: 키 요청 시 IP/시간 검증
  4. 워터마킹: 사용자 ID를 비디오에 삽입 (추적 가능) with open(‘decrypted_segment.ts’, ‘wb’) as f: f.write(decrypted)
---
## <a name="implementation"></a>8. 실전 구현
### 멀티 DRM 플레이어
```javascript
class MultiDRMPlayer {
  constructor(videoElement) {
    this.video = videoElement;
    this.drmSystem = null;
  }
  
  async initialize(manifestUrl, licenseUrl, authToken) {
    // DRM 시스템 감지
    this.drmSystem = await this.detectDRM();
    console.log(`🔒 DRM System: ${this.drmSystem}`);
    
    // DRM 설정
    await this.setupDRM(licenseUrl, authToken);
    
    // 비디오 로드
    this.video.src = manifestUrl;
    await this.video.play();
  }
  
  async detectDRM() {
    // 플랫폼별 DRM 감지
    const ua = navigator.userAgent;
    
    if (/iPhone|iPad|iPod/.test(ua)) {
      return 'fairplay';
    } else if (/Android/.test(ua)) {
      return 'widevine';
    } else if (/Windows/.test(ua)) {
      // Edge는 PlayReady와 Widevine 모두 지원
      return 'widevine';  // 또는 'playready'
    } else if (/Mac/.test(ua)) {
      return 'fairplay';
    }
    
    return 'widevine';  // 기본값
  }
  
  async setupDRM(licenseUrl, authToken) {
    if (this.drmSystem === 'fairplay') {
      await this.setupFairPlay(licenseUrl, authToken);
    } else if (this.drmSystem === 'widevine') {
      await this.setupWidevine(licenseUrl, authToken);
    } else if (this.drmSystem === 'playready') {
      await this.setupPlayReady(licenseUrl, authToken);
    }
  }
  
  async setupWidevine(licenseUrl, authToken) {
    const config = [{
      initDataTypes: ['cenc'],
      videoCapabilities: [{
        contentType: 'video/mp4; codecs="avc1.42E01E"',
        robustness: 'SW_SECURE_CRYPTO'
      }],
      audioCapabilities: [{
        contentType: 'audio/mp4; codecs="mp4a.40.2"',
        robustness: 'SW_SECURE_CRYPTO'
      }]
    }];
    
    const keySystemAccess = await navigator.requestMediaKeySystemAccess(
      'com.widevine.alpha',
      config
    );
    
    const mediaKeys = await keySystemAccess.createMediaKeys();
    await this.video.setMediaKeys(mediaKeys);
    
    // 라이선스 요청 처리
    this.video.addEventListener('encrypted', (event) => {
      const session = mediaKeys.createSession();
      
      session.addEventListener('message', async (event) => {
        const response = await fetch(licenseUrl, {
          method: 'POST',
          headers: {
            'Content-Type': 'application/octet-stream',
            'Authorization': `Bearer ${authToken}`
          },
          body: event.message
        });
        
        const license = await response.arrayBuffer();
        await session.update(license);
      });
      
      session.generateRequest(event.initDataType, event.initData);
    });
    
    console.log('✅ Widevine initialized');
  }
  
  async setupFairPlay(licenseUrl, authToken) {
    // FairPlay는 네이티브 구현 필요
    console.log('ℹ️  FairPlay requires native implementation');
  }
  
  async setupPlayReady(licenseUrl, authToken) {
    const config = [{
      initDataTypes: ['cenc'],
      videoCapabilities: [{
        contentType: 'video/mp4; codecs="avc1.42E01E"'
      }]
    }];
    
    const keySystemAccess = await navigator.requestMediaKeySystemAccess(
      'com.microsoft.playready',
      config
    );
    
    const mediaKeys = await keySystemAccess.createMediaKeys();
    await this.video.setMediaKeys(mediaKeys);
    
    console.log('✅ PlayReady initialized');
  }
}
// 사용
const player = new MultiDRMPlayer(document.querySelector('video'));
player.initialize(
  'https://cdn.example.com/manifest.mpd',
  'https://license.example.com/widevine',
  'user-auth-token-123'
);

9. DRM 우회 방지 전략

DRM만으로는 충분하지 않습니다. 완벽한 보안은 없지만, 다층 방어(Defense in Depth)로 공격 비용을 높이는 것이 목표입니다.

공격자의 3가지 유형:

유형기술 수준동기방어 전략
캐주얼 사용자낮음무료 시청기본 DRM으로 충분
기술적 사용자중간개인 사용, 공유DRM + 워터마킹 + 모니터링
전문 해커높음상업적 재배포모든 레이어 + 법적 대응

방어 비용 vs 공격 비용:

목표: 공격 비용 > 콘텐츠 가치

예시:
- 10,000원짜리 영화를 해킹하는데 100시간 소요
- 시급 10,000원이면 공격 비용 = 1,000,000원
→ 경제적으로 비효율적 → 대부분 포기

DRM의 진짜 목적:
- 100% 방어 (불가능)
- 공격 비용 ↑↑↑ (가능)

다층 보안 전략

레이어 1: 암호화 (Encryption)

flowchart TB
    subgraph Layer1["레이어 1: 암호화"]
        Encrypt["AES-128/256\n콘텐츠 암호화"]
    end
    
    subgraph Layer2["레이어 2: 키 관리"]
        KeyRotation[키 로테이션]
        KeyDerivation[키 파생]
    end
    
    subgraph Layer3["레이어 3: 하드웨어 보안"]
        TEE["Trusted Execution\nEnvironment"]
        SecurePath[Secure Video Path]
    end
    
    subgraph Layer4["레이어 4: 애플리케이션"]
        Obfuscation[코드 난독화]
        AntiDebug[디버깅 방지]
        Watermark[워터마크]
    end
    
    subgraph Layer5["레이어 5: 네트워크"]
        TokenAuth[토큰 인증]
        GeoBlock[지역 차단]
        RateLimit[재생 횟수 제한]
    end
    
    Encrypt --> KeyRotation
    KeyRotation --> TEE
    TEE --> Obfuscation
    Obfuscation --> TokenAuth

레이어 4: 포렌식 워터마킹 (Forensic Watermarking)

워터마킹이란?

  • 비디오에 보이지 않는 사용자 ID를 삽입
  • 불법 유출 시 추적 가능
  • 심리적 압박 효과 (유출 시 본인이 책임)

워터마킹 종류:

종류가시성강건성사용 예
Visible (가시적)보임낮음”User: [email protected]” 텍스트 표시
Invisible (비가시적)안 보임중간LSB (Least Significant Bit)
Forensic (포렌식)안 보임높음주파수 도메인 삽입, 압축 후에도 유지
Session-based안 보임매우 높음프레임마다 다른 패턴

실전 워터마킹 구현:

from PIL import Image, ImageDraw, ImageFont
import hashlib
import numpy as np

def add_visible_watermark(frame, user_id, opacity=0.3):
    """
    가시적 워터마크 (텍스트)
    - 장점: 구현 간단, 명확한 소유권 표시
    - 단점: 잘라내기 쉬움
    """
    img = Image.fromarray(frame)
    
    # 투명 레이어 생성
    watermark_layer = Image.new('RGBA', img.size, (0, 0, 0, 0))
    draw = ImageDraw.Draw(watermark_layer)
    
    # 폰트 설정 (크기는 해상도에 비례)
    font_size = max(20, img.width // 50)
    try:
        font = ImageFont.truetype("arial.ttf", font_size)
    except:
        font = ImageFont.load_default()
    
    # 워터마크 텍스트 (사용자 ID + 타임스탬프)
    from datetime import datetime
    watermark_text = f"User: {user_id} | {datetime.now().strftime('%Y-%m-%d %H:%M')}"
    
    # 텍스트 크기 계산
    bbox = draw.textbbox((0, 0), watermark_text, font=font)
    text_width = bbox[2] - bbox[0]
    text_height = bbox[3] - bbox[1]
    
    # 우측 하단에 배치
    position = (img.width - text_width - 20, img.height - text_height - 20)
    
    # 반투명 배경
    padding = 10
    bg_box = [
        position[0] - padding,
        position[1] - padding,
        position[0] + text_width + padding,
        position[1] + text_height + padding
    ]
    draw.rectangle(bg_box, fill=(0, 0, 0, int(255 * 0.5)))
    
    # 텍스트 그리기
    draw.text(position, watermark_text, fill=(255, 255, 255, int(255 * opacity)), font=font)
    
    # 원본과 합성
    img = img.convert('RGBA')
    img = Image.alpha_composite(img, watermark_layer)
    return np.array(img.convert('RGB'))


def add_forensic_watermark(frame, user_id, session_id):
    """
    포렌식 워터마크 (보이지 않음)
    - LSB (Least Significant Bit) 스테가노그래피
    - 육안으로 보이지 않음
    - 압축에 취약 (H.264 등)
    """
    img = np.array(frame, dtype=np.uint8)
    h, w, c = img.shape
    
    # 워터마크 데이터 생성
    watermark_data = f"{user_id}:{session_id}"
    watermark_hash = hashlib.sha256(watermark_data.encode()).hexdigest()
    
    # 바이너리로 변환
    watermark_bits = ''.join(format(ord(char), '08b') for char in watermark_hash)
    
    # LSB에 삽입 (Red 채널만 사용)
    bit_index = 0
    for i in range(h):
        for j in range(w):
            if bit_index < len(watermark_bits):
                # 최하위 비트를 워터마크 비트로 교체
                # 예: 11010110 → 11010111 (마지막 비트만 변경)
                pixel = img[i, j, 0]
                pixel = (pixel & 0xFE) | int(watermark_bits[bit_index])
                img[i, j, 0] = pixel
                bit_index += 1
            else:
                break
        if bit_index >= len(watermark_bits):
            break
    
    return img


def extract_forensic_watermark(frame):
    """포렌식 워터마크 추출"""
    img = np.array(frame, dtype=np.uint8)
    h, w, c = img.shape
    
    # LSB 추출
    extracted_bits = []
    for i in range(h):
        for j in range(w):
            # Red 채널의 LSB 추출
            pixel = img[i, j, 0]
            extracted_bits.append(str(pixel & 1))
            
            # SHA256 해시 길이 (64자 * 8비트 = 512비트)
            if len(extracted_bits) >= 512:
                break
        if len(extracted_bits) >= 512:
            break
    
    # 바이너리 → 문자열
    watermark_hash = ''
    for i in range(0, len(extracted_bits), 8):
        byte = ''.join(extracted_bits[i:i+8])
        watermark_hash += chr(int(byte, 2))
    
    return watermark_hash


def add_session_watermark_ffmpeg(input_video, output_video, user_id, session_id):
    """
    FFmpeg를 사용한 동적 워터마크
    - 프레임마다 위치 변경
    - 잘라내기 어려움
    """
    import subprocess
    from datetime import datetime
    
    # 워터마크 텍스트
    watermark_text = f"User: {user_id} | Session: {session_id}"
    timestamp = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
    
    # FFmpeg drawtext 필터
    # - x, y를 시간에 따라 변경
    # - fontcolor_expr로 투명도 변경
    filter_complex = f"""
    [0:v]drawtext=text='{watermark_text}':
    fontfile=/path/to/font.ttf:
    fontsize=24:
    [email protected]:
    x='if(lt(mod(t,10),5), 50, W-tw-50)':
    y='if(lt(mod(t,10),5), 50, H-th-50)':
    enable='between(t,0,3600)'[wm1];
    
    [wm1]drawtext=text='{timestamp}':
    fontfile=/path/to/font.ttf:
    fontsize=16:
    [email protected]:
    x='W-tw-10':
    y='10'[out]
    """
    
    # FFmpeg 명령어
    cmd = [
        'ffmpeg',
        '-i', input_video,
        '-filter_complex', filter_complex,
        '-map', '[out]',
        '-map', '0:a',  # 오디오 유지
        '-c:v', 'libx264',
        '-crf', '18',  # 고화질 유지
        '-c:a', 'copy',  # 오디오 재인코딩 안 함
        output_video
    ]
    
    subprocess.run(cmd, check=True)
    print(f"✅ Watermarked video: {output_video}")


# 사용 예시
if __name__ == "__main__":
    import cv2
    
    # 비디오 로드
    cap = cv2.VideoCapture('input.mp4')
    ret, frame = cap.read()
    
    # 가시적 워터마크
    watermarked_visible = add_visible_watermark(frame, "[email protected]")
    cv2.imwrite('watermarked_visible.jpg', watermarked_visible)
    
    # 포렌식 워터마크 (삽입)
    watermarked_forensic = add_forensic_watermark(frame, "user123", "session-abc-def")
    cv2.imwrite('watermarked_forensic.jpg', watermarked_forensic)
    
    # 포렌식 워터마크 (추출)
    extracted = extract_forensic_watermark(watermarked_forensic)
    print(f"Extracted watermark: {extracted}")
    
    cap.release()

워터마킹 Best Practices:

  1. 다중 워터마크 사용

    - 가시적: 사용자 인지 (심리적 압박)
    - 비가시적: 포렌식 추적 (법적 증거)
    - 세션 기반: 재생마다 다름 (녹화 추적)
  2. 워터마크 위치 변경

    # 시간에 따라 위치 변경 (잘라내기 방지)
    import math
    
    def get_dynamic_position(frame_number, width, height):
        # 사인파로 위치 이동
        t = frame_number / 30.0  # 30fps 기준
        x = int(width * 0.1 + width * 0.3 * math.sin(t * 0.5))
        y = int(height * 0.1 + height * 0.3 * math.cos(t * 0.5))
        return (x, y)
  3. 서버 사이드 워터마킹

    클라이언트 요청 시:
    1. 사용자 인증
    2. 세션 ID 생성
    3. 서버에서 워터마크 삽입
    4. 개인화된 스트림 전송
    
    장점: 클라이언트가 우회 불가능
    단점: 서버 부하 증가 (트랜스코딩)

레이어 5: 화면 캡처 및 녹화 방지

⚠️ 중요한 사실: 완벽한 화면 캡처 방지는 불가능합니다.

아날로그 홀 (Analog Hole):
- 화면에 표시되는 순간, 카메라로 촬영 가능
- HDMI 캡처 장치로 녹화 가능
- 가상 머신에서 녹화 가능

목표:
- 100% 방지 (불가능)
- 캐주얼 사용자의 녹화 시도 차단 (가능)
- 전문적인 녹화 추적 (워터마킹)

하드웨어 레벨 보호:

1. HDCP (High-bandwidth Digital Content Protection)
   - HDMI/DisplayPort 암호화
   - 디스플레이까지 암호화 유지
   - HDCP 미지원 기기: 재생 거부 or 화질 제한

2. Secure Video Path (Widevine L1, PlayReady SL3000)
   - GPU에서 직접 디스플레이로 출력
   - 프레임버퍼 메모리 접근 불가
   - 스크린샷 API 차단

Windows 예시:
- Protected Video Path (PVP) 활성화 시
- Print Screen → 검은 화면
- OBS/Bandicam → 검은 화면 (GPU 보호)

소프트웨어 레벨 감지 (우회 가능):

class ScreenCaptureDetector {
  constructor(video) {
    this.video = video;
    this.violations = 0;
    this.init();
  }
  
  init() {
    // 1. Page Visibility API (탭 전환 감지)
    document.addEventListener('visibilitychange', () => {
      if (document.hidden) {
        console.warn('⚠️ 탭이 숨겨짐 (화면 녹화 의심)');
        this.handleViolation('tab_hidden');
      }
    });
    
    // 2. 개발자 도구 감지
    this.detectDevTools();
    
    // 3. 화면 해상도 변경 감지 (가상 머신)
    window.addEventListener('resize', () => {
      console.warn('⚠️ 화면 크기 변경 (가상 머신 의심)');
    });
    
    // 4. 복사/붙여넣기 차단
    document.addEventListener('copy', (e) => {
      e.preventDefault();
      this.handleViolation('copy_attempt');
    });
    
    // 5. 우클릭 방지
    this.video.addEventListener('contextmenu', (e) => {
      e.preventDefault();
    });
    
    // 6. 드래그 방지
    this.video.addEventListener('dragstart', (e) => {
      e.preventDefault();
    });
    
    // 7. 키보드 단축키 차단 (Print Screen는 차단 불가)
    document.addEventListener('keydown', (e) => {
      // F12 (DevTools)
      if (e.key === 'F12') {
        e.preventDefault();
        this.handleViolation('f12_pressed');
      }
      
      // Ctrl+Shift+I (DevTools)
      if (e.ctrlKey && e.shiftKey && e.key === 'I') {
        e.preventDefault();
        this.handleViolation('devtools_shortcut');
      }
      
      // Ctrl+S (저장)
      if (e.ctrlKey && e.key === 's') {
        e.preventDefault();
        this.handleViolation('save_attempt');
      }
    });
    
    // 8. 비디오 다운로드 차단
    this.video.controlsList = 'nodownload';
    
    // 9. Picture-in-Picture 차단 (선택사항)
    this.video.disablePictureInPicture = true;
  }
  
  detectDevTools() {
    // 방법 1: debugger 문 사용
    setInterval(() => {
      const before = performance.now();
      debugger;  // DevTools 열려있으면 여기서 멈춤
      const after = performance.now();
      
      if (after - before > 100) {
        console.warn('⚠️ 개발자 도구 감지됨');
        this.handleViolation('devtools_open');
      }
    }, 1000);
    
    // 방법 2: console.log 오버라이드
    const originalLog = console.log;
    let logCount = 0;
    console.log = function(...args) {
      logCount++;
      if (logCount > 100) {
        // 개발자 도구에서 console 많이 사용 중
        console.warn('⚠️ 비정상적인 console 사용');
      }
      originalLog.apply(console, args);
    };
    
    // 방법 3: window.outerWidth/innerWidth 비교
    setInterval(() => {
      const widthDiff = window.outerWidth - window.innerWidth;
      const heightDiff = window.outerHeight - window.innerHeight;
      
      // DevTools가 도킹되면 차이가 큼
      if (widthDiff > 160 || heightDiff > 160) {
        console.warn('⚠️ DevTools 도킹 의심');
        this.handleViolation('devtools_docked');
      }
    }, 2000);
  }
  
  handleViolation(type) {
    this.violations++;
    console.error(`🚨 보안 위반: ${type} (총 ${this.violations}회)`);
    
    // 서버에 보고
    fetch('/api/security/violation', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({
        type,
        timestamp: new Date().toISOString(),
        violations: this.violations,
        userAgent: navigator.userAgent
      })
    });
    
    // 3회 위반 시 재생 중지
    if (this.violations >= 3) {
      this.video.pause();
      alert('보안 정책 위반으로 재생이 중지되었습니다.');
      
      // 세션 무효화
      this.video.src = '';
    }
  }
}

// 사용
const detector = new ScreenCaptureDetector(document.querySelector('video'));

Best Practices:

// 1. DRM + 감지 조합
class SecureVideoPlayer {
  constructor(videoElement) {
    this.video = videoElement;
    
    // DRM 초기화
    this.setupDRM();
    
    // 감지 시스템
    this.detector = new ScreenCaptureDetector(this.video);
    
    // 워터마크
    this.enableWatermark();
  }
  
  async setupDRM() {
    // Widevine L1 시도 (하드웨어 보호)
    try {
      await this.initWidevineL1();
      console.log('✅ 하드웨어 DRM 활성화 (L1)');
    } catch (e) {
      // L1 실패 시 L3로 폴백 + 화질 제한
      await this.initWidevineL3();
      this.video.style.maxWidth = '640px';  // SD로 제한
      console.warn('⚠️ 소프트웨어 DRM (L3) - SD 화질');
    }
  }
  
  enableWatermark() {
    // 캔버스에 워터마크 오버레이
    const canvas = document.createElement('canvas');
    const ctx = canvas.getContext('2d');
    
    this.video.addEventListener('play', () => {
      const drawWatermark = () => {
        if (this.video.paused) return;
        
        // 비디오 프레임 복사
        canvas.width = this.video.videoWidth;
        canvas.height = this.video.videoHeight;
        ctx.drawImage(this.video, 0, 0);
        
        // 워터마크 그리기
        ctx.font = '20px Arial';
        ctx.fillStyle = 'rgba(255, 255, 255, 0.3)';
        ctx.fillText(`User: ${userId}`, 10, 30);
        
        requestAnimationFrame(drawWatermark);
      };
      
      drawWatermark();
    });
  }
}

서버 측 모니터링:

# Flask 예시
@app.route('/api/security/violation', methods=['POST'])
def report_violation():
    data = request.json
    user_id = get_current_user_id()
    
    # 위반 로그 저장
    log_violation(
        user_id=user_id,
        type=data['type'],
        timestamp=data['timestamp'],
        ip=request.remote_addr,
        user_agent=data['userAgent']
    )
    
    # 누적 위반 횟수 확인
    violations = get_user_violations(user_id, last_days=7)
    
    if violations >= 10:
        # 계정 일시 정지
        suspend_user(user_id, duration=24 * 3600)  # 24시간
        send_email(user_id, '계정 일시 정지 안내')
        
        return jsonify({'action': 'account_suspended'}), 403
    
    return jsonify({'action': 'logged'}), 200

실전 시나리오

시나리오 1: Netflix 스타일 DRM

from flask import Flask, request, jsonify
import jwt
import time
import hashlib
app = Flask(__name__)
class DRMLicenseServer:
    def __init__(self):
        self.content_keys = {}  # content_id -> key
        self.user_licenses = {}  # user_id -> [content_ids]
    
    def generate_content_key(self, content_id):
        """콘텐츠 암호화 키 생성"""
        key = hashlib.sha256(f"{content_id}:{time.time()}".encode()).digest()[:16]
        self.content_keys[content_id] = key
        return key
    
    def verify_user_access(self, user_id, content_id):
        """사용자 접근 권한 확인"""
        # 구독 상태 확인
        subscription = get_user_subscription(user_id)
        if not subscription or not subscription.is_active():
            return False
        
        # 지역 제한 확인
        user_country = get_user_country(user_id)
        content_regions = get_content_regions(content_id)
        if user_country not in content_regions:
            return False
        
        # 동시 재생 제한 확인
        active_sessions = get_active_sessions(user_id)
        if len(active_sessions) >= subscription.max_streams:
            return False
        
        return True
    
    def issue_license(self, user_id, content_id, device_id):
        """라이선스 발급"""
        if not self.verify_user_access(user_id, content_id):
            return None
        
        # 라이선스 생성
        license_data = {
            'user_id': user_id,
            'content_id': content_id,
            'device_id': device_id,
            'issued_at': int(time.time()),
            'expires_at': int(time.time()) + 86400,  # 24시간
            'max_resolution': '1080p',
            'offline_allowed': False
        }
        
        # JWT로 서명
        license_token = jwt.encode(
            license_data,
            'secret-key',
            algorithm='HS256'
        )
        
        # 콘텐츠 키 (암호화되어 전달)
        content_key = self.content_keys.get(content_id)
        
        return {
            'license': license_token,
            'key': content_key.hex(),
            'expires_in': 86400
        }
@app.route('/license/widevine', methods=['POST'])
def widevine_license():
    # Widevine 라이선스 요청 처리
    challenge = request.data  # Widevine challenge
    auth_token = request.headers.get('Authorization')
    
    # 토큰 검증
    try:
        payload = jwt.decode(auth_token.replace('Bearer ', '), 'secret-key', algorithms=['HS256'])
        user_id = payload['user_id']
        content_id = payload['content_id']
    except:
        return jsonify({'error': 'Invalid token'}), 401
    
    # 라이선스 발급
    drm = DRMLicenseServer()
    license_data = drm.issue_license(user_id, content_id, request.remote_addr)
    
    if not license_data:
        return jsonify({'error': 'Access denied'}), 403
    
    # Widevine 라이선스 응답 (실제로는 Widevine SDK 사용)
    return license_data['key'], 200, {'Content-Type': 'application/octet-stream'}

시나리오 2: 오프라인 재생 (다운로드)

// 오프라인 DRM 콘텐츠 저장
class OfflineDRMPlayer {
  async downloadContent(manifestUrl, licenseUrl, authToken) {
    // Persistent License 요청
    const config = [{
      initDataTypes: ['cenc'],
      videoCapabilities: [{
        contentType: 'video/mp4; codecs="avc1.42E01E"'
      }],
      persistentState: 'required',  // 오프라인 지원
      sessionTypes: ['persistent-license']
    }];
    
    const keySystemAccess = await navigator.requestMediaKeySystemAccess(
      'com.widevine.alpha',
      config
    );
    
    const mediaKeys = await keySystemAccess.createMediaKeys();
    
    // Persistent Session 생성
    const session = mediaKeys.createSession('persistent-license');
    
    // 라이선스 요청
    session.addEventListener('message', async (event) => {
      const response = await fetch(licenseUrl, {
        method: 'POST',
        headers: {
          'Content-Type': 'application/octet-stream',
          'Authorization': `Bearer ${authToken}`
        },
        body: event.message
      });
      
      const license = await response.arrayBuffer();
      await session.update(license);
      
      // Session ID 저장 (나중에 재사용)
      const sessionId = session.sessionId;
      localStorage.setItem('drm-session-' + manifestUrl, sessionId);
      
      console.log('✅ Persistent license stored');
    });
    
    // 콘텐츠 다운로드 (Service Worker 사용)
    await this.downloadSegments(manifestUrl);
  }
  
  async playOffline(manifestUrl) {
    // 저장된 Session ID 로드
    const sessionId = localStorage.getItem('drm-session-' + manifestUrl);
    
    if (!sessionId) {
      throw new Error('No offline license found');
    }
    
    // MediaKeys 복원
    const keySystemAccess = await navigator.requestMediaKeySystemAccess(
      'com.widevine.alpha',
      [{ persistentState: 'required' }]
    );
    
    const mediaKeys = await keySystemAccess.createMediaKeys();
    await this.video.setMediaKeys(mediaKeys);
    
    // Session 로드
    const session = mediaKeys.createSession('persistent-license');
    await session.load(sessionId);
    
    // 오프라인 콘텐츠 재생
    this.video.src = 'offline://' + manifestUrl;
    await this.video.play();
    
    console.log('✅ Playing offline content');
  }
  
  async downloadSegments(manifestUrl) {
    // Service Worker에서 세그먼트 다운로드 및 캐싱
    const registration = await navigator.serviceWorker.ready;
    await registration.sync.register('download-' + manifestUrl);
  }
}

DRM 콘텐츠 패키징

Shaka Packager로 멀티 DRM

#!/bin/bash
INPUT="input.mp4"
OUTPUT_DIR="output"
# Widevine + PlayReady + FairPlay 동시 지원
packager \
  in=$INPUT,stream=video,output=$OUTPUT_DIR/video.mp4 \
  in=$INPUT,stream=audio,output=$OUTPUT_DIR/audio.mp4 \
  --mpd_output $OUTPUT_DIR/manifest.mpd \
  --hls_master_playlist_output $OUTPUT_DIR/master.m3u8 \
  \
  --enable_widevine_encryption \
  --key_server_url "https://license.widevine.com/cenc/getcontentkey/widevine_test" \
  --content_id "content-123" \
  --signer "widevine_test" \
  --aes_signing_key "1ae8ccd0e7985cc0b6203a55855a1034afc252980e970ca90e5202689f947ab9" \
  --aes_signing_iv "d58ce954203b7c9a9a9d467f59839249" \
  \
  --enable_playready_encryption \
  --playready_server_url "https://playready.example.com/rightsmanager.asmx" \
  --playready_key_id "10000000100010001000100000000001" \
  --playready_key "3a2a1b68dd2b9d8b8d8d8d8d8d8d8d8d" \
  \
  --enable_fairplay_encryption \
  --fairplay_key_uri "skd://fairplay-key-123" \
  \
  --protection_scheme cenc \
  --clear_lead 3
echo "✅ Multi-DRM packaging complete"

DASH 매니페스트 (CENC)

<?xml version="1.0"?>
<MPD xmlns="urn:mpeg:dash:schema:mpd:2011">
  <Period>
    <AdaptationSet mimeType="video/mp4" codecs="avc1.42E01E">
      <!-- DRM 정보 -->
      <ContentProtection schemeIdUri="urn:mpeg:dash:mp4protection:2011" value="cenc"/>
      
      <!-- Widevine -->
      <ContentProtection schemeIdUri="urn:uuid:edef8ba9-79d6-4ace-a3c8-27dcd51d21ed">
        <cenc:pssh>AAAAA...</cenc:pssh>
      </ContentProtection>
      
      <!-- PlayReady -->
      <ContentProtection schemeIdUri="urn:uuid:9a04f079-9840-4286-ab92-e65be0885f95">
        <cenc:pssh>BBBBB...</cenc:pssh>
      </ContentProtection>
      
      <Representation bandwidth="1000000" width="1280" height="720">
        <SegmentTemplate media="video_$Number$.m4s" initialization="video_init.mp4"/>
      </Representation>
    </AdaptationSet>
  </Period>
</MPD>

라이선스 획득 흐름 상세

이 절은 플레이어 코드·라이선스 서버·CDM이 맞물리는 순서를 타임라인으로 풀어 쓴 것입니다. 앞선 EME·Widevine 구현 예제와 중복되는 부분은 “왜 이 순서인가”에 초점을 맞춥니다.

1단계: 키 시스템 선택과 협상

  1. 앱이 navigator.requestMediaKeySystemAccess(keySystem, configurations)를 호출한다.
  2. 브라우저는 설치된 CDM, 코덱·컨테이너, robustness, persistentState, distinctiveIdentifier 요구를 평가한다.
  3. 성공 시 MediaKeySystemAccess가 반환되고, 이어서 createMediaKeys()CDM 인스턴스가 준비된다.

실패가 잦은 지점: 요청한 robustness가 기기에서 불가능하거나, 코덱 문자열이 실제 패키징과 불일치할 때입니다. 운영에서는 우선 낮은 보안으로 협상 후 상향하는 패턴도 쓰이지만, 스튜디오 정책상 최소 보안을 만족하지 못하면 재생 자체를 막는 경우가 많습니다.

2단계: setMediaKeys와 미디어 로드

  1. video.setMediaKeys(mediaKeys)특정 <video> 엘리먼트에 CDM 컨텍스트를 연결한다.
  2. src 또는 미디어 소스 확장(MSE)으로 암호화 세그먼트를 로드한다.
  3. 파서가 초기화 세그먼트·PSSH를 만나면 encrypted 이벤트가 발생하고, initData + initDataType이 앱에 전달된다.

주의: encrypted한 콘텐츠에 여러 번 발생할 수 있습니다(오디오·비디오 키가 다르거나, 키 로테이션). 세션을 하나만 만들지 말고, 매니페스트·키 ID 단위로 설계해야 합니다.

3단계: 세션 생성과 generateRequest

  1. mediaKeys.createSession(sessionType)temporary vs persistent-license(오프라인) 선택.
  2. session.generateRequest(initDataType, initData)가 CDM에 라이선스 요청(Challenge) 생성을 지시한다.
  3. CDM은 message 이벤트로 바이너리 Challenge를 내보낸다. 이것이 라이선스 서버에 POST할 본문입니다.

4단계: 라이선스 서버와 권한 결정

  1. 앱(또는 프록시)이 Challenge를 HTTPS로 전송한다. 이때 사용자 인증Authorization 헤더·쿠키·mTLS 등 앱 정책대로 붙는다.
  2. 서버는 구독·지역·기기 한도·동시 재생을 검증한다.
  3. Widevine/PlayReady/FairPlay SDK 또는 파트너 SaaS가 Challenge를 파싱하고, 콘텐츠 키(보통 KMS에 보관)를 라이선스에 바인딩해 응답 바이트를 생성한다.

서버가 반환하는 것은 “키 평문 JSON”이 아니라 CDM이 해독 가능한 라이선스 블롭입니다.

5단계: session.update와 키 준비

  1. await session.update(licenseBuffer) 호출.
  2. CDM이 서명·유효기간·정책을 검증하고 키를 캐시한다.
  3. keyStatuses가 갱신되고, 미디어 파이프라인이 샘플 단위 복호화를 시작한다.

6단계: 갱신·만료·오류

  • 장시간 재생license-renewal 메시지가 오면, 동일한 방식으로 추가 POST → update를 수행한다.
  • 만료·출력 제한keystatuseschange로 드러난다. 사용자에게는 “HDMI 연결을 확인하세요” 같은 하드웨어 안내가 효과적이다.
sequenceDiagram
    participant App as 웹 앱(JS)
    participant UA as 브라우저
    participant CDM as CDM
    participant Lic as 라이선스 서버

    App->>UA: requestMediaKeySystemAccess / createMediaKeys
    App->>UA: setMediaKeys, load media
    UA->>App: encrypted(initData)
    App->>UA: createSession + generateRequest
    UA->>CDM: build challenge
    CDM->>App: message(license-request)
    App->>Lic: POST challenge + auth
    Lic->>App: license blob
    App->>UA: session.update(license)
    UA->>CDM: install keys
    CDM->>App: keystatuseschange / 재생 계속

멀티 키·멀티 세션 패턴

  • DASH CENC에서 오디오·비디오 키 ID가 다르면 세션을 분리하거나, CDM이 허용하면 한 세션에 여러 키를 실어 보내는 방식을 씁니다. Shaka·ExoPlayer 등 라이브러리는 이를 추상화해 주지만, 커스텀 플레이어generateRequest 호출 횟수를 추적해야 합니다.
  • 키 로테이션(라이브)에서는 주기적으로 새 initData가 등장할 수 있으므로, 라이선스 지연(latency) 메트릭을 별도로 수집하는 것이 좋습니다.

라이선스 서버 구현

Node.js 라이선스 서버

const express = require('express');
const jwt = require('jsonwebtoken');
const crypto = require('crypto');
const app = express();
app.use(express.raw({ type: 'application/octet-stream', limit: '10mb' }));
// 콘텐츠 키 저장소
const contentKeys = new Map();
// 라이선스 발급
app.post('/license/:drm', async (req, res) => {
  const drmType = req.params.drm;  // widevine, fairplay, playready
  const challenge = req.body;
  const authToken = req.headers.authorization?.replace('Bearer ', ');
  
  try {
    // 토큰 검증
    const payload = jwt.verify(authToken, process.env.JWT_SECRET);
    const { userId, contentId, deviceId } = payload;
    
    // 권한 확인
    const hasAccess = await verifyUserAccess(userId, contentId);
    if (!hasAccess) {
      return res.status(403).json({ error: 'Access denied' });
    }
    
    // 콘텐츠 키 조회
    let contentKey = contentKeys.get(contentId);
    if (!contentKey) {
      // 새 키 생성
      contentKey = crypto.randomBytes(16);
      contentKeys.set(contentId, contentKey);
    }
    
    // DRM별 라이선스 생성
    let license;
    if (drmType === 'widevine') {
      license = await generateWidevineLicense(challenge, contentKey, userId);
    } else if (drmType === 'fairplay') {
      license = await generateFairPlayLicense(challenge, contentKey, userId);
    } else if (drmType === 'playready') {
      license = await generatePlayReadyLicense(challenge, contentKey, userId);
    }
    
    // 재생 로그
    await logPlayback(userId, contentId, deviceId, drmType);
    
    res.set('Content-Type', 'application/octet-stream');
    res.send(license);
    
    console.log(`✅ License issued: ${drmType} for user ${userId}`);
    
  } catch (err) {
    console.error('❌ License error:', err);
    res.status(401).json({ error: 'Invalid token' });
  }
});
async function verifyUserAccess(userId, contentId) {
  // 구독 확인
  const subscription = await getSubscription(userId);
  if (!subscription || !subscription.active) {
    return false;
  }
  
  // 지역 제한 확인
  const userCountry = await getUserCountry(userId);
  const contentRegions = await getContentRegions(contentId);
  if (!contentRegions.includes(userCountry)) {
    return false;
  }
  
  // 동시 재생 제한
  const activeSessions = await getActiveSessions(userId);
  if (activeSessions.length >= subscription.maxStreams) {
    return false;
  }
  
  return true;
}
async function generateWidevineLicense(challenge, contentKey, userId) {
  // 실제로는 Widevine SDK 사용
  // 여기서는 간소화된 예시
  
  // 1. Challenge 파싱
  // 2. 콘텐츠 키로 라이선스 생성
  // 3. 사용자별 제한 사항 추가 (해상도, 기간 등)
  
  const license = Buffer.from('widevine-license-data');
  return license;
}
app.listen(3000, () => {
  console.log('🔒 DRM License Server running on port 3000');
});

DRM 비교

기술적 비교

DRM개발사플랫폼보안 레벨최대 화질오프라인
Widevine L1GoogleAndroid, Chrome하드웨어4K
Widevine L3GoogleChrome, Firefox소프트웨어480p
FairPlayAppleiOS, Safari하드웨어4K
PlayReadyMicrosoftWindows, Xbox하드웨어4K
HLS AES-128Apple모든 플랫폼기본제한 없음
ClearKeyW3C모든 플랫폼없음제한 없음

서비스별 DRM 사용

flowchart TB
    subgraph Netflix
        N_W[Widevine L1/L3]
        N_F[FairPlay]
        N_P[PlayReady]
    end
    
    subgraph YouTube_Premium
        Y_W[Widevine L3]
        Y_F[FairPlay]
    end
    
    subgraph Disney_Plus
        D_W[Widevine L1]
        D_F[FairPlay]
        D_P[PlayReady]
    end
    
    subgraph Spotify
        S_W[Widevine L3]
        S_F[FairPlay]
    end

DRM 우회 기법과 대응

일반적인 우회 시도

flowchart TB
    Attack[DRM 우회 시도]
    
    A1[화면 캡처]
    A2[메모리 덤프]
    A3[디버거 사용]
    A4[CDM 추출]
    A5[HDMI 캡처]
    
    Attack --> A1
    Attack --> A2
    Attack --> A3
    Attack --> A4
    Attack --> A5
    
    A1 --> D1[대응: 워터마크]
    A2 --> D2[대응: 메모리 암호화]
    A3 --> D3[대응: 안티 디버깅]
    A4 --> D4[대응: TEE/하드웨어]
    A5 --> D5[대응: HDCP]

HDCP (High-bandwidth Digital Content Protection)

HDCP: 디지털 비디오 인터페이스 보호
HDMI/DisplayPort 연결에서 콘텐츠를 암호화하여
캡처 장비로 녹화를 방지
DRM L1 + HDCP → 완전한 보호 경로

안티 디버깅

// 디버거 감지
(function() {
  let devtoolsOpen = false;
  const threshold = 160;
  
  setInterval(() => {
    if (window.outerWidth - window.innerWidth > threshold ||
        window.outerHeight - window.innerHeight > threshold) {
      if (!devtoolsOpen) {
        devtoolsOpen = true;
        console.log('⚠️ DevTools detected, pausing playback');
        document.querySelector('video')?.pause();
      }
    } else {
      devtoolsOpen = false;
    }
  }, 500);
})();
// 코드 난독화 (webpack)
module.exports = {
  optimization: {
    minimize: true,
    minimizer: [
      new TerserPlugin({
        terserOptions: {
          mangle: true,
          compress: {
            drop_console: true,
            drop_debugger: true
          }
        }
      })
    ]
  }
};

실전 플레이어 라이브러리

Shaka Player (Google)

// Shaka Player로 멀티 DRM 지원
import shaka from 'shaka-player';
const video = document.querySelector('video');
const player = new shaka.Player(video);
// DRM 설정
player.configure({
  drm: {
    servers: {
      'com.widevine.alpha': 'https://license.example.com/widevine',
      'com.microsoft.playready': 'https://license.example.com/playready'
    },
    advanced: {
      'com.widevine.alpha': {
        'videoRobustness': 'SW_SECURE_CRYPTO',
        'audioRobustness': 'SW_SECURE_CRYPTO'
      }
    }
  }
});
// 라이선스 요청 인터셉터
player.getNetworkingEngine().registerRequestFilter((type, request) => {
  if (type === shaka.net.NetworkingEngine.RequestType.LICENSE) {
    // 인증 헤더 추가
    request.headers['Authorization'] = 'Bearer ' + getAuthToken();
  }
});
// 비디오 로드
player.load('https://cdn.example.com/manifest.mpd')
  .then(() => {
    console.log('✅ Video loaded');
    video.play();
  })
  .catch(err => {
    console.error('❌ Load error:', err);
  });

Video.js + videojs-contrib-eme

import videojs from 'video.js';
import 'videojs-contrib-eme';
const player = videojs('my-video', {
  plugins: {
    eme: {
      keySystems: {
        'com.widevine.alpha': {
          url: 'https://license.example.com/widevine',
          licenseHeaders: {
            'Authorization': 'Bearer ' + authToken
          },
          videoRobustness: 'SW_SECURE_CRYPTO',
          audioRobustness: 'SW_SECURE_CRYPTO'
        },
        'com.apple.fps.1_0': {
          certificateUri: 'https://example.com/fairplay.cer',
          licenseUri: 'https://license.example.com/fairplay',
          licenseHeaders: {
            'Authorization': 'Bearer ' + authToken
          }
        }
      }
    }
  }
});
player.src({
  src: 'https://cdn.example.com/manifest.mpd',
  type: 'application/dash+xml'
});
player.play();

DRM 테스트

Widevine 테스트 콘텐츠

// Google의 공개 테스트 스트림
const testStreams = {
  widevine: {
    manifest: 'https://storage.googleapis.com/shaka-demo-assets/angel-one-widevine/dash.mpd',
    license: 'https://cwip-shaka-proxy.appspot.com/no_auth'
  },
  clearKey: {
    manifest: 'https://storage.googleapis.com/shaka-demo-assets/angel-one/dash.mpd',
    license: 'https://cwip-shaka-proxy.appspot.com/clearkey'
  }
};
// Shaka Player로 테스트
async function testDRM() {
  const player = new shaka.Player(video);
  
  player.configure({
    drm: {
      servers: {
        'com.widevine.alpha': testStreams.widevine.license
      }
    }
  });
  
  try {
    await player.load(testStreams.widevine.manifest);
    console.log('✅ Widevine test successful');
  } catch (err) {
    console.error('❌ Widevine test failed:', err);
  }
}

브라우저 DRM 지원 확인

async function checkDRMSupport() {
  const drmSystems = [
    { name: 'Widevine', keySystem: 'com.widevine.alpha' },
    { name: 'FairPlay', keySystem: 'com.apple.fps.1_0' },
    { name: 'PlayReady', keySystem: 'com.microsoft.playready' },
    { name: 'ClearKey', keySystem: 'org.w3.clearkey' }
  ];
  
  const results = {};
  
  for (const drm of drmSystems) {
    try {
      const config = [{
        initDataTypes: ['cenc'],
        videoCapabilities: [{
          contentType: 'video/mp4; codecs="avc1.42E01E"'
        }]
      }];
      
      await navigator.requestMediaKeySystemAccess(drm.keySystem, config);
      results[drm.name] = '✅ Supported';
    } catch (e) {
      results[drm.name] = '❌ Not supported';
    }
  }
  
  console.table(results);
  return results;
}
// 실행
checkDRMSupport();

성능 최적화

라이선스 캐싱

class LicenseCache {
  constructor() {
    this.cache = new Map();
    this.maxAge = 3600 * 1000;  // 1시간
  }
  
  async getLicense(contentId, licenseUrl, authToken) {
    const cacheKey = `${contentId}:${authToken}`;
    const cached = this.cache.get(cacheKey);
    
    // 캐시 확인
    if (cached && Date.now() - cached.timestamp < this.maxAge) {
      console.log('✅ License from cache');
      return cached.license;
    }
    
    // 라이선스 요청
    const response = await fetch(licenseUrl, {
      method: 'POST',
      headers: {
        'Authorization': `Bearer ${authToken}`
      }
    });
    
    const license = await response.arrayBuffer();
    
    // 캐시 저장
    this.cache.set(cacheKey, {
      license,
      timestamp: Date.now()
    });
    
    console.log('✅ License from server');
    return license;
  }
  
  clear() {
    this.cache.clear();
  }
}
const licenseCache = new LicenseCache();

프리로딩

// 라이선스 미리 요청
async function preloadLicense(contentId, licenseUrl, authToken) {
  const config = [{
    initDataTypes: ['cenc'],
    videoCapabilities: [{
      contentType: 'video/mp4; codecs="avc1.42E01E"'
    }]
  }];
  
  const keySystemAccess = await navigator.requestMediaKeySystemAccess(
    'com.widevine.alpha',
    config
  );
  
  const mediaKeys = await keySystemAccess.createMediaKeys();
  const session = mediaKeys.createSession();
  
  // 미리 라이선스 요청
  session.addEventListener('message', async (event) => {
    const response = await fetch(licenseUrl, {
      method: 'POST',
      headers: { 'Authorization': `Bearer ${authToken}` },
      body: event.message
    });
    
    const license = await response.arrayBuffer();
    await session.update(license);
    
    console.log('✅ License preloaded');
  });
  
  // 더미 init data로 세션 생성
  const initData = new Uint8Array([/* PSSH box */]);
  await session.generateRequest('cenc', initData);
}

10. DRM 비교 및 선택 가이드

프로젝트별 DRM 선택 전략

의사결정 트리:

flowchart TD
    Start[DRM 필요성 판단] --> Q0{콘텐츠 가치는?}
    
    Q0 -->|낮음\n교육, 일반| Simple["HLS AES-128\n또는 DRM 없음"]
    Q0 -->|중간\n독점 콘텐츠| Mid[기본 DRM]
    Q0 -->|높음\n프리미엄| High[엔터프라이즈 DRM]
    
    Mid --> Q1{타겟 플랫폼은?}
    High --> Q1
    
    Q1 -->|iOS/Safari만| FairPlay[FairPlay]
    Q1 -->|Android/Chrome만| Widevine[Widevine]
    Q1 -->|Windows/Xbox만| PlayReady[PlayReady]
    Q1 -->|멀티 플랫폼| Multi["멀티 DRM\n(CENC)"]
    
    Multi --> Q2{예산은?}
    Q2 -->|제한적| Basic["Widevine L3\n+ FairPlay"]
    Q2 -->|충분함| Enterprise["Widevine L1\n+ FairPlay\n+ PlayReady SL3000"]

상세 비교표

1. 기능 비교

기능WidevineFairPlayPlayReadyHLS AES-128
하드웨어 보안✅ L1✅ SL3000
4K/HDR 지원✅ L1만✅ (보안 취약)
오프라인 재생⚠️ (키 저장 필요)
라이선스 체인✅ (Domain)
크로스 플랫폼✅ 광범위❌ Apple만⚠️ Windows 위주✅ HLS 지원 시
구현 난이도중간높음높음낮음
웹 표준 (EME)⚠️ (Safari만)⚠️ (비표준)

2. 플랫폼 지원

플랫폼WidevineFairPlayPlayReady권장
Chrome (Desktop)✅ L3Widevine
Chrome (Android)✅ L1/L3Widevine L1
Safari (macOS)FairPlay
Safari (iOS)FairPlay
Edge (Windows)✅ L3Widevine or PlayReady
Firefox✅ L3만Widevine L3
Android App✅ L1/L3Widevine L1
iOS AppFairPlay
Smart TV (Tizen)Widevine or PlayReady
Smart TV (webOS)Widevine or PlayReady
XboxPlayReady
PlayStationWidevine

3. 보안 레벨 비교

DRM레벨복호화 위치최대 화질공격 난이도
Widevine L1최고Hardware TEE4K/HDR매우 높음
Widevine L2중간Software (메모리)1080p높음
Widevine L3기본Software480p-720p중간
FairPlay최고Hardware (Secure Enclave)4K/HDR매우 높음
PlayReady SL3000최고Hardware TEE4K/HDR매우 높음
PlayReady SL2000중간Software (TEE)1080p높음
PlayReady SL150기본SoftwareSD중간
HLS AES-128낮음Software (평문 키)무제한낮음

4. 비용 비교

항목WidevineFairPlayPlayReadyHLS AES-128
초기 비용$0 (테스트)$99/년 (Apple Dev)문의 필요$0
라이선스 비용기기당 과금포함됨기기당 과금$0
서버 비용자체 구축 가능자체 구축 가능자체 구축 가능자체 구축
연간 유지비 (예상)$10K-$100K+$99 + 서버$50K-$200K+서버 비용만
CDN 비용동일 (CENC)HLS 전용동일 (CENC)동일

💡 비용 최적화 팁:

  • Widevine/PlayReady: 기기당 과금이므로 대규모 서비스는 볼륨 할인 협상
  • FairPlay: Apple 생태계만 타겟이면 가장 저렴
  • HLS AES-128: 완전 무료지만 보안 취약 → 저가 콘텐츠용

5. 구현 복잡도 및 개발 시간

DRM구현 복잡도예상 개발 기간필요 기술
Widevine⭐⭐⭐ 중간2-4주JavaScript (EME), Node.js
FairPlay⭐⭐⭐⭐ 높음3-6주Swift/Objective-C, 인증서 관리
PlayReady⭐⭐⭐⭐ 높음3-6주C#, Windows SDK
멀티 DRM⭐⭐⭐⭐⭐ 매우 높음6-12주위 모든 기술 + CENC
HLS AES-128⭐ 낮음1-2주FFmpeg, 기본 웹 개발

개발 단계별 소요 시간 (멀티 DRM):

1. 요구사항 분석: 1주
2. 인코딩 파이프라인 (CENC): 2주
3. Widevine 구현: 2주
4. FairPlay 구현: 3주 (인증서 발급 포함)
5. PlayReady 구현: 2주
6. 라이선스 서버: 2주
7. 테스트 (기기별): 2주
8. 배포 및 모니터링: 1주
───────────────────────────
총 15주 (약 4개월)

실전 선택 가이드: 프로젝트 유형별 추천

시나리오 1: 스타트업 OTT 서비스

상황:
- 예산 제한적 (초기 $10K)
- iOS + Android + 웹 지원
- 일반 드라마/영화 (중간 가치)

추천:
✅ Widevine L3 + FairPlay
   - Widevine L3: Android/Chrome (무료 테스트 가능)
   - FairPlay: iOS/Safari ($99/년)
   - CENC로 단일 파일 인코딩
   
❌ PlayReady 제외 (비용 대비 효과 낮음)
❌ Widevine L1 제외 (Android 기기 제한적)

예상 비용:
- FairPlay: $99/년
- Widevine: 초기 무료 → 성장 후 라이선스 계약
- CENC 인코딩: Shaka Packager (무료)
- 라이선스 서버: AWS Lambda ($50/월)
──────────────────────
총 초기 비용: ~$1,000

시나리오 2: 프리미엄 영화 스트리밍 (넷플릭스급)

상황:
- 예산 충분 ($500K+)
- 전 플랫폼 지원 (iOS, Android, 웹, TV, 콘솔)
- 고가 독점 콘텐츠 (4K/HDR)

추천:
✅ 풀스택 멀티 DRM
   - Widevine L1: Android, Chrome, Smart TV
   - FairPlay: iOS, Safari, Apple TV
   - PlayReady SL3000: Xbox, Windows, 일부 TV
   - HDCP 2.2 요구 (4K)
   - 포렌식 워터마킹
   - 24/7 모니터링 시스템

추가 보안:
- 서버 사이드 워터마킹
- 화면 캡처 감지 + 자동 차단
- 지역별 라이선스 제한
- 동시 재생 제한 (최대 4기기)

예상 비용:
- DRM 라이선스: $200K/년
- CDN (Cloudflare/Akamai): $100K/년
- 인코딩 (AWS MediaConvert): $50K/년
- 라이선스 서버: $30K/년
- 모니터링/보안: $50K/년
──────────────────────
총 연간 비용: ~$430K

시나리오 3: 교육 플랫폼 (저가 콘텐츠)

상황:
- 최소 예산
- 웹 위주
- 교육 콘텐츠 (피해 제한적)

추천:
✅ HLS AES-128
   - FFmpeg로 간단 구현
   - 토큰 기반 키 URL
   - 1시간 세션 만료
   
선택사항:
- 가시적 워터마크 (사용자 ID)
- Referer 검증
- IP 기반 접근 제한

예상 비용:
- DRM: $0 (HLS AES-128)
- CDN: $500/월 (Cloudflare)
- 서버: $100/월 (Heroku)
──────────────────────
총 월 비용: ~$600

시나리오 4: 기업 내부 교육 시스템

상황:
- 사내 전용 (외부 유출 위험 낮음)
- Windows 위주 환경
- 민감한 기업 정보

추천:
✅ PlayReady + 네트워크 제한
   - PlayReady SL2000 (Windows)
   - 회사 IP에서만 재생
   - VPN 필수
   - 도메인 라이선스 (부서별 공유)

추가 보안:
- Active Directory 연동
- 로그인 기록
- 다운로드 완전 차단

예상 비용:
- PlayReady: 기업 라이선스 협상 ($10K-$50K)
- 사내 서버: 기존 인프라 활용
──────────────────────
총 초기 비용: ~$20K

시나리오 5: 라이브 스포츠 중계

상황:
- 실시간 스트리밍
- 모바일 위주 (관중석에서 시청)
- 짧은 재생 시간 (경기 시간만)

추천:
✅ Widevine L3 + FairPlay (라이브 최적화)
   - 짧은 세그먼트 (2초)
   - 라이선스 캐싱 최소화
   - 지역 제한 엄격 (지리적 차단)
   - 세션 기반 워터마크

특수 요구사항:
- 저지연 모드 (LL-HLS, LL-DASH)
- CDN 엣지 라이선스 서버
- 동시 접속 대량 처리

예상 비용:
- DRM: Widevine + FairPlay (~$50K/년)
- CDN (초고트래픽): $200K/이벤트
- 엣지 서버: $100K/년
──────────────────────
총 이벤트당: ~$250K

DRM 없이 콘텐츠 보호하기 (대안)

DRM이 과한 경우:

  1. Signed URL + 만료 시간

    https://cdn.example.com/video.mp4?
      token=JWT_TOKEN&
      expires=1609459200&
      signature=sha256_hash
    
    - 장점: 간단, 무료
    - 단점: URL 유출 시 유효기간 동안 무방비
  2. Referer/Origin 검증

    # Nginx 설정
    location /videos/ {
        valid_referers www.example.com example.com;
        if ($invalid_referer) {
            return 403;
        }
    }
    
    - 장점: 외부 링크 차단
    - 단점: Referer 스푸핑 가능
  3. IP 화이트리스트

    - 기업 내부망 전용
    - VPN 필수
    - 장점: 물리적 접근 제한
    - 단점: 원격 근무 불편
  4. 사용자 인증 + 세션

    - 로그인 필수
    - 세션 토큰 관리
    - 동시 재생 제한
    - 장점: 계정 추적 가능
    - 단점: 계정 공유 방지 어려움

프로덕션 DRM 패턴

운영 환경에서는 “재생이 된다”를 넘어 가용성·관측 가능성·계약 준수가 요구됩니다. 아래는 대형 OTT·B2B SaaS에서 반복되는 아키텍처 패턴입니다.

라이선스 프록시(동일 출처)와 토큰화 URL

문제: 라이선스 서버가 DRM 벤더 호스트 또는 별도 서브도메인에 있으면, 브라우저 CORS·쿠키·Third-party 쿠키 차단 이슈가 복잡해집니다.

패턴: 앱과 동일 오리진/api/license 같은 리버스 프록시를 두고, 프록시만 장기 비밀·벤더 자격 증명을 가집니다.

  • 클라이언트는 Authorization: Bearer <짧은 수명 JWT>만 보냅니다.
  • 프록시가 JWT를 검증한 뒤, 내부망에서 실제 라이선스 서버로 Challenge를 전달합니다.
  • 라이선스 URL에 일회용 토큰을 쿼리로 붙이는 방식은 유출 시 재사용 위험이 있으므로, POST 본문 + 짧은 TTL이 더 흔합니다.

멀티 DRM 매니페스트와 패키징 단일화

패턴: 하나의 fMP4/CENC 자산Widevine + PlayReady + (필요 시 FairPlay용 HLS) 메타를 심고, 클라이언트는 UA별로 키 시스템만 선택합니다.

  • 서버 비용: 스토리지·인코딩 단일 파이프라인.
  • 운영 비용: 키 ID·PSSH·라이선스 정책을 중앙 KMS와 동기화해야 함.
  • 주의: FairPlay는 cbcs·HLS 요구가 섞이면 별도 패키징이 필요할 수 있어, “완전 단일 파일”이 항상 가능한 것은 아닙니다. 현실적인 목표는 비디오 트랙 소스는 공유, 패키징 프로파일은 2~3개입니다.

지역·동시 시청·기기 한도는 “라이선스 안”이 아니라 “라이선스 발급 게이트”

패턴: DRM 라이선스는 콘텐츠 키 전달 메커니즘에 가깝고, 비즈니스 규칙 전부를 라이선스 포맷에 억지로 넣기 어렵습니다. 실무에서는 라이선스 요청 직전에:

  • 지오펜스(CDN CF-IPCountry + 권한 DB)
  • 동시 스트림 카운트(Redis 등)
  • 기기 등록 한도

를 검사하고, 실패 시 HTTP 403명시적 에러 코드를 반환합니다.

관측 가능성: 반드시 수집할 메트릭

  • 라이선스 지연: generateRequest 시각부터 update 완료까지. P95/P99가 사용자 이탈과 상관됩니다.
  • CDM/OS별 실패율: 특정 Smart TV·구형 Android에서만 오류가 나는지 분리합니다.
  • messageType 비율: license-renewal 실패가 많으면 세션 TTL·라이브 패키징을 의심합니다.
  • keyStatuses 분포: output-restricted 급증은 HDCP·외부 모니터 이슈일 가능성이 큽니다.

파트너 SDK(EZDRM, BuyDRM, Axinom 등) vs 자체 구축

접근장점단점
파트너/매니지드스튜디오 감사 대응, 키 순환·라이선스 템플릿 제공벤더 종속·비용
자체 구축유연한 비즈니스 로직, 단가 최적화스펙 추적·인증·사고 대응 부담

초기에는 매니지드 라이선스 + 자체 권한 게이트 조합이 시간 대비 리스크가 가장 낮은 편입니다.

플레이어 라이브러리와 책임 경계

  • Shaka Player, hls.js + FairPlay 확장, ExoPlayer, AVPlayer 등은 EME·세션·일부 재시도를 캡슐화합니다.
  • 그럼에도 인증 헤더 삽입·401 시 토큰 리프레시·프록시 URL은 앱이 책임집니다. “플레이어가 다 해준다”로 가정하면 프로덕션 장애가 남습니다.

요약

프로덕션에서 DRM은 단일 기술이 아니라 패키징 + KMS + 라이선스 + 권한 + 관측의 묶음입니다. 위 패턴들은 그 경계를 명확히 나누는 실무 규칙으로 기억하면 됩니다.


베스트 프랙티스 및 체크리스트

DRM 구현 체크리스트

🔒 보안 (Security)

  • 암호화

    • AES-128 이상 사용
    • CENC 표준 준수
    • 키 로테이션 구현 (세그먼트별 또는 세션별)
    • Clear Lead 최소화 (3초 이하)
  • 키 관리

    • 키 저장소 암호화 (AWS KMS, Azure Key Vault 등)
    • 키 접근 로그
    • 정기적 키 갱신
    • 백업 키 관리
  • 라이선스 서버

    • HTTPS 필수 (TLS 1.3 권장)
    • 토큰 기반 인증 (JWT)
    • Rate Limiting (DDoS 방어)
    • 요청 서명 검증

👤 사용자 관리 (User Management)

  • 권한 검증

    • 구독 상태 확인
    • 지역 제한 (지리적 차단)
    • 기기 제한 (최대 N대)
    • 동시 재생 제한
  • 세션 관리

    • 세션 타임아웃 (24시간)
    • 라이선스 갱신 메커니즘
    • 강제 로그아웃 기능

📊 모니터링 (Monitoring)

  • 로깅

    • 모든 라이선스 요청 로그
    • 에러 로그 (DRM 실패, 권한 거부)
    • 재생 로그 (시작, 중단, 완료)
  • 이상 탐지

    • 비정상 요청 패턴 (1분에 100회 등)
    • 지역 불일치 (VPN 의심)
    • 기기 변경 빈도
  • 알림

    • 라이선스 서버 다운
    • DRM 에러 급증
    • 의심 활동 감지

🧪 테스트 (Testing)

  • 기기 테스트

    • iPhone (iOS 최신/구버전)
    • Android (다양한 제조사)
    • Chrome, Safari, Edge, Firefox
    • Smart TV (Tizen, webOS)
    • 게임 콘솔 (Xbox, PlayStation)
  • 시나리오 테스트

    • 정상 재생
    • 네트워크 끊김 후 복구
    • 탭 전환 후 재개
    • 백그라운드 재생
    • 라이선스 만료 처리
  • 보안 테스트

    • 토큰 변조 시도
    • 만료된 토큰 사용
    • 라이선스 재사용 시도
    • 개발자 도구로 키 추출 시도

💡 사용자 경험 (User Experience)

  • 에러 처리

    • 친화적인 에러 메시지
    • 재시도 메커니즘
    • 폴백 화질 제공
  • 성능

    • 라이선스 캐싱
    • 프리로딩
    • 초기 재생 시간 < 3초

1. 멀티 DRM 전략

// 플랫폼 감지 후 적절한 DRM 선택
function selectDRM() {
  const ua = navigator.userAgent;
  
  if (/iPhone|iPad|iPod/.test(ua)) {
    return {
      system: 'com.apple.fps.1_0',
      licenseUrl: 'https://license.example.com/fairplay'
    };
  } else if (/Android/.test(ua)) {
    return {
      system: 'com.widevine.alpha',
      licenseUrl: 'https://license.example.com/widevine'
    };
  } else if (/Windows/.test(ua)) {
    // PlayReady 또는 Widevine
    return {
      system: 'com.widevine.alpha',
      licenseUrl: 'https://license.example.com/widevine'
    };
  }
  
  return {
    system: 'com.widevine.alpha',
    licenseUrl: 'https://license.example.com/widevine'
  };
}

2. 에러 처리

video.addEventListener('error', (event) => {
  const error = video.error;
  
  if (error.code === MediaError.MEDIA_ERR_ENCRYPTED) {
    console.error('❌ DRM error:', error.message);
    
    // 사용자에게 친화적인 메시지
    showError('이 콘텐츠를 재생할 수 없습니다. 브라우저를 업데이트하거나 다른 기기를 사용해주세요.');
  }
});
// MediaKeySession 에러
session.addEventListener('keystatuseschange', () => {
  session.keyStatuses.forEach((status, keyId) => {
    console.log(`Key ${keyId}: ${status}`);
    
    if (status === 'expired') {
      console.warn('⚠️ License expired, requesting new license');
      requestNewLicense();
    } else if (status === 'internal-error') {
      console.error('❌ DRM internal error');
    }
  });
});

3. 성능 최적화 팁

// 1. 라이선스 프리로딩
async function preloadLicense() {
  // 비디오 로드 전에 라이선스 미리 요청
  // 초기 재생 시간 단축 (3초 → 1초)
  await requestLicense();
  video.src = manifestURL;
}

// 2. 라이선스 캐싱
const licenseCache = new Map();
session.addEventListener('message', async (event) => {
  const cacheKey = contentId;
  
  if (licenseCache.has(cacheKey)) {
    // 캐시된 라이선스 사용 (네트워크 요청 생략)
    await session.update(licenseCache.get(cacheKey));
    return;
  }
  
  // 서버 요청 및 캐싱
  const license = await fetchLicense(event.message);
  licenseCache.set(cacheKey, license);
  await session.update(license);
});

// 3. 적응형 DRM
async function selectOptimalDRM() {
  // L1 시도 → 실패 시 L3 폴백
  try {
    return await initWidevineL1();
  } catch (e) {
    console.warn('L1 unavailable, falling back to L3');
    return await initWidevineL3();
  }
}

// 4. CDN 엣지 라이선스 서버
// Cloudflare Workers 예시
addEventListener('fetch', event => {
  if (event.request.url.includes('/license')) {
    event.respondWith(handleLicense(event.request));
  }
});

async function handleLicense(request) {
  // 엣지에서 라이선스 발급 → 지연 시간 감소
  // 중앙 서버: 100ms → 엣지: 10ms
  const license = await generateLicense(request);
  return new Response(license, {
    headers: { 'Content-Type': 'application/octet-stream' }
  });
}

4. 일반적인 실수 및 해결

❌ 실수 1: 모든 플랫폼에 동일한 키 사용

// 나쁜 예
const KEY = 'fixed-key-for-all-users';

// 좋은 예
function generateUserKey(userId, contentId, sessionId) {
  return crypto.createHash('sha256')
    .update(`${userId}:${contentId}:${sessionId}`)
    .digest();
}

❌ 실수 2: 라이선스 서버에 인증 없음

// 나쁜 예
app.post('/license', async (req, res) => {
  const license = await generateLicense(req.body);
  res.send(license);  // 누구나 접근 가능
});

// 좋은 예
app.post('/license', authenticateToken, async (req, res) => {
  const { userId } = req.user;
  
  if (!await hasAccess(userId, contentId)) {
    return res.status(403).json({ error: 'Access denied' });
  }
  
  const license = await generateLicense(req.body, userId);
  res.send(license);
});

❌ 실수 3: 에러 처리 부족

// 나쁜 예
const mediaKeys = await keySystemAccess.createMediaKeys();
await video.setMediaKeys(mediaKeys);  // 실패 시 앱 크래시

// 좋은 예
try {
  const mediaKeys = await keySystemAccess.createMediaKeys();
  await video.setMediaKeys(mediaKeys);
} catch (error) {
  console.error('DRM initialization failed:', error);
  
  // 사용자 친화적 메시지
  showNotification('이 기기는 보호된 콘텐츠를 재생할 수 없습니다.');
  
  // 폴백: 낮은 화질 또는 프리뷰
  video.src = previewURL;
}

❌ 실수 4: 키를 코드에 하드코딩

// 나쁜 예
const ENCRYPTION_KEY = '1234567890abcdef';  // Git에 커밋됨

// 좋은 예
const ENCRYPTION_KEY = process.env.ENCRYPTION_KEY;  // 환경 변수

// 더 좋은 예: Key Management Service
const KEY = await kms.decrypt({
  CiphertextBlob: Buffer.from(encryptedKey, 'base64')
}).promise();

❌ 실수 5: HTTPS 미사용

나쁜 예: http://example.com/license
좋은 예: https://example.com/license

DRM은 HTTPS 필수:
- EME API는 보안 컨텍스트에서만 동작
- HTTP에서는 navigator.requestMediaKeySystemAccess 실패

최종 요약 및 핵심 포인트

핵심 포인트

1. DRM은 완벽하지 않다

  • 목표: 100% 방어 (불가능) → 공격 비용 증가 (가능)
  • 아날로그 홀 존재 (화면 녹화는 막을 수 없음)
  • 다층 방어 전략 필요

2. 플랫폼별 DRM 필수

iOS/Safari → FairPlay
Android/Chrome → Widevine
Windows/Xbox → PlayReady
멀티 플랫폼 → CENC (단일 파일, 여러 DRM)

3. 보안 레벨 = 화질

하드웨어 DRM (L1/SL3000) → 4K/HDR
소프트웨어 DRM (L3/SL150) → SD/720p
AES-128 → 제한 없음 (보안 취약)

4. 비용 vs 보안 트레이드오프

저비용 → HLS AES-128 ($0)
       교육 콘텐츠, 저가 영상
       
중비용 → Widevine + FairPlay ($10K-$50K/년)
       일반 OTT, 드라마, 웹툰
       
고비용 → 풀스택 멀티 DRM ($200K+/년)
       프리미엄 영화, 스포츠 중계

5. 구현 우선순위

1단계: 암호화 (CENC)
2단계: 라이선스 서버 (권한 검증)
3단계: 기본 DRM (Widevine + FairPlay)
4단계: 모니터링 시스템
5단계: 워터마킹
6단계: 고급 보안 (PlayReady, 화면 캡처 방지)

실무자를 위한 3줄 요약

  1. 스타트업/중소기업: Widevine L3 + FairPlay + CENC로 시작. HLS AES-128은 교육 콘텐츠만 사용.
  2. 프리미엄 서비스: Widevine L1 + FairPlay + PlayReady + 워터마킹 + 24/7 모니터링.
  3. 예산 제로: Signed URL + 짧은 만료 시간 + Referer 검증으로 기본 보호.

다음 단계

DRM 구축 로드맵 (0 → 프로덕션)

Week 1-2: 요구사항 정의
├─ 타겟 플랫폼 확인
├─ 보안 수준 결정
├─ 예산 책정
└─ DRM 벤더 선택

Week 3-4: 인코딩 파이프라인
├─ FFmpeg/Shaka Packager 설치
├─ CENC 암호화 테스트
├─ 여러 화질 생성 (ABR)
└─ CDN 업로드 자동화

Week 5-6: 라이선스 서버
├─ Node.js/Python 서버 구축
├─ JWT 인증 구현
├─ 데이터베이스 설계 (키, 사용자)
└─ 권한 검증 로직

Week 7-8: 클라이언트 구현
├─ Widevine (웹)
├─ FairPlay (iOS)
├─ PlayReady (필요 시)
└─ 에러 처리 및 폴백

Week 9-10: 테스트
├─ 여러 기기 테스트
├─ 보안 테스트
├─ 성능 테스트
└─ 사용자 시나리오 테스트

Week 11-12: 배포 및 모니터링
├─ 프로덕션 배포
├─ 로그 시스템 구축
├─ 알림 설정
└─ 문서화

Week 13+: 고도화
├─ 워터마킹 추가
├─ 화면 캡처 방지
├─ A/B 테스트
└─ 지속적 개선

자주 묻는 질문 (FAQ)

Q: Widevine L1을 사용하려면 어떻게 해야 하나요? A: 기기가 TEE (Trusted Execution Environment)를 지원해야 합니다. Android는 일부 최신 기기만 L1 지원. 개발자가 L1을 “켜는” 것이 아니라, 기기가 지원하는 최고 레벨을 자동 선택합니다.

Q: Netflix는 어떤 DRM을 사용하나요? A: Widevine (Android/Chrome), FairPlay (iOS/Safari), PlayReady (Windows/Xbox) 모두 사용. CENC로 단일 파일 인코딩 후 플랫폼별로 라이선스 제공.

Q: DRM 없이 Signed URL만으로 충분한가요? A: 교육 콘텐츠나 저가 영상은 가능. 하지만 URL 유출 시 유효기간 동안 무방비. 프리미엄 콘텐츠는 DRM 필수.

Q: 오프라인 다운로드를 지원하려면? A: DRM은 오프라인 재생을 지원합니다. 라이선스에 “persistent” 설정 추가. 기기에 암호화된 파일 + 라이선스 저장 → 유효기간 내 재생 가능.

Q: HDCP가 무엇인가요? A: HDMI/DisplayPort 케이블 암호화. 디스플레이까지 영상을 암호화 상태로 전송. HDCP 미지원 디스플레이에 연결 시 4K 재생 거부 또는 SD로 다운그레이드.

Q: 라이선스 서버를 직접 구축할 수 있나요? A: 가능하지만 복잡합니다. Widevine/PlayReady는 벤더 SDK 사용 필요. FairPlay는 Apple 인증서 발급 후 직접 구축 가능. 또는 BuyDRM, Irdeto 같은 SaaS 사용.

Q: 워터마크를 제거할 수 있나요? A: 가시적 워터마크는 비교적 쉽게 제거 가능. 포렌식 워터마크(주파수 도메인)는 제거 어려움. 세션 기반 동적 워터마크가 가장 강력.


관련 글 (내부 링크)

DRM과 함께 보면 좋은 관련 기술 가이드입니다:


참고 자료 및 공식 스펙

핵심 공식 스펙 문서

1. W3C Encrypted Media Extensions (EME) Recommendation

공식 스펙: https://www.w3.org/TR/encrypted-media/

읽어야 할 핵심 섹션:

  • Section 2. Definitions: MediaKeys, MediaKeySession, CDM 등 핵심 용어 정의
  • Section 5. MediaKeys Interface: createSession(), setServerCertificate() API
  • Section 6. MediaKeySession Interface: generateRequest(), update(), 라이선스 획득 흐름
  • Section 7. Events: encrypted, message, keystatuseschange 이벤트 명세
  • Section 9.1. Key IDs: 키 식별자 형식 및 처리 방법
  • Appendix A. Example: 실제 구현 예제 코드

왜 읽어야 하나:

  • EME는 모든 브라우저 DRM의 표준 API
  • 이 문서를 이해하면 Widevine, FairPlay, PlayReady 모두 같은 방식으로 접근 가능
  • 브라우저 호환성 이슈 해결의 기준이 됨

읽는 방법:

1단계: Section 2 (Definitions) 정독 - 용어 정리
2단계: Section 5-6 (Interface) - API 흐름 파악
3단계: Section 7 (Events) - 이벤트 기반 프로그래밍 이해
4단계: Appendix A (Example) - 실제 코드로 확인

2. ISO/IEC 23001-7: CENC (Common Encryption)

공식 스펙: ISO/IEC 23001-7:2016

무료 대체 문서: MPEG DASH Industry Forum - CENC Guidelines

읽어야 할 핵심 섹션:

  • Section 6: Protection System Specific Header (PSSH) 박스 구조
  • Section 7: Track Encryption Box 형식
  • Section 8.1: cenc 스킴 (AES-CTR 모드)
  • Section 8.2: cbcs 스킴 (AES-CBC 모드, FairPlay 호환)
  • Section 9: 여러 DRM 시스템의 PSSH 공존 방법
  • Annex A: 샘플 PSSH 박스 예제

왜 읽어야 하나:

  • 단일 파일로 여러 DRM 지원의 핵심 표준
  • PSSH 박스 구조 이해 → 인코딩 파이프라인 디버깅 가능
  • Widevine, PlayReady, FairPlay의 공통 부분 파악

실전 활용:

# MP4 파일의 PSSH 박스 확인 (Bento4 도구)
mp4dump --format json video.mp4 | grep -A 20 '"type": "pssh"'

# 각 DRM의 System ID 확인:
# Widevine: edef8ba9-79d6-4ace-a3c8-27dcd51d21ed
# PlayReady: 9a04f079-9840-4286-ab92-e65be0885f95
# FairPlay: 94ce86fb-07ff-4f43-adb8-93d2fa968ca2

3. Apple FairPlay Streaming Programming Guide

공식 문서: https://developer.apple.com/streaming/fps/

읽어야 할 핵심 섹션:

  • FPS Deployment Package: 인증서 발급 절차 (필수)
  • Key Server Module (KSM) Specification: SPC/CKC 형식 및 교환 프로토콜
  • HLS Authoring Specification for FairPlay: #EXT-X-KEY 태그 사용법
  • Best Practices: 키 관리, 오프라인 재생, 에러 처리

특별히 봐야 할 부분:

  • Certificate Request Form: 발급 신청서 작성 가이드
  • Server Playback Context (SPC) Format: 바이너리 구조 설명
  • Content Key Context (CKC) Format: 응답 형식
  • Error Codes: -42650, -42656 등 FairPlay 전용 에러 코드 의미

실전 팁:

1. 먼저 "Getting Started" PDF 다운로드 (로그인 필요)
2. Sample Code 다운로드: FairPlay 레퍼런스 구현 포함
3. KSM 문서의 "Protocol Flow" 다이어그램 정독
4. Xcode에서 샘플 프로젝트 실행하며 학습

4. Google Widevine DRM Architecture Overview

공식 문서: https://www.widevine.com/solutions/widevine-drm

상세 문서: Widevine Integration Guide (파트너 전용)

공개 레퍼런스:

읽어야 할 핵심:

  • Widevine L1 vs L3 차이: 하드웨어 요구사항 명세
  • robustness Levels: SW_SECURE_CRYPTO, HW_SECURE_ALL 등의 정확한 의미
  • License Request Format: Challenge 바이너리 구조
  • License Response Format: 암호화된 콘텐츠 키, 정책 필드

실전 활용:

// Widevine 로그 활성화 (디버깅용)
localStorage.setItem('shaka.log.level', 'DEBUG');
localStorage.setItem('shaka.log.alwaysWarn', 'true');

// Chrome에서 Widevine 정보 확인
chrome://components/ → "Widevine Content Decryption Module"
chrome://media-internals/ → 실시간 DRM 이벤트 로그

5. Microsoft PlayReady Documentation

공식 문서: https://docs.microsoft.com/en-us/playready/

읽어야 할 핵심 섹션:

  • Overview/Features: Security Levels (SL150, SL2000, SL3000)
  • PlayReady Header Specification: XML 형식의 라이선스 요청/응답
  • Domain Support: 도메인 라이선스 구조 (고유 기능)
  • Output Protection Levels: HDCP, Miracast, AirPlay 제어 방법
  • License Chaining: 루트 라이선스 → 리프 라이선스 체인

특별히 봐야 할 부분:

  • PlayReady Header Object: XML 구조 및 파싱 방법
  • License Acquisition URL: 라이선스 서버 URL 지정 방식
  • Custom Attributes: 커스텀 메타데이터 추가 방법

C# 개발자를 위한 리소스:

// PlayReady 네임스페이스
using Windows.Media.Protection.PlayReady;

// 필독 API 문서:
// - PlayReadyLicenseAcquisitionServiceRequest
// - PlayReadyIndividualizationServiceRequest
// - PlayReadyDomain (도메인 라이선스)

표준 문서 읽는 전략

난이도별 접근법:

입문자:
1. W3C EME - Appendix A (예제) 먼저
2. Shaka Player 문서 (구현체로 학습)
3. 각 DRM 벤더의 "Getting Started"

중급자:
1. EME Section 5-7 (API 명세)
2. CENC Section 6-9 (PSSH, 암호화 스킴)
3. 벤더별 Integration Guide

고급자:
1. ISO/IEC 23001-7 전문 (CENC 원문)
2. Chromium/WebKit 소스 코드 분석
3. 벤더 SDK 내부 구조 (NDA 필요)

스펙 문서 효율적으로 읽기:

  1. 용어집부터 정독: 같은 개념을 벤더마다 다르게 부름

    • EME: MediaKeySession = FairPlay: AVContentKeySession
    • Widevine: robustness = PlayReady: SecurityLevel
  2. 흐름도 우선: 텍스트보다 다이어그램이 이해 빠름

    • EME: “Figure 1: Basic Flow”
    • CENC: “Annex B: Informative Examples”
  3. 샘플 코드 분석: 스펙의 추상적 설명 → 구체적 코드로 확인

  4. 에러 코드 섹션: 디버깅 시 가장 자주 참조

공식 문서 외 필수 자료

오픈소스 도구

SaaS 솔루션

커뮤니티


마지막 조언: DRM은 기술적 솔루션이지만, 법적 보호사용자 경험의 균형이 중요합니다. 과도한 DRM은 정상 사용자에게도 불편을 줍니다. 콘텐츠 가치에 맞는 적절한 보안 수준을 선택하세요.

한 줄 요약: 멀티 플랫폼 지원을 위해 Widevine, FairPlay, PlayReady를 모두 지원하는 CENC 방식을 사용하고, 라이선스 서버에서 철저한 권한 검증을 수행하여 콘텐츠를 보호하세요.

내부 동작과 핵심 메커니즘

이 글의 주제는 「DRM 완벽 가이드 | Widevine·FairPlay·PlayReady·AES-128 총정리」입니다. 앞선 튜토리얼을 구현·런타임 관점에서 다시 압축합니다. 구성 요소 간 책임 분리와 관측 가능한 지점을 기준으로 “입력이 어디서 검증되고, 핵심 연산이 어디서 일어나며, 부작용(I/O·네트워크·디스크)·동시성이 어디서 터지는가”를 한 장면으로 그리면 장애 분석이 빨라집니다.

처리 파이프라인(개념도)

flowchart TD
  A[입력·요청·이벤트] --> B[파싱·검증·디코딩]
  B --> C[핵심 연산·상태 전이]
  C --> D[부작용: I/O·네트워크·동시성]
  D --> E[결과·관측·저장]

경계에서의 지연·실패(시퀀스 관점)

sequenceDiagram
  participant C as 클라이언트/호출자
  participant B as 경계(프로세스·런타임·게이트웨이)
  participant D as 의존성(외부 API·DB·큐)
  C->>B: 요청/이벤트
  B->>D: 조회·쓰기·RPC
  D-->>B: 지연·부분 실패·재시도 가능
  B-->>C: 응답 또는 오류(코드·상관 ID)

알고리즘·프로토콜·리소스 관점 체크포인트

  • 불변 조건(Invariant): 각 단계가 만족해야 하는 조건(버퍼 경계, 프로토콜 상태, 트랜잭션 격리, 파일 디스크립터 상한)을 문장으로 적어 두면 디버깅 비용이 줄어듭니다.
  • 결정성: 동일 입력에 동일 출력이 보장되는 순수 층과, 시간·네트워크·스레드 스케줄에 의해 달라질 수 있는 층을 분리해야 테스트와 장애 분석이 쉬워집니다.
  • 경계 비용: 직렬화/역직렬화, 문자 인코딩, syscall 횟수, 락 경합, GC·할당, 캐시 미스처럼 누적 비용을 의심 목록에 넣습니다.
  • 백프레셔: 생산자가 소비자보다 빠를 때(소켓 버퍼, 큐 깊이, 스트림) 어디서 어떤 신호로 속도를 줄일지 정의합니다.

프로덕션 운영 패턴

실서비스에서는 기능과 함께 관측·배포·보안·비용·규제가 동시에 요구됩니다.

영역운영 관점 질문
관측성요청 단위 상관 ID, 에러율/지연 분위수(p95/p99), 의존성 타임아웃·재시도가 대시보드에 보이는가
안전성입력 검증·권한·비밀·감사 로그가 코드 경로마다 일관적인가
신뢰성재시도는 멱등 연산에만 적용되는가, 서킷 브레이커·백오프·DLQ가 있는가
성능캐시 계층·배치 크기·커넥션 풀·인덱스·백프레셔가 데이터 규모에 맞는가
배포롤백 룬북, 카나리/블루그린, 마이그레이션 호환성·플래그가 문서화되어 있는가
용량피크 트래픽·디스크·파일 디스크립터·스레드 풀 상한을 주기적으로 검증하는가

스테이징은 데이터 양·네트워크 RTT·동시성을 가능한 한 프로덕션에 가깝게 맞추는 것이 재현율을 높입니다.


확장 예시: 엔드투엔드 미니 시나리오

「DRM 완벽 가이드 | Widevine·FairPlay·PlayReady·AES-128 총정리」을 실제 배포·운영 흐름으로 옮긴 체크리스트형 시나리오입니다. 도메인에 맞게 단계 이름만 바꿔 적용할 수 있습니다.

  1. 입력 계약 고정: 스키마·버전·최대 페이로드·타임아웃·에러 코드 표를 API 또는 이벤트 경계에 둔다.
  2. 핵심 경로 계측: 요청 ID, 단계별 지연, 외부 호출 결과 코드를 한 화면(로그+메트릭+트레이스)에서 추적한다.
  3. 실패 주입: 의존성 타임아웃·5xx·부분 데이터·락 대기를 스테이징에서 재현한다.
  4. 호환·롤백: 설정/마이그레이션/클라이언트 버전을 되돌릴 수 있는지(또는 피처 플래그) 확인한다.
  5. 부하 후 검증: 피크 대비 p95/p99, 에러율, 리소스 상한, 알림 임계값이 기대 범위인지 본다.

의사코드 스케치(프레임워크 무관)

handle(request):
  ctx = newCorrelationId()
  validated = validateSchema(request)        // 경계에서 거절
  authorize(validated, ctx)                  // 권한·테넌트
  result = domainCore(validated)             // 순수에 가까운 규칙
  persistOrEmit(result, idempotentKey)       // I/O: 멱등·재시도 정책
  recordMetrics(ctx, latency, outcome)
  return result

문제 해결(Troubleshooting)

증상가능 원인조치
간헐적 실패레이스, 타임아웃, 외부 의존성 불안정, DNS최소 재현 스크립트, 분산 트레이스·로그 상관관계, 재시도·서킷 설정 점검
성능 저하N+1, 동기 I/O, 락 경합, 과도한 직렬화, 캐시 미스프로파일러·APM으로 핫스팟 확인 후 한 가지씩 제거
메모리 증가캐시 무제한, 구독/리스너 누수, 대용량 버퍼, 커넥션 미반납상한·TTL·힙/FD 스냅샷 비교
빌드·배포만 실패환경 변수, 권한, 플랫폼 차이, lockfileCI 로그와 로컬 diff, 런타임·이미지 버전 핀
설정이 로컬과 다름프로필·시크릿·기본값, 지역 리전단일 소스(예: 스키마 검증된 설정)와 배포 매트릭스 표준화
데이터 불일치비멱등 재시도, 부분 쓰기, 캐시 무효화 누락멱등 키·아웃박스·트랜잭션 경계 재검토

권장 순서: (1) 최소 재현 (2) 최근 변경 범위 축소 (3) 환경·의존성 차이 (4) 관측으로 가설 검증 (5) 수정 후 회귀·부하 테스트.


같이 보면 좋은 글 (내부 링크)

이 주제와 연결되는 다른 글입니다.


이 글에서 다루는 키워드 (관련 검색어)

DRM, Widevine, FairPlay, PlayReady, EME, CENC, 암호화, 스트리밍, 저작권 등으로 검색하시면 이 글이 도움이 됩니다.