오프라인 스트리밍 기능이 있는 PWA

Derek Herman
Derek Herman
Jaroslav Polakovič
Jaroslav Polakovič

프로그레시브 웹 앱은 이전에 네이티브 애플리케이션을 위해 예약했던 많은 기능을 웹에 제공합니다. PWA와 관련하여 가장 눈에 띄는 기능 중 하나는 오프라인 경험입니다.

오프라인 스트리밍 미디어 경험은 훨씬 더 좋으며, 이 환경은 사용자에게 몇 가지 다른 향상된 기능을 제공할 수 있습니다. 하지만 미디어 파일이 매우 클 수 있다는 점에서 정말 고유한 문제가 생깁니다. 따라서 다음과 같이 질문할 수 있습니다.

  • 대용량 동영상 파일을 다운로드하고 저장하려면 어떻게 해야 하나요?
  • 사용자에게 제공하려면 어떻게 해야 하나요?

이 도움말에서는 이러한 질문에 대한 답변을 설명하고 기능 또는 프레젠테이션 프레임워크를 사용하지 않고 오프라인 스트리밍 미디어 환경을 구현하는 방법에 관한 실제 예를 제공하는 Kino 데모 PWA를 참조합니다. 다음 예는 주로 교육 목적으로 사용됩니다. 대부분의 경우 기존 미디어 프레임워크 중 하나를 사용하여 이러한 기능을 제공해야 하기 때문입니다.

자체 개발과 관련된 적절한 비즈니스 사례가 없다면 오프라인 스트리밍으로 PWA를 빌드하는 데 어려움이 있습니다. 이 도움말에서는 사용자에게 고품질 오프라인 미디어 환경을 제공하는 데 사용되는 API와 기법을 알아봅니다.

대용량 미디어 파일 다운로드 및 저장

프로그레시브 웹 앱은 일반적으로 편리한 Cache API를 사용하여 오프라인 환경을 제공하는 데 필요한 애셋(문서, 스타일 시트, 이미지 등)을 다운로드하고 저장합니다.

다음은 서비스 워커 내에서 Cache API를 사용하는 기본적인 예입니다.

const cacheStorageName = 'v1';

this.addEventListener('install', function(event) {
  event.waitUntil(
    caches.open(cacheStorageName).then(function(cache) {
      return cache.addAll([
        'index.html',
        'style.css',
        'scripts.js',

        // Don't do this.
        'very-large-video.mp4',
      ]);
    })
  );
});

위의 예는 기술적으로 작동하지만 Cache API를 사용하면 몇 가지 제한사항이 있어서 대용량 파일에 사용하기 비효율적입니다.

예를 들어 Cache API는 다음 작업을 실행하지 않습니다.

  • 다운로드를 쉽게 일시중지하고 재개할 수 있습니다.
  • 다운로드 진행 상황을 추적할 수 있음
  • HTTP 범위 요청에 올바르게 응답하는 방법을 제공합니다.

이러한 모든 문제는 모든 동영상 애플리케이션에 매우 심각한 제한사항입니다. 더 적합할 수 있는 다른 옵션을 검토해 보겠습니다.

현재 Fetch API는 브라우저 간을 사용하여 원격 파일에 비동기식으로 액세스하는 방법입니다. 이 사용 사례에서는 대용량 동영상 파일에 스트림으로 액세스하고 HTTP 범위 요청을 사용하여 이를 청크로 점진적으로 저장할 수 있습니다.

이제 Fetch API를 사용하여 데이터 청크를 읽을 수 있으므로 청크도 저장해야 합니다. 이름, 설명, 런타임 길이, 카테고리 등 미디어 파일과 연결된 많은 메타데이터가 있을 수 있습니다.

미디어 파일 하나만 저장하는 것이 아니라 구조화된 객체를 저장합니다. 미디어 파일은 그 속성 중 하나일 뿐입니다.

이 경우 IndexedDB API는 미디어 데이터와 메타데이터를 모두 저장할 수 있는 훌륭한 솔루션을 제공합니다. 방대한 양의 바이너리 데이터를 쉽게 보유할 수 있을 뿐만 아니라, 매우 빠른 데이터 조회를 수행할 수 있는 색인도 제공합니다.

Fetch API를 사용하여 미디어 파일 다운로드

