본문으로 건너뛰기

"pwa" 태그로 연결된 5개 게시물개의 게시물이 있습니다.

모든 태그 보기

Workbox5 버전의 주요 변경사항

· 약 2분

Workbox5 버전

잘 돌던 workbox-cli 가 5버전 릴리즈 후부터 돌지 않아서 확인해보았다. 전체 문서는 여기서 확인 가능하다.

injectManifest

self.__WB_MANIFEST 를 주입받는 방식으로 변경되었다.

// v4:
precacheAndRoute([]);

// v5:
precacheAndRoute(self.__WB_MANIFEST);

blacklist, whitelist 에서 denylist, allowlist 로 키 명이 변경되었다.

BroadcastChannel

broadcast-update 가 자체 API 에서 postMessage()로 변경되었다. 이벤트 리스너가 많아져 복잡해지고, 기존 API에서는 메세징의 버퍼 기능이 없었기 때문이다.

// v4:
const updatesChannel = new BroadcastChannel("api-updates");
updatesChannel.addEventListener("message", (event) => {
const { cacheName, updatedUrl } = event.data.payload;
// ... your code here ...
});

// v5:
// This listener should be added as early as possible in your page's lifespan
// to ensure that messages are properly buffered.
navigator.serviceWorker.addEventListener("message", (event) => {
// Optional: ensure the message came from workbox-broadcast-update
if (event.meta === "workbox-broadcast-update") {
const { cacheName, updatedUrl } = event.data.payload;
// ... your code here ...
}
});

서비스워커로 POST Request 캐싱하기

· 약 4분

앞서

서비스워커로 Navigation Request 나 Static Assets 에 대한 리소스 캐시는 쉽다. (이전 포스팅 참조)

하지만 POST Request 에 대한 레퍼런스는 찾기 힘들어 결국 만들어버렸다. 복잡한 로직이지만 Request Body 를 SHA1로 해싱해 키로 IndexedDB 에 저장하고 그 키가 맞으면 꺼내주는 방식이다.

소스

// IndexedDB 는 Promisify 되어있지 않아서 라이브러리가 필요하다.
importScripts(
"https://cdn.jsdelivr.net/npm/[email protected]/dist/localforage.min.js",
);

// 캐시하고 싶은 POST 엔드포인트
const ENDPOINT = "https://your-domain/post-request";

const bin2Hex = (buffer) => {
let digest = "";
const dataView = new DataView(buffer);
for (let i = 0, len = dataView.byteLength; i < len; i += 4) {
const value = dataView.getUint32(i);
// hex 로 바꾸면 패딩비트 0 이 제거된다.
const hex = value.toString(16);
// uint32 는 4bytes 로 나온다.
const padding = "00000000";
// 패딩을 더해서 뒤에서 잘라준다.
const paddedValue = (padding + hex).slice(-padding.length);
digest += paddedValue;
}

return digest;
};

const postRequestFetchListener = (fetchEvent) => {
const requestUrl = fetchEvent.request.url;
const method = fetchEvent.request.method.toUpperCase();
// 맞는 엔드포인트인지 확인
if (!(method === "POST" && requestUrl === ENDPOINT)) {
return;
}

fetchEvent.respondWith(
fetchEvent.request
.clone()
.arrayBuffer()
.then((buffer) => {
const requestBody = String.fromCharCode.apply(
null,
new Uint8Array(buffer),
);
// request body 에 원하는 조건만 캐시처리할 수 있게 한다.
if (requestBody.includes("cache=1")) {
// 속도면에서 다른 해싱 알고리즘을 사용해도 무방하다.
return crypto.subtle.digest("SHA-1", buffer);
}

return Promise.reject();
})
.then((sha1Buffer) => {
const sha1Hash = bin2Hex(sha1Buffer);
console.log("SHA1 Hash => ", sha1Hash);

// IndexedDB 에서 캐시된 키를 찾는다.
return localforage.getItem(sha1Hash).then((cachedResponse) => {
if (cachedResponse) {
console.log("Cached repsonse => ", cachedResponse);
return new Response(cachedResponse, {
// 여기서 statusCode 를 304 로 내보내고 싶었으나, Body 를 반환할 수 없었다.
status: 200,
statusText: "OK",
headers: {
"Content-Length": cachedResponse.length,
"Content-Type": "application/json",
// 그래서 커스텀 헤더를 추가했다.
"X-SW-Cache-Hit": 1,
"X-SW-Cache-Type": "POST",
},
});
}

// 캐시된 데이터가 없을 경우 새로 요청한다.
return fetch(fetchEvent.request).then((response) => {
console.log("Fetching response => ", response.clone());

// 정상적일 경우만 IndexedDB 에 저장한다.
if (200 <= response.status && response.status < 400) {
// 이 작업은 비동기지만 굳이 기다리지 않아도 된다.
response
.clone()
.text()
.then((textResponse) => {
console.log("Caching response => ", textResponse);
return localforage.setItem(sha1Hash, textResponse);
});
}

return response;
});
});
})
.catch(() => fetch(fetchEvent.request)),
);
};

