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

Google 어시스턴트 앱, Slack 앱, Zoom 앱, 휴대전화나 컴퓨터의 거의 모든 기타 플랫폼별 앱의 공통점은 무엇일까요? 맞아요. 항상 무언가를 주죠. 네트워크에 연결되어 있지 않은 경우에도 어시스턴트 앱을 열거나 Slack에 들어가거나 Zoom을 실행할 수 있습니다. 특별히 의미 있는 결과를 얻지 못하거나 원하는 결과를 달성하지 못할 수도 있지만 최소한 무언가를 얻을 수 있고 앱이 제어됩니다.

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

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

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

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

반면 웹에서는 오프라인 상태일 때 아무것도 얻을 수 없습니다. Chrome에서는 오프라인 공룡 게임을 제공하지만 그게 전부입니다.

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

오프라인 공룡 게임이 표시된 Google 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>

데모

아래에 삽입된 데모에서 오프라인 대체 페이지가 작동하는 것을 확인할 수 있습니다. 관심이 있다면 GitHub에서 소스 코드를 살펴볼 수 있습니다.

앱을 설치 가능하게 만드는 방법에 관한 참고사항

이제 사이트에 오프라인 대체 페이지가 있으므로 다음 단계를 고려해 볼 수 있습니다. 앱을 설치 가능하게 만들려면 웹 앱 매니페스트를 추가하고 선택적으로 설치 전략을 마련해야 합니다.

Workbox.js를 사용하여 오프라인 대체 페이지를 제공하는 방법

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

다음으로 앱의 설치 전략을 정의하는 방법을 알아보세요.