오프라인 설명서

Jake Archibald
Jake Archibald

우리는 서비스 워커를 사용하면서 오프라인에서 해결하려는 시도를 포기하고 개발자에게 스스로 문제를 해결할 수 있는 이동 수단을 제공했습니다. 이를 통해 캐싱 및 요청 처리 방법을 제어할 수 있습니다. 즉, 자신만의 패턴을 만들 수 있습니다. 가능한 몇 가지 패턴을 따로 살펴보겠지만 실제로는 URL과 컨텍스트에 따라 여러 패턴을 동시에 사용할 수 있습니다.

이러한 패턴 중 일부의 실제 데모는 Trained-to-thrill과 성능에 미치는 영향을 보여주는 이 동영상을 참고하세요.

캐시 머신 - 리소스를 저장하는 시기

서비스 워커를 사용하면 캐싱과는 별개로 요청을 처리할 수 있으므로 별도로 보여드리겠습니다. 첫째, 캐싱은 언제 해야 할까요?

설치 시 - 종속 항목으로

설치 시 - 종속 항목으로
설치 시 - 종속 항목

서비스 워커는 install 이벤트를 제공합니다. 이를 통해 다른 이벤트를 처리하기 전에 준비해야 하는 항목을 준비할 수 있습니다. 이 문제가 발생하더라도 이전 버전의 서비스 워커는 계속 실행 중이며 페이지를 제공하고 있으므로 여기서 수행하는 작업이 이를 방해해서는 안 됩니다.

이상적인 대상: CSS, 이미지, 글꼴, JS, 템플릿... 기본적으로 사이트의 해당 '버전'에 정적인 것으로 간주되는 모든 것.

가져오기에 실패할 경우 사이트가 완전히 작동하지 않게 하는 요인으로, 플랫폼별로 동등한 앱이 초기 다운로드의 일부를 구성합니다.

self.addEventListener('install', function (event) {
  event.waitUntil(
    caches.open('mysite-static-v3').then(function (cache) {
      return cache.addAll([
        '/css/whatever-v3.css',
        '/css/imgs/sprites-v6.png',
        '/css/fonts/whatever-v8.woff',
        '/js/all-min-v4.js',
        // etc.
      ]);
    }),
  );
});

event.waitUntil는 프로미스를 사용하여 설치의 길이와 성공을 정의합니다. Promise가 거부되면 설치가 실패로 간주되며 이 서비스 워커는 중단됩니다 (이전 버전이 실행 중인 경우 그대로 유지됨). caches.open()cache.addAll()는 프로미스를 반환합니다. 리소스 중 하나라도 가져오지 못하면 cache.addAll() 호출이 거부됩니다.

training-to-thrill에서 이를 사용하여 정적 애셋을 캐시합니다.

설치 시(종속 항목 아님)

설치 시 - 종속 항목이 아닙니다.
설치 시 - 종속 항목이 아닙니다.

이는 위와 비슷하지만 설치 완료가 지연되지 않으며 캐싱이 실패해도 설치에 실패하지 않습니다.

이상적인 대상: 게임의 후반부 레벨을 위한 애셋과 같이 당장 필요하지 않은 더 큰 리소스

self.addEventListener('install', function (event) {
  event.waitUntil(
    caches.open('mygame-core-v1').then(function (cache) {
      cache
        .addAll
        // levels 11–20
        ();
      return cache
        .addAll
        // core assets and levels 1–10
        ();
    }),
  );
});

위의 예에서는 레벨 11~20에 대한 cache.addAll 프로미스를 event.waitUntil에 다시 전달하지 않으므로 실패하더라도 게임을 계속 오프라인에서 사용할 수 있습니다. 물론 이러한 수준의 부재 가능성을 처리하고 누락되면 캐싱을 다시 시도해야 합니다.

서비스 워커는 이벤트 처리가 완료되었기 때문에 레벨 11~20이 다운로드되는 동안 종료될 수 있습니다. 즉, 서비스 워커는 캐시되지 않습니다. 향후 Web Periodic Background Synchronization API에서 이러한 사례와 영화와 같은 대용량 다운로드를 처리할 예정입니다. 이 API는 현재 Chromium 포크에서만 지원됩니다.

활성화 시

활성화 시
활성화 시.