// 리스너를 등록해준다.
self.addEventListener("fetch", postRequestFetchListener);

여담

  • WorkBox 를 사용할 수 있다면 CacheableResponse와 CacheFirst 정책으로 단번에 처리 가능할 것이다.
  • 굳이 해시를 키로 사용하지 않아도 된다. RequestBody 의 Serialize 를 키로 써도 된다. (만들면서 crypto 라이브러리를 사용해보고 싶었을 뿐)

Chrome 75 Webview에서 서비스워커의 fetch request 가 실패하는 문제

· 약 5분

발단

  • 서비스워커는 n:m (사이트:디바이스)로 모든 클라이언트 기기에 설치된다.
  • 배포 후 아무 문제 없이 동작하였으나, 190604 이후 웹뷰에서 net::ERR_ABORTED 페이지가 보인다는 버그가 들어오기 시작했다.
  • 랜덤하게 발생되고 있어 추적이 어려웠으나 4일에 Chrome의 메이저 버전이 업데이트 되었다라는 것을 확인했고 디버깅을 시작했다.

디버깅

chromium#973048

  • 서비스워커가 내려오지 않은 경우 페이지가 100% 정상동작을 하였다.
  • 서비스워커가 내려온 경우 랜덤하게 페이지가 로드되지 않았다.
  • 문제는 서비스워커로 확인되었고, 세부적인 디버깅 내역은 다음과 같다.
    • fetchListener 내부 cacheStorage 접근 예외처리: 재현
    • fetchListener 제거: 재현 안됨
  • Chrome 75버전의 웹뷰에서 서비스워커의 fetching 방식이 변경되었다는 걸 확인할 수 있었다.
  • 74, 75버전의 Diff를 찾을 수 있어 서비스워커 코어가 어마어마하게 변경되었다는 걸 확인할 수 있었으나 이 코드를 디버깅하는 것보다 퇴사 후 행복하게 사는 게 멋질 것이란 판단이 들었다.
  • 다행히 구글러와 연락이 닿아 private 버그리포팅을 했고, Chrome은 오픈소스라 구글에서도 일일히 확인하기 힘들다라는 답변을 들을 수 있었다.

웹뷰와 서비스워커

  • 웹뷰의 서비스워커와 브라우저의 서비스워커는 다른 인스턴스이므로 서로 공유되지 않는다.
  • 그렇다면 웹뷰에서 재접속시에 앱 셸을 빠르게 로드하는 이점 뿐이라는 말이다. === 어드벤티지 없음
  • WebView UA in Lollipop and Above에 따르면 안드로이드 롤리팝 이후부터 User Agent에 wv란 값을 물고 들어온다.

해결

예외처리

  • Chrome 엔진이 업데이트 된다고 서비스워커가 제거되지 않는다.
  • 따라서 UA 에 android, wv 값이 있는 경우 서비스워커를 설치하지 않을 뿐 아니라 설치된 서비스워커를 제거해주는 로직이 있어야한다.
  • 아니라면 A way to immediately unregister a service worker 기능을 브라우저 벤더들이 개발해줘야한다.
  • 더 특정한 버전을 줘서 예외처리를 한다면 다음과 같을 것이다.

Chrome 75.0.3770.67 ~ 75.0.3770.101 버전의 모든 안드로이드 웹뷰에서 서비스워커 설치를 차단, 이미 설치가 되어있다면 삭제

패치

  • 몇일 뒤에 다른 업체에서 public 하게 버그리포팅을 올렸고 버그를 찾아서 조만간 패치될 예정이다.
  • This is affecting tens of thousands of our readers 로 보아 나와 같은 빡침이 느껴져서 아련했다.
  • 패치된 코드는 여기서 볼 수 있다.

여담

  • 브라우저에 버그 발생시 대처하는 방법은 거의 불가능하다.
  • 실수는 여기든 저기든 다 똑같구나

PWA 홈 설치의 모든 것 (A2HS)

· 약 15분

PWA의 캐싱과 푸쉬 기능은 익히 알려져 있지만, 홈 설치에 대해선 제대로 되어있는 문서가 거의 없다. 기능은 기존 바로가기의 전체화면 버전이라 간단해보이지만 구현해보면 빙산의 일각인 걸 절실히 깨닫게 된다.

A2HS

Add To Home Screen, 홈 설치, 웹 앱 설치

