오프라인 스트리밍 기능이 있는 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를 사용하여 미디어 파일 다운로드

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용 맞춤 쓰기 버퍼

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 통합
  • 자막, 포스터 이미지와 같이 미디어 파일과 연결된 기타 애셋을 기존의 Cache API를 사용하여 캐시합니다.
  • 앱 내에서 동영상 스트림 (DASH, HLS) 다운로드 지원. 스트림 매니페스트는 일반적으로 비트 전송률이 다른 여러 소스를 선언하므로 매니페스트 파일을 변환하고 오프라인 보기를 위해 저장하기 전에 미디어 버전 하나만 다운로드해야 합니다.

다음으로 오디오 및 동영상 미리 로드를 사용한 빠른 재생에 대해 알아봅니다.