Service Worker를 통해 오프라인에서 문제를 해결하려는 시도를 포기하고 개발자가 직접 문제를 해결할 수 있는 동작하는 부분을 제공했습니다. 이를 통해 캐싱과 요청 처리 방식을 제어할 수 있습니다. 즉, 나만의 패턴을 만들 수 있습니다. 몇 가지 가능한 패턴을 개별적으로 살펴보겠습니다. 하지만 실제로는 URL과 컨텍스트에 따라 여러 패턴을 함께 사용할 가능성이 높습니다.
이러한 패턴의 작동 데모는 Trained-to-thrill을 참고하고 성능 영향을 보여주는 이 동영상을 참고하세요.
캐시 머신: 리소스를 저장해야 하는 경우
Service Worker를 사용하면 캐싱과 별개로 요청을 처리할 수 있으므로 별도로 설명하겠습니다. 먼저 캐싱은 언제 해야 하나요?
설치 시 - 종속 항목으로

서비스 워커는 install
이벤트를 제공합니다. 이를 사용하여 다른 이벤트를 처리하기 전에 준비해야 하는 항목을 준비할 수 있습니다. 이 작업이 진행되는 동안 이전 버전의 Service Worker는 계속 실행되고 페이지를 제공하므로 여기에서 실행하는 작업이 이를 방해해서는 안 됩니다.
적합한 리소스: CSS, 이미지, 글꼴, JS, 템플릿 등 사이트의 해당 '버전'에 정적이라고 간주되는 모든 리소스
가져오기에 실패하면 사이트가 완전히 작동하지 않게 되는 항목으로, 이에 상응하는 플랫폼별 앱에서 초기 다운로드의 일부로 만드는 항목입니다.
self.addEventListener('install', function (event) {
event.waitUntil(
caches.open('mysite-static-v3').then(function (cache) {
return cache.addAll([
'/css/whatever-v3.css',
'/css/imgs/sprites-v6.png',
'/css/fonts/whatever-v8.woff',
'/js/all-min-v4.js',
// etc.
]);
}),
);
});
event.waitUntil
는 설치의 길이와 성공 여부를 정의하는 약속을 사용합니다. 약속이 거부되면 설치가 실패로 간주되고 이 서비스 워커는 중단됩니다 (이전 버전이 실행 중인 경우 그대로 유지됨). caches.open()
및 cache.addAll()
는 프로미스를 반환합니다.
리소스를 가져오지 못하면 cache.addAll()
호출이 거부됩니다.
trained-to-thrill에서는 이를 사용하여 정적 애셋을 캐시합니다.
설치 시(종속 항목이 아님)

위와 유사하지만 설치 완료가 지연되지 않으며 캐싱에 실패하더라도 설치가 실패하지 않습니다.
적합한 경우: 게임의 후반 레벨에 필요한 애셋과 같이 당장 필요하지 않은 대용량 리소스
self.addEventListener('install', function (event) {
event.waitUntil(
caches.open('mygame-core-v1').then(function (cache) {
cache
.addAll
// levels 11–20
();
return cache
.addAll
// core assets and levels 1–10
();
}),
);
});
위 예에서는 11~20레벨의 cache.addAll
프로미스를 event.waitUntil
에 다시 전달하지 않으므로 실패하더라도 게임을 오프라인으로 계속 사용할 수 있습니다. 물론 이러한 수준이 없을 수 있으므로 이를 고려하고 누락된 경우 캐싱을 다시 시도해야 합니다.
이벤트 처리가 완료되었으므로 수준 11~20이 다운로드되는 동안 Service Worker가 종료될 수 있습니다. 즉, 캐시되지 않습니다. 향후 Web Periodic Background Synchronization API가 이와 같은 사례와 영화와 같은 대용량 다운로드를 처리할 예정입니다. 이 API는 현재 Chromium 포크에서만 지원됩니다.
활성화 시

