리소스 요청을 캐싱할 수는 없을까
UX를 악화시키는 요소는 비효율적인 css effect timeline 등 여러 가지가 있는데... 그 중 큰 부분을 차지하는 요소 중 하나는 리소스를 요청하는 데 있어 걸리는 시간이 아닐까 싶다.
여기서 말하는 리소스란 소스 코드, CSS, Fonts, Image 등을 의미하며 이러한 리소스를 브라우저에서 Loading하는 데 있어 시간이 오래 걸릴수록 그 이후의 작업 또한 늦춰지게 되기에 결과적으로 UX를 악화시키게 될 것이다.
뭐 어디 좋은 방법이 없을까? 여러 방법이 있겠지만, 그 중 가장 간단하다 생각되는 방법은... 브라우저에서
fetch
이벤트가 발생될 때 이를 후킹하여,- 만약 캐시되지 않은 리소스라면 ⇒ fetching 후 해당 리소스 캐싱
- 캐시된 리소스라면 ⇒ fetch하지 않고 & 캐시된 리소스 사용
이러한 방식이다. 서버 코드 수정 없이 클라이언트 코드만 수정하여 해결할 수 있는 방식인데, 그럼 이걸 어떻게 구현할 수 있을까?
Service Worker와 Cache Storage API
답은 Service Worker 그리고 Cache Storage API에 있다. 대부분의 모던 브라우저에서 지원하며, 이를 이용해 UX를 향상시켜 보자. 먼저 Service Worker의 경우, 아래와 같은 브라우저에서 사용이 가능하다.
Cache Storage는 아래와 같다.
구현해보기
Service Worker와 Cache Storage API는 각각 다음과 같은 용도로 사용한다.
- Service Worker:
fetch
후킹
- Cache Storage API: 리소스 캐싱
좀 거창하긴?한데 구현 자체는 어렵지 않다. 먼저 다음 두 개의 파일을 구성해주자
<!-- 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
가 반환된다.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
: 캐시의 이름
self
:ServiceWorkerGlobalScope
객체를 가리킨다self.caches
로 Cache Storage 접근 가능
fetch
:fetch
이벤트가 발생되었을 때의 event- Handler로
FetchEvent
객체가 파라미터로 전달된다
코드 설명
fetch
가 발생되었을 때, Service Worker를 이용하면... 중간 과정을 가로채 반환할 Response를 마음대로 수정할 수 있게 된다. 아무튼 뭐 코드를 하나씩 설명해보자면 이렇다. (코드에서 #n
형태로 번호 매겨놓았으니 참고)fetch
이벤트에 대한 hooking을 하기 위해addEventLister
로 handler 등록
- 이후
FetchEvent.respondWith
메서드를 이용해 기존 브라우저에서 진행했던 fetching 작업을 직접 할 수 있도록 구성
FetchEvent.request
를 이용해 Cache Storage에 해당 Request를 key로 갖는 캐시가 존재하는지 여부 확인
- 이는
caches.match
메서드를 이용하는데, 이 메서드의 결과로Promise
객체가 반환됨
Promise
resolve로 들어오는Response
객체는 아래와 같다- 캐시가 존재함 ⇒ 해당 리소스 객체가 들어감 (이를 그대로 반환)
- 캐시가 없음 ⇒
undefined
- 캐시가 존재하지 않는 경우, 일반적인 fetching을 진행
- 이 때, fetching 한 리소스를 caching해야 하니 캐시 이름을 이용해
caches.open
⇒ 마찬가지로Promise
반환되며, resolve 객체 내에는 해당 이름을 가진cache
객체가 들어감
cache.put
메서드를 이용해evt.request
를 키로 하여resp
객체를 put ⇒clone
하는 이유는Response
객체는 한 번만 접근할 수 있기 때문 (so)
- 이를 Client에서 사용할 수 있도록
Response
를 반환
이런 과정으로 동작한다.
퍼포먼스 측정
이렇게
fetch
하는 모든 리소스에 대해 캐싱이 가능하다. 그럼 실제로 성능은 얼마나 차이가 날까? Lighthouse로 측정한 결과는 이렇다.Service Worker를 이용하지 않은 경우
Service Worker를 이용한 경우
전체적으로 리소스를 가져오는 데 약 200ms 정도 빨라졌음을 볼 수 있다. 참고로... Network Throttling은 아래와 같이 설정해 시뮬레이팅을 진행했으며:
기본적으로 크롬이 메모리에 리소스를 저장하는 것을 막고자 다음과 같이 개발자 도구에서 메모리에 캐싱하는 기능을 disable 했다.
Network 탭에서 보면 좀 더 명확하게 알 수 있다. 가령 Google Material Icons CSS 파일을 가져오는 경우라면...
Service Worker를 이용하지 않은 경우
⇒ 리소스를 가져오는 데 269ms이 소요되었다.
Service Worker를 이용한 경우
⇒ 리소스를 가져오는 데 21ms이 소요되었다.
유의사항 1 - 브라우저 설정
따라서, UX 향상을 위해 Service Worker를 이용하도록 하자. 다만 개발 시 유의해야 할 것이 있는데...
개발 시에는 캐싱하지 않도록 설정해야 한다는 것이다. 또 왜?
⇒ 캐싱하도록 하면 코드가 갱신되어도 적용되지 않기 때문
따라서 개발 시에는 다음과 같이 브라우저를 설정하도록 하자 (크롬기준)
- Update on reload : Service Worker 갱신 시 이를 적용하라는 설정
- Bypass for network : Service Worker 이벤트를 실행하지 않도록 함
- Disable cache : Chrome 메모리에 리소스를 캐싱하지 않도록 함
유의사항 2 - Size Quota
마지막으로, Size Quota 관련하여 유의사항이 하나 있다. 여기서 사용했던 Cache Storage API를 포함하여 Browser data storage(IndexedDB, asm.js caching, Cache API, Cookies)는 사용할 수 있는 Size Quota가 존재한다는 것. 게다가 이 값은 브라우저마다 다르기도 하다.
일반적으로, 모바일 브라우저는 50MB를 가지며, 그 외의 브라우저는 여기를 참고.
아무튼 일단, Size Quota 이상으로 데이터를 브라우저에 저장하게 되면 QuotaExceededError가 발생되며, 자칫 애플리케이션이 Crashed 될 수도 있다.
이러한 이슈를 피하기 위해서는... 그냥 간단히 현재 사용중인 브라우저의 Size quota와 캐시된 리소스의 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 resources const cache = await caches.open(CACHE_NAME); cache.put(evt.request, resp.clone()); return resp; })()));
이는 보이는 것과 같이, SotrageEstimate API와
Content-Length
헤더를 이용해 측정이 가능하다. 단, SotrageEstimate API는 Safari 및 IE에서 사용이 불가능하기에... 해당 브라우저는 위에서 언급했던 이 링크를 토대로 직접 Quota와 Usage를 계산해줘야 한다.참고로, 여기서
QUOTA_LIMIT_RATIO
라는 값을 사용했는데, 이는 Quota를 넘어버리게 되면 바로 Error가 발생되기에 Safe zone을 만들어주기 위함. 즉, 조금 여유를 남기고 캐싱할 수 있도록, 그리고 다른 API에서 사용할 수도 있는 Browser storage를 위해 이러한 ratio 값을 사용하게 된 것이다.