오프라인 대체 페이지 만들기

Google 어시스턴트 앱, Slack 앱, Zoom 앱, 그리고 휴대전화나 컴퓨터에 설치된 거의 모든 플랫폼별 앱의 공통점은 무엇일까요? 맞습니다. 그들은 항상 적어도 무언가를 제공합니다. 네트워크에 연결되어 있지 않아도 어시스턴트 앱을 열거나, Slack에 들어가거나, Zoom을 실행할 수 있습니다. 특별히 의미 있는 결과를 얻지 못하거나 달성하고자 하는 목표를 이루지 못할 수도 있지만, 적어도 무언가를 얻고 앱을 제어할 수 있습니다.

오프라인 상태인 Google 어시스턴트 모바일 앱
Google 어시스턴트.

오프라인 상태의 Slack 모바일 앱
Slack

오프라인 상태에서 Zoom 모바일 앱 사용하기
확대/축소

플랫폼별 앱에서는 네트워크에 연결되어 있지 않아도 아무것도 얻을 수 없습니다.

반면에 웹에서는 일반적으로 오프라인 상태일 때 아무 것도 받지 못합니다. Chrome은 오프라인 공룡 게임을 제공합니다.

오프라인 공룡 게임을 보여주는 Chrome 모바일 앱입니다.
iOS용 Chrome

오프라인 공룡 게임이 표시된 Chrome 데스크톱 앱입니다.
macOS용 Chrome

웹에서 네트워크에 연결되어 있지 않으면 기본적으로 아무것도 표시되지 않습니다.

커스텀 서비스 워커가 포함된 오프라인 대체 페이지

하지만 반드시 이렇게 할 필요는 없습니다. 서비스 워커와 Cache Storage API를 사용하면 사용자에게 맞춤설정된 오프라인 환경을 제공할 수 있습니다. 이는 사용자가 현재 오프라인 상태라는 정보가 포함된 간단한 브랜드 페이지일 수 있지만 수동 재연결 버튼과 자동 재연결 시도 카운트다운이 있는 유명한 trivago 오프라인 미로 게임과 같이 보다 창의적인 솔루션이 될 수도 있습니다.

trivago 오프라인 미로가 있는 trivago 오프라인 페이지
trivago 오프라인 미로입니다.

서비스 워커 등록

이를 위해서는 서비스 워커를 사용해야 합니다. 아래 코드 샘플과 같이 기본 페이지에서 서비스 워커를 등록할 수 있습니다. 일반적으로 이 작업은 앱이 로드된 후 실행됩니다.

window.addEventListener("load", () => {
  if ("serviceWorker" in navigator) {
    navigator.serviceWorker.register("service-worker.js");
  }
});

서비스 워커 코드

실제 서비스 워커 파일의 내용은 언뜻 보기에 약간 복잡하게 보일 수 있지만 아래 샘플의 주석으로 내용을 명확하게 파악할 수 있습니다. 핵심 아이디어는 실패한 탐색 요청에만 제공되는 offline.html 파일을 사전 캐시하고 브라우저가 다른 모든 사례를 처리하도록 하는 것입니다.

/*
Copyright 2015, 2019, 2020, 2021 Google LLC. All Rights Reserved.
 Licensed under the Apache License, Version 2.0 (the "License");
 you may not use this file except in compliance with the License.
 You may obtain a copy of the License at
 http://www.apache.org/licenses/LICENSE-2.0
 Unless required by applicable law or agreed to in writing, software
 distributed under the License is distributed on an "AS IS" BASIS,
 WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 See the License for the specific language governing permissions and
 limitations under the License.
*/

// Incrementing OFFLINE_VERSION will kick off the install event and force
// previously cached resources to be updated from the network.
// This variable is intentionally declared and unused.
// Add a comment for your linter if you want:
// eslint-disable-next-line no-unused-vars
const OFFLINE_VERSION = 1;
const CACHE_NAME = "offline";
// Customize this with a different URL if needed.
const OFFLINE_URL = "offline.html";

self.addEventListener("install", (event) => {
  event.waitUntil(
    (async () => {
      const cache = await caches.open(CACHE_NAME);
      // Setting {cache: 'reload'} in the new request ensures that the
      // response isn't fulfilled from the HTTP cache; i.e., it will be
      // from the network.
      await cache.add(new Request(OFFLINE_URL, { cache: "reload" }));
    })()
  );
  // Force the waiting service worker to become the active service worker.
  self.skipWaiting();
});