Google은 Kino라고 하는 데모 PWA의 Fetch API를 중심으로 몇 가지 흥미로운 기능을 빌드했습니다. 소스 코드는 공개 코드이므로 자유롭게 살펴보세요.

  • 완료되지 않은 다운로드를 일시중지했다가 재개하는 기능
  • 데이터베이스에 데이터 청크를 저장하기 위한 커스텀 버퍼.

이러한 기능을 구현하는 방법을 보여주기 전에 먼저 Fetch API를 사용하여 파일을 다운로드하는 방법을 간략하게 살펴보겠습니다.

/**
 * Downloads a single file.
 *
 * @param {string} url URL of the file to be downloaded.
 */
async function downloadFile(url) {
  const response = await fetch(url);
  const reader = response.body.getReader();
  do {
    const { done, dataChunk } = await reader.read();
    // Store the `dataChunk` to IndexedDB.
  } while (!done);
}

await reader.read()가 루프에 있는 것을 알 수 있나요? 이렇게 하면 네트워크에서 읽을 수 있는 스트림에서 데이터 청크를 수신할 수 있습니다. 데이터가 네트워크에서 도착하기 전에도 데이터 처리를 시작할 수 있다는 것이 얼마나 유용한지 고려해 보세요.

다운로드 재개

다운로드가 일시중지되거나 중단되면 도착한 데이터 청크는 IndexedDB 데이터베이스에 안전하게 저장됩니다. 그런 다음 애플리케이션에서 다운로드를 재개하는 버튼을 표시할 수 있습니다. Kino 데모 PWA 서버는 HTTP 범위 요청을 지원하므로 다운로드를 재개하는 것이 다소 간단합니다.

async downloadFile() {
  // this.currentFileMeta contains data from IndexedDB.
  const { bytesDownloaded, url, downloadUrl } = this.currentFileMeta;
  const fetchOpts = {};

  // If we already have some data downloaded,
  // request everything from that position on.
  if (bytesDownloaded) {
    fetchOpts.headers = {
      Range: `bytes=${bytesDownloaded}-`,
    };
  }

  const response = await fetch(downloadUrl, fetchOpts);
  const reader = response.body.getReader();

  let dataChunk;
  do {
    dataChunk = await reader.read();
    if (!dataChunk.done) this.buffer.add(dataChunk.value);
  } while (!dataChunk.done && !this.paused);
}

IndexedDB용 커스텀 쓰기 버퍼

이론적으로 dataChunk 값을 IndexedDB 데이터베이스에 쓰는 프로세스는 간단합니다. 이러한 값은 이미 ArrayBuffer 인스턴스이며 IndexedDB에 직접 저장할 수 있으므로 적절한 형태의 객체를 만들고 저장하면 됩니다.

const dataItem = {
  url: fileUrl,
  rangeStart: dataStartByte,
  rangeEnd: dataEndByte,
  data: dataChunk,
}

// Name of the store that will hold your data.
const storeName = 'fileChunksStorage'

// `db` is an instance of `IDBDatabase`.
const transaction = db.transaction([storeName], 'readwrite');
const store = transaction.objectStore(storeName);
const putRequest = store.put(data);

putRequest.onsuccess = () => { ... }

이 접근법은 효과가 있지만 IndexedDB 쓰기가 다운로드보다 훨씬 느릴 수 있습니다. IndexedDB 쓰기가 느리기 때문이 아니라 네트워크에서 수신하는 모든 데이터 청크에 대해 새 트랜잭션을 생성하여 트랜잭션 오버헤드가 많기 때문입니다.

다운로드한 청크는 다소 작을 수 있으며 스트림에서 빠르게 연달아 내보낼 수 있습니다. IndexedDB 쓰기 속도를 제한해야 합니다. Kino 데모 PWA에서는 중개 쓰기 버퍼를 구현하여 이 작업을 실행합니다.

데이터 청크가 네트워크에서 도착하면 먼저 버퍼에 추가합니다. 수신되는 데이터가 맞지 않으면 나머지 데이터를 추가하기 전에 전체 버퍼를 데이터베이스에 플러시하고 이를 지웁니다. 따라서 IndexedDB 쓰기 빈도가 줄어 쓰기 성능이 크게 향상됩니다.

오프라인 저장소에서 미디어 파일 제공