사전지식

아래 사전지식이 없으면 이 문서가 읽기 힘들 수 있다.

  • es6
  • 서비스워커
  • 캐싱 전략

조건

홈 설치가 되려면 다음과 같은 조건이 필요하다. 크롬 측 설명인데 다른점이 많아서 하나씩 까보자.

  • HTTPS
  • 서비스워커 설치
  • start_url 이 오프라인 사용가능
  • 최소 192px 아이콘 지원
  • manifest.json 에 name 과 short_name 값 등록

HTTPS

HTTPS 를 통해 서비스가 제공되어야한다고 나오는데, 도메인 뿐아니라 페이지의 모든 리소스가 HTTPS 프로톨콜로 전송되어야한다.

서비스워커 설치

PWA 를 시작하는데 필수 요소니 자세한 설명은 생략

start_url 오프라인 사용가능

어느 캐싱전략을 사용해도 상관없으나 오프라인 모드로 연결이 되어야한다. 국내 서비스일 경우 크게 느리지 않으므로 Network First Strategy 로 캐싱하는 걸 추천한다. 국외 서비스일 경우 로드되는 리소스까지 캐싱전략을 세워야될 수 있다.

여기서 start_url 에 querystring 을 추가해 사용하는 경우가 대부분일텐데 캐싱되는 URL 은 querystring 이 없다는 걸 감안해야한다.

예를 들어보자.

{
// 보통 fetch request 로 접근되는 경로는 다음과 같다.
"start_url": "/",
// 검색 파라미터가 달려있다면 이런 모양일 것이다.
"start_url": "/?NaPm=",

// 하지만 manifest.json 에 들어있는 경로는 다음과 같을 것이다.
"start_url": "/?pwa=1&utm_source=pwa"
}

유저가 접근시 ['NaPm', 'pwa', 'utm_source'] 등의 파라미터를 제거해야 경로가 캐시처리되며 오프라인 접근이 될 것이다.

아이콘 지원

크롬에선 최소 192px 의 아이콘을 지원하라고 나와있지만 스플래시 스크린에서 사용되는 아이콘은 512px 이 우선이다. 게다가 엣지 등의 다른 브라우저는 512px 도 있어야한다.

큰 사이즈의 이미지가 있을 경우 설치시에 네이티브 코드에서 이미지를 리사이징해서 아이콘을 만드므로 512px 하나만 있어도 되지만 홈 설치 시간이 느려지는 원인이 되므로 최소 192px, 512px 두 개는 지원해주자.

여력이 된다면 더 많은 사이즈의 이미지를 지원해줘도 된다. 이미지 업로드 하는 페이지의 리사이징 비용과 이미지별 트래픽 비용도 추가되겠지만 항상 유저의 속도가 중요한 것 아닌가?

이미지가 많을 경우 아래 쪽에 언급할 compatible 기능에서 문제가 생길 수 있다.

앱 이름

name 속성은 스플래시 스크린에 사용되며, short_name 은 바로가기 앱 명에 사용된다. name 속성에 빈 값이 들어갈 경우 (빈 값엔 space 및 'ㄱ' 한자 1키도 포함된다.) short_name 으로 대체된다. 두 속성이 모두 빈 값일 경우 홈 설치가 비활성화 된다.

이 기능은 스펙에 명시되어 있어서 변경 불가능하다.

크롬에선 스플래시 스크린 하단에 앱 이름이 노출되며 기타 브라우저에선 중앙에 노출된다. 최근에 하단으로 변경된 것으로 보인다.

고려 대상

필수 조건에는 없지만 고려해야할 대상들은 아래와 같다.

  • background_color
  • theme_color
  • display
  • related_applications

background_color

이 속성은 스플래시 스크린의 배경색을 담당하는데, 배경색은 스플래시 스크린 상의 앱 이름 (name 속성)의 색상에 영향을 미친다.

앱 이름은 배경색이 거므스름한지의 여부에 따라 흰색 또는 검정색으로 노출된다. 이 공식은 contrast ratio 란 스펙으로 정의되어 있으며, 안드로이드 내부에 구현되어있는 소스는 다음과 같다.

private static final float CONTRAST_LIGHT_ITEM_THRESHOLD = 3f;

/**
* Calculates the contrast between the given color and white, using the algorithm provided by
* the WCAG v2 in http://www.w3.org/TR/WCAG20/#contrast-ratiodef.
*/
private static float getContrastForColor(int color) {
float bgR = Color.red(color) / 255f;
float bgG = Color.green(color) / 255f;
float bgB = Color.blue(color) / 255f;
bgR = (bgR < 0.03928f) ? bgR / 12.92f : (float) Math.pow((bgR + 0.055f) / 1.055f, 2.4f);
bgG = (bgG < 0.03928f) ? bgG / 12.92f : (float) Math.pow((bgG + 0.055f) / 1.055f, 2.4f);
bgB = (bgB < 0.03928f) ? bgB / 12.92f : (float) Math.pow((bgB + 0.055f) / 1.055f, 2.4f);
float bgL = 0.2126f * bgR + 0.7152f * bgG + 0.0722f * bgB;
return Math.abs((1.05f) / (bgL + 0.05f));
}

