오디오 및 동영상 미리 로드로 빠른 재생

리소스를 적극적으로 미리 로드하여 미디어 재생 속도를 높이는 방법

프랑수아 보포르
프랑수아 보퍼

재생 시작 속도가 빠르면 동영상을 시청하거나 오디오를 듣는 사용자가 많아집니다. 알려진 사실입니다. 이 도움말에서는 사용 사례에 따라 리소스를 능동적으로 미리 로드하여 오디오 및 동영상 재생 속도를 높이는 데 사용할 수 있는 기법을 살펴봅니다.

제공: copyright Blender Foundation | www.blender.org

미디어 파일을 미리 로드하는 세 가지 방법을 먼저 각각의 장단점부터 설명하겠습니다.

아주 좋아요... 하지만...
동영상 미리 로드 속성 웹 서버에서 호스팅되는 고유한 파일에 간편하게 사용할 수 있습니다. 브라우저는 이 속성을 완전히 무시할 수도 있습니다.
리소스 가져오기는 HTML 문서가 완전히 로드되고 파싱되면 시작됩니다.
미디어 소스 확장 프로그램 (MSE)은 미디어 요소의 preload 속성을 무시합니다. 앱이 MSE에 미디어를 제공하는 역할을 하기 때문입니다.
링크 미리 로드 브라우저가 문서의 onload 이벤트를 차단하지 않고 동영상 리소스를 요청하도록 강제합니다. HTTP 범위 요청이 호환되지 않습니다.
MSE 및 파일 세그먼트와 호환됩니다. 전체 리소스를 가져올 때 작은 미디어 파일(5MB 미만)에만 사용해야 합니다.
수동 버퍼링 전체 제어 복잡한 오류를 처리하는 것은 웹사이트의 책임입니다.

동영상 미리 로드 속성

동영상 소스가 웹 서버에서 호스팅되는 고유한 파일인 경우 동영상 preload 속성을 사용하여 브라우저에 미리 로드할 정보나 콘텐츠의 양에 관한 힌트를 제공하는 것이 좋습니다. 즉, 미디어 소스 확장 프로그램(MSE)preload와 호환되지 않습니다.

리소스 가져오기는 초기 HTML 문서가 완전히 로드되고 파싱된 경우에만 (예: DOMContentLoaded 이벤트 실행) 시작되지만 리소스를 실제로 가져오면 매우 다른 load 이벤트가 실행됩니다.

preload 속성을 metadata로 설정하면 사용자에게 동영상이 필요하지 않지만 메타데이터 (크기, 트랙 목록, 길이 등)를 가져오는 것이 바람직함을 나타냅니다. Chrome 64부터 preload의 기본값은 metadata입니다. (이전에는 auto였습니다.)

<video id="video" preload="metadata" src="file.mp4" controls></video>

<script>
  video.addEventListener('loadedmetadata', function() {
    if (video.buffered.length === 0) return;

    const bufferedSeconds = video.buffered.end(0) - video.buffered.start(0);
    console.log(`${bufferedSeconds} seconds of video are ready to play.`);
  });
</script>

preload 속성을 auto로 설정하면 브라우저에서 추가 버퍼링을 중단하지 않고도 재생을 완료할 수 있을 만큼 데이터를 캐시할 수 있음을 나타냅니다.

<video id="video" preload="auto" src="file.mp4" controls></video>

<script>
  video.addEventListener('loadedmetadata', function() {
    if (video.buffered.length === 0) return;

    const bufferedSeconds = video.buffered.end(0) - video.buffered.start(0);
    console.log(`${bufferedSeconds} seconds of video are ready to play.`);
  });
</script>

하지만 몇 가지 주의사항이 있습니다. 이는 힌트에 불과하므로 브라우저는 preload 속성을 완전히 무시할 수 있습니다. 이 문서의 작성 시점을 기준으로 Chrome에 적용되는 규칙은 다음과 같습니다.

  • 데이터 절약 모드가 사용 설정되면 Chrome은 preload 값을 none로 강제 설정합니다.
  • Android 4.3에서는 Android 버그로 인해 Chrome에서 preload 값을 none로 강제 적용합니다.
  • 모바일 데이터 연결 (2G, 3G, 4G)에서 Chrome은 preload 값을 metadata로 강제 설정합니다.

웹사이트에 동일한 도메인의 동영상 리소스가 여러 개 포함된 경우 preload 값을 metadata로 설정하거나 poster 속성을 정의하고 preloadnone로 설정하는 것이 좋습니다. 이렇게 하면 동일한 도메인의 최대 HTTP 연결 수 (HTTP 1.1 사양에 따라 6개)에 도달하여 리소스 로드 중단을 방지할 수 있습니다. 동영상이 핵심 사용자 환경의 일부가 아닌 경우에도 이렇게 하면 페이지 속도가 향상될 수 있습니다.