적합한 경우: 정리 및 이전
새 서비스 워커가 설치되고 이전 버전이 사용되지 않으면 새 버전이 활성화되고 activate
이벤트가 발생합니다. 이전 버전이 지원 중단되었으므로 IndexedDB의 스키마 이전을 처리하고 사용하지 않는 캐시를 삭제할 때입니다.
self.addEventListener('activate', function (event) {
event.waitUntil(
caches.keys().then(function (cacheNames) {
return Promise.all(
cacheNames
.filter(function (cacheName) {
// Return true if you want to remove this cache,
// but remember that caches are shared across
// the whole origin
})
.map(function (cacheName) {
return caches.delete(cacheName);
}),
);
}),
);
});
활성화 중에 fetch
와 같은 다른 이벤트가 대기열에 추가되므로 활성화 시간이 길면 페이지 로드가 차단될 수 있습니다. 활성화는 최대한 간소하게 유지하고 이전 버전이 활성 상태일 때 할 수 없었던 작업에만 사용하세요.
trained-to-thrill에서는 이를 사용하여 이전 캐시를 삭제합니다.
사용자 상호작용 시

적합한 경우: 전체 사이트를 오프라인으로 전환할 수 없으며 사용자가 오프라인에서 사용할 콘텐츠를 선택하도록 허용하도록 선택한 경우 예를 들어 YouTube의 동영상, 위키피디아의 기사, Flickr의 특정 갤러리 등이 있습니다.
사용자에게 '나중에 읽기' 또는 '오프라인 저장' 버튼을 제공합니다. 클릭되면 네트워크에서 필요한 항목을 가져와 캐시에 넣습니다.
document.querySelector('.cache-article').addEventListener('click', function (event) {
event.preventDefault();
var id = this.dataset.articleId;
caches.open('mysite-article-' + id).then(function (cache) {
fetch('/get-article-urls?id=' + id)
.then(function (response) {
// /get-article-urls returns a JSON-encoded array of
// resource URLs that a given article depends on
return response.json();
})
.then(function (urls) {
cache.addAll(urls);
});
});
});
caches API는 페이지와 서비스 워커에서 모두 사용할 수 있으므로 페이지에서 직접 캐시에 추가할 수 있습니다.
네트워크 응답

적합한 리소스: 사용자의 받은편지함 또는 도움말 콘텐츠와 같이 자주 업데이트되는 리소스 아바타와 같은 중요하지 않은 콘텐츠에도 유용하지만 주의가 필요합니다.
요청이 캐시의 항목과 일치하지 않으면 네트워크에서 가져와 페이지로 전송하고 동시에 캐시에도 추가합니다.
아바타와 같은 다양한 URL에 대해 이 작업을 실행하는 경우 출처의 저장소가 과도하게 늘어나지 않도록 주의해야 합니다. 사용자가 디스크 공간을 재사용해야 하는 경우 기본 후보가 되기 싫습니다. 더 이상 필요하지 않은 캐시 항목을 삭제해야 합니다.
self.addEventListener('fetch', function (event) {
event.respondWith(
caches.open('mysite-dynamic').then(function (cache) {
return cache.match(event.request).then(function (response) {
return (
response ||
fetch(event.request).then(function (response) {
cache.put(event.request, response.clone());
return response;
})
);
});
}),
);
});
메모리를 효율적으로 사용하려면 응답/요청의 본문을 한 번만 읽을 수 있습니다. 위의 코드는 .clone()
를 사용하여 별도로 읽을 수 있는 추가 사본을 만듭니다.
trained-to-thrill에서는 이를 사용하여 Flickr 이미지를 캐시합니다.
Stale-while-revalidate

적합한 경우: 최신 버전이 필수적이지 않은 리소스를 자주 업데이트하는 경우 아바타가 이 카테고리에 속할 수 있습니다.
캐시된 버전이 있는 경우 이를 사용하고 다음에 업데이트를 가져옵니다.
self.addEventListener('fetch', function (event) {
event.respondWith(
caches.open('mysite-dynamic').then(function (cache) {
return cache.match(event.request).then(function (response) {
var fetchPromise = fetch(event.request).then(function (networkResponse) {
cache.put(event.request, networkResponse.clone());
return networkResponse;
});
return response || fetchPromise;
});
}),
);
});
이는 HTTP의 stale-while-revalidate와 매우 유사합니다.
푸시 메시지