/**
* Check whether lighter or darker foreground elements (i.e. text, drawables etc.)
* should be used depending on the given background color.
* @param backgroundColor The background color value which is being queried.
* @return Whether light colored elements should be used.
*/
public static boolean shouldUseLightForegroundOnBackground(int backgroundColor) {
return getContrastForColor(backgroundColor) >= CONTRAST_LIGHT_ITEM_THRESHOLD;
}

theme_color

이 속성은 주소창의 색상 또는 standalone 모드 시의 상단 상태바의 색상으로 노출된다. 하지만 IOS Safari 에서는 ['black', 'black-translucent'] 두 가지 색상으로만 선택이 가능하다.

이 것도 위의 Contrast 알고리즘을 활용해 거므스름한 여부에 따라 설정하면 된다.

display

크롬 문서엔 standalone, fullscreen, minimal-ui 모두 A2HS 의 기능을 활용할 수 있다고 설명되어 있으나, 실제로는 앞의 두 속성만 가능하다. 삼성브라우저는 minimal-ui 속성을 지원하지 않는다.

앱을 가지고 있을 경우 앱 다운로드를 유도할 수 있는데, manifest 에 아래 속성만 넣으면 된다. id 는 apk 업로드할 때 그 것과 같다.

{
"prefer_related_applications": true,
"related_applications": [
{
"platform": "play",
"id": "com.google.samples.apps.iosched"
}
]
}

Install Banner

App Install Prompt 또는 각각 브라우저마다 불리는 용어가 다른 이 기능은 PWA 접속시 홈 화면에 추가하라는 유도 배너를 말한다. 이 기능은 브라우저별로 이벤트 및 노출 유무가 천차만별이다.

스펙

배너설치아이콘배너재노출일브라우저아이콘
Chrome노출-~3 months-
Firefox-노출-표시
Samsung노출노출~2 weeks표시
Edge노출-?표시
UC?-??
Opera노출-?표시
Whale?---

크롬 빼고는 지원되는 브라우저에서 홈 설치시에 브라우저 아이콘이 모두 노출된다. 이러면 웹 푸쉬를 받을 시에 원래 브라우저에 뱃지가 생긴다고 보면 된다.

배너를 닫을 시에 재노출 기간이 비상식적으로 길다. 배너는 prompt() 메소드로 강제 노출이 불가능하며, 버튼을 클릭하는 액션이 있어야만 한다. 재노출일을 초기화할 시에는 브라우저의 데이터를 모두 삭제해야한다. (서비스워커 삭제로 불가능, 계정 또는 브라우저에 저장되는 값으로 추정됨)

UC 브라우저는 문서도 있고 설치가 되었던 것 같은데 최근 설치가 되지 않는다. Whale 는 크로미움 기반이라 설치가 되었던 것 같은데 최근 설치가 되지 않는다.

IOS Safari

모바일계의 IE인 사파리는 Web App Manifest 기능을 아직 개발 중에 있다. 그리고 매우 불친절한 개발자 문서1불친절한 개발자 문서2만 가지고 있다.

사파리 버그

사파리에서만 나타나는 크리티컬한 버그가 몇 가지 있다.

OAuth 인증 불가

정확히 말하면 타 도메인 또는 새 창을 사용하는 SSO 와 OAuth 인증이 불가능하다. 사파리 자체 버그로 IOS 12.2.0 버전에 수정되었다.

제스처 사용 불가

뒤로가기 제스처 사용이 불가능하다. 이를 위해 하이브리드 웹앱처럼 하단 네비게이션 바를 만들어줄 수 있다. 이 버그도 IOS 12.2.0 버전에 수정되었다.

전용 속성

홈 화면 아이콘과 스플래시 스크린, 테마 색상을 meta, link 태그로 만들어줘야한다. 이를 위해 manifest.json 의 값을 가져와 canvas 를 통해 프론트에서 이미지를 만들어야한다.

<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-title" content="AppTitle" />
<meta name="apple-mobile-web-app-status-bar-style" content="black" />
<meta name="apple-itunes-app" content="app-id=myAppStoreID" />
<link rel="apple-touch-icon" href="touch-icon-iphone.png" />
<link rel="apple-touch-startup-image" href="icon.png" />

Compatible