다른 도움말에서 다루었듯이 link preload는 선언적 가져오기로, 이를 사용하면 브라우저에서 load 이벤트를 차단하지 않고 페이지를 다운로드하는 동안 리소스를 요청하도록 강제할 수 있습니다. <link rel="preload">를 통해 로드된 리소스는 브라우저에 로컬로 저장되며 DOM, JavaScript 또는 CSS에서 명시적으로 참조될 때까지 사실상 비활성 상태입니다.

미리 로드는 현재 탐색에 중점을 두고 유형 (스크립트, 스타일, 글꼴, 동영상, 오디오 등)에 따라 우선순위에 따라 리소스를 가져온다는 점에서 미리 가져오기와 다릅니다. 현재 세션의 브라우저 캐시를 준비하는 데 사용해야 합니다.

전체 동영상 미리 로드

다음은 자바스크립트가 동영상 콘텐츠 가져오기를 요청할 때 리소스가 브라우저에서 이미 캐시되었을 수 있으므로 전체 동영상을 캐시에서 읽도록 웹사이트에 전체 동영상을 미리 로드하는 방법입니다. 미리 로드 요청이 아직 완료되지 않은 경우 일반 네트워크 가져오기가 발생합니다.

<link rel="preload" as="video" href="https://cdn.com/small-file.mp4">

<video id="video" controls></video>

<script>
  // Later on, after some condition has been met, set video source to the
  // preloaded video URL.
  video.src = 'https://cdn.com/small-file.mp4';
  video.play().then(() => {
    // If preloaded video URL was already cached, playback started immediately.
  });
</script>

이 예에서 미리 로드된 리소스는 동영상 요소에 의해 사용되므로 as 미리 로드된 링크 값은 video입니다. 오디오 요소라면 as="audio"가 됩니다.

첫 번째 세그먼트 미리 로드

아래 예는 <link rel="preload">로 동영상의 첫 번째 세그먼트를 미리 로드하여 미디어 소스 확장 프로그램과 함께 사용하는 방법을 보여줍니다. MSE JavaScript API에 익숙하지 않다면 MSE 기본사항을 참고하세요.

편의상 전체 동영상이 file_1.webm, file_2.webm, file_3.webm 등의 더 작은 파일로 분할되었다고 가정해 보겠습니다.

<link rel="preload" as="fetch" href="https://cdn.com/file_1.webm">

<video id="video" controls></video>

<script>
  const mediaSource = new MediaSource();
  video.src = URL.createObjectURL(mediaSource);
  mediaSource.addEventListener('sourceopen', sourceOpen, { once: true });

  function sourceOpen() {
    URL.revokeObjectURL(video.src);
    const sourceBuffer = mediaSource.addSourceBuffer('video/webm; codecs="vp09.00.10.08"');

    // If video is preloaded already, fetch will return immediately a response
    // from the browser cache (memory cache). Otherwise, it will perform a
    // regular network fetch.
    fetch('https://cdn.com/file_1.webm')
    .then(response => response.arrayBuffer())
    .then(data => {
      // Append the data into the new sourceBuffer.
      sourceBuffer.appendBuffer(data);
      // TODO: Fetch file_2.webm when user starts playing video.
    })
    .catch(error => {
      // TODO: Show "Video is not available" message to user.
    });
  }
</script>

지원

아래 스니펫으로 <link rel=preload>의 다양한 as 유형 지원을 감지할 수 있습니다.

function preloadFullVideoSupported() {
  const link = document.createElement('link');
  link.as = 'video';
  return (link.as === 'video');
}

function preloadFirstSegmentSupported() {
  const link = document.createElement('link');
  link.as = 'fetch';
  return (link.as === 'fetch');
}

수동 버퍼링

Cache API 및 서비스 워커를 자세히 살펴보기 전에 먼저 MSE로 동영상을 수동으로 버퍼링하는 방법을 알아보겠습니다. 아래 예에서는 웹 서버가 HTTP Range 요청을 지원하지만 파일 세그먼트와 매우 유사하다고 가정합니다. Google의 Shaka Player, JW Player, Video.js와 같은 일부 미들웨어 라이브러리는 이를 처리하도록 빌드됩니다.

<video id="video" controls></video>

