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

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

François Beaufort
François Beaufort

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

크레딧: 저작권 Blender Foundation | www.blender.org

미디어 파일을 미리 로드하는 세 가지 방법을 장단점과 함께 설명하겠습니다.

좋습니다. 하지만...
동영상 미리 로드 속성 웹 서버에 호스팅된 고유한 파일에 간편하게 사용할 수 있습니다. 브라우저는 이 속성을 완전히 무시할 수 있습니다.
리소스 가져오기는 HTML 문서가 완전히 로드되고 파싱된 후에 시작됩니다.
Media Source Extensions (MSE)는 앱이 MSE에 미디어를 제공할 책임이 있으므로 미디어 요소의 preload 속성을 무시합니다.
링크 미리 로드 브라우저가 문서의 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에서 Chrome은 Android 버그로 인해 preload 값을 none로 강제합니다.
  • 모바일 데이터 연결 (2G, 3G, 4G)에서 Chrome은 preload 값을 metadata로 강제합니다.

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

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

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

전체 동영상 미리 로드

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

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

자세한 내용은 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개가 포함된 웹페이지에 있는 경우 각 동영상에서 세그먼트 파일 하나를 가져올 만큼 메모리가 충분할 수 있지만 숨겨진 <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.
      });
    }
  });
}

서비스 워커로 범위 응답 만들기

이제 전체 동영상 파일을 가져와 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.blob()는 파일 핸들만 제공하는 반면 response.arrayBuffer()는 전체 파일을 렌더러 메모리로 가져옵니다.

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

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