Service Worker를 이용한 Assets Caching
📄

Service Worker를 이용한 Assets Caching

Created
May 23, 2021 11:15 AM
Tags
ux
js
 
(1)
UX를 악화시키는 요소는 비효율적인 css effect timeline 등 여러 가지가 있는데...
그 중 큰 부분을 차지하는 요소 중 하나는 Assets을 Loading하는 데 있어 걸리는 시간이 아닐까 싶다.
 
여기서 말하는 Assets이란 소스 코드, CSS, Fonts, Image 등을 의미하며
이러한 Aseets을 브라우저에서 Loading하는 데 있어 시간이 오래 걸릴수록
그 이후의 작업 또한 늦춰지게 되기에 결과적으로 UX를 악화시키게 될 것이다.
 
뭐 어디 좋은 방법이 없을까?
 
 
(2)
여러 방법이 있겠지만, 그 중 가장 간단하다 생각되는 방법은...
브라우저에서 fetch 이벤트가 발생될 때, 이를 후킹하여
 
  • 만약 캐시되지 않은 Asset이라면 ⇒ fetching 후 해당 asset 캐싱
  • 캐시된 assets라면 ⇒ fetch하지 않고, 캐시된 asset 사용
 
이러한 방식이다.
 
서버 코드 수정 없이 클라이언트 코드만 수정하여 해결할 수 있는 방식인데,
그럼 이걸 어떻게 구현할 수 있을까?
 
 
(3)
답은 Service Worker 그리고 Cache Storage API에 있다.
대부분의 모던 브라우저에서 지원하며, 이를 이용해 UX를 향상시켜 보자.
 
먼저 Service Worker의 경우, 아래와 같은 브라우저에서 사용이 가능하다.
 
https://caniuse.com/serviceworkers
 
Cache Storage는 아래와 같다.
 
https://caniuse.com/?search=cachestorage
 
 
(4)
Service Worker와 Cache Storage API는 각각 다음과 같은 용도로 사용한다.
 
  • Service Worker: fetch 후킹
  • Cache Storage API: Assets 캐싱
 
좀 거창하긴?한데 구현 자체는 어렵지 않다.
먼저 다음 두 개의 파일을 구성해주자
 
<!-- index.html -->

<!DOCTYPE html>

<html>
  <head>
    <meta charset="utf-8">
  </head>
  <body>
    <script src="./index.js"></script>
  </body>
</html>
 
// index.js