<script>
  const mediaSource = new MediaSource();
  video.src = URL.createObjectURL(mediaSource);
  mediaSource.addEventListener('sourceopen', sourceOpen, { once: true });

  function sourceOpen() {
    URL.revokeObjectURL(video.src);
    const sourceBuffer = mediaSource.addSourceBuffer('video/webm; codecs="vp09.00.10.08"');

    // Fetch beginning of the video by setting the Range HTTP request header.
    fetch('file.webm', { headers: { range: 'bytes=0-567139' } })
    .then(response => response.arrayBuffer())
    .then(data => {
      sourceBuffer.appendBuffer(data);
      sourceBuffer.addEventListener('updateend', updateEnd, { once: true });
    });
  }

  function updateEnd() {
    // Video is now ready to play!
    const bufferedSeconds = video.buffered.end(0) - video.buffered.start(0);
    console.log(`${bufferedSeconds} seconds of video are ready to play.`);

    // Fetch the next segment of video when user starts playing the video.
    video.addEventListener('playing', fetchNextSegment, { once: true });
  }

  function fetchNextSegment() {
    fetch('file.webm', { headers: { range: 'bytes=567140-1196488' } })
    .then(response => response.arrayBuffer())
    .then(data => {
      const sourceBuffer = mediaSource.sourceBuffers[0];
      sourceBuffer.appendBuffer(data);
      // TODO: Fetch further segment and append it.
    });
  }
</script>

고려사항

이제 전체 미디어 버퍼링 환경을 제어할 수 있으므로 미리 로드를 고려할 때는 기기의 배터리 잔량, '데이터 절약 모드' 사용자 환경설정, 네트워크 정보를 고려하는 것이 좋습니다.

배터리 인식

동영상을 미리 로드하기 전에 사용자 기기의 배터리 수준을 고려하세요. 이렇게 하면 전력 수준이 낮을 때 배터리 수명을 보존할 수 있습니다.

기기의 배터리가 소진되면 미리 로드를 사용 중지하거나 해상도가 낮은 동영상은 미리 로드합니다.

if ('getBattery' in navigator) {
  navigator.getBattery()
  .then(battery => {
    // If battery is charging or battery level is high enough
    if (battery.charging || battery.level > 0.15) {
      // TODO: Preload the first segment of a video.
    }
  });
}

'데이터 절약 모드' 감지

Save-Data 클라이언트 힌트 요청 헤더를 사용하면 브라우저에서 '데이터 절약' 모드를 선택한 사용자에게 빠르고 가벼운 애플리케이션을 제공할 수 있습니다. 이 요청 헤더를 식별하면 애플리케이션에서 비용 및 성능이 제한된 사용자에게 최적화된 사용자 환경을 맞춤설정하고 제공할 수 있습니다.

자세한 내용은 데이터 절약으로 빠르고 가벼운 애플리케이션 제공을 참조하세요.

네트워크 정보를 기반으로 한 스마트 로드

미리 로드하기 전에 navigator.connection.type를 확인하는 것이 좋습니다. cellular로 설정하면 미리 로드를 방지하고 모바일 네트워크 이동통신사에서 대역폭 요금을 부과할 수 있다고 사용자에게 알리고, 이전에 캐시된 콘텐츠의 자동 재생만 시작할 수 있습니다.

if ('connection' in navigator) {
  if (navigator.connection.type == 'cellular') {
    // TODO: Prompt user before preloading video
  } else {
    // TODO: Preload the first segment of a video.
  }
}

네트워크 정보 샘플도 네트워크 변경사항에 대응하는 방법을 알아보세요.

여러 첫 번째 세그먼트를 사전 캐시

이제 사용자가 어떤 미디어를 선택할지 모르는 상태에서 일부 미디어 콘텐츠를 추측 방식으로 미리 로드하려면 어떻게 해야 할까요? 사용자가 동영상이 10개 포함된 웹페이지에 있다면 각 동영상에서 세그먼트 파일 1개를 가져올 만큼 메모리가 충분할 수 있지만 숨겨진 <video> 요소 10개와 MediaSource 객체 10개를 만들어 데이터 제공을 시작해서는 안 됩니다.

아래 두 부분으로 구성된 예에서는 강력하고 사용하기 쉬운 Cache API를 사용하여 동영상의 첫 번째 세그먼트 여러 개를 미리 캐시하는 방법을 보여줍니다. IndexedDB에서도 유사한 작업을 수행할 수 있습니다. Cache API는 window 객체에서도 액세스할 수 있으므로 아직 서비스 워커를 사용하지 않습니다.

가져오기 및 캐시

const videoFileUrls = [
  'bat_video_file_1.webm',
  'cow_video_file_1.webm',
  'dog_video_file_1.webm',
  'fox_video_file_1.webm',
];

// Let's create a video pre-cache and store all first segments of videos inside.
window.caches.open('video-pre-cache')
.then(cache => Promise.all(videoFileUrls.map(videoFileUrl => fetchAndCache(videoFileUrl, cache))));

function fetchAndCache(videoFileUrl, cache) {
  // Check first if video is in the cache.
  return cache.match(videoFileUrl)
  .then(cacheResponse => {
    // Let's return cached response if video is already in the cache.
    if (cacheResponse) {
      return cacheResponse;
    }
    // Otherwise, fetch the video from the network.
    return fetch(videoFileUrl)
    .then(networkResponse => {
      // Add the response to the cache and return network response in parallel.
      cache.put(videoFileUrl, networkResponse.clone());
      return networkResponse;
    });
  });
}

