서비스 워커를 통해 개발자는 네트워크 연결을 해결할 수 있습니다. 캐싱 및 요청 처리 방식을 제어할 수 있습니다. 즉, 나만의 패턴을 만들 수 있습니다. 몇 가지 가능한 패턴을 개별적으로 살펴보지만 실제로는 URL과 컨텍스트에 따라 함께 사용할 가능성이 높습니다.
이러한 패턴의 작동 데모는 Trained-to-thrill을 참고하세요.
리소스를 저장해야 하는 경우
서비스 워커를 사용하면 캐싱과 독립적으로 요청을 처리할 수 있으므로 별도로 설명하겠습니다. 먼저 캐시를 사용해야 하는 시점을 결정합니다.
설치 시 종속 항목으로
서비스 워커 API는 install 이벤트를 제공합니다. 이를 사용하여 다른 이벤트를 처리하기 전에 준비해야 하는 항목을 준비할 수 있습니다. install 동안 이전 버전의 서비스 워커가 계속 실행되고 페이지를 제공합니다. 이때 수행하는 작업은 기존 서비스 워커를 방해해서는 안 됩니다.
적합한 항목: 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단계가 다운로드되는 동안 종료될 수 있으며, 이는 캐시되지 않음을 의미합니다. Web Periodic Background Synchronization API는 이와 같은 사례와 영화와 같은 대규모 다운로드를 처리할 수 있습니다.
활성화 시
용도: 정리 및 이전
새 서비스 워커가 설치되고 이전 버전이 사용되지 않으면 새 서비스 워커가 활성화되고 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의 동영상, Wikipedia의 문서, 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);
});
});
});
Cache 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와 매우 유사합니다.
푸시 메시지
푸시 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/');
}
});
백그라운드 동기화 시
백그라운드 동기화는 서비스 워커를 기반으로 빌드된 또 다른 기능입니다. 일회성으로 또는 (매우 휴리스틱한) 간격으로 백그라운드 데이터 동기화를 요청할 수 있습니다. 사용자가 사이트 탭을 열지 않은 경우에도 이 문제가 발생합니다. 서비스 워커만 절전 모드에서 해제됩니다. 페이지에서 이 작업을 실행할 권한을 요청하면 사용자에게 메시지가 표시됩니다.
적합한 경우: 긴급하지 않은 업데이트, 특히 업데이트당 푸시 메시지가 너무 자주 사용자에게 전송되는 소셜 타임라인이나 뉴스 기사와 같이 정기적으로 발생하는 업데이트
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.
});
물론 사용자가 권한을 부여해야 합니다. 이를 위해서는 권한 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 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 rejects when a promise rejects before fulfilling.
// To make a 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);
}),
);
});
캐시 후 네트워크
적합한 콘텐츠: 자주 업데이트되는 콘텐츠 예: 기사, 소셜 미디어 타임라인, 게임 리더보드
이렇게 하려면 페이지에서 캐시에 대한 요청 하나와 네트워크에 대한 요청 하나 등 두 개의 요청을 해야 합니다. 아이디어는 캐시된 데이터를 먼저 표시한 다음 네트워크 데이터가 도착하면 페이지를 업데이트하는 것입니다.
새 데이터가 도착하면 (예: 게임 리더보드) 현재 데이터를 대체하면 되는 경우도 있지만, 콘텐츠가 클수록 중단될 수 있습니다. 기본적으로 사용자가 읽거나 상호작용하는 항목이 '사라지지' 않도록 해야 합니다.
Twitter는 이전 콘텐츠 위에 새 콘텐츠를 추가하고 사용자가 중단되지 않도록 스크롤 위치를 조정합니다. 이는 트위터가 콘텐츠에 대해 거의 선형적인 순서를 유지하기 때문에 가능합니다. 이 패턴을 trained-to-thrill에 복사하여 콘텐츠가 도착하는 즉시 최신 콘텐츠를 표시하면서 최대한 빨리 콘텐츠를 화면에 표시했습니다.
페이지의 코드:
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);
}),
);
});
추가 자료
- 서비스 워커 및 Cache Storage API
- JavaScript Promises—an Introduction(JavaScript 프로미스 소개): 프로미스 가이드
크레딧
멋진 아이콘의 경우:
- 코드(buzzyrobot)
- Scott Lewis의 Calendar
- Network by Ben Rizzo
- 토마스 르 바스의 SD
- CPU(iconsmind.com 제공)
- trasnik의 휴지통
- @daosme의 알림
- Layout(Mister Pixel)
- Cloud(P.J. Onori)
'게시'를 누르기 전에 많은 오류를 포착해 준 Jeff Posnick에게도 감사드립니다.