if ('serviceWorker' in navigator) {
  // install service worker
  window.addEventListener('load', () =>
    navigator.serviceWorker
      .register('./sw.js')
      .then(() => console.log('sw installed'));
}
 
Service Worker는 window.navigator.serviceWorker.register 를 이용해 설치가 가능하며, 해당 메서드를 통해 Promise 가 반환된다.
 
 
(5)
sw.js 파일은 Service Worker의 동작을 명시하는 파일이며,
여기서는 다음의 동작을 진행하도록 구현해보겠다.
 
  • fetch 이벤트로 요청하는 모든 리소스 캐싱
  • 요청한 리소스가 이미 캐시된 경우, 이를 이용
 
코드는 아래와 같다.
 
// sw.js

// set cache name
const CACHE_NAME = 'v1';

// hooking fetch event
self.addEventListener('fetch', evt => // #1
  evt.respondWith( // #2
    (async () => {
      const cachedResp = await caches.match(evt.request); // #3, #4
      
      // if already cached
      if (cachedResp !== undefined) { // #5
        return cachedResp;
      }
      
      // or not => try caching
      const resp = await fetch(evt.request); // #6
      const cache = await caches.open(CACHE_NAME); // #7
      cache.put(evt.request, resp.clone()); // #8
      
      return resp; // #9
    })()));
 
  • CACHE_NAME : 캐시의 이름
  • fetch : fetch 이벤트가 발생되었을 때의 event
    • Handler로 FetchEvent 객체가 파라미터로 전달된다
 
 
(6)
fetch 가 발생되었을 때, Service Worker를 이용하면...
중간 과정을 가로채 반환할 Response를 마음대로 수정할 수 있게 된다.
 
아무튼 뭐 코드를 하나씩 설명해보자면 이렇다.
(코드에서 #n 형태로 번호 매겨놓았으니 참고)
 
  1. fetch 이벤트에 대한 hooking을 하기 위해 addEventLister 로 handler 등록
  1. 이후 FetchEvent.respondWith 메서드를 이용해 기존 브라우저에서 진행했던 fetching 작업을 직접 할 수 있도록 구성
  1. FetchEvent.request 를 이용해 Cache Storage에 해당 Request를 key로 갖는 캐시가 존재하는지 여부 확인
  1. 이는 caches.match 메서드를 이용하는데, 이 메서드의 결과로 Promise 객체가 반환됨
  1. Promise resolve로 들어오는 Response 객체는 아래와 같다
      • 캐시가 존재함 ⇒ 해당 리소스 객체가 들어감 (이를 그대로 반환)
      • 캐시가 없음 ⇒ undefined
  1. 캐시가 존재하지 않는 경우, 일반적인 fetching을 진행
  1. 이 때, fetching 한 리소스를 caching해야 하니 캐시 이름을 이용해 caches.open ⇒ 마찬가지로 Promise 반환되며, resolve 객체 내에는 해당 이름을 가진 cache 객체가 들어감
  1. cache.put 메서드를 이용해 evt.request 를 키로 하여 resp 객체를 put ⇒ clone 하는 이유는 Response 객체는 한 번만 접근할 수 있기 때문 (so)
  1. 이를 Client에서 사용할 수 있도록 Response 를 반환
 
이런 과정으로 동작한다.
 
(7)
이렇게 fetch 하는 모든 리소스에 대해 캐싱이 가능하다.
그럼 실제로 성능은 얼마나 차이가 날까? Lighthouse로 측정한 결과는 이렇다.
 
Service Worker를 이용하지 않은 경우
notion image
 
Service Worker를 이용한 경우
notion image
 
전체적으로 리소스를 가져오는 데 약 200ms 정도 빨라졌음을 볼 수 있다.
 
참고로... Network Throttling은 아래와 같이 설정해 시뮬레이팅을 진행했으며,
 
notion image
 
기본적으로 크롬이 메모리에 리소스를 저장하는 것을 막고자
다음과 같이 개발자 도구에서 메모리에 캐싱하는 기능을 disable 했다.
 
notion image
 
 
(8)
Network 탭에서 보면 좀 더 명확하게 알 수 있다.
가령 Google Material Icons CSS 파일을 가져오는 경우라면...
 
Service Worker를 이용하지 않은 경우
notion image
⇒ 리소스를 가져오는 데 269ms이 소요되었다.
 
Service Worker를 이용한 경우
notion image
⇒ 리소스를 가져오는 데 21ms이 소요되었다.
 
 
(9)
따라서, UX 향상을 위해 Service Worker를 이용하도록 하자.
다만 개발 시 유의해야 할 것이 있는데...
 
개발 시에는 캐싱하지 않도록 설정해야 한다는 것이다. 또 왜?
⇒ 캐싱하도록 하면 코드가 갱신되어도 적용되지 않기 때문
 
따라서 개발 시에는 다음과 같이 브라우저를 설정하도록 하자 (크롬기준)
 
notion image
 
  • Update on reload : Service Worker 갱신 시 이를 적용하라는 설정
  • Bypass for network : Service Worker 이벤트를 실행하지 않도록 함
 
notion image
 
  • Disable cache : Chrome 메모리에 리소스를 캐싱하지 않도록 함
 
(10)
마지막으로, Size Quota 관련하여 유의사항이 하나 있다.
여기서 사용했던 Cache Storage API를 포함하여 Browser data storage(IndexedDB, asm.js caching, Cache API, Cookies)는 사용할 수 있는 Size Quota가 존재한다는 것. 게다가 이 값은 브라우저마다 다르기도 하다.
 
일반적으로, 모바일 브라우저는 50MB를 가지며, 그 외의 브라우저는 여기를 참고.
 
아무튼 일단, Size Quota 이상으로 데이터를 브라우저에 저장하게 되면 QuotaExceededError가 발생되며, 자칫 애플리케이션이 Crashed 될 수도 있다.
 
notion image
 
이러한 이슈를 avoid하기 위해서는... 그냥 간단히 현재 사용중인 브라우저의 Size quota와 Cached 된 Assets의 size를 측정하여 Quota 크기 이상으로는 저장하지 않도록 하면 된다.
 
// sw.js

const CACHE_NAME = 'v1';
const QUOTA_LIMIT_RATIO = 0.9;

self.addEventListener('fetch', evt =>
  evt.respondWith(
    (async () => {
      const cachedResp = await caches.match(evt.request);
      
      // get storage usage and quota
      const { usage, quota } = await navigator.storage.estimate(); // StorageEstimate API
      
      if (cachedResp !== undefined) {
        return cachedResp;
      }
      
      const resp = await fetch(evt.request);
      
      if (usage + Number(resp.headers.get('Content-Length')) > quota * QUOTA_LIMIT_RATIO) {
        // not caching
        return resp;
      }
      
      // caching assets
      const cache = await caches.open(CACHE_NAME);
      cache.put(evt.request, resp.clone());
      
      return resp;
    })()));
 
이는 보이는 것과 같이, SotrageEstimate APIContent-Length 헤더를 이용해 측정이 가능하다.
단, SotrageEstimate API는 Safari 및 IE에서 사용이 불가능하기에...
해당 브라우저는 위에서 언급했던 이 링크를 토대로 직접 Quota와 Usage를 계산해줘야 한다.
 
참고로, 여기서 QUOTA_LIMIT_RATIO 라는 값을 사용했는데, 이는 Quota를 넘어버리게 되면 바로 Error가 발생되기에 Safe zone을 만들어주기 위함. 즉, 조금 여유를 남기고 캐싱할 수 있도록, 그리고 다른 API에서 사용할 수도 있는 Browser storage를 위해 이러한 ratio 값을 사용하게 된 것이다.
 
 
(0)
참고 가능한 문서들
 
 

Loading Comments...