front-end / / 2024. 1. 7. 20:27

[PWA] Caching

Providing Offline Support

웹 앱에서 리소스 캐싱을 하면 오프라인 상태에서도 사용할 수 있다. 그런데 인터넷 연결이 안되는 환경에서 웹앱을 꼭 사용해야할까? 이러한 유스케이스는 여러가지가 있을 수 있는데 그 사례를 한번 보자.

왜 offline 접근을 지원해야 할까?

느린 인터넷 환경(Poor Connection) - 많은 관중들이 군집해 있는 빅 스포츠 이벤트가 있다고 생각해보자. 때때로 인터넷 연결이 느릴수도 혹은 안될수도 있다. 그 중에 여러분은 새 인터넷 뉴스를 보고 싶을 수도 있다. 뉴스를 보기위해서는 몇 개의 이미지와 html 파일만 필요하지만 인터넷이 안되서 동작하지 않는다.

그런 환경에서 service worker로 리소스 파일을 캐싱하여 페이지를 표시할 수 있다.

인터넷 안되는 환경(No Connection) - 여러분이 엘레베이터에 있고 인터넷 연결이 안되어서 몇 초 혹은 몇 분 동안 사용하지 못할수도 있다. 그 안에서 인터넷을 보거나 이메일 확인 같은 일을 할 수도 있다. 이런 환경에서 service worker로 웹 애플리케이션의 일정 부분을 캐싱할 수 있다.

Cache API

브라우저는 캐시를 자동으로 관리한다. 브라우저에서 관리되는 캐쉬는 하나의 단점이 있다. 브라우저에서만 관리되고 명시적으로 특정 리소스를 캐시하게 할 수 없다. 개발자가 어떤 리소스는 캐싱을 하고 어떤 것을 캐싱하지 말라고 제어할 수 없다는 의미이다. 그리고 제한된 사용량만 가능하다.
그래서 cache api가 필요하게 된다. 브라우저에는 특정 캐쉬 저장소가 있다.

캐시는 key-value를 가지고 있고 HTTP request로 요청을 하게 된다. 특정 key로 캐싱을 하고 난 이후 인터넷이 안되는 환경에서 사용할 수가 있다.
Cache API는 service worker와 일반 javascript로 접근이 가능하다. service worker는 백그라운드로 접근이 가능하기 때문에 더 강력한 기능을 발휘하게 된다.
service worker는 백그라운드로 동작하기 때문에 페이지 접근시 마다 가져올 필요가 없다. 인터넷이 안되는 환경에서 네트워크 요청을 보내는 대신 캐쉬를 활용할 수 있다. 즉, 네트워크 프록시 역할을 할 수 있는 것이다.

브라우저 지원

https://developer.mozilla.org/en-US/docs/Web/API/Cache

어떤 데이터를 캐싱해야 할까?

페이지에는 헤더, 메뉴, 고정된(static) 페이지, 고정 이미지 등 정적으로 구성된 데이터가 있고 사용자가 등록한 화면 등 시간이 지남에 따라 변경되는 페이지 및 데이터가 있다. 이는 동적 데이터이다. 이 모든 것이 캐싱되어야 할 데이터들이다.

정적(static) 데이터와 동적(dynamic) 데이터를 구분하여 캐싱을 한다.

정적 캐싱 (static caching)

service worker의 installation 시 정적 캐싱을 하는 방법을 알아보자.

여러가지 javascript, css, image 등의 파일이 있고 이 파일들을 캐싱해야 한다. service worker를 설치할 때 이러한 파일들을 cache API를 활용해서 캐싱한다. 사용자가 접근하여 데이터를 캐싱하고 난 이후 인터넷이 안되는 환경에서 이러한 Cache API를 활용해서 페이지를 표시한다.

[sw.js]

self.addEventListener("install", function (event) {
  console.log("[Service Worker] Installing Service Worker...", event);

  event.waitUntil(
    caches.open("static-v1").then(function (cache) {
      console.log("[Service Worker] Precaching App Shell");
      cache.addAll([
        "/",
        "/index.html",
        "/static/app.js",
        "/static/semantic.min.js",
        "/static/semantic.min.css",
        "https://code.jquery.com/jquery-3.1.1.min.js",
      ]);
    }),
  );
});

위의 코드에서 정적 리소스에 대한 파일을 입력한다.
여기서는 index.html, app.js, semantic.min.js, semantic.min.css, jquery-3.1.1.min.js를 추가하였다.

이렇게 추가하고 service worker를 갱신한 다음 개발자 도구를 확인하면 Cache storage에 해당 파일이 들어가 있는 것을 확인할 수 있다.

캐싱 조회

캐싱된 데이터를 조회하는 방법이다. 모든 네트워크 요청은 fetch 이벤트를 통해 가져온다. fetch 이벤트에서 캐싱이 되어 있는지 확인하여 있다면 캐싱된 리소스를 표시하고 없으면 네트워크를 통해 요청을 한다.

self.addEventListener('fetch', function(event) {
  event.respondWith(
    caches.match(event.request) 
      .then(function(response) {
        if (response) { // 캐싱이 되어 있으면
          return response;
        } else { // 캐싱이 되어 있지 않으면
          return fetch(event.request);
        }
      })
  );
});