이상적인 대상: 정리 및 이전

새 서비스 워커가 설치되고 이전 버전이 사용되지 않으면 새 서비스 워커가 활성화되고 activate 이벤트가 발생합니다. 이전 버전이 지원 중단되었으므로 IndexedDB의 스키마 이전을 처리하고 사용되지 않는 캐시를 삭제하는 것이 좋습니다.

self.addEventListener('activate', function (event) {
  event.waitUntil(
    caches.keys().then(function (cacheNames) {
      return Promise.all(
        cacheNames
          .filter(function (cacheName) {
            // Return true if you want to remove this cache,
            // but remember that caches are shared across
            // the whole origin
          })
          .map(function (cacheName) {
            return caches.delete(cacheName);
          }),
      );
    }),
  );
});

활성화하는 동안 fetch와 같은 다른 이벤트가 큐에 배치되므로 활성화가 길면 페이지 로드가 차단될 수 있습니다. 활성화를 최대한 가볍게 유지하고 이전 버전이 활성 상태일 때 할 수 없는 작업에만 사용합니다.

train-to-thrill에서 이를 사용하여 이전 캐시를 삭제합니다.

사용자 상호작용 시

사용자 상호작용 시
사용자 상호작용 시

이상적인 대상: 전체 사이트를 오프라인으로 전환할 수 없고 사용자가 오프라인에서 사용할 콘텐츠를 선택할 수 있도록 허용한 경우 YouTube 등의 동영상, 위키백과 기사, Flickr의 특정 갤러리를 예로 들 수 있습니다.

사용자에게 '나중에 읽기' 또는 '오프라인용으로 저장' 버튼을 부여합니다. 이 버튼을 클릭하면 네트워크에서 필요한 것을 가져와 캐시에 넣습니다.

document.querySelector('.cache-article').addEventListener('click', function (event) {
  event.preventDefault();

  var id = this.dataset.articleId;
  caches.open('mysite-article-' + id).then(function (cache) {
    fetch('/get-article-urls?id=' + id)
      .then(function (response) {
        // /get-article-urls returns a JSON-encoded array of
        // resource URLs that a given article depends on
        return response.json();
      })
      .then(function (urls) {
        cache.addAll(urls);
      });
  });
});

caches API는 서비스 워커는 물론 페이지에서 사용할 수 있으므로 페이지에서 직접 캐시에 추가할 수 있습니다.

네트워크 응답 시

네트워크 응답 시
네트워크 응답 시:

이상적인 대상: 사용자의 받은편지함이나 도움말 콘텐츠와 같은 리소스를 자주 업데이트하는 경우 아바타와 같이 비필수 콘텐츠에도 유용하지만 주의가 필요합니다.

요청이 캐시의 어떤 항목과도 일치하지 않으면 네트워크에서 요청을 가져와서 페이지로 보내고 동시에 캐시에 추가합니다.

아바타와 같은 다양한 URL에 대해 이 작업을 수행하는 경우 원본 저장소가 팽창되지 않도록 주의해야 합니다. 사용자가 디스크 공간을 회수해야 하는 경우 주요 후보가 되지 않도록 해야 합니다. 캐시에서 더 이상 필요하지 않은 항목을 삭제해야 합니다.

self.addEventListener('fetch', function (event) {
  event.respondWith(
    caches.open('mysite-dynamic').then(function (cache) {
      return cache.match(event.request).then(function (response) {
        return (
          response ||
          fetch(event.request).then(function (response) {
            cache.put(event.request, response.clone());
            return response;
          })
        );
      });
    }),
  );
});

효율적인 메모리 사용을 위해 응답/요청의 본문을 한 번만 읽을 수 있습니다. 위의 코드는 .clone()를 사용하여 별도로 읽을 수 있는 추가 사본을 만듭니다.

train-to-thrill에서 이를 사용하여 Flickr 이미지를 캐시합니다.

재확인 중 비활성 상태

재검증하는 동안 비활성 상태입니다.
비활성 상태 재검증.

이상적인 대상: 최신 버전을 유지하는 것이 필수적이지 않은 리소스를 자주 업데이트하는 경우 아바타가 이 카테고리에 속할 수 있습니다.

캐시된 버전이 있으면 사용하되 다음 업데이트를 위해 업데이트를 가져옵니다.

