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 지원
| 플랫폼 | Widevine | FairPlay | PlayReady | ClearKey |
|---|---|---|---|---|
| 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계층 보안 모델을 사용합니다:
- 암호화된 콘텐츠: AES-128/AES-256으로 암호화
- CDM (Content Decryption Module): 브라우저/OS에 내장된 보안 모듈
- TEE (Trusted Execution Environment): 하드웨어 보안 영역 (L1만 해당)
보안 레벨별 차이점:
| 레벨 | 복호화 위치 | 비디오 파이프라인 | 최대 화질 | 사용 예시 |
|---|---|---|---|---|
| L1 | Hardware TEE | Secure Video Path | 4K/HDR | 프리미엄 구독 (Netflix 4K) |
| L2 | Software (메모리) | 일반 파이프라인 | 1080p | 일반 구독 |
| L3 | Software | 일반 파이프라인 | 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과 가장 넓은 공격 표면
L3는 Widevine 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 핵심 차이:
| 항목 | Widevine | FairPlay |
|---|---|---|
| 프로토콜 | 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) |
| API | EME (웹 표준) | AVFoundation (Apple 독자) |
| 라이선스 요청 | Challenge/Response | SPC/CKC |
| 플랫폼 | 크로스 플랫폼 | Apple 전용 |
FairPlay의 특수한 점:
-
FPS Certificate (인증서) 필수
- Apple에 사업자 등록 후 발급 (Apple Developer Program 필요)
- 인증서 없이는 테스트조차 불가능
- 유효기간 1년 (갱신 필요)
-
skd:// URL 스킴
- HLS m3u8에서
skd://로 시작하는 특수 URL 사용 - 예:
skd://fps.example.com?contentId=movie123 - AVPlayer가 이를 감지하면 FairPlay 활성화
- HLS m3u8에서
-
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 가입
- 연간 $99 (기업은 $299)
- https://developer.apple.com/programs/
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:
| 항목 | PlayReady | Widevine |
|---|---|---|
| 주요 생태계 | Windows, Xbox, 기업 | Android, Chrome, TV |
| 보안 레벨 | SL150/2000/3000 | L3/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 구현 전 알아야 할 것:
- Windows 플랫폼 전용: UWP (Universal Windows Platform) 또는 Win32 앱
- MediaProtectionManager: DRM 관리 핵심 클래스
- 두 가지 서비스 요청:
- 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)
EME는 W3C 웹 표준으로, 브라우저에서 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 · 미디어 파이프라인
| 경계 | 책임 | 비고 |
|---|---|---|
| JavaScript | requestMediaKeySystemAccess, 세션 생성, 라이선스 바이트를 네트워크로 전달 | 평문 콘텐츠 키·복호화된 샘플에 직접 접근 불가 |
| 브라우저(UA) | CDM 프로세스 생성, IPC, 미디어 파이프라인과 CDM 연결 | 구현마다 샌드박스·프로세스 모델 상이 |
| CDM | 라이선스 파싱, 키 캐시, 복호화 연산 요청 처리, 키 상태 보고 | Widevine·PlayReady·FairPlay 등 키 시스템별 구현 |
| OS/하드웨어 | TEE, 디코더, 디스플레이 경로, HDCP | L1에서 특히 중요 |
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()가 성공하면 MediaKeySession의 keyStatuses 맵이 갱신됩니다. 각 키 ID는 대략 다음 같은 상태를 가질 수 있습니다(스펙 용어).
usable: 재생에 사용 가능.expired: 만료. 갱신 실패 시 재생 중단으로 이어질 수 있음.output-restricted,internal-error등: HDCP·출력 경로 또는 CDM 내부 오류. 하드웨어·케이블·외부 모니터 문제와 연관되는 경우가 많음.
플레이어는 waitingforkey·encrypted와 함께 keystatuseschange 이벤트를 구독해 사용자 메시지(“외부 디스플레이는 HD 지원이 제한됩니다”)로 연결할 수 있습니다.
네트워크 계층: CORS·자격 증명·바디
- 라이선스 요청은 보통
POST,Content-Type: application/octet-stream입니다. JSON이 아닙니다. - 동일 출처 정책 때문에, 라이선스 서버가 다른 오리진이면 CORS 헤더가 필요합니다. 쿠키·HTTP-only 세션을 쓰면
fetch에credentials: '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 Packager | 무료, DASH/HLS 지원, 문서 우수 | CLI만 지원 | |
| Bento4 | Axiomatic | 무료, 유연한 CLI | 문서 부족 |
| FFmpeg | 커뮤니티 | 만능, 무료 | DRM 기능 제한적 |
| AWS MediaConvert | AWS | 클라우드, 자동화 | 유료 |
| Bitmovin | Bitmovin | 엔터프라이즈급 | 고가 |
추천: 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 보안 강화 방법:
-
키 URL에 인증 토큰 추가
#EXT-X-KEY:METHOD=AES-128,URI="https://example.com/key.bin?token=JWT_TOKEN" -
키를 동적으로 생성 (사용자별)
- 같은 콘텐츠라도 사용자마다 다른 키 제공
- 키 유출 시 해당 사용자만 차단
-
짧은 키 유효기간
- 1시간 ~ 24시간마다 키 교체
- 세션 기반 키 생성
-
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. 키가 동적 (사용자/세션별로 다름)
보안 강화 방법:
- 토큰 기반 키 URL (앞서 설명한 Node.js 예시)
- 키 로테이션: 세그먼트마다 다른 키 사용
#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 - 지역 제한 + 시간 제한: 키 요청 시 IP/시간 검증
- 워터마킹: 사용자 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:
-
다중 워터마크 사용
- 가시적: 사용자 인지 (심리적 압박) - 비가시적: 포렌식 추적 (법적 증거) - 세션 기반: 재생마다 다름 (녹화 추적) -
워터마크 위치 변경
# 시간에 따라 위치 변경 (잘라내기 방지) 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) -
서버 사이드 워터마킹
클라이언트 요청 시: 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단계: 키 시스템 선택과 협상
- 앱이
navigator.requestMediaKeySystemAccess(keySystem, configurations)를 호출한다. - 브라우저는 설치된 CDM, 코덱·컨테이너,
robustness,persistentState,distinctiveIdentifier요구를 평가한다. - 성공 시
MediaKeySystemAccess가 반환되고, 이어서createMediaKeys()로 CDM 인스턴스가 준비된다.
실패가 잦은 지점: 요청한 robustness가 기기에서 불가능하거나, 코덱 문자열이 실제 패키징과 불일치할 때입니다. 운영에서는 우선 낮은 보안으로 협상 후 상향하는 패턴도 쓰이지만, 스튜디오 정책상 최소 보안을 만족하지 못하면 재생 자체를 막는 경우가 많습니다.
2단계: setMediaKeys와 미디어 로드
video.setMediaKeys(mediaKeys)로 특정<video>엘리먼트에 CDM 컨텍스트를 연결한다.src또는 미디어 소스 확장(MSE)으로 암호화 세그먼트를 로드한다.- 파서가 초기화 세그먼트·PSSH를 만나면
encrypted이벤트가 발생하고,initData+initDataType이 앱에 전달된다.
주의: encrypted는 한 콘텐츠에 여러 번 발생할 수 있습니다(오디오·비디오 키가 다르거나, 키 로테이션). 세션을 하나만 만들지 말고, 매니페스트·키 ID 단위로 설계해야 합니다.
3단계: 세션 생성과 generateRequest
mediaKeys.createSession(sessionType)—temporaryvspersistent-license(오프라인) 선택.session.generateRequest(initDataType, initData)가 CDM에 라이선스 요청(Challenge) 생성을 지시한다.- CDM은
message이벤트로 바이너리 Challenge를 내보낸다. 이것이 라이선스 서버에 POST할 본문입니다.
4단계: 라이선스 서버와 권한 결정
- 앱(또는 프록시)이 Challenge를 HTTPS로 전송한다. 이때 사용자 인증은
Authorization헤더·쿠키·mTLS 등 앱 정책대로 붙는다. - 서버는 구독·지역·기기 한도·동시 재생을 검증한다.
- Widevine/PlayReady/FairPlay SDK 또는 파트너 SaaS가 Challenge를 파싱하고, 콘텐츠 키(보통 KMS에 보관)를 라이선스에 바인딩해 응답 바이트를 생성한다.
서버가 반환하는 것은 “키 평문 JSON”이 아니라 CDM이 해독 가능한 라이선스 블롭입니다.
5단계: session.update와 키 준비
await session.update(licenseBuffer)호출.- CDM이 서명·유효기간·정책을 검증하고 키를 캐시한다.
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 L1 | Android, Chrome | 하드웨어 | 4K | ✅ | |
| Widevine L3 | Chrome, Firefox | 소프트웨어 | 480p | ❌ | |
| FairPlay | Apple | iOS, Safari | 하드웨어 | 4K | ✅ |
| PlayReady | Microsoft | Windows, Xbox | 하드웨어 | 4K | ✅ |
| HLS AES-128 | Apple | 모든 플랫폼 | 기본 | 제한 없음 | ✅ |
| ClearKey | W3C | 모든 플랫폼 | 없음 | 제한 없음 | ✅ |
서비스별 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. 기능 비교
| 기능 | Widevine | FairPlay | PlayReady | HLS AES-128 |
|---|---|---|---|---|
| 하드웨어 보안 | ✅ L1 | ✅ | ✅ SL3000 | ❌ |
| 4K/HDR 지원 | ✅ L1만 | ✅ | ✅ | ✅ (보안 취약) |
| 오프라인 재생 | ✅ | ✅ | ✅ | ⚠️ (키 저장 필요) |
| 라이선스 체인 | ❌ | ❌ | ✅ (Domain) | ❌ |
| 크로스 플랫폼 | ✅ 광범위 | ❌ Apple만 | ⚠️ Windows 위주 | ✅ HLS 지원 시 |
| 구현 난이도 | 중간 | 높음 | 높음 | 낮음 |
| 웹 표준 (EME) | ✅ | ⚠️ (Safari만) | ✅ | ⚠️ (비표준) |
2. 플랫폼 지원
| 플랫폼 | Widevine | FairPlay | PlayReady | 권장 |
|---|---|---|---|---|
| Chrome (Desktop) | ✅ L3 | ❌ | ❌ | Widevine |
| Chrome (Android) | ✅ L1/L3 | ❌ | ❌ | Widevine L1 |
| Safari (macOS) | ❌ | ✅ | ❌ | FairPlay |
| Safari (iOS) | ❌ | ✅ | ❌ | FairPlay |
| Edge (Windows) | ✅ L3 | ❌ | ✅ | Widevine or PlayReady |
| Firefox | ✅ L3만 | ❌ | ❌ | Widevine L3 |
| Android App | ✅ L1/L3 | ❌ | ❌ | Widevine L1 |
| iOS App | ❌ | ✅ | ❌ | FairPlay |
| Smart TV (Tizen) | ✅ | ❌ | ✅ | Widevine or PlayReady |
| Smart TV (webOS) | ✅ | ❌ | ✅ | Widevine or PlayReady |
| Xbox | ❌ | ❌ | ✅ | PlayReady |
| PlayStation | ✅ | ❌ | ❌ | Widevine |
3. 보안 레벨 비교
| DRM | 레벨 | 복호화 위치 | 최대 화질 | 공격 난이도 |
|---|---|---|---|---|
| Widevine L1 | 최고 | Hardware TEE | 4K/HDR | 매우 높음 |
| Widevine L2 | 중간 | Software (메모리) | 1080p | 높음 |
| Widevine L3 | 기본 | Software | 480p-720p | 중간 |
| FairPlay | 최고 | Hardware (Secure Enclave) | 4K/HDR | 매우 높음 |
| PlayReady SL3000 | 최고 | Hardware TEE | 4K/HDR | 매우 높음 |
| PlayReady SL2000 | 중간 | Software (TEE) | 1080p | 높음 |
| PlayReady SL150 | 기본 | Software | SD | 중간 |
| HLS AES-128 | 낮음 | Software (평문 키) | 무제한 | 낮음 |
4. 비용 비교
| 항목 | Widevine | FairPlay | PlayReady | HLS 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이 과한 경우:
-
Signed URL + 만료 시간
https://cdn.example.com/video.mp4? token=JWT_TOKEN& expires=1609459200& signature=sha256_hash - 장점: 간단, 무료 - 단점: URL 유출 시 유효기간 동안 무방비 -
Referer/Origin 검증
# Nginx 설정 location /videos/ { valid_referers www.example.com example.com; if ($invalid_referer) { return 403; } } - 장점: 외부 링크 차단 - 단점: Referer 스푸핑 가능 -
IP 화이트리스트
- 기업 내부망 전용 - VPN 필수 - 장점: 물리적 접근 제한 - 단점: 원격 근무 불편 -
사용자 인증 + 세션
- 로그인 필수 - 세션 토큰 관리 - 동시 재생 제한 - 장점: 계정 추적 가능 - 단점: 계정 공유 방지 어려움
프로덕션 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줄 요약
- 스타트업/중소기업: Widevine L3 + FairPlay + CENC로 시작. HLS AES-128은 교육 콘텐츠만 사용.
- 프리미엄 서비스: Widevine L1 + FairPlay + PlayReady + 워터마킹 + 24/7 모니터링.
- 예산 제로: 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과 함께 보면 좋은 관련 기술 가이드입니다:
- H.264/AVC 비디오 코덱 완벽 가이드 | 프로파일·레벨·인코딩 최적화 - DRM으로 보호할 영상 인코딩
- HEVC/H.265 실전 가이드 | 4K HDR·Main 10·인코딩 최적화 - 고화질 콘텐츠 인코딩
- AAC 오디오 코덱 완전 가이드 | LC-AAC·HE-AAC·FFmpeg 실전 - DRM 콘텐츠 오디오 처리
- FFmpeg 완벽 가이드 | 비디오·오디오 처리·스트리밍·변환 - DRM 콘텐츠 인코딩 도구
- HTTP 프로토콜 완벽 가이드 | HTTP/1.1·HTTP/2·HTTP/3 비교 - DRM 라이선스 서버 통신
- Cloudflare Workers 완벽 가이드 | Edge Computing·Serverless·KV - 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 (파트너 전용)
공개 레퍼런스:
- Android CTS (Compatibility Test Suite): Widevine 테스트 케이스
- Chromium Source Code: media/cdm/
읽어야 할 핵심:
- 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 필요)
스펙 문서 효율적으로 읽기:
-
용어집부터 정독: 같은 개념을 벤더마다 다르게 부름
- EME:
MediaKeySession= FairPlay:AVContentKeySession - Widevine:
robustness= PlayReady:SecurityLevel
- EME:
-
흐름도 우선: 텍스트보다 다이어그램이 이해 빠름
- EME: “Figure 1: Basic Flow”
- CENC: “Annex B: Informative Examples”
-
샘플 코드 분석: 스펙의 추상적 설명 → 구체적 코드로 확인
-
에러 코드 섹션: 디버깅 시 가장 자주 참조
공식 문서 외 필수 자료
- Widevine DRM - 공식 사이트 (파트너십 필요)
- Apple FairPlay Streaming - 완전한 가이드 및 샘플 코드
- Microsoft PlayReady - 기술 문서 및 SDK
오픈소스 도구
- Shaka Player - JavaScript DRM 플레이어
- Shaka Packager - CENC 인코더
- Bento4 - MP4 도구
- FFmpeg - 비디오 인코딩
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 총정리」을 실제 배포·운영 흐름으로 옮긴 체크리스트형 시나리오입니다. 도메인에 맞게 단계 이름만 바꿔 적용할 수 있습니다.
- 입력 계약 고정: 스키마·버전·최대 페이로드·타임아웃·에러 코드 표를 API 또는 이벤트 경계에 둔다.
- 핵심 경로 계측: 요청 ID, 단계별 지연, 외부 호출 결과 코드를 한 화면(로그+메트릭+트레이스)에서 추적한다.
- 실패 주입: 의존성 타임아웃·5xx·부분 데이터·락 대기를 스테이징에서 재현한다.
- 호환·롤백: 설정/마이그레이션/클라이언트 버전을 되돌릴 수 있는지(또는 피처 플래그) 확인한다.
- 부하 후 검증: 피크 대비 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 스냅샷 비교 |
| 빌드·배포만 실패 | 환경 변수, 권한, 플랫폼 차이, lockfile | CI 로그와 로컬 diff, 런타임·이미지 버전 핀 |
| 설정이 로컬과 다름 | 프로필·시크릿·기본값, 지역 리전 | 단일 소스(예: 스키마 검증된 설정)와 배포 매트릭스 표준화 |
| 데이터 불일치 | 비멱등 재시도, 부분 쓰기, 캐시 무효화 누락 | 멱등 키·아웃박스·트랜잭션 경계 재검토 |
권장 순서: (1) 최소 재현 (2) 최근 변경 범위 축소 (3) 환경·의존성 차이 (4) 관측으로 가설 검증 (5) 수정 후 회귀·부하 테스트.
같이 보면 좋은 글 (내부 링크)
이 주제와 연결되는 다른 글입니다.
- [Arrays and Lists](/en/blog/algorithm-series-01-array-list/
- Astro Content Collections 완벽 가이드 | 타입 안전·스키마·MDX·블로그 구축
- Chrome 확장 프로그램 가이드 — Manifest V3·서비스 워커·메시징·콘텐츠 스크립트·프로덕션 패턴
이 글에서 다루는 키워드 (관련 검색어)
DRM, Widevine, FairPlay, PlayReady, EME, CENC, 암호화, 스트리밍, 저작권 등으로 검색하시면 이 글이 도움이 됩니다.