Push API는 서비스 워커를 기반으로 빌드된 또 다른 기능입니다. 이렇게 하면 서비스 워커가 OS의 메시지 서비스의 메시지에 응답하여 깨어날 수 있습니다. 이는 사용자가 사이트 탭을 열지 않은 경우에도 발생합니다. 서비스 워커만 깨워집니다. 페이지에서 이 작업을 위한 권한을 요청하면 사용자에게 메시지가 표시됩니다.
적합한 콘텐츠: 채팅 메시지, 속보 기사, 이메일 등 알림과 관련된 콘텐츠 또한 할 일 목록 업데이트나 캘린더 변경과 같이 즉시 동기화의 이점을 누리는 콘텐츠는 자주 변경되지 않습니다.
일반적인 최종 결과는 탭하면 관련 페이지가 열리거나 포커스가 맞춰지는 알림이지만, 이 전에 캐시를 업데이트하는 것이 매우 중요합니다. 사용자는 푸시 메시지를 수신할 때는 분명히 온라인 상태이지만 결국 알림과 상호작용할 때는 그렇지 않을 수 있으므로 이 콘텐츠를 오프라인에서도 사용할 수 있도록 하는 것이 중요합니다.
이 코드는 알림을 표시하기 전에 캐시를 업데이트합니다.
self.addEventListener('push', function (event) {
if (event.data.text() == 'new-email') {
event.waitUntil(
caches
.open('mysite-dynamic')
.then(function (cache) {
return fetch('/inbox.json').then(function (response) {
cache.put('/inbox.json', response.clone());
return response.json();
});
})
.then(function (emails) {
registration.showNotification('New email', {
body: 'From ' + emails[0].from.name,
tag: 'new-email',
});
}),
);
}
});
self.addEventListener('notificationclick', function (event) {
if (event.notification.tag == 'new-email') {
// Assume that all of the resources needed to render
// /inbox/ have previously been cached, e.g. as part
// of the install handler.
new WindowClient('/inbox/');
}
});
background-sync 사용