self.addEventListener('fetch', function (event) {
  event.respondWith(
    caches.open('mysite-dynamic').then(function (cache) {
      return cache.match(event.request).then(function (response) {
        var fetchPromise = fetch(event.request).then(function (networkResponse) {
          cache.put(event.request, networkResponse.clone());
          return networkResponse;
        });
        return response || fetchPromise;
      });
    }),
  );
});

이는 HTTP의 stale-while-revalidate와 매우 유사합니다.

푸시 메시지 시

푸시 메시지에서
푸시 메시지에서

Push API는 서비스 워커를 기반으로 빌드된 또 다른 기능입니다. 이를 통해 OS 메시징 서비스의 메시지에 응답하여 서비스 워커를 깨울 수 있습니다. 이는 사용자가 사이트에 탭을 열지 않은 경우에도 발생합니다. 서비스 워커만 활성화됩니다. 페이지에서 이 작업을 실행할 수 있는 권한을 요청하면 사용자에게 메시지가 표시됩니다.

이상적인 대상: 채팅 메시지, 속보, 이메일과 같은 알림과 관련된 콘텐츠 또한 할 일 목록 업데이트나 캘린더 변경과 같이 즉각적인 동기화를 활용하는 드물게 변경되는 콘텐츠도 있습니다.

일반적인 최종 결과는 탭할 때 관련 페이지를 열거나 포커스 설정하는 알림이지만, 이 경우 이러한 상황이 발생하기 전에 캐시를 업데이트하는 것이 extremely 중요합니다. 사용자는 푸시 메시지를 수신할 때 분명히 온라인 상태이지만 알림과 최종적으로 상호작용할 때는 온라인 상태가 아닐 수 있으므로 이 콘텐츠를 오프라인에서 사용할 수 있도록 하는 것이 중요합니다.

다음 코드는 알림을 표시하기 전에 캐시를 업데이트합니다.

self.addEventListener('push', function (event) {
  if (event.data.text() == 'new-email') {
    event.waitUntil(
      caches
        .open('mysite-dynamic')
        .then(function (cache) {
          return fetch('/inbox.json').then(function (response) {
            cache.put('/inbox.json', response.clone());
            return response.json();
          });
        })
        .then(function (emails) {
          registration.showNotification('New email', {
            body: 'From ' + emails[0].from.name,
            tag: 'new-email',
          });
        }),
    );
  }
});

self.addEventListener('notificationclick', function (event) {
  if (event.notification.tag == 'new-email') {
    // Assume that all of the resources needed to render
    // /inbox/ have previously been cached, e.g. as part
    // of the install handler.
    new WindowClient('/inbox/');
  }
});

백그라운드 동기화 시

백그라운드 동기화 사용
백그라운드 동기화 시

백그라운드 동기화는 서비스 워커를 기반으로 빌드된 또 다른 기능입니다. 이를 통해 일회성으로 또는 (매우 휴리스틱) 간격으로 백그라운드 데이터 동기화를 요청할 수 있습니다. 이는 사용자가 사이트에 탭을 열지 않은 경우에도 발생합니다. 서비스 워커만 활성화됩니다. 페이지에서 이 작업을 수행할 수 있는 권한을 요청하면 사용자에게 메시지가 표시됩니다.

이상적인 대상: 긴급하지 않은 업데이트, 특히 소셜 타임라인이나 뉴스 기사와 같이 업데이트당 푸시 메시지가 사용자에게 너무 자주 표시될 정도로 주기적으로 발생하는 업데이트

self.addEventListener('sync', function (event) {
  if (event.id == 'update-leaderboard') {
    event.waitUntil(
      caches.open('mygame-dynamic').then(function (cache) {
        return cache.add('/leaderboard.json');
      }),
    );
  }
});

캐시 지속성

원본에는 원하는 작업을 할 수 있는 일정한 여유 공간이 제공됩니다. 이 여유 공간은 (로컬) 스토리지, IndexedDB, 파일 시스템 액세스, 캐시 등 모든 원본 스토리지 간에 공유됩니다.

표시되는 금액은 사양이 되지 않습니다. 기기 및 저장용량 조건에 따라 다릅니다. 다음을 통해 보유 금액을 확인할 수 있습니다.