Chrome, Safari, Edge 등 호환성을 맞추다보면 머리에 쥐가 날지도 모른다. 이럴 때 브라우저별 호환성을 맞춰주는 google 에서 제공되는 라이브러리가 있다.

pwa.compat 으로 다음과 같이 로드하면 된다.

<link rel="manifest" href="manifest.json" />
<script async src="path/to/pwacompat.min.js"></script>

다음과 같은 호환성이 해결된다.

  • Safari 전용 속성
  • Safari 홈 화면 아이콘과 스플래시 이미지
  • Edge, IE 전용 속성
  • meta 태그로 추가되지 않은 manifest.json 속성

단점

manifest.json 과 icon 배열의 수만큼 request 수가 증가한다. static resource 이므로 HTTP Cache 로 해결 가능하다.

버그

삼성브라우저에서 앱 이름에 " 큰따옴표가 들어간 경우 설치가 실패한다. Manifest JSON 을 파싱하면서 브라우저 내에서 오류가 발생한다.

통계

홈 설치를 한 뒤에 유저가 얼마나 설치했을지 통계를 내는 것 또한 중요한데, beforeInstallPromptappInstalled 이벤트는 브라우저 호환성이 보장되지 않는다.

따라서 위 이벤트로 로깅하는 건 무의미하고, start_url 에 들어온 파라미터로 통계를 내면 된다.

테스트

크롬 개발자도구의 application > Manifest > Add to homescreen 으로 이벤트를 발생시킬 수 있다고 하나 잘 되진 않는다.

홈 설치 조건을 만족시킬 경우, 크롬 설정 아이콘에서 "앱 명 추가" 라는 설정 메뉴가 하나 더 보이는데 이 기능으로 조건을 만족하는지와 브라우저 PWA 정도는 테스트 가능하다.

모바일 디버깅용으론 eruda를 사용하자.

여담

IE7-8 호환성 맞추던 시절이 떠올랐다. 아직 픽스될 버그가 많으며, 안정될 때까지 최소 1년은 더 필요할 것으로 보인다. 추가되고 고쳐져야할 기능도 몇 가지 있다.

  • 브라우저 호환성 (주소창 옆 아이콘으로 통일될 듯)
  • 이벤트 호환성
  • manifest href 에 blob 형태 지원 (현재 json decode 하면서 경로 오류 발생)
  • manifest가 변경될 경우 업데이트알림 (이슈)

개인 홈페이지에 적용할 경우 Web App Manifest Generator 를 이용하는게 속 편하다.

Desktop PWATWA 는 다루지 않았다.

레퍼런스

PWA - 서비스 워커 웹 캐싱 (Web Caching)

· 약 16분

개요

Progressive Web App의 시대가 왔다. Client단에서 모든 static 파일을 브라우저에 캐시를 할 수 있고 웹을 앱처럼 오프라인에서 작업하게 할 수 있다. 그 첫번째로 캐싱에 대해 알아보자.

준비

Promise, Fetch, Worker 및 Javascript 의 실행 구조, DOM 에 대한 사전지식이 필히 있어야 한다. HTTP Cache 를 걸어봤다면 이해가 쉬울 듯 싶다.

ServiceWorker

서비스 워커는 브라우저가 백그라운드에서 실행하는 스크립트이며, 클라이언트에 설치되는 프록시다. 이 개념은 중요해서 외워야한다. 브라우저 백그라운드에서 네트워크를 가로채는 Thread 라고 보면 된다.

웹 캐싱 뿐만 아니라 백그라운드 동기화, 웹 푸쉬 등의 기능을 처리할 수 있다. 궁금하다면 여기를 들어가보자. (포스팅을 다 읽고 들어가보는 걸 추천한다.)

웹 캐싱은 CacheStorage를 사용한다. Sqlite 같은 클라이언트 데이터베이스인데, Key:value 로 구성된 데이터베이스라고 보면 된다.

주의

https 환경 또는 localhost 도메인에서만 이 기능을 사용할 수 있다.

브라우저별 지원상황은 다음과 같다 1803기준 Safari에도 Shipped가 되었다! 1712 기준 Safari가 아직도 Developement 상태인게 조금 아쉽다

세팅

sw.js란 파일을 public폴더(index.html이 있는)에 생성하자. 그리고 메인 script파일에 다음과 같이 service-worker를 불러오는 구문을 추가한다.

// navigator (브라우저)에 serviceWorker 기능이 있는지 확인
if ("serviceWorker" in navigator) {
// 서비스워커 설치시 DOM 블로킹을 막아준다.
window.addEventListener("load", function () {
// 서비스워커를 register 하면 promise를 반환한다.
navigator.serviceWorker
.register("/sw.js")
.then(() => {
console.log("서비스 워커가 등록되었다.");
})
.catch((error) => {
console.log(error);
});
});
}

