영상 플레이어를 운영하다 보면 콘솔이나 에러 로그에는 Shaka 에러가 찍혔는데 영상은 멀쩡히 재생되고 있는 상황을 자주 만납니다. 반대로 에러 하나에 재생이 완전히 멈추기도 합니다. 이 차이를 이해하려면 Shaka Player가 에러를 어떻게 분류하고, 어떤 에러를 스스로 회복하는지 알아야 합니다.
이 글에서는 Shaka Player 5.3.4 소스코드를 따라가며 다음을 확인합니다.
CRITICAL, RECOVERABLE Severity가 정해지고 바뀌는 과정결론부터 말하면, 재생 파이프라인에서 난 에러는 대부분 플레이어가 먼저 회복을 시도합니다. 다만 RECOVERABLE이라고 해서 모두 자동으로 회복되는 것은 아닙니다. API를 잘못 호출해서 난 에러는 RECOVERABLE이어도 애플리케이션이 처리해야 합니다.
Shaka Player가 던지는 에러는 모두 shaka.util.Error 클래스의 인스턴스입니다.
| 필드 | 의미 |
|---|---|
severity | 심각도. RECOVERABLE(1) 또는 CRITICAL(2) |
category | 에러가 발생한 영역. NETWORK(1)부터 ADS(10)까지 |
code | 구체적인 에러 번호 |
data | 에러 코드별 부가 정보를 담은 배열 |
handled | 에러가 이미 처리되었는지를 나타내는 값 |
message | 컴파일 빌드에서는 Shaka Error 1001처럼 번호만, 디버그 빌드에서는 카테고리와 코드 이름까지 표시 |
data의 구조는 코드마다 다릅니다. 예를 들어 BAD_HTTP_STATUS(1001)는 다음 값을 담습니다.
| 인덱스 | 값 |
|---|---|
data[0] | 요청 URI |
data[1] | HTTP 상태 코드 |
data[2] | 응답 본문 텍스트 |
data[3] | 응답 헤더 |
data[4] | 요청 타입(NetworkingEngine.RequestType) |
data[5] | 리다이렉트를 거친 최종 URI |
shaka.util.Error는 네이티브 Error를 상속하지 않습니다. 소스 주석에 따르면 instanceof Error가 false가 되도록 의도한 것으로, 애플리케이션이 처리되지 않은 네이티브 에러와 Shaka 에러를 구분할 수 있게 하기 위함입니다.
에러는 10개의 카테고리로 나뉘고, 코드 번호의 앞자리는 대체로 카테고리 번호와 일치합니다.
| 카테고리 | 영역 | 대표 코드 |
|---|---|---|
NETWORK (1) | 네트워크 | BAD_HTTP_STATUS 1001, HTTP_ERROR 1002, TIMEOUT 1003, SEGMENT_MISSING 1011 |
TEXT (2) | 자막 파싱 | INVALID_TEXT_HEADER 2000, TEXT_COULD_NOT_GUESS_MIME_TYPE 2011 |
MEDIA (3) | 오디오/비디오 처리 | BUFFER_READ_OUT_OF_BOUNDS 3000, MEDIA_SOURCE_OPERATION_FAILED 3014, MEDIA_SOURCE_OPERATION_THREW 3015, VIDEO_ERROR 3016, QUOTA_EXCEEDED_ERROR 3017, TRANSMUXING_FAILED 3018 |
MANIFEST (4) | 매니페스트 파싱 | UNABLE_TO_GUESS_MANIFEST_TYPE 4000, DASH_INVALID_XML 4001, RESTRICTIONS_CANNOT_BE_MET 4012, HLS_PLAYLIST_HEADER_MISSING 4015, CONTENT_UNSUPPORTED_BY_BROWSER 4032, NO_VARIANTS 4036 |
STREAMING (5) | 스트리밍 | STREAMING_ENGINE_STARTUP_INVALID_STATE 5006 |
DRM (6) | DRM | NO_RECOGNIZED_KEY_SYSTEMS 6000, REQUESTED_KEY_SYSTEM_CONFIG_UNAVAILABLE 6001, LICENSE_REQUEST_FAILED 6007, LICENSE_RESPONSE_REJECTED 6008, EXPIRED 6014 |
PLAYER (7) | 플레이어 일반 | LOAD_INTERRUPTED 7000, OPERATION_ABORTED 7001, NO_VIDEO_ELEMENT 7002, CONTENT_NOT_LOADED 7004 |
CAST (8) | Cast | CAST_API_UNAVAILABLE 8000 |
STORAGE (9) | 오프라인 저장소 | STORAGE_NOT_SUPPORTED 9000 |
ADS (10) | 광고 | CS_IMA_SDK_MISSING 10000 |
운영 중에 자주 살펴보게 되는 코드의 의미는 다음과 같습니다.
BAD_HTTP_STATUS: 서버가 실패 상태 코드를 응답했습니다. 404, 403처럼 상태 코드의 종류를 가리지 않습니다.HTTP_ERROR: 서버 응답이 아닌 이유로 요청이 실패했습니다. 연결 실패나 CORS 차단이 여기에 해당할 수 있습니다.MEDIA_SOURCE_OPERATION_FAILED, MEDIA_SOURCE_OPERATION_THREW: MSE 동작이 실패했거나 예외를 던졌습니다.VIDEO_ERROR: <video> 요소가 에러를 보고했습니다. 브라우저의 MediaError 코드가 data에 담깁니다.RESTRICTIONS_CANNOT_BE_MET: 재생 가능한 Variant가 제한 조건에 모두 걸렸습니다.LOAD_INTERRUPTED: 진행 중인 load()가 unload()나 다른 load()에 의해 중단되었습니다.소스코드는 두 Severity를 다음과 같이 정의합니다.
Severity는 에러를 생성할 때 생성자의 첫 번째 인자로 정해집니다. 소스 전체에서 Severity.CRITICAL은 247번, Severity.RECOVERABLE은 38번 등장합니다. 그런데 severity는 고정된 값이 아닙니다. 에러가 플레이어 내부를 지나는 동안 맥락에 따라 다시 쓰입니다.
세그먼트 요청 하나가 실패할 때를 따라가 보면 값이 이렇게 바뀝니다.
| 순서 | 위치 | 상황 | Severity |
|---|---|---|---|
| 1 | HTTP 플러그인 | 요청 실패 | RECOVERABLE |
| 2 | NetworkingEngine | 재시도 소진 | CRITICAL |
| 3 | StreamingEngine | 세그먼트 가져오기/추가 실패 처리 | CRITICAL |
| 4 | StreamingEngine | 대체 스트림으로 전환 성공 | RECOVERABLE |
HTTP 요청이 실패하면 플러그인은 RECOVERABLE 에러를 만듭니다. 상태 코드를 구분하지 않기 때문에 404나 403도 처음에는 RECOVERABLE이고 재시도 대상입니다.
재시도까지 모두 실패해 NetworkingEngine 밖으로 나가는 에러는 예외 없이 CRITICAL로 다시 분류됩니다.
error.js의 RECOVERABLE 주석에는 "세그먼트 재시도는 절대 CRITICAL이 되지 않는다(계속 재시도한다)"라고 적혀 있습니다. 하지만 기본 설정은 maxAttempts: 2이고, 위 코드에 따라 재시도가 끝나면 CRITICAL이 됩니다. 주석의 설명은 maxAttempts를 무한대로 설정했을 때에 해당합니다.
StreamingEngine은 세그먼트를 가져와 추가하는 과정의 실패를 받아, 별도로 처리하는 경우(요청 중단, 무시 설정된 자막 실패, 버퍼 용량 초과)를 빼고는 CRITICAL로 표시합니다. RECOVERABLE로 만들어진 SEGMENT_MISSING도 여기서 CRITICAL이 됩니다.
반대로 플레이어가 회복할 방법을 찾으면 Severity를 낮춥니다. 낮추는 지점은 세 곳입니다.
| 위치 | 조건 |
|---|---|
StreamingEngine.handleStreamingError_ | 문제 스트림을 비활성화하고 대체 스트림으로 전환할 수 있을 때 |
Player.defaultStreamingFailureCallback_ | 라이브 스트림에서 BAD_HTTP_STATUS, HTTP_ERROR, TIMEOUT이 발생했을 때 |
| DASH, HLS 파서의 매니페스트 갱신 | 갱신 요청이 실패해 다시 시도할 때 |
DRM의 일부 에러는 설정에 따라 Severity가 결정되기도 합니다. drm.persistentSessionOnlinePlayback이 켜져 있으면 저장된 세션을 불러오지 못해도 RECOVERABLE, 꺼져 있으면(기본값) CRITICAL입니다.
재생 중 발생하는 에러의 회복 경로를 하나씩 살펴보겠습니다.
모든 요청은 NetworkingEngine에서 재시도됩니다. 기본값은 lib/net/backoff.js에 정의되어 있고, streaming, manifest, drm의 retryParameters로 각각 변경할 수 있습니다.
| 설정 | 기본값 | 의미 |
|---|---|---|
maxAttempts | 2 | 첫 시도를 포함한 총 시도 횟수 |
baseDelay | 1000 | 첫 재시도 전 대기 시간(ms) |
backoffFactor | 2 | 재시도마다 대기 시간에 곱하는 값 |
fuzzFactor | 0.5 | 대기 시간을 ±50% 범위에서 무작위로 조정 |
timeout | 30000 | 요청 전체 제한 시간(ms) |
stallTimeout | 5000 | 데이터 수신이 멈췄을 때의 제한 시간(ms) |
connectionTimeout | 10000 | 연결 제한 시간(ms) |
재시도할 때는 retry 이벤트가 발생합니다. 재시도 중에는 Player의 error 이벤트가 발생하지 않습니다. 공식 튜토리얼도 "실패한 네트워크 요청의 재시도는 RECOVERABLE 에러로 보고되지 않는다"고 명시합니다.
재시도가 끝난 뒤에도 실패하면 StreamingEngine은 문제 스트림을 비활성화하고 다른 화질로 전환합니다. 이때 Severity가 RECOVERABLE로 낮아집니다.
비활성화된 스트림은 streaming.maxDisabledTime(기본 30초)이 지나면 다시 후보로 돌아옵니다. MSE 동작 실패가 원인이면 비활성화 시간은 무한대입니다.
전환은 항상 가능한 것이 아닙니다. Player.disableStream은 대표적으로 다음 경우에 false를 돌려주고, 이때는 스트림 전환이 일어나지 않습니다.
abr.enabled가 false인 경우navigator.onLine이 false인 경우즉 ABR을 끄고 화질을 고정한 플레이어에서는 이 회복 경로가 동작하지 않습니다. TIMEOUT 에러도 코드에서 볼 수 있듯이 이 경로에서 제외됩니다.
SEGMENT_MISSING은 세그먼트가 없다고 표시된 경우에 발생합니다. 위의 스트림 전환으로 처리되지 않으면 StreamingEngine은 누락되지 않은 다음 세그먼트까지 건너뛰어 스트리밍을 이어 갑니다. 건너뛸 세그먼트를 찾은 경우에는 error 이벤트를 발생시키지 않습니다.
QUOTA_EXCEEDED_ERROR는 SourceBuffer에 세그먼트를 넣을 공간이 없을 때 발생합니다. 플레이어는 이미 재생한 구간의 버퍼를 비우고, 버퍼링 목표를 단계적으로 줄이면서 다시 시도합니다.
이 과정에서는 애플리케이션에 에러가 전달되지 않고, 4%까지 줄여도 해결되지 않을 때 스트림 전환을 시도합니다.
MSE 계열 에러는 MediaSource를 다시 만들어 회복합니다. 경로는 두 가지입니다.
StreamingEngine은 세그먼트 추가가 실패하면, 이미 망가졌을 수 있는 SourceBuffer에 다시 데이터를 넣지 않도록 먼저 MediaSource를 강제로 리셋하고 다른 스트림으로 전환합니다.
Player의 onError_도 에러를 앱에 알리기 전에 리셋을 시도합니다. 대상은 VIDEO_ERROR, MEDIA_SOURCE_OPERATION_FAILED, MEDIA_SOURCE_OPERATION_THREW, STREAMING_NOT_ALLOWED, TRANSMUXING_FAILED입니다. 리셋에 성공하면 error 이벤트 대신 mediasourcerecovered 이벤트가 발생합니다.
이 경로의 리셋은 streaming.allowMediaSourceRecoveries(기본 true)와 streaming.minTimeBetweenRecoveries(기본 5초)의 영향을 받습니다. 5초 안에 다시 실패하면 리셋하지 않고 error 이벤트로 넘어갑니다.
라이브 스트림은 매니페스트를 주기적으로 갱신합니다. 갱신 요청이 재시도까지 모두 실패해도 파서가 Severity를 RECOVERABLE로 낮추고 다시 시도합니다.
DASH 파서도 같은 방식으로 동작합니다. manifest.raiseFatalErrorOnManifestUpdateRequestFailure를 켜면 다시 시도하지 않고 에러를 그대로 전달합니다.
대체 스트림이 없을 때 라이브와 VOD의 동작이 갈립니다. Player의 기본 failureCallback은 라이브에서만 스트리밍을 다시 시도합니다.
또한 Player는 브라우저의 online 이벤트를 듣고 있어서, 네트워크가 돌아오면 비활성화된 스트림을 복원하고 retryStreaming()을 호출합니다. 이 동작은 VOD에도 적용됩니다.
| 회복 경로 | 애플리케이션에 보이는 것 |
|---|---|
| 네트워크 재시도 | NetworkingEngine의 retry 이벤트 |
| 대체 스트림 전환 | error 이벤트(RECOVERABLE). SEGMENT_MISSING은 발생하지 않음 |
| 세그먼트 건너뛰기 | 없음 |
| 버퍼 용량 초과 대응 | 없음 |
| Player의 MediaSource 리셋 | mediasourcerecovered 이벤트 |
| 매니페스트 갱신 재시도 | error 이벤트(RECOVERABLE) |
| 라이브 자동 재시도 | error 이벤트(CRITICAL로 보임) |
마지막 줄은 주의가 필요합니다. handleStreamingError_는 error 이벤트를 먼저 발생시키고 그다음에 failureCallback을 호출합니다. 라이브 재시도에서 Severity를 낮추는 일은 failureCallback 안에서 일어나므로, error 리스너가 받는 시점의 Severity는 아직 CRITICAL입니다. 플레이어는 곧이어 재시도하는데도 말입니다.
RECOVERABLE이 항상 "플레이어가 알아서 고친다"는 뜻은 아닙니다. 소스에서 RECOVERABLE로 직접 생성하는 32곳 중 18곳은 재시도나 전환 없이 API를 호출한 쪽에 에러를 그대로 돌려줍니다.
| API 영역 | 대표 에러 | 발생 상황 |
|---|---|---|
addTextTrackAsync, addThumbnailsTrack, addChaptersTrack | CONTENT_NOT_LOADED | load()가 끝나기 전에 호출 |
addTextTrackAsync | CANNOT_ADD_EXTERNAL_TEXT_TO_LIVE_STREAM | 라이브 스트림에 외부 자막 추가 |
| 트랙 추가 API | TEXT_COULD_NOT_GUESS_MIME_TYPE | 파일의 MIME 타입을 알 수 없음 |
| Cast | CAST_API_UNAVAILABLE, NO_CAST_RECEIVERS, ALREADY_CASTING | Cast를 사용할 수 없는 상태 |
| 광고 | CURRENT_DAI_REQUEST_NOT_FINISHED, MEDIATAILOR_REQUEST_FAILED | 광고 스트림 요청 실패 또는 중복 |
이 에러들은 호출 하나가 실패했을 뿐 재생 자체에는 영향이 없다는 쪽에 가깝습니다. 호출 코드의 try/catch나 .catch()에서 직접 처리해야 합니다.
load() 중에 난 에러는 load()의 Promise를 reject합니다. 이때 플레이어는 내부적으로 unload()까지 수행하므로, 다시 재생하려면 load()를 새로 호출해야 합니다.
UNABLE_TO_GUESS_MANIFEST_TYPE, DASH_INVALID_XML, HLS_PLAYLIST_HEADER_MISSING, NO_VARIANTSCONTENT_UNSUPPORTED_BY_BROWSER, RESTRICTIONS_CANNOT_BE_METNO_RECOGNIZED_KEY_SYSTEMS, REQUESTED_KEY_SYSTEM_CONFIG_UNAVAILABLENO_VIDEO_ELEMENT(attach() 전에 load() 호출)LOAD_INTERRUPTED도 CRITICAL이지만, 다른 load()나 unload()가 끼어들었다는 의미이므로 보통은 에러 화면을 띄우지 않고 무시합니다.
failureCallback은 아무것도 하지 않고, 해당 스트림의 스트리밍이 멈춥니다. player.retryStreaming()으로 재개할 수 있습니다.LICENSE_REQUEST_FAILED, LICENSE_RESPONSE_REJECTED, EXPIREDMediaSource 리셋으로도 넘기지 못한 VIDEO_ERROR, MSE 계열 에러streaming.ignoreTextStreamFailures의 기본값이 false이므로, 자막 세그먼트 실패도 다른 스트림 실패와 같은 경로를 탑니다. true로 설정하면 자막 없이 재생을 이어 갑니다.fatalError_가 설정됩니다. 이 상태에서는 retryStreaming()도 false를 반환하므로 콘텐츠를 다시 로드해야 합니다.재생 중 에러는 error 이벤트로, 로드 중 에러는 load()의 Promise로 전달됩니다. 두 경로를 같은 함수로 받으면 분기가 단순해집니다.
앞서 본 것처럼 라이브 자동 재시도의 경우 error 이벤트의 CRITICAL이 곧 재생 종료를 의미하지 않습니다. 그래서 위 코드는 라이브의 네트워크 에러를 에러 화면 대상에서 제외합니다.
streaming.failureCallback은 플레이어가 처리하지 못한 스트리밍 에러에 대해 호출되며, 스트리밍 실패 시의 정책을 바꾸고 싶을 때 사용합니다. 이 설정을 바꾸면 기본 콜백을 대체하므로, 앞에서 본 라이브 자동 재시도도 함께 사라집니다. 라이브 재시도가 필요하면 직접 호출해야 합니다.
failureCallback은 세그먼트 스트리밍 과정의 에러만 받습니다. DRM 에러나 버퍼 용량 초과의 최종 실패처럼 다른 경로로 전달되는 CRITICAL은 호출되지 않으므로, error 리스너는 항상 함께 등록해야 합니다.
error 이벤트는 취소할 수 없는 이벤트입니다. event.preventDefault()를 호출해도 아무 효과가 없으므로, 이 방법으로 failureCallback호출을 막을 수는 없습니다.
재시도해도 소용없는 요청은 retry 이벤트에서 끊을 수 있습니다. 이 이벤트는 취소가 가능해서 preventDefault()를 호출하면 재시도가 바로 중단됩니다. 다음은 VOD 매니페스트가 404일 때 재시도하지 않는 예시입니다.
반대로 일시적인 네트워크 불안정에 더 강하게 대응하고 싶다면 재시도 횟수를 늘릴 수 있습니다.
자체 회복된 에러는 사용자에게 드러나지 않지만, 쌓이면 품질 저하의 신호가 됩니다. error 이벤트의 RECOVERABLE과 mediasourcerecovered 이벤트를 수집하고, 누적 값은 player.getStats().nonFatalErrorCount로 확인할 수 있습니다.
severity, category, code, data, handled 로 구성되며, 네이티브 Error를 상속하지 않습니다.severity는 생성 시점에 정해지지만 고정된 값이 아닙니다. 네트워크 재시도를 소진하면 CRITICAL이 되고, 대체 스트림 전환에 성공하면 다시 RECOVERABLE이 됩니다.RECOVERABLE이어도 호출한 쪽에서 처리해야 하고, 로드 단계의 CRITICAL은 load()를 다시 호출해야 합니다.error 이벤트에 CRITICAL로 보이므로, 에러 화면을 띄울 때는 Severity와 함께 스트림 종류와 에러 코드도 확인해야 합니다.