navigator.storageQuota.queryInfo('temporary').then(function (info) {
  console.log(info.quota);
  // Result: <quota in bytes>
  console.log(info.usage);
  // Result: <used data in bytes>
});

그러나 모든 브라우저 저장소와 마찬가지로 브라우저는 기기에 저장 압력이 발생하는 경우 데이터를 자유롭게 버릴 수 있습니다. 안타깝게도 브라우저는 보관하려는 영화와 별로 관심 없는 게임을 구분할 수 없습니다.

이 문제를 해결하려면 StorageManager 인터페이스를 사용합니다.

// From a page:
navigator.storage.persist()
.then(function(persisted) {
  if (persisted) {
    // Hurrah, your data is here to stay!
  } else {
   // So sad, your data may get chucked. Sorry.
});

물론 사용자가 권한을 부여해야 합니다. 이를 위해 Permissions API를 사용합니다.

이제 사용자가 삭제를 제어할 수 있으므로 이 흐름의 사용자를 이 흐름의 일부로 만드는 것이 중요합니다. 기기가 저장 압력을 받고 불필요한 데이터를 지워도 문제가 해결되지 않으면 사용자는 어떤 항목을 유지하고 삭제할지 판단하게 됩니다.

이를 위해서는 브라우저를 단일 항목으로 보고하지 않고 스토리지 사용량 분석에서 '지속 가능한' 출처를 플랫폼별 앱과 동등하게 취급하는 운영체제가 필요합니다.

제안 제공—요청에 응답

얼마나 많은 캐싱을 수행하는가는 중요하지 않으며, 사용자가 언제, 어떻게 해야 하는지를 알리지 않는 한 서비스 워커는 캐시를 사용하지 않습니다. 다음은 요청을 처리하기 위한 몇 가지 패턴입니다.

캐시 전용

캐시 전용입니다.
캐시 전용.

이상적인 대상: 사이트의 특정 '버전'에 정적인 것으로 간주되는 모든 것 설치 이벤트에서 이를 캐시해야 하므로 있는 그대로 사용할 수 있습니다.

self.addEventListener('fetch', function (event) {
  // If a match isn't found in the cache, the response
  // will look like a connection error
  event.respondWith(caches.match(event.request));
});

이 경우를 구체적으로 처리할 필요는 없지만 캐시 요청 후 네트워크로 복귀에서 이 문제를 다루고 있습니다.

네트워크 전용

네트워크 전용입니다.
네트워크 전용

이상적인 대상: 분석 핑, 비 GET 요청과 같이 상응하는 오프라인 요청이 없는 항목

self.addEventListener('fetch', function (event) {
  event.respondWith(fetch(event.request));
  // or simply don't call event.respondWith, which
  // will result in default browser behavior
});

이 경우를 구체적으로 처리할 필요는 없지만 캐시 요청 후 네트워크로 복귀에서 이 문제를 다루고 있습니다.

캐시, 네트워크로 복귀

캐시, 네트워크로 돌아갑니다.
캐시 요청 시 네트워크로 돌아갑니다.

이상적인 대상: 오프라인 우선 빌드. 이러한 경우 대부분의 요청을 다음과 같이 처리합니다. 다른 패턴은 수신 요청에 따른 예외입니다.

self.addEventListener('fetch', function (event) {
  event.respondWith(
    caches.match(event.request).then(function (response) {
      return response || fetch(event.request);
    }),
  );
});

이렇게 하면 캐시 항목에 대한 '캐시 전용' 동작과 캐시되지 않은 항목 (캐시할 수 없는 모든 비 GET 요청 포함)의 '네트워크 전용' 동작이 제공됩니다.

캐시와 네트워크의 경합

캐시와 네트워크의 경합
캐시와 네트워크의 경합

이상적인 대상: 디스크 액세스 속도가 느린 기기에서 성능을 추적하려는 작은 애셋

오래된 하드 드라이브, 바이러스 스캐너 및 빠른 인터넷 연결을 일부 조합하면 네트워크에서 리소스를 가져오는 것이 디스크로 이동하는 것보다 빠를 수 있습니다. 그러나 사용자가 기기에 콘텐츠를 가지고 있을 때 네트워크로 이동하는 것은 데이터 낭비가 될 수 있으므로 주의해야 합니다.

// Promise.race is no good to us because it rejects if
// a promise rejects before fulfilling. Let's make a proper
// race function:
function promiseAny(promises) {
  return new Promise((resolve, reject) => {
    // make sure promises are all promises
    promises = promises.map((p) => Promise.resolve(p));
    // resolve this promise as soon as one resolves
    promises.forEach((p) => p.then(resolve));
    // reject if all promises reject
    promises.reduce((a, b) => a.catch(() => b)).catch(() => reject(Error('All failed')));
  });
}

self.addEventListener('fetch', function (event) {
  event.respondWith(promiseAny([caches.match(event.request), fetch(event.request)]));
});

네트워크 캐시로 폴백

네트워크에서 캐시로 돌아갑니다.
네트워크가 캐시로 돌아갑니다.

이상적인 대상: 사이트의 '버전'과 상관없이 자주 업데이트되는 리소스에 대한 빠른 수정 기사, 아바타, 소셜 미디어 타임라인, 게임 리더보드 등이 여기에 해당합니다.

즉, 온라인 사용자에게는 최신 콘텐츠를 제공하지만 오프라인 사용자는 이전에 캐시된 버전을 사용합니다. 네트워크 요청이 성공하면 캐시 항목을 업데이트하는 것이 좋습니다.

그러나 이 방법에는 결함이 있습니다. 사용자의 연결이 간헐적이거나 느린 경우 이미 기기에서 완전히 허용되는 콘텐츠를 가져오기 전에 네트워크가 실패할 때까지 기다려야 합니다. 이 과정은 매우 오래 걸릴 수 있으며 사용자에게 불편을 줄 수 있습니다. 더 나은 솔루션은 다음 패턴인 캐시 요청 후 네트워크를 참조하세요.

self.addEventListener('fetch', function (event) {
  event.respondWith(
    fetch(event.request).catch(function () {
      return caches.match(event.request);
    }),
  );
});

캐시 이후 네트워크

캐시, 네트워크,
캐시한 다음 네트워크.

이상적인 대상: 자주 업데이트되는 콘텐츠. 기사, 소셜 미디어 타임라인, 게임 리더보드 등이 여기에 해당합니다.

이렇게 하려면 페이지가 두 개의 요청, 즉 캐시와 네트워크에 각각 하나씩 요청해야 합니다. 캐시된 데이터를 먼저 표시한 다음 네트워크 데이터가 도착하면 페이지를 업데이트하는 것입니다.

새 데이터 (예: 게임 리더보드)가 도착하면 현재 데이터만 바꿀 수 있지만 더 큰 콘텐츠로 인해 불편을 겪을 수 있습니다. 기본적으로 사용자가 읽고 있거나 상호작용 중일 수 있는 콘텐츠를 '사라지게 하지' 마세요.

트위터는 사용자가 중단되지 않도록 이전 콘텐츠 위에 새 콘텐츠를 추가하고 스크롤 위치를 조정합니다. Twitter는 대체로 콘텐츠에 선형적인 순서를 유지하기 때문에 이러한 방식이 가능합니다. 이 패턴을 복사했습니다. 즉, training-to-thrill이 콘텐츠를 최대한 빨리 화면에 표시하고 최신 콘텐츠가 도착하자마자 표시할 수 있도록 하기 위해서입니다.

페이지 내 코드:

var networkDataReceived = false;

startSpinner();

// fetch fresh data
var networkUpdate = fetch('/data.json')
  .then(function (response) {
    return response.json();
  })
  .then(function (data) {
    networkDataReceived = true;
    updatePage(data);
  });

// fetch cached data
caches
  .match('/data.json')
  .then(function (response) {
    if (!response) throw Error('No data');
    return response.json();
  })
  .then(function (data) {
    // don't overwrite newer network data
    if (!networkDataReceived) {
      updatePage(data);
    }
  })
  .catch(function () {
    // we didn't get cached data, the network is our last hope:
    return networkUpdate;
  })
  .catch(showErrorMessage)
  .then(stopSpinner);

서비스 워커의 코드:

항상 네트워크로 이동하여 캐시를 업데이트해야 합니다.

self.addEventListener('fetch', function (event) {
  event.respondWith(
    caches.open('mysite-dynamic').then(function (cache) {
      return fetch(event.request).then(function (response) {
        cache.put(event.request, response.clone());
        return response;
      });
    }),
  );
});

train-to-thrill에서 fetch 대신 XHR을 사용하고 Accept 헤더를 악용하여 서비스 워커에 결과를 가져올 위치를 알려주는 방식으로 이 문제를 해결했습니다(페이지 코드, 서비스 워커 코드).

일반적인 대체

일반적인 대체입니다.
일반 대체.

캐시 또는 네트워크에서 무언가를 제공하지 못하는 경우 일반적인 대체를 제공하는 것이 좋습니다.

이상적인 대상: 아바타, 실패한 POST 요청, '오프라인에서 사용할 수 없음' 페이지 등의 보조 이미지

self.addEventListener('fetch', function (event) {
  event.respondWith(
    // Try the cache
    caches
      .match(event.request)
      .then(function (response) {
        // Fall back to network
        return response || fetch(event.request);
      })
      .catch(function () {
        // If both fail, show a generic fallback:
        return caches.match('/offline.html');
        // However, in reality you'd have many different
        // fallbacks, depending on URL and headers.
        // Eg, a fallback silhouette image for avatars.
      }),
  );
});

대체할 항목은 설치 종속 항목일 가능성이 높습니다.

페이지에서 이메일을 게시하는 경우 서비스 워커는 대신 이메일을 IndexedDB '보낼 편지함'에 저장하고 보내기에 실패했지만 데이터는 성공적으로 보관되었음을 페이지에 알릴 수 있습니다.

서비스 워커 측 템플릿

ServiceWorker 측 템플릿.
ServiceWorker 측 템플릿.

이상적인 대상: 서버 응답을 캐시할 수 없는 페이지

서버에서 페이지를 렌더링하면 작업 속도가 빨라지지만 캐시에 적합하지 않을 수 있는 상태 데이터(예: '...으로 로그인됨')가 포함될 수 있습니다. 서비스 워커가 페이지를 제어하는 경우, 템플릿과 함께 JSON 데이터를 요청하고 이를 렌더링할 수 있습니다.

importScripts('templating-engine.js');

self.addEventListener('fetch', function (event) {
  var requestURL = new URL(event.request.url);

  event.respondWith(
    Promise.all([
      caches.match('/article-template.html').then(function (response) {
        return response.text();
      }),
      caches.match(requestURL.path + '.json').then(function (response) {
        return response.json();
      }),
    ]).then(function (responses) {
      var template = responses[0];
      var data = responses[1];

      return new Response(renderTemplate(template, data), {
        headers: {
          'Content-Type': 'text/html',
        },
      });
    }),
  );
});

종합

이 방법 중 하나만 사용할 수 있는 것은 아닙니다. 실제로 요청 URL에 따라 많은 매개변수를 사용하게 될 것입니다. 예를 들어 training-to-thrill은 다음을 사용합니다.

요청을 살펴보고 취할 조치를 결정하세요.

self.addEventListener('fetch', function (event) {
  // Parse the URL:
  var requestURL = new URL(event.request.url);

  // Handle requests to a particular host specifically
  if (requestURL.hostname == 'api.example.com') {
    event.respondWith(/* some combination of patterns */);
    return;
  }
  // Routing for local URLs
  if (requestURL.origin == location.origin) {
    // Handle article URLs
    if (/^\/article\//.test(requestURL.pathname)) {
      event.respondWith(/* some other combination of patterns */);
      return;
    }
    if (/\.webp$/.test(requestURL.pathname)) {
      event.respondWith(/* some other combination of patterns */);
      return;
    }
    if (request.method == 'POST') {
      event.respondWith(/* some other combination of patterns */);
      return;
    }
    if (/cheese/.test(requestURL.pathname)) {
      event.respondWith(
        new Response('Flagrant cheese error', {
          status: 512,
        }),
      );
      return;
    }
  }

  // A sensible default pattern
  event.respondWith(
    caches.match(event.request).then(function (response) {
      return response || fetch(event.request);
    }),
  );
});

...이해할 수 있을 것입니다.

크레딧

...아름다운 아이콘:

'게시'를 누르기 전에 수많은 오류를 잡아내신 Jeff Posnick님께도 감사의 말씀을 전합니다.

추가 자료