개발자도구를 열고 Application > ServiceWorkers 탭으로 가면 설치가 된 것을 확인할 수 있다.

image from hexo

이렇게 뜨면 설치된 것이다.

Basic

서비스워커에서는 self 키워드로 자기 자신을 접근할 수 있다. 몇 가지 static 파일들을 캐싱처리 해보자. 모던 브라우저에서만 지원이 되므로 arrow function을 사용해도 된다.

sw.js
const PRE_CACHE_NAME = "캐시-스토리지1";
// 캐시하고 싶은 리소스
const urlsToCache = [
"/public/image/image1.png",
"/public/css/font-awesome.min.css",
];

// 서비스워커가 설치될 때
self.addEventListener("install", (event) => {
// 캐시 등록 이벤트가 끝날 때까지 기다려
event.waitUntil(
// '캐시-스토리지1'을 연다.
// @return {Promise} 연결된 Cache Database를 반환한다.
caches
.open(PRE_CACHE_NAME)
.then((cache) => {
console.log("캐시 디비와 연결됨");
// addAll 메소드로 내가 캐싱할 리소스를 다 넣어주자.
return cache.addAll(urlsToCache);
})
.then(() => {
// 설치 후에 바로 활성화 단계로 들어갈 수 있게 해준다.
return self.skipWaiting();
}),
);
});

이렇게 추가해주고 개발자도구의 Application 탭으로 가서 좌측 메뉴의 Cache Storage 를 새로고침 하면 방금 추가한 캐시-스토리지1에 내 이미지와 css가 등록된 걸 확인할 수 있다.

그리고 개발자도구의 Network 탭에서 호출하는 이미지의 size에 **(from ServiceWorker)**가 보인다.

Intermediate

내용이 업데이트 되야하는 페이지나 리소스에 대해서는 동적으로 캐싱 처리를 해야한다.

Dynamic caching

sw.js
const DYNAMIC_CACHE_NAME = "다이나믹-캐시-스토리지1";

// fetch event는 어딘가에서 리소스를 가져올 때 모두 실행된다.
// js를 가져오거나 이미지를 가져오거나 페이지를 가져오거나 등등
self.addEventListener("fetch", (event) => {
event.respondWith(
caches.match(event.request).then((response) => {
// 캐시에 있으면 repsonse를 그대로 돌려준다.
if (response) {
return response;
}

// 여기서 request를 복사해준다.
// request는 스트림으로 fetch 당 한 번만 사용해야하기 때문이다.
// 근데 event.request로 받아도 실행은 된다
const fetchRequest = event.request.clone();

// if (response) return response 구문을 하나로 합칠 수도 있다.
// return response || fetch(fetchRequest)
return fetch(fetchRequest).then((response) => {
// 응답이 제대로 왔는지 체크한다.
// 구글 문서에는 다음과 같이 처리하라고 되어있는데
// 이 경우 Cross Site Request에 대해 캐싱 처리를 할 수가 없다.
// if(!response || response.status !== 200 || response.type !== 'basic') {
if (!response) {
return response;
}

// 응답은 꼭 복사 해줘야한다.
const responseToCache = response.clone();

// 캐시 스토리지를 열고 정말 캐싱을 해준다.
caches.open(DYNAMIC_CACHE_NAME).then((cache) => {
cache.put(event.request, responseToCache);
});

// 여기서 response를 내보내줘야 캐싱 처리 후에 리소스를 반환한다.
return response;
});
}),
);
});

오래된 캐시 삭제

캐시 스토리지명을 바꿔 캐시의 버전을 올리면 기존 캐시 스토리지는 삭제해줘야한다.

sw.js
// 서비스 워커가 활성화 될 때
self.addEventListener("activate", (event) => {
// 영구적으로 가져갈 캐시 스트리지 화이트리스트
const cacheWhiteList = [PRE_CACHE_NAME, DYNAMIC_CACHE_NAME];

event.waitUntil(
// 캐시 스토리지의 모든 스토리지명을 가져온다.
caches.keys().then((cacheNames) => {
// 캐시를 삭제하는 건 Promise를 반환하므로 Promise.all을 사용해 끝날 시점을 잡아야한다.
return Promise.all(
// 이 결과는 [Promise, Promise...] 형태가 되시겠다.
cacheNames.map((cacheName) => {
// 각각의 캐시 스토리지명이 화이트 리스트와 같지 않을 경우
if (cacheWhitelist.indexOf(cacheName) === -1) {
// 캐시를 삭제하는 Promise를 배열에 추가한다.
return caches.delete(cacheName);
}
}),
);
}),
);

// activate 시에는 clients claim 메소드를 호출해서
// 브라우저에 대한 제어권을 가져와야한다.
return self.clients.claim();
});