개발자 도구에서 네트워크 조회를 해보면 위에서 캐싱한 리소스는 Service Worker에서 조회되는 것을 확인할 수 있다.

offline 모드로 변경하고 데이터가 잘 조회되는 지 해보자.

개발자 도구의 Application > Service workers에서 offline을 체크하고 새로고침 하자.

Static 캐싱을 한 데이터를 제외하고 나머지는 모두 조회가 실패한 것을 확인할 수 있다. (failed)

이는 위에서 캐싱한 데이터 외에 나머지 파일 (manifest.json, 이미지 등)은 static 캐싱을 하지 않았기 때문이다. 그래서 다음에 동적 캐싱을 활용해 볼 것이다.

fetch 시 동적(Dynamic) 캐싱

정적 캐싱 외에 동적으로 캐싱을 할 수도 있다.

동적 캐싱 구현

동적 캐싱은 fetch에서 정적 캐싱의 응답이 없을 경우에 동적 캐싱을 한다.

self.addEventListener("fetch", function (event) {
  event.respondWith(
    caches.match(event.request).then(function (response) {
      if (response) {
        return response;
      } else {
        return fetch(event.request).then(function (res) {
          return caches.open("dynamic").then(function (cache) {
            cache.put(event.request.url, res.clone());
            return res;
          });
        });
      }
    }),
  );
});

동적 캐싱을 설정하고 다시 페이지를 새로고침 해보자. (service worker unregister 필요)

dynamic 캐쉬가 생성되었음을 확인할 수 있다. 또한 service worker를 offline으로 설정하고 새로고침을 해보자.
오프라인 상황에도 페이지가 정상적으로 로딩되는 것을 확인할 수 있다.

여기서 모든 리소스가 service worker에서 가져오는 것을 확인할 수 있다.

Cache 버저닝 (versioning)

Cache에 있는 리소스가 변경되면 어떻게 새로 가져와야 할까? 기존에 이미 캐싱에 해당 리소스가 저장되어 있으므로 실제 리소스가 변경되더라도 캐싱되어 있는 리소스를 먼저 조회하기 때문에 새로 가져오지 않는다. 그래서 캐싱 시 버저닝 정보가 필요하다.

버저닝은 static과 dynamic 모두 필요하다.

[sw.js]

var CACHE_STATIC_NAME = "static-v1";
var CACHE_DYNAMIC_NAME = "dynamic-v1";

self.addEventListener("install", function (event) {
  console.log("[Service Worker] Installing Service Worker...", event);

  event.waitUntil(
    caches.open(CACHE_STATIC_NAME).then(function (cache) {
      console.log("[Service Worker] Precaching App Shell");
      cache.addAll([
        "/",
        "/index.html",
      ..
      ]);
    }),
  );
});

self.addEventListener("fetch", function (event) {
  event.respondWith(
    caches.match(event.request).then(function (response) {
      if (response) {
        return response;
      } else {
        return fetch(event.request)
          .then(function (res) {
            return caches.open(CACHE_DYNAMIC_NAME).then(function (cache) {
              cache.put(event.request.url, res.clone());
              return res;
            });
          })
          .catch(function (err) {
          });
      }
     }),
  );
});

위에서 CACHE_STATIC_NAMECACHE_DYNAMIC_NAME로 상수를 지정하고 변경 시 위의 버전정보를 증가시켜준다. 그러면 새로운 이름의 캐시가 생성이 된다.

또한 버전이 증가되는 경우 기존 캐시는 사용하지 않기 때문에 삭제를 해야 한다. 삭제하지 않으면 아래와 같이 기존 캐시가 계속 저장되어있는 것을 볼 수 있다.

현재 static-v2를 사용중인데 static-v1이 아직 남아 있다.

이를 위해 activate 시 현재 사용중인 캐시가 아닌 경우 삭제하는 코드를 넣어주자.

self.addEventListener("activate", function (event) {
  console.log("[Service Worker] Activating Service Worker...", event);

  event.waitUntil(
    caches.keys().then(function (keyList) {
      return Promise.all(
        keyList.map(function (key) {
          if (key !== CACHE_STATIC_NAME && key !== CACHE_DYNAMIC_NAME) {
            console.log("[Service Worker] Removing old cache.", key);
            return caches.delete(key);
          }
        }),
      );
    }),
  );

  return self.clients.claim();
});

Fallback 페이지

일단 한번이라도 방문을 한 페이지의 경우 static과 dynamic 캐쉬를 활용하여 오프라인에서도 사용할 수 있게 된다. 하지만 방문을 하지 않은 페이지는 어떻게 표시해야 할까?

Home화면 접속 후 offline을 체크해보자. (service worker의 offline)

위의 페이지 중 Help 페이지(help.html)는 아직 캐싱이 되어 있지 않다. 캐싱이 되어있지 않은 페이지를 접속하면 offline모드이기 때문에 페이지를 가져오지 못할 것이다. 그래서 접속하면 아래와 같은 오류가 표시된다.

