Shaka Player 파헤치기 - 화질 변경 방식 분석

들어가며 — 재생 중 화질을 바꾼다는 것

720p로 잘 나오던 영상을 사용자가 화질 메뉴에서 1080p로 바꾸는 순간, 플레이어 안에서는 생각보다 많은 일이 벌어집니다. 스트리밍 영상은 MSE(Media Source Extensions)를 통해 세그먼트 단위로 SourceBuffer에 쌓여 재생되고 있고, "화질을 바꾼다"는 것은 결국 이미 버퍼에 쌓인 영상을 어떻게 처리하면서 새 화질의 세그먼트로 갈아탈 것인가의 문제로 귀결되기 때문입니다.

Shaka Player는 이 전환을 위한 API로 selectVariantTrack을 제공합니다. 그런데 이 하나의 API를 어떻게 호출하느냐에 따라 "지금 당장 화질이 바뀌는" 경험과 "다음 구간부터 자연스럽게 바뀌는" 경험이 갈립니다. 게다가 화질을 자동으로 조정하는 ABR(Adaptive Bitrate)을 켜둔 채로 수동 전환을 시도하면 선택이 무시되기도 합니다.

이 글에서는 Shaka Player 5.1.0 소스코드를 따라가며 화질을 바꾸는 세 가지 방법 — clearBuffer 옵션으로 즉시/지연 전환을 고르는 법, ABR을 끄고 특정 화질에 고정하는 법, 그리고 ABR을 켜둔 채 restrictions로 화질 상한만 거는 법 — 을 정리합니다.

INFO

Shaka Player에서 화질은 독립적인 "비디오 트랙"이 아니라 오디오·비디오가 한 쌍으로 묶인 Variant 단위로 다뤄집니다. 그래서 화질을 고르는 API도 selectVideoTrack이 아니라 selectVariantTrack입니다. Variant 개념은 오디오 트랙 선정 글에서 더 자세히 다뤘습니다.

화질 목록과 selectVariantTrack

화질을 바꾸려면 먼저 선택 가능한 화질 목록이 필요합니다. player.getVariantTracks()가 현재 재생 가능한 Variant 목록을 돌려주고, 그중 하나를 selectVariantTrack에 넘기면 됩니다.

1// 재생 가능한 Variant 목록에서 1080p를 찾아 선택
2const tracks = player.getVariantTracks();
3const target = tracks.find((t) => t.height === 1080);
4
5player.selectVariantTrack(target);

API의 시그니처는 다음과 같습니다.