결과적으로 DYNAMIC_CACHE_NAME이 아닌 캐시 스토리지는 삭제된다.

offline fallback

캐시된 페이지와 리소스는 오프라인에서도 접근이 가능하다. 여기서 오류가 발생하면 offline.html 같은 페이지로 떨어지게 할 수 있다. (마치 404 오류 페이지처럼)

sw.js
self.addEventListener("fetch", (event) => {
event.respondWith(
caches
.match(event.request)
.then((response) => {
// ...
})
.catch((error) => {
// 에러 발생시 캐시되어있는 offline.html로 이동시킨다.
return caches.open(CACHE_NAME).then((cache) => {
// 들어온 요청의 Accept 헤더가 text/html 을 포함하고 있다면 (페이지 요청이라면)
if (event.request.headers.get("accept").includes("text/html")) {
// 캐시된 offline fallback 페이지를 보여준다.
return cache.match("/offline.html");
}
});
}),
);
});

Advanced

서비스워커에 static, dynamic cache를 첨가했는데 소스가 너무 지저분하다. 직관적으로 만들어보자.

리팩토링

sw.js
(() => {
const STATIC_CACHE_NAME = "STATIC_CACHE_VERSION_1";
const DYNAMIC_CACHE_NAME = "DYNAMIC_CACHE_VERSION_1";

const WEB_CACHE = {
init() {
self.addEventListener("install", this.staticCacheStrategy.bind(this));
self.addEventListener("activate", this.deleteOldCache.bind(this));
self.addEventListener("fetch", this.dynamicCacheStrategy.bind(this));
},

staticCacheStrategy(event) {
// 스태틱 캐싱
},

deleteOldCache(event) {
// 캐시 삭제
},

dynamicCacheStrategy(event) {
// 다이나믹 캐싱
},
};

WEB_CACHE.init();
})();

IIFE를 사용해 좀 더 예쁘게 변했다.

cross domain request

서비스워커에서 fetch로 외부 리소스를 가지고오면 opaque response가 반환된다. cors 정책이 설정되어 있지 않아 아무 정보도 가지고 올 수 없는 건데, 이런 리소스만 골라서 캐싱처리를 하고 싶다면 request url이나 response content-type을 가지고 처리할 수 있다.

sw.js
const dynamicCacheStrategy = (event) => {
// 캐싱 처리하고 싶은 content-type
const cacheContentsTypes = [
"image/png",
"image/gif",
"image/jpeg",
"application/font-woff",
];

event.respondWith(
caches.match(event.request).then((response) => {
const fetchRequest = event.request.clone();

return (
response ||
fetch(fetchRequest)
.then((response) => {
if (!response) {
return response;
}

// response header에서 content-type을 가져와 비교한다.
// 아니면 request.url이 캐싱처리를 할 외부 url인지 확인한다.
if (
cacheContentsTypes.indexOf(
response.headers.get("content-type"),
) !== -1 ||
event.request.url.indexOf("external.url") !== -1
) {
caches.open(DYNAMIC_CACHE_NAME).then((cache) => {
cache.put(event.request, response.clone());
});
}

return response;
})
.catch((error) => console.log(error))
);
}),
);
};

WorkBox

이제 클라이언트 캐시가 어떻게 돌아가는지 확인했으니 구글에서 만든 멋진 라이브러리를 사용해보자.

간결하고 직관적인 문법으로 위의 구문들을 예쁘게 만들 수 있다.

Stratergy

그 전에 캐싱 전략을 알아야한다.

cacheFirst

image from hexo

캐시부터 요청하고 네트워크를 접근해 리소스를 보여준다.

오프라인을 우선적으로 보여주는 페이지에 적합하다.

cacheOnly

image from hexo

캐시에서만 가져온다.

static file들이 여기에 해당된다.

networkFirst

image from hexo

네트워크를 먼저 접근하고 오프라인일 경우 캐시를 가져온다. 이 방법은 연결이 원활하지 않거나 느린 경우 네트워크가 실패할 때까지 기다린 뒤 리소스가 보여지므로 UX에 좋지 않을 수 있다.

networkOnly

image from hexo

캐시가 필요없는 GET 메소드가 아닌 다른 메소드가 주로 여기에 해당된다.

staleWhileRevalidate

image from hexo

이 방식은 그림을 보고 이해하는게 빠르다. 캐시를 먼저 가져오고 다음 요청은 네트워크에서 요청된 리소스의 캐시를 반환한다.

주로 이 방식이 사용된다.

Cache then network

image from hexo