아직 캐시되지 않은 페이지에는 기본 fallback 페이지가 있다면 좋을 것 같다.

offline.html파일을 생성하자.

<!doctype html>
<html lang="en">
<head>
...
</head>

<body>


<div style="margin-top: 50px">
    <div class="ui negative message">
        <i class="close icon"></i>
        <div class="header">
            페이지가 캐싱되지 않았습니다.
        </div>
        <p><a href="/">홈화면으로 가기</a></p>
        </p></div>
</div>

<script src="static/app.js"></script>

</body>
</html>

그리고 service worker의 fetch 시 오류가 발생하면 offline.html를 표시하는 코드를 추가하자.

[sw.js]

event.respondWith(
    caches.match(event.request).then(function (response) {
      if (response) {
        return response;
      } else {
        return fetch(event.request)
          .then(function (res) {
            return caches.open(CACHE_DYNAMIC_NAME).then(function (cache) {
              cache.put(event.request.url, res.clone());
              return res;
            });
          })
          .catch(function (err) {
            return caches.open(CACHE_STATIC_NAME).then(function (cache) {
              return cache.match("/offline.html");
            });
          });
      }
    }),
  );

또한 static 캐시에 offline.html은 사전 캐싱이 되어 있어야 한다. 그래서 install 시 offline.html을 추가하자.

self.addEventListener("install", function (event) {
  console.log("[Service Worker] Installing Service Worker...", event);

  event.waitUntil(
    caches.open(CACHE_STATIC_NAME).then(function (cache) {
      console.log("[Service Worker] Precaching App Shell");
      cache.addAll([
        "/",
          ..
        "/offline.html",
          ..
      ]);
    }),
  );
});

service worker 새로고침 후 home화면을 접속하자. 그리고 offline 모드를 체크한 이후 Help 페이지를 접속해보자.

그러면 페이지 Not found 오류 대신 위에서 만든 offline.html 페이지가 표시되는 것을 확인할 수 있다.

캐시 전략 (Strategy)

Strategy: Cache with Network Fallback

우선 캐시를 접근하고 캐시에 데이터가 없다면 네트워크를 접근하는 방법이다.

페이지는 service worker에 요청을 보내고 캐시를 조회한다. 캐시에 있다면 리턴하고 없다면 네트워크를 조회해서 리턴한다.

장점은 리소스가 캐시에 있으면 빠르게 로딩할 수 있다는 것이다.

단점은 캐시에 있는 리소스의 경우 네트워크 접근을 하지 않기 때문에 이전버전을 가지고 있을 가능성이 있다는 것이다.

Strategy: Cache Only

캐시만 사용하는 전략이다. service worker를 요청한 다음 거기에 있는 캐시를 찾아 리턴하는 방식이다. 네트워크 연결은 완전히 무시한다.

코드로 표현하면 아래와 같다.

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

이 방식은 인터넷 연결을 사용하지 않기 때문에 dynamic 캐싱은 없다. 실제 사용하기에는 문제가 많다.

Strategy: Network Only

Cache Only의 반대인 Network Only는 Service Worker를 전혀 사용하지 않는다.

Network Only는 기존 fetch 이벤트 시 사용하는 코드를 제거하면 된다.

self.addEventListener("fetch", function (event) {
//    event.respondWith(event.respondWith(caches.match(event.request)));
});

Strategy: Network with Cache Fallback

우선 service worker에 요청하고 네트워크로 요청을 보내 다음 fetch가 실패하는 경우에만 캐시로 부터 리소스를 리턴하는 방식이다. 네트워크 연결이 성공이면 캐시가 아닌 네트워크의 응답을 리턴한다.

하지만 캐시에 데이터가 있어도 네트워크 연결이 정상적이면 보다 빠른 캐시를 사용하지 못하는 단점이 있다.

코드로 구현하면 아래와 같다.

self.addEventListener("fetch", function (event) {
  event.respondWith(
    fetch(event.request)
      .then(function (res) {
        return caches.open(CACHE_DYNAMIC_NAME).then(function (cache) {
          cache.put(event.request.url, res.clone());
          return res;
        });
      })
      .catch(function (err) {
        return caches.match(event.request);
      }),
  );
});

offline으로 설정을 해도 화면이 정상적으로 표시되는 것을 확인할 수 있다.

Strategy: Cache, then Network

이번에는 캐싱 후 네트워크 전략이다. 개념은 가능한 빠르게 캐시로 부터 조회를 하고 이후 네트워크로 부터 최신데이터를 조회하는 것이다. Network First, Cache Fallback의 개선된 버전이다.

어떻게 동작하는지 알아보자.

우선 페이지는 캐시에 직접 접근한다. 그리고 응답한다. 여기에는 service worker가 관련되어 있지 않다. 동시에 service worker에 접근하여 네트워크로 부터 응답값을 받는다. 그리고 캐시에 데이터를 저장한다. 그리고 나서 페이지에 fetch한 데이터를 리턴한다.

참고

https://www.udemy.com/course/progressive-web-app-pwa-the-complete-guide

반응형
  • 네이버 블로그 공유
  • 네이버 밴드 공유
  • 페이스북 공유
  • 카카오스토리 공유