미디어 파일을 다운로드한 후에는 서비스 워커가 네트워크에서 파일을 가져오는 대신 IndexedDB에서 미디어 파일을 제공하려고 할 수 있습니다.

/**
 * The main service worker fetch handler.
 *
 * @param {FetchEvent} event Fetch event.
 */
const fetchHandler = async (event) => {
  const getResponse = async () => {
    // Omitted Cache API code used to serve static assets.

    const videoResponse = await getVideoResponse(event);
    if (videoResponse) return videoResponse;

    // Fallback to network.
    return fetch(event.request);
  };
  event.respondWith(getResponse());
};
self.addEventListener('fetch', fetchHandler);

그렇다면 getVideoResponse()에서 무엇을 해야 할까요?

  • event.respondWith() 메서드는 Response 객체를 매개변수로 예상합니다.

  • Response() 생성자Response 객체를 인스턴스화하는 데 사용할 수 있는 여러 유형의 객체(Blob, BufferSource, ReadableStream 등)가 있음을 알려줍니다.

  • 메모리에 모든 데이터를 보관하지 않는 객체가 필요하므로 ReadableStream를 선택할 수 있습니다.

또한 Google은 대용량 파일을 다루고 있으며 브라우저가 현재 필요한 파일 부분만 요청할 수 있도록 하기 위해 HTTP 범위 요청을 위한 몇 가지 기본 지원을 구현해야 합니다.

/**
 * Respond to a request to fetch offline video file and construct a response
 * stream.
 *
 * Includes support for `Range` requests.
 *
 * @param {Request} request  Request object.
 * @param {Object}  fileMeta File meta object.
 *
 * @returns {Response} Response object.
 */
const getVideoResponse = (request, fileMeta) => {
  const rangeRequest = request.headers.get('range') || '';
  const byteRanges = rangeRequest.match(/bytes=(?<from>[0-9]+)?-(?<to>[0-9]+)?/);

  // Using the optional chaining here to access properties of
  // possibly nullish objects.
  const rangeFrom = Number(byteRanges?.groups?.from || 0);
  const rangeTo = Number(byteRanges?.groups?.to || fileMeta.bytesTotal - 1);

  // Omitting implementation for brevity.
  const streamSource = {
     pull(controller) {
       // Read file data here and call `controller.enqueue`
       // with every retrieved chunk, then `controller.close`
       // once all data is read.
     }
  }
  const stream = new ReadableStream(streamSource);

  // Make sure to set proper headers when supporting range requests.
  const responseOpts = {
    status: rangeRequest ? 206 : 200,
    statusText: rangeRequest ? 'Partial Content' : 'OK',
    headers: {
      'Accept-Ranges': 'bytes',
      'Content-Length': rangeTo - rangeFrom + 1,
    },
  };
  if (rangeRequest) {
    responseOpts.headers['Content-Range'] = `bytes ${rangeFrom}-${rangeTo}/${fileMeta.bytesTotal}`;
  }
  const response = new Response(stream, responseOpts);
  return response;

Kino 데모 PWA 서비스 워커 소스 코드를 확인하여 IndexedDB에서 파일 데이터를 읽고 실제 애플리케이션에서 스트림을 구성하는 방법을 알아보세요.

기타 고려사항

주요 문제를 해결했으니 이제 동영상 애플리케이션에 몇 가지 유용한 기능을 추가할 수 있습니다. 다음은 Kino 데모 PWA에 포함된 기능의 몇 가지 예입니다.

  • Media Session API가 통합됩니다. 이 API를 사용하면 사용자가 전용 하드웨어 미디어 키 또는 미디어 알림 팝업을 사용하여 미디어 재생을 제어할 수 있습니다.
  • 자막과 같은 미디어 파일과 연결된 다른 애셋 및 포스터 이미지를 캐시하는 것은 기존의 좋은 Cache API를 사용합니다.
  • 앱 내에서 동영상 스트림 (DASH, HLS) 다운로드를 지원합니다. 스트림 매니페스트는 일반적으로 다양한 비트 전송률의 여러 소스를 선언하므로 오프라인 보기를 위해 저장하기 전에 매니페스트 파일을 변환하여 하나의 미디어 버전만 다운로드해야 합니다.

다음에는 오디오 및 동영상 미리 로드를 통한 빠른 재생에 관해 알아보겠습니다.