페이지가 두 개의 요청(캐시에 요청, 네트워크에 요청)을 동시에 하고 캐시된 데이터를 먼저 표시한 다음 네트워크 데이터가 도착하면 페이지를 업데이트를 한다. WorkBox에는 없는 strategy인데 networkFirst보다 UX상 좋다고 한다.

설치

ServiceWorker에서는 라우팅 기능을 사용하기 위해 구글 CDN에서 제공하는 스크립트를 사용하고 Pre-Cache를 정의하기 위해 로컬에서 workbox-cli를 추가해야한다.

importScript

importScript는 ServiceWorker 파일에서만 사용가능한 구문으로 importrequire와 같다고 보면 된다.

importScripts(
"https://storage.googleapis.com/workbox-cdn/releases/3.2.0/workbox-sw.js",
);

workbox-cli

yarn global add workbox-cli

메소드

precache

프리캐시는 정적 자원들을 미리 캐싱처리해서 Cache Only Stratergy를 사용하는 것이다. 주로 이미지나 css, vendor-js 등을 여기에 담아준다.

workbox-cli를 설치하고 wizard 명령어를 실행하면 config 파일이 생성된다.

workbox wizard

설정을 입맛에 맞게 변경해주고 pre-cache할 파일 목록을 생성해주는 명령어를 실행하자

workbox injectManifest config.js

ServiceWorker에 workbox.precaching.precacheAndRoute([]) 구문이 있다면 목록이 들어가 있는 걸 확인할 수 있다.

workbox.precaching.precacheAndRoute([
{
url: "css/fonts/fontawesome/fontawesome-webfont.eot",
revision: "674f50d287a8c48dc19ba404d20fe713",
},
{
//...
},
]);

Routing

WorkBox를 사용하는 이유는 바로 이 라우팅에 있다. 위에서 많은 양의 라우팅 분기 코드로 캐싱을 처리했는데 WorkBox는 다음과 같이 간결해진다. 아래는 이 블로그에 사용하고 있는 코드이다.

importScripts(
"https://storage.googleapis.com/workbox-cdn/releases/3.4.1/workbox-sw.js",
);

// importScripts 후 타이밍 차이로 인해 모듈을 못 불러오는 경우를 방지하기 위해
// 캐싱 정책 모듈 로드를 기다린다.
workbox.loadModule("workbox-strategies");

workbox.skipWaiting();
workbox.clientsClaim();

// accept 헤더에 text/html 값이 있으면 (html 페이지 요청일 경우)
// networkFirst 캐싱
workbox.routing.registerRoute((routeData) => {
return routeData.event.request.headers.get("accept").includes("text/html");
}, workbox.strategies.networkFirst());

// imgur 요청일 경우 cacheFirst 캐싱
workbox.routing.registerRoute(
/.*(?:imgur)\.com.*$/,
workbox.strategies.cacheFirst(),
);

// jsdelivr 요청일 경우 stateWhileRevalidate 캐싱
workbox.routing.registerRoute(
/.*(?:jsdelivr)\.net.*$/,
workbox.strategies.staleWhileRevalidate(),
);

// bootcss 요청일 경우 stateWhileRevalidate 캐싱
workbox.routing.registerRoute(
/.*(?:bootcss)\.com.*$/,
workbox.strategies.staleWhileRevalidate(),
);

코드가 너무 예뻐졌다. callback으로 Request의 모든 걸 캐치해 낼 수 있다. 더 구체적으로 쓰고 싶으면 문서를 참조하자

부가 기능

이 기능 말고도 다음과 같은 멋진 기능을 사용할 수 있다, 하지만 언제 쯤 써볼 수 있을지..

offline GA

오프라인에서도 Google Analytics 를 사용할 수 있다.

한 줄의 코드만 삽입해주면 된다.

workbox.googleAnalytics.initialize();

Webpack

웹팩에 WorkBox-Webpack-Plugin을 붙힐 수 있는데 아직 도전을 안 해봤다. 후술할 sw-precache를 굳이 뺄 이유가 없고, 레퍼런스도 워낙 많기에..

sw-precache

create-react-app이나 Vue-Cli@angular/cli 모두 클라이언트 캐싱에 WorkBox 대신 이 라이브러리를 사용하고 있다. (WorkBox에서 캐싱기능만 떼어낸 라이브러리라고 보면 된다)

기본 설정을 굳이 안 건들여도 되고, exact해서 사용 중이라면 옵션을 라이브러리를 한 번 쯤 봐주는 것도 나쁘지 않다.

참고

여담

  • 기술 정리하는 건 시간이 너무 오래 걸린다.
  • Redis, eTag, Vanish, SW 까지 사용하면 성능상에 이점은 있겠지만 키를 어떻게 관리해야될지가 중요할 듯