백그라운드 동기화는 서비스 워커를 기반으로 빌드된 또 다른 기능입니다. 이를 통해 일회성으로 또는 (매우 휴리스틱한) 간격으로 백그라운드 데이터 동기화를 요청할 수 있습니다. 이는 사용자가 사이트 탭을 열지 않은 경우에도 발생합니다. 서비스 워커만 깨워집니다. 페이지에서 이 작업을 위한 권한을 요청하면 사용자에게 메시지가 표시됩니다.
적합한 케이스: 긴급하지 않은 업데이트, 특히 소셜 타임라인이나 뉴스 기사와 같이 업데이트당 푸시 메시지가 사용자에게 너무 자주 전송될 정도로 정기적으로 발생하는 업데이트
self.addEventListener('sync', function (event) {
if (event.id == 'update-leaderboard') {
event.waitUntil(
caches.open('mygame-dynamic').then(function (cache) {
return cache.add('/leaderboard.json');
}),
);
}
});
캐시 지속성
출처에는 원하는 작업을 할 수 있는 일정량의 여유 공간이 제공됩니다. 이 여유 공간은 모든 출처 저장소((로컬) 저장소, IndexedDB, 파일 시스템 액세스, 물론 캐시) 간에 공유됩니다.
지급되는 금액은 지정되지 않습니다. 기기 및 저장소 상태에 따라 다릅니다. 다음을 통해 잔액을 확인할 수 있습니다.
if (navigator.storage && navigator.storage.estimate) {
const quota = await navigator.storage.estimate();
// quota.usage -> Number of bytes used.
// quota.quota -> Maximum number of bytes available.
const percentageUsed = (quota.usage / quota.quota) * 100;
console.log(`You've used ${percentageUsed}% of the available storage.`);
const remaining = quota.quota - quota.usage;
console.log(`You can write up to ${remaining} more bytes.`);
}
그러나 모든 브라우저 저장소와 마찬가지로 기기의 저장공간이 부족해지면 브라우저에서 데이터를 자유롭게 삭제할 수 있습니다. 안타깝게도 브라우저는 사용자가 어떤 영화를 절대 삭제하고 싶지 않은지, 어떤 게임은 별로 중요하지 않은지 구분할 수 없습니다.
이 문제를 해결하려면 StorageManager 인터페이스를 사용하세요.
// From a page:
navigator.storage.persist()
.then(function(persisted) {
if (persisted) {
// Hurrah, your data is here to stay!
} else {
// So sad, your data may get chucked. Sorry.
});
물론 사용자는 권한을 부여해야 합니다. 이를 위해 Permissions API를 사용하세요.
이제 사용자가 삭제를 제어할 수 있으므로 이 흐름에 사용자를 포함하는 것이 중요합니다. 기기의 저장용량이 부족해지고 불필요한 데이터를 삭제해도 문제가 해결되지 않으면 사용자는 보관할 항목과 삭제할 항목을 판단합니다.
이를 사용하려면 운영체제에서 브라우저를 단일 항목으로 보고하는 대신 저장용량 사용량 분석에서 '지속형' 출처를 플랫폼별 앱과 동일하게 취급해야 합니다.
추천 표시 - 요청에 응답
캐싱을 얼마나 하든 관계없이 언제 어떻게 캐시를 사용할지 알려주지 않으면 서비스 워커는 캐시를 사용하지 않습니다. 다음은 요청을 처리하는 몇 가지 패턴입니다.
캐시만

적합한 케이스: 사이트의 특정 '버전'에 정적이라고 간주되는 모든 항목 설치 이벤트에서 이러한 항목을 캐시해야 하며, 그러면 이러한 항목이 있는지 확인할 수 있습니다.
self.addEventListener('fetch', function (event) {
// If a match isn't found in the cache, the response
// will look like a connection error
event.respondWith(caches.match(event.request));
});
이 사례를 구체적으로 처리할 필요는 없지만 캐시, 네트워크로 대체에서 다룹니다.
네트워크 전용

적합한 경우: 애널리틱스 핑, GET 이외의 요청과 같이 오프라인에 상응하는 항목이 없는 경우
self.addEventListener('fetch', function (event) {
event.respondWith(fetch(event.request));
// or simply don't call event.respondWith, which
// will result in default browser behavior
});
이 사례를 구체적으로 처리할 필요는 없지만 캐시, 네트워크로 대체에서 다룹니다.
캐시, 네트워크로 대체

적합한 경우: 오프라인 우선 빌드 이 경우 대부분의 요청을 다음과 같이 처리합니다. 다른 패턴은 수신 요청에 따라 예외가 됩니다.
self.addEventListener('fetch', function (event) {
event.respondWith(
caches.match(event.request).then(function (response) {
return response || fetch(event.request);
}),
);
});
이렇게 하면 캐시의 항목에는 '캐시 전용' 동작이, 캐시되지 않은 항목에는 '네트워크 전용' 동작이 적용됩니다 (캐시할 수 없으므로 GET이 아닌 모든 요청이 여기에 포함됨).
캐시 및 네트워크 경합

적합한 경우: 디스크 액세스가 느린 기기에서 성능을 높이는 소규모 애셋
오래된 하드 드라이브, 바이러스 스캐너, 더 빠른 인터넷 연결을 조합하면 디스크에 액세스하는 것보다 네트워크에서 리소스를 가져오는 것이 더 빠를 수 있습니다. 하지만 사용자가 기기에 콘텐츠를 보유하고 있을 때 네트워크로 이동하면 데이터가 낭비될 수 있으므로 주의하세요.
// Promise.race is no good to us because it rejects if
// a promise rejects before fulfilling. Let's make a proper
// race function:
function promiseAny(promises) {
return new Promise((resolve, reject) => {
// make sure promises are all promises
promises = promises.map((p) => Promise.resolve(p));
// resolve this promise as soon as one resolves
promises.forEach((p) => p.then(resolve));
// reject if all promises reject
promises.reduce((a, b) => a.catch(() => b)).catch(() => reject(Error('All failed')));
});
}
self.addEventListener('fetch', function (event) {
event.respondWith(promiseAny([caches.match(event.request), fetch(event.request)]));
});
네트워크가 캐시로 대체됨

적합한 경우: 사이트의 '버전' 외부에서 자주 업데이트되는 리소스의 빠른 수정 예: 기사, 아바타, 소셜 미디어 타임라인, 게임 리더보드
즉, 온라인 사용자에게는 최신 콘텐츠가 제공되지만 오프라인 사용자에게는 이전에 캐시된 버전이 제공됩니다. 네트워크 요청이 성공하면 캐시 항목을 업데이트하는 것이 좋습니다.
그러나 이 방법에는 결함이 있습니다. 사용자의 연결이 간헐적이거나 느린 경우 네트워크가 다운될 때까지 기다려야 기기에 이미 있는 완벽하게 허용되는 콘텐츠를 사용할 수 있습니다. 이 작업은 매우 오래 걸릴 수 있으며 사용자에게 불편을 끼칠 수 있습니다. 더 나은 해결 방법은 다음 패턴인 캐시 후 네트워크를 참고하세요.
self.addEventListener('fetch', function (event) {
event.respondWith(
fetch(event.request).catch(function () {
return caches.match(event.request);
}),
);
});
캐시 후 네트워크

적합한 콘텐츠: 자주 업데이트되는 콘텐츠 예: 기사, 소셜 미디어 타임라인, 게임 리더보드
이렇게 하려면 페이지에서 캐시와 네트워크에 각각 하나씩 두 번 요청해야 합니다. 먼저 캐시된 데이터를 표시한 후 네트워크 데이터가 도착하면 페이지를 업데이트하는 것입니다.
새 데이터가 도착하면 현재 데이터를 대체하는 것만으로도 충분한 경우 (예: 게임 리더보드)가 있지만, 콘텐츠가 방대하면 문제가 발생할 수 있습니다. 기본적으로 사용자가 읽고 있거나 상호작용하는 항목을 '사라지게' 하지 마세요.
트위터는 새 콘텐츠를 기존 콘텐츠 위에 추가하고 사용자가 중단되지 않도록 스크롤 위치를 조정합니다. 이는 트위터가 대부분 선형적인 콘텐츠 순서를 유지하기 때문에 가능합니다. 최대한 빨리 콘텐츠를 화면에 표시하고 최신 콘텐츠가 도착하는 즉시 표시하기 위해 흥미진진한 콘텐츠에 이 패턴을 적용했습니다.
페이지의 코드:
var networkDataReceived = false;
startSpinner();
// fetch fresh data
var networkUpdate = fetch('/data.json')
.then(function (response) {
return response.json();
})
.then(function (data) {
networkDataReceived = true;
updatePage(data);
});
// fetch cached data
caches
.match('/data.json')
.then(function (response) {
if (!response) throw Error('No data');
return response.json();
})
.then(function (data) {
// don't overwrite newer network data
if (!networkDataReceived) {
updatePage(data);
}
})
.catch(function () {
// we didn't get cached data, the network is our last hope:
return networkUpdate;
})
.catch(showErrorMessage)
.then(stopSpinner);
서비스 워커의 코드:
항상 네트워크로 이동하여 진행 중인 작업을 업데이트해야 합니다.
self.addEventListener('fetch', function (event) {
event.respondWith(
caches.open('mysite-dynamic').then(function (cache) {
return fetch(event.request).then(function (response) {
cache.put(event.request, response.clone());
return response;
});
}),
);
});
trained-to-thrill에서는 fetch 대신 XHR을 사용하고 Accept 헤더를 악용하여 서비스 워커에게 결과를 가져올 위치(페이지 코드, 서비스 워커 코드)를 알려줌으로써 이 문제를 해결했습니다.
일반 대체

캐시 또는 네트워크에서 항목을 게재하지 못하는 경우 일반 대체 항목을 제공하는 것이 좋습니다.
적합한 경우: 아바타, 실패한 POST 요청, '오프라인 상태에서는 사용할 수 없음' 페이지와 같은 보조 이미지
self.addEventListener('fetch', function (event) {
event.respondWith(
// Try the cache
caches
.match(event.request)
.then(function (response) {
// Fall back to network
return response || fetch(event.request);
})
.catch(function () {
// If both fail, show a generic fallback:
return caches.match('/offline.html');
// However, in reality you'd have many different
// fallbacks, depending on URL and headers.
// Eg, a fallback silhouette image for avatars.
}),
);
});
대체하는 항목은 종속 항목 설치일 가능성이 높습니다.
페이지에서 이메일을 게시하는 경우 서비스 워커는 IndexedDB '보관함'에 이메일을 저장하는 대체 방법을 사용하고 전송에 실패했지만 데이터는 성공적으로 보관되었다고 페이지에 알림을 보내 응답할 수 있습니다.
서비스 워커 측 템플릿

적합한 페이지: 서버 응답을 캐시할 수 없는 페이지
서버에서 페이지를 렌더링하면 속도가 빨라집니다. 하지만 이렇게 하면 캐시에서 의미가 없는 상태 데이터(예: '로그인한 사용자 이름')가 포함될 수 있습니다. 페이지가 서비스 워커에 의해 제어되는 경우 템플릿과 함께 JSON 데이터를 요청하고 대신 렌더링할 수 있습니다.
importScripts('templating-engine.js');
self.addEventListener('fetch', function (event) {
var requestURL = new URL(event.request.url);
event.respondWith(
Promise.all([
caches.match('/article-template.html').then(function (response) {
return response.text();
}),
caches.match(requestURL.path + '.json').then(function (response) {
return response.json();
}),
]).then(function (responses) {
var template = responses[0];
var data = responses[1];
return new Response(renderTemplate(template, data), {
headers: {
'Content-Type': 'text/html',
},
});
}),
);
});
종합
이러한 방법 중 하나로 제한되지는 않습니다. 실제로 요청 URL에 따라 여러 개의 리소스를 사용할 가능성이 높습니다. 예를 들어 trained-to-thrill은 다음을 사용합니다.
- 정적 UI 및 동작의 경우 설치 시 캐시
- Flickr 이미지 및 데이터의 네트워크 응답 캐시
- 대부분의 요청에 대해 캐시에서 가져오고 네트워크로 대체
- Flickr 검색 결과의 캐시에서 가져온 다음 네트워크에서 가져오기
요청을 보고 어떻게 할지 결정합니다.
self.addEventListener('fetch', function (event) {
// Parse the URL:
var requestURL = new URL(event.request.url);
// Handle requests to a particular host specifically
if (requestURL.hostname == 'api.example.com') {
event.respondWith(/* some combination of patterns */);
return;
}
// Routing for local URLs
if (requestURL.origin == location.origin) {
// Handle article URLs
if (/^\/article\//.test(requestURL.pathname)) {
event.respondWith(/* some other combination of patterns */);
return;
}
if (/\.webp$/.test(requestURL.pathname)) {
event.respondWith(/* some other combination of patterns */);
return;
}
if (request.method == 'POST') {
event.respondWith(/* some other combination of patterns */);
return;
}
if (/cheese/.test(requestURL.pathname)) {
event.respondWith(
new Response('Flagrant cheese error', {
status: 512,
}),
);
return;
}
}
// A sensible default pattern
event.respondWith(
caches.match(event.request).then(function (response) {
return response || fetch(event.request);
}),
);
});
…이해가 되셨죠?
크레딧
…아름다운 아이콘:
- buzzyrobot의 코드
- 스콧 루이스 Calendar
- 벤 리조의 네트워크
- Thomas Le Bas의 SD
- iconsmind.com의 CPU
- 트라스닉의 Trash
- @daosme의 알림
- Mister Pixel의 레이아웃
- P.J. Onori의 Cloud
'게시'를 누르기 전에 많은 오류를 포착해 준 제프 포스닉님께도 감사드립니다.
추가 자료
- 서비스 워커 소개
- Service Worker 준비 상태: 주요 브라우저에서 구현 상태를 추적합니다.
- JavaScript 약속 - 소개 - 약속 가이드