1// player.js — selectVariantTrack
2selectVariantTrack(track, clearBuffer = false, safeMargin = 0) {

두 번째 인자 clearBuffer가 이 글의 첫 번째 핵심입니다. 공식 JSDoc이 그 의미를 명확히 설명합니다.

1// player.js — selectVariantTrack JSDoc (발췌)
2// Changing variants will take effect once the currently buffered content has
3// been played. To force the change to happen sooner, use
4// clearBuffer with safeMargin. Setting clearBuffer to true will clear all
5// buffered content after safeMargin, allowing the new variant to start
6// playing sooner.

기본값(clearBuffer = false)으로 호출하면 화질 전환은 "이미 버퍼에 쌓인 내용을 다 재생한 뒤"에야 반영됩니다. 지금 당장 바꾸고 싶다면 clearBuffertrue로 줘서 버퍼를 비워야 합니다. 이 두 경로가 내부에서 어떻게 갈리는지 따라가 봅시다.

clearBuffer로 갈리는 두 가지 전환 방식

selectVariantTrack은 넘겨받은 clearBuffer, safeMargin을 그대로 내부의 switchVariant_로 전달하고,

1// player.js — selectVariantTrack (발췌)
2this.switchVariant_(
3    variant, /* fromAdaptation= */ false,
4    clearBuffer || false, safeMargin || 0);

switchVariant_는 다시 이를 StreamingEngine.switchVariant로 넘깁니다. 최종적으로 스트림별 전환을 처리하는 곳이 StreamingEngineswitchInternal_이고, 두 방식의 분기가 바로 여기 있습니다.

1// media/streaming_engine.js — switchInternal_ (발췌)
2if (clearBuffer) {
3  if (mediaState.clearingBuffer) {
4    // 이미 버퍼를 비우는 중이면, flush만 확실히 하도록 표시
5    mediaState.waitingToFlushBuffer = true;
6  } else if (mediaState.performingUpdate) {
7    // 업데이트(세그먼트 fetch) 중이면 끝난 뒤 비우도록 예약
8    mediaState.waitingToClearBuffer = true;
9    mediaState.clearBufferSafeMargin = safeMargin;
10    mediaState.waitingToFlushBuffer = true;
11  } else {
12    // 진행 중인 타이머를 취소하고 즉시 버퍼를 비운다
13    this.cancelUpdate_(mediaState);
14    this.clearBuffer_(mediaState, /* flush= */ true, safeMargin)
15        .catch((error) => { /* ... */ });
16  }
17} else {
18  if (!mediaState.performingUpdate && !mediaState.updateTimer) {
19    this.scheduleUpdate_(mediaState, 0);
20  }
21}

두 갈래의 성격이 코드에 그대로 드러납니다.

  • clearBuffer: true — 즉시 전환. 진행 중인 업데이트 타이머를 cancelUpdate_로 취소하고, clearBuffer_(mediaState, /* flush= */ true, safeMargin)를 호출해 이미 쌓인 SourceBuffer의 내용을 비웁니다. 비워진 자리는 새로 선택된 Variant의 세그먼트로 다시 채워지므로, 사용자는 곧바로 새 화질을 보게 됩니다. flush = true이므로 브라우저가 이미 디코딩한 프레임까지 밀어냅니다.
  • clearBuffer: false — 지연 전환. 버퍼를 건드리지 않고 scheduleUpdate_(mediaState, 0)으로 다음 세그먼트 로드만 예약합니다. 이미 버퍼에 있는 이전 화질 세그먼트는 그대로 재생되고, 그 뒤에 새로 이어붙는 세그먼트부터 새 화질이 됩니다. mediaState.stream은 이미 새 Variant로 교체된 상태라, 다음 update가 자연스럽게 새 화질을 내려받습니다.

정리하면, switchInternal_은 그 위에서 mediaState.stream을 새 스트림으로 이미 바꿔둔 뒤, "이미 쌓인 것을 지울지(clearBuffer_) 말지(scheduleUpdate_)"만 이 분기에서 결정하는 셈입니다.

TIP

safeMarginclearBuffer: true일 때만 의미가 있습니다. 재생 지점 이후 이 값(초)만큼의 버퍼는 남겨두고 그 뒤부터 비우기 때문에, 전환 직후 버퍼가 텅 비어 발생하는 Video Stall을 완화할 수 있습니다. JSDoc은 세그먼트 두 개 분량 정도를 최소 기준으로 권합니다.

1// player.js — safeMargin JSDoc (발췌)
2// Can cause hiccups on some browsers if chosen too small, e.g. The amount of
3// two segments is a fair minimum to consider as safeMargin value.

즉시 반영이 필요하면 clearBuffer: true, 대역폭 낭비 없이 부드럽게 넘기려면 false. 여기까지가 "어떻게 갈아탈지"의 선택입니다. 그런데 이 전환에는 한 가지 전제 조건이 있습니다. ABR을 꺼야 한다는 것입니다.

왜 ABR을 꺼야 하는가

Shaka Player는 기본적으로 네트워크 대역폭에 맞춰 화질을 자동으로 올리고 내리는 ABR(Adaptive Bitrate)을 켠 상태로 동작합니다. 문제는, ABR이 켜진 채로 selectVariantTrack을 호출하면 수동 선택이 곧바로 ABR의 다음 판단에 덮어써질 수 있다는 점입니다. Shaka는 이 상황을 감지해 경고까지 남깁니다.

1// player.js — selectVariantTrack (발췌)
2const active = this.streamingEngine_.getCurrentVariant();
3if (this.config_.abr.enabled && (active.video != variant.video ||
4    (active.audio && variant.audio &&
5    active.audio.language == variant.audio.language &&
6    active.audio.channelsCount == variant.audio.channelsCount &&
7    active.audio.label == variant.audio.label))) {
8  shaka.log.alwaysWarn('Changing tracks while abr manager is enabled ' +
9                       'will likely result in the selected track ' +
10                       'being overridden. Consider disabling abr ' +
11                       'before calling selectVariantTrack().');
12}

경고 문구가 곧 해법입니다. "selectVariantTrack을 호출하기 전에 abr을 끄는 것을 고려하라." ABR이 켜져 있는 한, 지금 720p를 강제로 1080p로 바꿔놓아도 잠시 뒤 대역폭 측정 결과에 따라 ABR이 다시 화질을 바꿔버릴 수 있기 때문입니다.

따라서 특정 화질에 고정하려는 시나리오라면, 전환 전에 ABR을 꺼야 합니다.

1// ABR을 끄고 화질을 고정한 뒤 전환
2player.configure({ abr: { enabled: false } });
3
4const target = player.getVariantTracks().find((t) => t.height === 1080);
5player.selectVariantTrack(target, /* clearBuffer= */ true);

configure로 넘긴 abr.enabled 값은 내부적으로 applyConfig_에서 실제 ABR 매니저의 enable/disable로 이어집니다.

1// player.js — applyConfig_ (발췌)
2if (this.abrManager_) {
3  this.abrManager_.configure(this.config_.abr);
4  // 여러 번 호출해도 효과는 동일하므로 매번 enable/disable을 반영
5  if (this.config_.abr.enabled) {
6    this.abrManager_.enable();
7  } else {
8    this.abrManager_.disable();
9  }
10  this.onAbrStatusChanged_();
11}

ABR을 끄면 더 이상 플레이어가 임의로 화질을 바꾸지 않으므로, selectVariantTrack으로 고른 화질이 그대로 유지됩니다. 사용자에게 화질 메뉴를 열어주고 "이 화질로 계속 봐라"라는 경험을 제공하려면 이 방식이 맞습니다.

WARNING

ABR을 끄면 대역폭이 급감해도 플레이어가 자동으로 낮은 화질로 내려가지 못합니다. 고정 화질이 사용자의 네트워크로 감당되지 않으면 버퍼링이 잦아질 수 있으므로, "고정" UX를 제공할 때는 이 트레이드오프를 감안해야 합니다.

ABR을 끄지 않고 화질을 제한하는 법 — restrictions

그런데 요구사항이 "특정 화질로 고정"이 아니라 "화질 상한만 걸고 그 아래에서는 여전히 ABR이 알아서 조정"인 경우도 많습니다. 데이터 절약 모드처럼 "1080p 이상은 틀지 말되, 720p·480p 사이는 대역폭에 맞춰 유동적으로"를 원하는 상황이죠. 이때는 ABR을 끌 필요 없이 restrictions를 설정하면 됩니다.

1// 720p(1280x720) 이하로만 재생하도록 상한을 건다 (ABR은 켜둔 채)
2player.configure({
3  restrictions: {
4    maxWidth: 1280,
5    maxHeight: 720,
6  },
7});

이 설정이 반영되는 흐름은 applyConfig_에서 시작됩니다. 설정이 바뀌면 매니페스트의 각 Variant에 대해 제약 조건을 다시 적용하고, ABR의 후보 목록을 갱신한 뒤, 현재 재생 중인 화질이 새 제약에 걸리면 즉시 다른 화질로 갈아탑니다.

1// player.js — applyConfig_ (발췌)
2// 제약(restrictions)을 다시 적용한다
3if (this.manifestFilterer_.filterManifestWithRestrictions(this.manifest_)) {
4  this.onTracksChanged_();
5}
6
7if (this.abrManager_) {
8  // 새 설정에 맞춰 ABR 후보 Variant를 갱신
9  this.updateAbrManagerVariants_();
10}
11
12// 재생 중인 스트림이 제약에 걸렸다면 새 Variant로 전환
13const activeVariant = this.streamingEngine_.getCurrentVariant();
14if (activeVariant) {
15  if (!activeVariant.allowedByApplication ||
16      !activeVariant.allowedByKeySystem) {
17    shaka.log.debug('Choosing new variant after changing configuration');
18    this.chooseVariantAndSwitch_();
19  }
20}

핵심은 각 Variant에 붙는 allowedByApplication 플래그입니다. filterManifestWithRestrictions는 결국 StreamUtils.applyRestrictions를 호출하고, 이 함수가 Variant마다 제약 통과 여부를 계산해 플래그로 기록합니다.

1// util/stream_utils.js — applyRestrictions (발췌)
2for (const variant of variants) {
3  const originalAllowed = variant.allowedByApplication;
4  variant.allowedByApplication = shaka.util.StreamUtils.meetsRestrictions(
5      variant, restrictions, maxHwRes);
6
7  if (originalAllowed != variant.allowedByApplication) {
8    tracksChanged = true;
9  }
10}

실제 판정은 meetsRestrictions가 맡습니다. 우리가 넘긴 maxWidth/maxHeight가 여기서 각 Variant의 해상도와 비교됩니다.

1// util/stream_utils.js — meetsRestrictions (발췌)
2const inRange = (x, min, max) => x >= min && x <= max;
3
4if (variant && variant.video &&
5    variant.video.width && variant.video.height) {
6  let videoWidth = variant.video.width;
7  let videoHeight = variant.video.height;
8  if (videoHeight > videoWidth) {
9    // 세로 영상이면 가로/세로를 뒤집어 비교
10    [videoWidth, videoHeight] = [videoHeight, videoWidth];
11  }
12
13  if (!inRange(videoWidth,
14      restrictions.minWidth,
15      Math.min(restrictions.maxWidth, maxHwRes.width))) {
16    return false;
17  }
18
19  if (!inRange(videoHeight,
20      restrictions.minHeight,
21      Math.min(restrictions.maxHeight, maxHwRes.height))) {
22    return false;
23  }
24  // ... minPixels/maxPixels, frameRate, channelsCount, bandwidth 검사 ...
25}

maxWidth/maxHeight를 넘는 Variant는 meetsRestrictionsfalse를 반환하고, 그 결과 allowedByApplicationfalse가 됩니다. 이렇게 걸러진 Variant는 updateAbrManagerVariants_가 갱신하는 ABR 후보군에서 빠지므로, ABR은 이제 상한 아래의 화질들 중에서만 자동 조정하게 됩니다. 그리고 마침 상한을 초과하는 화질을 재생 중이었다면 chooseVariantAndSwitch_가 즉시 허용 범위 안의 화질로 전환해 줍니다.

INFO

Math.min(restrictions.maxWidth, maxHwRes.width)에서 보이듯, 애플리케이션이 설정한 상한(restrictions)과 하드웨어가 지원하는 최대 해상도(maxHwRes) 중 더 낮은 값이 실질 상한이 됩니다. 그래서 기기가 감당 못 하는 화질은 우리가 따로 막지 않아도 자동으로 후보에서 제외됩니다.

정리 — 서비스 정책에 맞는 방식 선택

Shaka Player로 재생 중인 영상의 화질을 바꾸는 방법을 세 갈래로 정리하면 다음과 같습니다.

  1. selectVariantTrack(track, clearBuffer) — 어떻게 갈아탈지 고르기.
    • clearBuffer: true → 이미 쌓인 버퍼를 비우고 즉시 새 화질로 전환(clearBuffer_). 빠르지만 이미 받은 세그먼트를 버리므로 재다운로드 비용이 든다.
    • clearBuffer: false(기본) → 버퍼는 그대로 두고 다음 세그먼트부터 전환(scheduleUpdate_). 대역폭 낭비가 없지만 반영이 늦다.
  2. 특정 화질에 고정하려면 ABR을 꺼라. ABR이 켜진 채 수동 선택하면 덮어써진다는 경고가 코드에 있다. configure({ abr: { enabled: false } })로 끈 뒤 selectVariantTrack을 호출한다.
  3. 화질 상한만 걸고 그 아래는 자동 조정을 유지하려면 restrictions를 써라. configure({ restrictions: { maxWidth, maxHeight } })meetsRestrictionsallowedByApplication을 거쳐 ABR 후보군을 좁히고, 현재 화질이 상한을 넘으면 자동으로 전환한다. ABR은 끄지 않아도 된다.

결국 어떤 방식을 쓸지는 영상 서비스의 정책이 정합니다. "사용자가 고른 화질로 계속 본다"는 고정형 UX라면 ABR을 끄고 selectVariantTrack을, "데이터 절약 모드에서 상한만 건다"는 유동형 정책이라면 restrictions를, 그리고 전환의 즉시성 여부는 clearBuffer로 조절하면 됩니다. 각 방식이 버퍼와 ABR을 어떻게 건드리는지를 이해하고 나면, 서비스가 원하는 재생 경험에 맞는 조합을 자신 있게 고를 수 있습니다.