self.addEventListener("activate", (event) => {
  event.waitUntil(
    (async () => {
      // Enable navigation preload if it's supported.
      // See https://developers.google.com/web/updates/2017/02/navigation-preload
      if ("navigationPreload" in self.registration) {
        await self.registration.navigationPreload.enable();
      }
    })()
  );

  // Tell the active service worker to take control of the page immediately.
  self.clients.claim();
});

self.addEventListener("fetch", (event) => {
  // Only call event.respondWith() if this is a navigation request
  // for an HTML page.
  if (event.request.mode === "navigate") {
    event.respondWith(
      (async () => {
        try {
          // First, try to use the navigation preload response if it's
          // supported.
          const preloadResponse = await event.preloadResponse;
          if (preloadResponse) {
            return preloadResponse;
          }

          // Always try the network first.
          const networkResponse = await fetch(event.request);
          return networkResponse;
        } catch (error) {
          // catch is only triggered if an exception is thrown, which is
          // likely due to a network error.
          // If fetch() returns a valid HTTP response with a response code in
          // the 4xx or 5xx range, the catch() will NOT be called.
          console.log("Fetch failed; returning offline page instead.", error);

          const cache = await caches.open(CACHE_NAME);
          const cachedResponse = await cache.match(OFFLINE_URL);
          return cachedResponse;
        }
      })()
    );
  }

  // If our if() condition is false, then this fetch handler won't
  // intercept the request. If there are any other fetch handlers
  // registered, they will get a chance to call event.respondWith().
  // If no fetch handlers call event.respondWith(), the request
  // will be handled by the browser as if there were no service
  // worker involvement.
});

오프라인 대체 페이지

offline.html 파일에서 광고 소재를 만들어 필요에 맞게 조정하고 브랜딩을 추가할 수 있습니다. 아래 예는 가능한 작업들을 간단히 보여줍니다. 버튼 누르기를 기반으로 한 수동 새로고침과 online 이벤트를 기반으로 한 자동 새로고침 및 일반적인 서버 폴링을 모두 보여줍니다.

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="utf-8" />
    <meta http-equiv="X-UA-Compatible" content="IE=edge" />
    <meta name="viewport" content="width=device-width, initial-scale=1" />

    <title>You are offline</title>

    <!-- Inline the page's stylesheet. -->
    <style>
      body {
        font-family: helvetica, arial, sans-serif;
        margin: 2em;
      }

      h1 {
        font-style: italic;
        color: #373fff;
      }

      p {
        margin-block: 1rem;
      }

      button {
        display: block;
      }
    </style>
  </head>
  <body>
    <h1>You are offline</h1>

    <p>Click the button below to try reloading.</p>
    <button type="button">⤾ Reload</button>

    <!-- Inline the page's JavaScript file. -->
    <script>
      // Manual reload feature.
      document.querySelector("button").addEventListener("click", () => {
        window.location.reload();
      });

      // Listen to changes in the network state, reload when online.
      // This handles the case when the device is completely offline.
      window.addEventListener('online', () => {
        window.location.reload();
      });

      // Check if the server is responding and reload the page if it is.
      // This handles the case when the device is online, but the server
      // is offline or misbehaving.
      async function checkNetworkAndReload() {
        try {
          const response = await fetch('.');
          // Verify we get a valid response from the server
          if (response.status >= 200 && response.status < 500) {
            window.location.reload();
            return;
          }
        } catch {
          // Unable to connect to the server, ignore.
        }
        window.setTimeout(checkNetworkAndReload, 2500);
      }

      checkNetworkAndReload();
    </script>
  </body>
</html>

데모

아래에 삽입된 데모에서 오프라인 대체 페이지의 작동 방식을 확인할 수 있습니다. 관심이 있다면 Glitch의 소스 코드를 살펴보세요.

앱을 설치 가능하게 만들기 위한 참고 사항

이제 사이트에 오프라인 대체 페이지가 있으므로 다음 단계가 궁금할 수 있습니다. 앱을 설치할 수 있도록 하려면 웹 앱 매니페스트를 추가하고 선택적으로 설치 전략을 제시해야 합니다.

Workbox.js로 오프라인 대체 페이지를 제공할 때의 참고사항

Workbox에 대해 들어보셨을 것입니다. Workbox는 웹 앱에 오프라인 지원을 추가하기 위한 JavaScript 라이브러리 세트입니다. 직접 서비스 워커 코드를 더 적게 작성하려는 경우 오프라인 페이지만에 Workbox 레시피를 사용할 수 있습니다.

이제 앱의 설치 전략을 정의하는 방법을 알아보세요.