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

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

오프라인 상태에서 Google 어시스턴트 모바일 앱을 사용합니다.
Google 어시스턴트.

오프라인 상태에서 Slack 모바일 앱
Slack.

오프라인 상태에서 모바일 앱 Zoom을 사용하세요.
확대/축소합니다.

플랫폼별 앱을 사용하면 네트워크에 연결되어 있지 않아도 항상 결과를 얻을 수 있습니다.

반면 웹에서는 오프라인 상태일 때 아무것도 표시되지 않았습니다. Chrome에서는 오프라인 공룡 게임만 제공합니다.

오프라인 공룡 게임이 표시된 Google Chrome 모바일 앱
iOS용 Chrome입니다.

오프라인 공룡 게임이 표시된 Google Chrome 데스크톱 앱
macOS용 Google 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 레시피를 사용하면 됩니다.

다음으로 앱의 설치 전략을 정의하는 방법을 알아봅니다.