HTTP Range 요청을 사용하려면 Cache API가 아직 Range 응답을 지원하지 않으므로 Response 객체를 수동으로 다시 만들어야 합니다. networkResponse.arrayBuffer()를 호출하면 응답의 전체 콘텐츠를 한 번에 렌더기 메모리로 가져오므로 작은 범위를 사용하는 것이 좋습니다.

참고로 위 예의 일부를 수정하여 HTTP 범위 요청을 동영상 사전 캐시에 저장했습니다.

    ...
    return fetch(videoFileUrl, { headers: { range: 'bytes=0-567139' } })
    .then(networkResponse => networkResponse.arrayBuffer())
    .then(data => {
      const response = new Response(data);
      // Add the response to the cache and return network response in parallel.
      cache.put(videoFileUrl, response.clone());
      return response;
    });

동영상 재생

사용자가 재생 버튼을 클릭하면 가능한 경우 즉시 재생이 시작되도록 Cache API에서 제공되는 첫 번째 동영상 세그먼트를 가져옵니다. 그렇지 않으면 네트워크에서 가져옵니다. 브라우저와 사용자가 캐시를 삭제할 수도 있습니다.

앞에서 본 것처럼 MSE를 사용하여 동영상의 첫 번째 세그먼트를 동영상 요소에 제공합니다.

function onPlayButtonClick(videoFileUrl) {
  video.load(); // Used to be able to play video later.

  window.caches.open('video-pre-cache')
  .then(cache => fetchAndCache(videoFileUrl, cache)) // Defined above.
  .then(response => response.arrayBuffer())
  .then(data => {
    const mediaSource = new MediaSource();
    video.src = URL.createObjectURL(mediaSource);
    mediaSource.addEventListener('sourceopen', sourceOpen, { once: true });

    function sourceOpen() {
      URL.revokeObjectURL(video.src);

      const sourceBuffer = mediaSource.addSourceBuffer('video/webm; codecs="vp09.00.10.08"');
      sourceBuffer.appendBuffer(data);

      video.play().then(() => {
        // TODO: Fetch the rest of the video when user starts playing video.
      });
    }
  });
}

서비스 워커로 Range 응답 만들기

이제 전체 동영상 파일을 가져와서 Cache API에 저장했다면 어떻게 될까요? 브라우저에서 HTTP Range 요청을 보낼 때 Cache API가 아직 Range 응답을 지원하지 않기 때문에 전체 동영상을 렌더러 메모리로 가져오는 것은 바람직하지 않습니다.

이러한 요청을 가로채서 서비스 워커에서 맞춤설정된 Range 응답을 반환하는 방법을 보여드리겠습니다.

addEventListener('fetch', event => {
  event.respondWith(loadFromCacheOrFetch(event.request));
});

function loadFromCacheOrFetch(request) {
  // Search through all available caches for this request.
  return caches.match(request)
  .then(response => {

    // Fetch from network if it's not already in the cache.
    if (!response) {
      return fetch(request);
      // Note that we may want to add the response to the cache and return
      // network response in parallel as well.
    }

    // Browser sends a HTTP Range request. Let's provide one reconstructed
    // manually from the cache.
    if (request.headers.has('range')) {
      return response.blob()
      .then(data => {

        // Get start position from Range request header.
        const pos = Number(/^bytes\=(\d+)\-/g.exec(request.headers.get('range'))[1]);
        const options = {
          status: 206,
          statusText: 'Partial Content',
          headers: response.headers
        }
        const slicedResponse = new Response(data.slice(pos), options);
        slicedResponse.setHeaders('Content-Range': 'bytes ' + pos + '-' +
            (data.size - 1) + '/' + data.size);
        slicedResponse.setHeaders('X-From-Cache': 'true');

        return slicedResponse;
      });
    }

    return response;
  }
}

여기서 중요한 점은 이 슬라이스 응답을 다시 만들기 위해 response.blob()를 사용했다는 점에 유의해야 합니다. 이는 단순히 파일에 대한 핸들을 제공하는 반면 response.arrayBuffer()은 전체 파일을 렌더기 메모리로 가져오기 때문입니다.

커스텀 X-From-Cache HTTP 헤더를 사용하면 이 요청이 캐시에서 발생한 것인지 네트워크에서 온 것인지를 알 수 있습니다. ShakaPlayer와 같은 플레이어가 네트워크 속도의 지표로 사용하는 응답 시간을 무시하는 데 사용할 수 있습니다.

Range 요청을 처리하는 방법에 관한 완전한 솔루션은 공식 샘플 미디어 앱, 특히 ranged-response.js 파일을 참고하세요.