재생 엔진을 갈아 끼울 수 있는 HLS Player 모듈 설계하기
마이크를 끈 상담 녹화만 재생되지 않던 장애에서 시작해 Shaka Player와 hls.js를 어댑터로 갈아 끼울 수 있는 HLS 재생 모듈을 만든 과정입니다. 브라우저에 HLS 라이브러리가 필요한 이유부터 엔진을 고를 수 있게 만드는 설계까지 다룹니다.
비대면 상담 녹화가 재생되지 않는 장애가 났어요. 수어 상담처럼 상담원과 고객이 둘 다 마이크를 끄고 진행한 상담만 그랬고, 마이크를 켠 상담은 정상이었습니다. 원인은 쓰고 있던 재생 라이브러리였어요. 오디오가 없는 영상을 열지 못했거든요. 그날 안에 그 영상을 재생할 수 있는 다른 라이브러리로 바꿔서 복구했고, 그 뒤로 제품 라인 두 개가 서로 다른 라이브러리로 영상을 재생하게 됐어요.
이 글은 그 두 재생 코드를 하나로 합치면서 라이브러리를 나중에 갈아 끼울 수 있게 만든 과정입니다. HLS가 무엇인지, 브라우저에서 HLS를 재생하려면 왜 라이브러리가 필요한지, 왜 라이브러리를 하나로 고르지 않았는지, 그 결정이 어떤 인터페이스와 코드 구조가 됐는지를 다뤄요.
브라우저에서 HLS를 재생하는 코드를 앱마다 직접 짜고 있다면, 그 코드를 어디까지 떼어내야 하는지 판단하는 데 참고가 될 거예요. 라이브 스트리밍은 다루지 않습니다.
HLS는 영상을 조각으로 나눠 HTTP로 보내요
HLS(HTTP Live Streaming)는 애플이 만든 영상 전송 방식입니다. 큰 영상 파일 하나를 통째로 내려받는 대신 몇 초짜리 조각으로 잘라 HTTP로 하나씩 받아요. 특별한 스트리밍 서버가 필요 없고 일반 HTTP 서버와 CDN을 그대로 씁니다.
구성 요소는 두 가지예요.
| 요소 | 확장자 | 내용 |
|---|---|---|
| 매니페스트 | .m3u8 | 세그먼트 목록과 재생 정보를 적은 텍스트 파일 |
| 세그먼트 | .ts, .m4s | 실제 영상 조각 |
매니페스트는 이렇게 생겼어요. 6초짜리 조각 두 개로 된 암호화 영상이에요.
#EXTM3U
#EXT-X-VERSION:3
#EXT-X-TARGETDURATION:6
#EXT-X-KEY:METHOD=AES-128,URI="key.bin"
#EXTINF:6.000,
segment0.ts
#EXTINF:6.000,
segment1.ts
#EXT-X-ENDLIST플레이어는 이 파일을 먼저 받아 목록을 읽고, 세그먼트를 순서대로 요청하며 버퍼를 채워요. EXT-X-KEY가 있으면 세그먼트가 암호화돼 있다는 뜻이라, 거기 적힌 주소로 복호화 키를 먼저 받아 세그먼트를 풀면서 재생해요. 앞에서 말한 상담 녹화를 재생하는 우리 제품은 바로 이 두 요청, 키 요청과 세그먼트 요청을 가로채서 고쳐 써야 합니다. 무엇을 왜 고치는지는 조금 뒤에 나와요.
그래도 재생 라이브러리를 쓰는 이유가 있어요
브라우저마다 사정이 다릅니다. Safari는 video.src에 m3u8 주소만 넣으면 재생하고, Chrome도 2025년 가을부터 데스크톱에서 재생하기 시작했어요(Chrome 143 (새 창에서 열림)부터는 기본으로 켜져 있습니다). 데스크톱 Firefox는 2026년 8월 기준으로 재생하지 못해요.
안 되는 곳에서는 자바스크립트가 그 일을 대신해야 합니다. 매니페스트를 읽고 세그먼트를 받아 브라우저의 미디어 버퍼(MSE)에 넣어주는 라이브러리가 여럿 있고, Shaka Player와 hls.js가 대표적입니다. 이 글에서는 이런 라이브러리를 재생 엔진이라고 부르겠습니다.
그런데 브라우저가 재생해 주는 곳에서도 라이브러리는 여전히 쓰입니다. 네이티브 재생은 알아서 다 해주는 대신 손댈 자리를 주지 않거든요. 받아주는 코덱이 좁고, 화질 선택이나 버퍼 조절도 안 되고, 나가는 요청을 가로챌 수도 없어요.
우리 제품이 걸리는 지점이 마지막의 요청 가로채기예요. 상담 녹화는 매니페스트와 복호화 키가 파일 주소 없이 API 응답으로 오는 구조라서 규칙 두 개가 필요합니다.
- 매니페스트가 키를 요청하면 API가 준 값으로 응답한다
- 매니페스트 안의 상대 경로 파일명은 기준 주소가 없으니 서버 기준 절대 경로로 바꾼다
video.src에 주소를 넣는 방식에는 이 요청을 가로챌 자리가 없어요. 엔진에는 있습니다.
두 엔진은 그대로 바꿔 끼울 수 없어요
이름만 보면 같은 일을 하는 라이브러리지만 실제로 붙여 보면 다른 점이 분명히 있습니다.
| 다른 점 | Shaka Player | hls.js |
|---|---|---|
| 다루는 프로토콜 | HLS와 DASH 둘 다 | HLS만 |
| 기능 범위 | DRM 전반, 오프라인 저장, 저지연 HLS | HLS에 집중, 저지연 HLS와 광고 삽입 |
| 요청을 가로채는 자리 | 네트워킹 엔진의 요청 필터 | 커스텀 로더(loader, pLoader, fLoader) |
| 에러 객체 | category와 code를 가진 객체 | 이벤트의 type과 details |
어느 쪽을 고르든 그 선택이 도메인 코드까지 따라 들어가요. 요청을 가로채는 자리가 다르니 복호화 키를 어떻게 넘길지가 달라지고, 에러 객체의 모양이 다르니 실패를 어떻게 분류할지가 달라집니다. 이 글의 나머지는 Shaka Player와 hls.js를 직접 다루는 코드를 각각 파일 하나로 모으고, 그 바깥은 엔진을 바꿔도 그대로 두게 만든 과정입니다.
마이크를 끄면 녹화가 재생되지 않았어요
양쪽이 마이크를 끄면 마이크 스트림 자체가 만들어지지 않아요. 수어 상담이 그런 경우입니다. 녹음할 소리가 애초에 없으니 녹화 결과물에 오디오 트랙이 아예 생기지 않고, 비디오만 있는 HLS가 만들어집니다.
우리가 쓰던 버전의 Shaka Player는 그 스트림을 열지 못했습니다. 재생을 누르면 아무 일도 일어나지 않았고, 콘솔에는 미디어 조각을 버퍼에 넣다가 실패했다는 메시지만 남았어요. 설정으로 우회를 시도했지만 소용없었습니다.
원인은 Shaka의 코덱 추측이에요. 매니페스트에 코덱 정보(
CODECS)가 없으면 오디오가 있다고 추측해서 채우는데, 실제 스트림에 오디오가 없으면 Chrome이 그 조합을 거부합니다. 그래서 매니페스트에 코덱 정보가 적혀 있으면 이 문제가 일어나지 않고, Shaka v4.6.0에 들어온manifest.hls.disableCodecGuessing설정으로도 피할 수 있어요. 지금 같은 증상을 만났다면 교체까지 가기 전에 이쪽을 먼저 확인해 보세요.
그날 안에 그 고객사 라인의 재생 코드를 hls.js로 교체했습니다. hls.js는 비디오만 있는 HLS를 별도 설정 없이 재생하거든요. 마이크를 켠 경우와 끈 경우를 조합해 플랫폼별로 검증하고 배포했어요.
장애는 복구했지만 그 뒤로 제품 라인 두 개가 다른 엔진을 쓰게 됐습니다. 기본 라인은 Shaka Player, 수어 상담을 지원하는 고객사 라인은 hls.js. 두 재생 코드는 한 줄도 공유하지 않았습니다.
비슷한 문제가 1년 사이 두 번 생겼어요
몇 달 뒤 PM님이 AES-256으로 암호화한 영상도 재생할 수 있어야 한다며 지원 여부를 물었습니다. 확인해 보니 한 번에 답할 수 없는 질문이었습니다. Shaka Player는 최신 버전에서야 지원해서 앱마다 제각각이던 버전을 하나하나 올려야 했어요. hls.js는 그때 아직 지원하지 않았어요. 다행히 hls.js를 쓰는 고객사 라인에는 이 요구사항이 없었어요. 같은 질문과 같은 업그레이드를 앱 수만큼 반복한 셈입니다.
두 사건은 증상이 전혀 다른데 제가 한 일은 똑같았어요. "지금 쓰는 재생 엔진이 이걸 지원하나?" 그때마다 엔진 문서와 이슈 트래커를 찾아봤습니다.
방향이 매번 같지도 않습니다. 오디오 트랙이 없는 스트림은 hls.js가 열었고 Shaka는 열지 못했어요. AES-256은 반대였습니다. 그때 hls.js는 AES-256을 지원하지 않았어요. 지원은 v1.6.0(2025년 3월) (새 창에서 열림)에야 들어왔고, Shaka가 v4.8.0(2024년 4월) (새 창에서 열림)부터 지원했으니 열한 달 늦은 셈입니다. 기다리면 라이브러리가 따라오긴 하지만 어느 쪽이 먼저일지는 요구사항마다 달라요. 그럴 만한 이유가 있어요. AES-256은 HLS 표준(RFC 8216 (새 창에서 열림))에 없는, 두 라이브러리가 각자 확장으로 넣은 기능이거든요. 표준 밖의 기능을 쓰고 있다면 엔진을 바꿀 때 반드시 다시 확인해야 하는 항목이 된다는 뜻이에요.
여기서 문제를 다시 정의했습니다. 어느 엔진이 더 좋은지 비교하는 일은 답을 주지 못했어요. 이런 요구는 앞으로도 계속 올 테고 그때마다 앞서는 엔진이 다를 테니, 요구사항에 맞게 엔진을 바꿀 수 있어야 했습니다. 이건 엔진을 고를 수 있어야 하는 문제였어요.
엔진을 바꾸려면 재생 코드 전체를 다시 써야 했어요
"엔진을 바꾸자"가 왜 간단하지 않았는지는 코드를 열어보고 알았습니다. 재생이라는 하나의 기능이 공용 모듈 없이 앱마다 따로 구현돼 있었거든요.
| 위치 | 무엇이 있었나 | 왜 문제였나 |
|---|---|---|
| 관리자 콘솔 | npm으로 설치한 라이브러리를 정적 import한 뒤 window.shaka에 주입 | 엔진이 전역에 고정되고 첫 화면 번들에 통째로 실림 |
| 상담원 앱 | index.html의 CDN script 태그로 동기 로드 | 녹화 재생에서만 쓰는 라이브러리를 모든 화면의 첫 렌더가 기다림 |
| 고객사 라인 | hls.js와 별도 UI 라이브러리로 만든 자체 훅 | 위 어느 방식과도 인터페이스를 공유하지 않음 |
그러니까 이런 상태였어요. 같은 Shaka Player를 쓰는 두 앱조차 불러오는 방법이 npm import와 CDN script 태그로 서로 달랐고, 고객사 라인은 아예 다른 라이브러리인 hls.js로 훅을 따로 만들어 쓰고 있었어요. 재생이라는 한 가지 기능이 세 곳에 따로 구현돼 있는데 서로 아무것도 공유하지 않았어요. 한 곳을 고치면 나머지 두 곳은 그대로였습니다.
여기에 도메인 규칙이 문제를 하나 더 얹습니다. 키를 응답하고 세그먼트 주소를 바꾸는 규칙 두 개가 Shaka Player의 요청 필터라는 자리에 구현돼 있었거든요. 요청이 나가기 전에 한 번 불러주는 함수인데, Shaka에만 있는 기능이에요. hls.js에는 그 자리가 없어요.
긴급 교체 날 이 차이가 바로 문제가 됐습니다. 규칙 두 개는 그대로인데 hls.js에는 둘 자리가 없으니, 요청이 나가기 직전에 불리는 콜백에 같은 규칙을 하루 만에 다시 구현했습니다. 급하게 짠 코드는 요청의 종류를 URL 모양으로 추측했고, 재생하려고 만든 임시 데이터를 지우는 부분도 없었어요.
재생 코드는 세 곳에 흩어져 있고, 도메인 규칙은 특정 엔진의 전용 기능에 붙어 있고, 엔진을 바꾸면 같은 규칙을 전혀 다른 자리에 전혀 다른 방식으로 다시 써야 해요. "엔진을 바꾸자"가 재생 코드 전체를 다시 쓰자는 말이었던 이유입니다.
무엇을 보장할지 먼저 정했어요
코드를 짜기 전에 설계 문서부터 썼습니다. 무엇을 만들지 기능으로 나열하면 "엔진을 두 개 지원한다" 같은 말이 나오는데, 그건 판단 기준이 되지 못하거든요. 대신 무엇을 보장할지를 미리 정의하고 시작했습니다.
- 쓰는 쪽이 재생 엔진을 고른다
- 녹화 도메인 규칙, 그러니까 복호화 키 전달과 세그먼트 경로 변경은 엔진과 무관하게 한 곳에만 둔다
- 엔진이 지원하지 않는 스트림 조합은 재생을 시도하기 전에 막고 명확한 에러로 알린다
- 고르지 않은 엔진은 빌드 결과에 들어올 경로 자체가 없다
- 라이브러리를 번들에 포함할지 CDN에서 내려받을지도 쓰는 쪽이 정한다
번들이냐 CDN이냐까지 쓰는 쪽이 정하게 한 것은 두 앱이 이미 서로 다르게 불러오고 있었기 때문입니다. 관리자 콘솔은 npm, 상담원 앱은 CDN이었어요. 모듈이 한 방식만 지원하면 둘 중 하나는 지금 쓰는 방식을 버려야 했습니다.
가장 먼저 검토하고 기각한 것은 통일이었습니다.
| 대안 | 판정과 사유 |
|---|---|
| 한 엔진으로 통일 | 기각. 어느 쪽으로 통일해도 운영 중인 라인 하나를 전면 교체해야 합니다. 얻는 것은 코드 일관성뿐인데 감수할 위험이 커요 |
| 라인마다 재생 코드를 복제 | 기각. 엔진뿐 아니라 녹화 도메인 규칙까지 복제됩니다. 지금 상태를 굳히는 선택이에요 |
| 엔진을 갈아 끼울 수 있는 모듈 | 채택. 지원 범위를 확인해야 하는 요구가 1년 사이 두 번 반복됐기 때문입니다 |
모듈을 골랐다고 공짜는 아니어서, 어댑터 층을 유지하고 엔진 두 개를 계속 검증하는 비용은 남습니다. 그런데도 이쪽을 고른 것은 두 엔진의 스트림 호환성이 실제로 엇갈리고 있었고, 운영 중인 라인을 교체하는 것보다 세 곳에 중복된 재생 코드부터 합치는 쪽이 바꾸는 범위가 작았기 때문입니다.
설계 결정 세 가지
여기서부터 모듈 안쪽 이야기입니다. 코어는 이 모듈에서 재생 엔진을 직접 다루지 않는 부분이에요. 상태를 관리하고 에러를 정리하고 Blob URL 수명을 챙기는데, 어떤 라이브러리로 재생하는지는 몰라요.
전체 구조는 이렇게 생겼어요.
코어 아래로 계약이 둘 있습니다. PlayerEngine이 엔진과의 경계이고 ResourceResolver가 도메인 규칙과의 경계입니다. 어댑터 셋이 앞의 계약을 구현하고, 녹화 리졸버 하나가 뒤의 계약을 구현해요.
재생 중 상태는 엔진이 아니라 video 요소에서 읽어요
엔진마다 상태 이벤트의 이름과 발생 시점이 다릅니다. 엔진에게 상태를 물으면 그 차이가 상태 값에 그대로 드러납니다. 그래서 상태의 출처를 나눴어요.
| 상태 | 출처 |
|---|---|
| playing, paused, ended | video 요소 이벤트 |
| preparing, loading, ready | 코어가 관리 |
| error | 코어가 엔진 에러를 통합 코드로 바꿈 |
playing과 paused는 어떤 엔진을 쓰든 video 요소가 알아요. 엔진이 보고할 것은 로드 단계와 에러뿐이에요. 이렇게 나누니 엔진에게 시킬 일이 "미디어를 붙이고 로드한다"까지로 줄었어요.
지원 범위를 코드로 선언하게 하고 재생 직전에 대조해요
앞의 장애를 다시 겪지 않기 위한 결정입니다. 엔진이 자기가 재생할 수 있는 스트림 종류를 선언하고, 소스는 자기 특성을 알려요. 코어가 재생 직전에 둘을 대조해서 불가능한 조합이면 요청을 보내기 전에 막아요.
interface EngineCapabilities {
videoOnlyHls: boolean;
}
interface SourceTraits {
hasAudio: boolean | "unknown";
encrypted: boolean;
}소스 특성은 이미 손에 있는 매니페스트 텍스트에서 얻어요. 오디오 트랙의 흔적이 있는지 정규식으로 찾는 정도이고 파서까지는 가지 않아요. 우리 녹화 파이프라인이 만드는 매니페스트 형식을 전제로 한 확인이라 이 정도로 충분합니다.
hasAudio는 처음에 boolean이었는데 오탐이 났어요. 세그먼트 목록만 있는 매니페스트는 파일 안을 열기 전에는 오디오 유무를 알 수 없거든요. 모르는 것을 없다고 답하면 멀쩡한 영상을 막게 돼요. 그래서 오디오가 없다고 명시적으로 확인된 경우만 막고, 판단할 수 없으면 통과시킨 뒤 엔진 에러로 받아요.
장애 때는 "재생이 안 된다"였습니다. 지금은 세그먼트도 키도 요청하기 전에 VIDEO_ONLY_UNSUPPORTED가 나와요.
이 검증은 실제로 장애가 났던 비디오 전용 조합에만 걸려 있습니다. AES-256 사례까지 막으려면 암호화 여부가 아니라 암호화 방식을 특성으로 올려야 하는데 거기까지는 가지 않았어요. 필요해지면 그때 늘릴 생각입니다.
엔진 계약은 꼭 필요한 것만 남겨요
계약은 타입스크립트 인터페이스입니다. 코어가 엔진에게 무엇을 시킬 수 있는지를 그 인터페이스가 정하고, 코어는 그 너머를 몰라요. 어댑터는 특정 라이브러리의 API를 그 계약 모양으로 바꿔주는 파일이에요. Shaka용 하나, hls.js용 하나 있어요. 여기서 방향이 중요합니다. 코어는 어느 라이브러리에도 맞추지 않고, 각 라이브러리가 코어가 정한 계약에 맞춰 들어와요. 의존성 역전이라고 부르는 그 방향입니다.
계약을 "Shaka Player가 하는 일의 합집합"으로 잡으면 hls.js 어댑터가 억지 구현으로 채워집니다. 그래서 재생 수명주기에서 우리 제품에 반드시 필요한 것부터 적고, 두 엔진이 그걸 무리 없이 구현할 수 있는지 확인하며 줄였어요.
interface PlayerEngine {
readonly name: string;
readonly capabilities: EngineCapabilities;
prepare(): Promise<void>;
attach(video: HTMLVideoElement): void | Promise<void>;
load(uri: string, options: EngineLoadOptions): Promise<void>;
onError(listener: (error: HlsPlayerError) => void): Unsubscribe;
destroy(): void | Promise<void>;
}일곱 멤버가 전부입니다. 재생 상태를 물어보는 메서드는 없어요. 그건 코어가 video 요소에서 직접 읽으니까요. configure() 같은 엔진 고유 API는 계약에 올리지 않고 어댑터를 만들 때 넘기는 옵션으로 격리했어요.
메서드 목록보다 중요한 것이 계약에 붙은 문장들이에요. load()는 재생을 시작할 수 있는 시점에 resolve해요. 로드 단계의 실패는 load()의 reject로 오고, 재생이 시작된 뒤의 실패만 onError로 와요. prepare()는 몇 번을 불러도 라이브러리를 한 번만 받아요. 모달을 빠르게 여닫아 겹쳐 불려도 두 번 받지 않습니다.
경계는 코드 구조로 지켜요
경계는 선언만으로 지켜지지 않습니다. 그래서 몇 가지를 코드로 강제해 뒀어요.
안 쓰는 엔진은 번들에 들어가지 않아요
주의해서 import하자는 약속으로는 고르지 않은 엔진을 막을 수 없습니다. import 한 줄이면 무너지거든요. 그래서 패키지 구조로 막았어요.
| 진입점 | 내용 | 필요한 라이브러리 |
|---|---|---|
. | 코어, 계약, 타입 | 없음 |
/shaka | Shaka 어댑터 | shaka-player |
/hlsjs | hls.js 어댑터 | hls.js |
/react | useHlsPlayer | react |
/testing | MemoryEngine, 픽스처 | 없음 |
shaka-player와 hls.js는 optional peerDependency로 선언했습니다. 이 모듈이 라이브러리를 직접 설치하지 않고 쓰는 앱이 설치해 둔 것을 쓴다는 선언이고, hls.js만 쓰는 앱은 Shaka를 설치할 필요가 없어요. 앱이 /shaka를 한 번도 import하지 않으면 빌드 도구가 그 어댑터 파일에 닿을 방법이 없고, 닿지 않는 파일은 결과물에 실리지 않아요. 약속으로 막지 않고 도달 가능성으로 막았습니다. 빌드 결과를 검사해 엔진 라이브러리 실체가 0건인 것도 확인했습니다.
도메인 규칙은 엔진을 모르는 함수 하나로 둬요
Shaka Player는 요청 필터로, hls.js는 커스텀 로더로 요청을 가로챕니다. 이 차이를 어댑터가 흡수하고, 규칙 자체는 요청 종류와 URI만 보고 지시를 돌려주는 순수 함수로 표현했어요. 지시는 세 가지예요. 그대로 보내기, 다른 주소로 보내기, 본문을 직접 응답하기
interface ResourceRequest {
kind: "manifest" | "segment" | "key";
uri: string;
}
type ResolvedDirective = { type: "pass" } | { type: "redirect"; uri: string };
type ResourceDirective = ResolvedDirective | { type: "inline"; data: BlobPart; contentType?: string };
interface ResourceResolver {
resolve(request: ResourceRequest): ResourceDirective;
}
const recordingResolver = ({ key, docOrigin, uploadUri }: RecordingContext): ResourceResolver => ({
resolve(request) {
switch (request.kind) {
case "key":
return { type: "inline", data: key, contentType: "text/plain" };
case "segment": {
const fileName = request.uri.split("/").pop();
return { type: "redirect", uri: `${docOrigin}/${uploadUri}/${fileName}` };
}
case "manifest":
return { type: "pass" };
}
}
});API가 준 키와 서버 주소를 밖에서 받아 리졸버를 만들고, 만들어진 리졸버는 요청 하나를 받아 지시 하나를 돌려줘요. 네트워크도 엔진도 모르니 테스트가 요청과 지시를 표로 대조하는 것으로 끝납니다. 세그먼트 지시가 파일명만 남기는 것은 녹화 세그먼트가 전부 한 업로드 경로 아래에 파일명으로만 저장되기 때문이에요. 매니페스트가 그냥 통과인 이유는 이 제품의 매니페스트가 파일 주소로 오지 않고 API 응답 본문으로 오기 때문입니다. 코어가 그 텍스트를 Blob URL로 만들어 엔진에 넘기니 리졸버가 손댈 것이 없어요. Blob URL은 메모리에 있는 데이터에 브라우저가 임시 주소를 붙여주는 기능이라, 엔진이 그 주소로 요청하면 네트워크로 나가지 않고 메모리에서 읽어요.
inline은 코어가 한 번 변환합니다. 두 엔진에서 우리가 공통 확장 지점으로 고른 요청 가로채기 자리에서는 응답 본문을 직접 줄 수 없거든요. Shaka의 요청 필터는 요청 객체만 받고, hls.js는 로더를 처음부터 다시 쓰면 가능하지만 그 순간 재시도와 타임아웃까지 로더가 하던 일 전부가 우리 몫이 돼요. 두 엔진이 공통으로 아는 지시는 통과와 리다이렉트까지예요. 그래서 코어가 키 본문을 Blob URL로 만들어 그 주소로 리다이렉트하라고 어댑터에 넘겨요. 어댑터가 받는 ResolvedDirective에 inline이 없는 이유가 이거예요. 어댑터가 inline 처리를 잊는 일은 타입 수준에서 일어날 수 없습니다.
Blob URL이 생기면 해제할 책임도 생깁니다. 만든 쪽이 해제한다. 생성은 코어가 하고 해제는 쓰는 쪽에 맡기는 식으로 책임을 나누면 둘 사이에서 누수가 나거든요. 긴급 교체 코드가 정확히 그 상태였어요. 키 요청마다 Blob URL을 만들었고 해제하는 줄은 없었습니다.
구현하면서 확정한 것들이에요.
- 어댑터 파일 두 개는 라이브러리를 런타임에 import하지 않아요. 라이브러리는 전부 주입된 로더로만 들어와요. 그 대가로 라이브러리의 타입 선언을 쓸 수 없어서 어댑터가 쓰는 만큼을 구조적 타입으로 직접 선언해요
- Shaka 에러는
instanceof로 판별할 수 없어요. 에러 클래스를 가져올 수 없으니category와code필드가 있는지 보고 가려내요 - hls.js 어댑터는 긴급 교체 코드가 쓰던
xhrSetup콜백을 버리고 기본 로더를 상속해 주소만 바꿔요. 로더 컨텍스트는 키 요청에는 키 정보를, 세그먼트 요청에는 조각 정보를 실어 줘서 URL 모양을 추측하지 않아도 되거든요
실패를 아홉 개 코드로 나눠요
"재생이 안 된다"로 뭉뚱그려지던 실패를 통합 에러 코드로 나눴습니다. 먼저 원인이 어느 계층에 있는지로 가려요.
| 계층 | 통합 코드 |
|---|---|
| 엔진 | ENGINE_LOAD_FAILED, ENGINE_UNSUPPORTED, VIDEO_ONLY_UNSUPPORTED |
| 매니페스트 | MANIFEST_INVALID |
| 리소스 | KEY_DELIVERY_FAILED, SEGMENT_FETCH_FAILED |
| 미디어 | MEDIA_DECODE_FAILED |
| 그 밖 | INVALID_STATE, UNKNOWN |
Shaka는 에러 분류표로, hls.js는 이벤트 이름으로 가려서 각 어댑터가 통합 코드로 바꿔요. 어느 쪽이든 원본 에러는 cause에 보존합니다. UI와 로그는 통합 코드만 보니까 엔진을 바꿔도 에러 처리는 그대로예요. 장애 때 이게 없어서 원인을 찾는 데 반나절을 썼거든요.
상태 머신은 하나로 두고 늦게 온 이벤트는 버려요
에러로 빠지는 상태가 다섯이에요. 재생이 시작된 뒤에도 세그먼트를 못 받거나 디코딩이 실패하면 에러로 가요. destroy()는 어느 상태에서든 부를 수 있고 그림에 없어요. 거기서 세션을 끝내는 일이라 다음 상태가 없으니까요.
코어 인스턴스 하나가 재생 세션 하나이고 load()는 한 세션에 한 번만 부를 수 있어요. 두 번 부르면 INVALID_STATE가 나요. 소스를 바꾸는 일은 새 인스턴스를 만드는 일이고, React 훅이 이 규칙을 대신 관리해요. 모달이 닫히면 destroy()가 표시를 남기고, 그 뒤에 도착하는 상태 전이와 에러는 전부 버려져요. 자동완성 검색이 늦게 온 응답을 버리는 것과 같은 구조인데, 여기서는 비교할 번호조차 필요 없어요. 이전 세션은 이미 destroy된 객체니까요.
쓰는 쪽은 훅 선언 하나로 끝나요.
import { recordingResolver } from "@my-org/hls-player";
import { useHlsPlayer } from "@my-org/hls-player/react";
import { fromModule, shakaEngine } from "@my-org/hls-player/shaka";
// npm으로 설치한 shaka-player를 재생이 시작될 때 불러온다
const engine = shakaEngine({ source: fromModule(() => import("shaka-player")) });
function RecordModal({ manifestText, decryptionKey, docOrigin, uploadUri }: Props) {
const resolver = useMemo(
() => recordingResolver({ key: decryptionKey, docOrigin, uploadUri }),
[decryptionKey, docOrigin, uploadUri]
);
const source = useMemo(() => ({ kind: "manifest-text" as const, manifest: manifestText }), [manifestText]);
const { videoRef, state, error } = useHlsPlayer({ engine, resolver, source });
return <video ref={videoRef} controls />;
}엔진을 바꾸는 것은 /shaka import 한 줄과 팩토리 호출을 바꾸는 일이에요. hls.js로 가려면 /shaka를 /hlsjs로, shakaEngine을 hlsJsEngine으로 바꿔요. 도메인 규칙과 훅 코드는 그대로예요. 재생 시점에 CDN에서 받고 싶으면 fromModule 자리에 fromCdn(주소)를 넣으면 돼요.
어려웠던 건 시그니처가 아니라 의미였어요
계약을 맞추는 일은 메서드 이름과 인자를 맞추는 일처럼 보입니다. 그런데 실제로 시간을 쓴 곳은 같은 이름이 같은 순간을 가리키게 만드는 쪽이었어요.
ready가 그랬어요. 우리 계약은 ready를 엔진이 재생을 시작해도 된다고 판단한 시점으로 정했습니다. Shaka Player는 load가 resolve되는 시점이 거기 해당해요. hls.js는 매니페스트를 읽었다는 MANIFEST_PARSED로는 일러요. 이름만 보고 거기 맞추면 스피너가 먼저 사라지고 화면은 아직 검은 상태가 되거든요. 그래서 첫 세그먼트가 버퍼에 들어온 FRAG_BUFFERED에 맞췄습니다. 물론 버퍼에 들어간 것과 video 요소가 첫 프레임 데이터를 확보한 것도 또 다른 순간이에요. 그래서 첫 프레임 로드 시점은 엔진에게 묻지 않고 loadeddata 이벤트에서 따로 재요.
키 실패도 같은 종류의 문제였어요. Shaka Player에서 복호화 키를 못 받으면 네트워크 계열 에러로 올라와요. 이름은 네트워크 실패인데 우리가 알아야 하는 것은 키 실패입니다. 어댑터가 키로 판별한 요청의 URI를 기억했다가 실패한 URI와 대조해서 구분했어요.
같은 이름이 두 구현에서 다른 순간을 가리키는 일은 어디서나 일어납니다. 프로세스가 살아 있는 것과 요청을 받을 준비가 된 것을 한 이름으로 부르면 배포가 깨집니다. 어댑터를 만들 때 시그니처 표를 그리는 것보다 각 상태가 정확히 어느 순간을 뜻하는지 한 줄씩 정의해 두는 편이 도움이 됐습니다.
무엇이 달라졌나
이 모듈은 이제 특정 라이브러리를 모릅니다. 코어에도 녹화 도메인 규칙에도 Shaka Player와 hls.js라는 이름이 나오지 않아요. 두 이름은 어댑터 파일 두 개에만 있어요.
기본 라인 두 앱을 이 모듈로 옮기면서 전역 주입과 타입 우회 선언, 모든 화면의 첫 렌더를 붙잡던 HTML의 script 태그가 사라졌어요. 지금은 두 앱이 같은 모듈을 써요. 관리자 콘솔은 라이브러리를 번들에 넣고, 상담원 앱은 재생할 때 CDN에서 내려받아요. 이 차이가 팩토리 호출 한 줄에 담깁니다.
나중에 Shaka Player나 hls.js 말고 또 다른 라이브러리를 써야 하더라도 붙이는 방법은 정해져 있어요. 계약을 구현한 어댑터 파일 하나를 만들고 진입점을 하나 늘리면 돼요. 코어와 도메인 규칙, 쓰는 쪽 코드는 그대로예요. 이미 그렇게 붙어 있는 엔진이 하나 더 있어요. 테스트용 MemoryEngine은 실제 라이브러리가 아닌데도 진짜 엔진과 똑같은 계약으로 코어에 붙거든요.
잃은 것도 있어요. 계약을 꼭 필요한 범위로 좁히면 엔진 고유 기능은 계약 밖으로 밀려나요. 현재 화질과 대역폭 추정, 버퍼 상태처럼 엔진만 아는 정보가 그래요. 녹화 재생만 하는 지금은 문제가 없지만 비디오 플레이어에서 작은 제약은 아니에요. Shaka의 오프라인 저장이 필요해지는 날이 오면 계약을 넓히거나 그 기능이 어댑터 옵션으로 빠져나가는 것을 허용해야 해요.
영상 재생만의 일이 아니에요
이 글은 HLS Player 이야기였지만 크게 보면 결국 모듈 설계 이야기입니다. 외부 라이브러리를 어디까지 격리할지, 도메인 규칙을 어디에 둘지, 쓰는 쪽이 쓰기 좋은 모양이 무엇일지를 정하는 일이었어요. HTTP 클라이언트에 인증 갱신이 붙을 때도, 차트 라이브러리에 데이터 가공이 붙을 때도 같은 문제를 만나게 됩니다.
그때 이 글에서 가져갈 순서는 두 가지예요. 인터페이스는 쓰는 쪽에 꼭 필요한 것만으로 잡고, 후보들이 그것을 억지 없이 구현할 수 있는지 확인해요. 그리고 도메인 규칙은 라이브러리를 모르는 자리로 뺍니다. 앞은 라이브러리와의 결합도를 낮추는 일이고 뒤는 도메인 규칙의 응집도를 높이는 일이에요. 라이브러리를 바꿔도 그대로인 코드가 어디까지인지가 그 경계에서 정해집니다.
소리 없는 녹화가 재생되지 않던 오류 하나에서 시작한 일이 여기까지 왔습니다. 다음에 어떤 요구사항으로 어떤 엔진을 써야 하게 될지는 몰라요. 다만 그날 할 일은 정해져 있습니다. 계약에 맞는 어댑터 하나를 만들면 나머지는 그대로예요.
참고자료
- IETF, RFC 8216 HTTP Live Streaming (새 창에서 열림)
- Chromium, 네이티브 HLS 재생을 켜는 기능 플래그 (새 창에서 열림)
- Chromium, 네이티브 HLS 경로가 인정하는 코덱 목록 (새 창에서 열림)
- shaka-player, Issue #5769 코덱 정보 없는 비디오 전용 HLS 재생 실패 (v4.6.0의 disableCodecGuessing으로 종결) (새 창에서 열림)
- shaka-player, Issue #6001 AES-256 지원 요청 (v4.8.0에서 구현) (새 창에서 열림)
- hls.js, v1.6.0 릴리스 노트 (AES-256 지원 추가) (새 창에서 열림)
이런 글도 읽어보세요
[RADIO로 시스템 디자인하기 #1] RADIO 프레임워크 뜯어보기
RADIO로 시스템 디자인하기 시리즈의 첫 글입니다. 프론트엔드 시스템 디자인을 구조적으로 풀어내는 RADIO 프레임워크를 Requirements부터 Optimization까지 다섯 단계로 나눠, 각 단계가 무엇을 묻고 왜 그 질문이 필요한지 차근차근 살펴봅니다.
[RADIO로 시스템 디자인하기 #3] 실시간 채팅 앱 설계 부숴보기
RADIO로 시스템 디자인하기 시리즈의 세 번째 글입니다. 서버가 먼저 말을 거는 실시간 채팅을 소재로, WebSocket 선택부터 낙관적 병합, 순서 보장, 가변 높이 가상화까지 RADIO 다섯 단계로 처음부터 끝까지 설계해봅니다.
[RADIO로 시스템 디자인하기 #2] 자동완성 검색창 설계 부숴보기
RADIO로 시스템 디자인하기 시리즈의 두 번째 글입니다. Input과 Dropdown뿐인 작은 UI 안에 디바운스, 캐시, race condition, 접근성까지 자동완성 검색창을 RADIO 다섯 단계로 처음부터 끝까지 설계해봅니다.