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

데릭 허먼
데릭 허먼
야로슬라프 폴라코비치
자로슬라프 폴라코비치

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

더 좋은 점은 오프라인 스트리밍 미디어 환경이며, 이는 몇 가지 방법으로 사용자에게 제공할 수 있는 개선사항입니다. 그러나 이는 미디어 파일이 매우 클 수 있다는 실로 독특한 문제를 야기합니다. 다음과 같이 질문할 수 있습니다.

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

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

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

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

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

다음은 서비스 워커 내에서 캐시 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은 데모 PWA에 Kino라는 흥미로운 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용 커스텀 쓰기 버퍼

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

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를 선택하는 것이 좋습니다.

또한 대용량 파일을 다루고 있으며 브라우저가 현재 필요한 파일 중 일부만 요청할 수 있도록 하기 위해 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) 다운로드를 지원합니다. 스트림 매니페스트는 일반적으로 다양한 비트 전송률의 여러 소스를 선언하므로 매니페스트 파일을 변환하고 하나의 미디어 버전만 다운로드한 후에 오프라인 보기를 위해 저장해야 합니다.

다음 과정에서는 오디오 및 동영상을 미리 로드하여 빠른 재생에 관해 알아봅니다.