타입스크립트에서의 서비스 워커
이 글은 타입스크립트에서의 서비스 워커에 대해 설명합니다.
타입스크립트에서의 서비스 워커와 실제 예제를 포함하여 설명하겠습니다.
YouTube Video
타입스크립트에서의 서비스 워커
서비스 워커는 브라우저와 네트워크 사이에 위치한 '요청 프록시'입니다. 서비스 워커를 사용하면 요청 가로채기, 캐시 제어, 오프라인 지원, 백그라운드 처리(동기화 및 푸시) 등이 가능합니다. 타입스크립트를 사용하면 타입 안정성이 확보되고 유지 보수성이 향상됩니다.
타입스크립트 설정
tsconfig.json
(WebWorker 타입 활성화)
tsconfig.json
에서 WebWorker 타입을 활성화하는 예를 살펴봅시다.
1{
2 "compilerOptions": {
3 "target": "ES2020",
4 "module": "ES2020",
5 "lib": ["ES2020", "WebWorker"],
6 "moduleResolution": "Bundler",
7 "strict": true,
8 "noEmitOnError": true,
9 "outDir": "out",
10 "skipLibCheck": true
11 },
12 "include": ["sw.ts"]
13}
lib
배열에WebWorker
를 추가하면ServiceWorkerGlobalScope
와 같은 타입을 사용할 수 있습니다.DOM
과WebWorker
는 서로 다른 타입을 가지고 있기 때문에, 브라우저(메인 앱)와Service Worker
의tsconfig.json
설정을 분리하는 것이 일반적인 관행입니다.Service Worker
파일은 최종적으로 스코프와 일치하는 경로(보통 사이트 루트/sw.js
)에 출력됩니다.- 보안상의 이유로 서비스 워커는 HTTPS(또는
localhost
)에서만 실행됩니다.
브라우저(메인) 측에서의 등록 코드
register-sw.ts
1// register-sw.ts
2async function registerServiceWorker() {
3 if (!('serviceWorker' in navigator)) return;
4
5 try {
6 const registration = await navigator.serviceWorker.register(
7 '/sw.js', { scope: '/' }
8 );
- 이 과정은
Service Worker
를 등록합니다.scope
는Service Worker
가 제어할 수 있는 경로의 범위를 의미합니다. 예를 들어,/sw.js
를 루트 디렉토리 바로 아래에 두고scope
를 루트 디렉토리(/
)로 설정하면, 사이트의 모든 리소스를 제어할 수 있습니다. 반면에/app/
과 같은 특정 디렉토리를 지정하면, 해당 디렉토리 아래의 내용만 제어됩니다.
1 // If there's a waiting worker, notify the user.
2 if (registration.waiting) {
3 promptUserToUpdate(registration);
4 }
waiting
은 새로운 Service Worker가 설치 완료되어 활성화를 기다리고 있는 상태를 나타냅니다. 이 단계에서는 기존 페이지가 여전히 이전Service Worker
에 의해 제어되고 있으므로, 사용자에게 확인을 요청하고 승인을 받은 후skipWaiting()
을 호출하여 새로운Service Worker
를 즉시 활성화하는 것이 일반적입니다. 이렇게 하면 다음 페이지 리로드를 기다리지 않고 최신 프로세스를 반영할 수 있습니다.
1 // When a new SW is installing, monitor its state changes
2 registration.addEventListener('updatefound', () => {
3 const newWorker = registration.installing;
4 if (!newWorker) return;
5 newWorker.addEventListener('statechange', () => {
6 if (newWorker.state === 'installed' &&
7 navigator.serviceWorker.controller) {
8 // New content available, prompt the user
9 promptUserToUpdate(registration);
10 }
11 });
12 });
updatefound
는 새로운 Service Worker의 설치가 시작될 때 트리거됩니다. 이 이벤트가 발생하면registration.installing
에 새로운 worker가 설정되기 때문에,statechange
를 감시함으로써 설치가 완료(installed
)된 시점을 확인할 수 있습니다. 또한navigator.serviceWorker.controller
가 존재한다면, 이는 이전 버전의 Service Worker가 이미 페이지를 제어하고 있다는 의미이므로, 새로운 버전의 존재를 사용자에게 알릴 수 있는 기회입니다.
1 // When the active worker changes (e.g., after skipWaiting), reload if desired
2 navigator.serviceWorker.addEventListener('controllerchange', () => {
3 // Optionally reload to let the new SW take over
4 window.location.reload();
5 });
6 } catch (err) {
7 console.error('Service Worker registration failed: ', err);
8 }
9}
controllerchange
이벤트는 새로운 Service Worker가 현재 페이지를 제어하기 시작하는 순간에 발생합니다. 이 시점에서 리로드하면 새로운 캐시 전략과 처리가 즉시 적용됩니다. 하지만 자동 리로드는 사용자 경험을 저하시킬 수 있으므로, 사용자 동의를 얻은 후 리로드하는 것이 바람직합니다..
1function promptUserToUpdate(reg: ServiceWorkerRegistration) {
2 // Show UI to user. If user accepts:
3 if (reg.waiting) {
4 reg.waiting.postMessage({ type: 'SKIP_WAITING' });
5 }
6}
7
8registerServiceWorker();
- 클라이언트에서
Service Worker
에postMessage({ type: 'SKIP_WAITING' })
를 보내고, Service Worker가self.skipWaiting()
을 호출하게 함으로써 업데이트를 유도할 수 있습니다.
sw.ts
에서의 스코프 선언
다음으로 앱 쉘 캐싱을 구현하는 대표적인 서비스 워커 예제를 살펴봅시다.
타입스크립트에서 서비스 워커를 사용할 때 self
에 올바른 타입을 지정하는 것이 유용합니다.
1// sw.ts
2export default null;
3declare const self: ServiceWorkerGlobalScope;
- TypeScript에서는
self
가 기본적으로any
로 처리되기 때문에, 추가적인 타입 지정이 없으면skipWaiting()
이나clients
같은 Service Worker 전용 API에 대해 타입 완성이나 타입 체크를 받을 수 없습니다. ServiceWorkerGlobalScope
로 타입을 지정하면 자동 완성이 가능하고 오용을 방지할 수 있어 일반 DOM 스크립트와 분리된 안전한 개발이 가능합니다.
기본 서비스 워커 (설치/활성화/패치)
이 예제는 간단한 캐시 버전 관리, 설치 시 프리캐싱, 활성화 시 기존 캐시 삭제, 패치 시 캐시 전략(정적 에셋은 캐시 우선, API는 네트워크 우선)을 보여줍니다.
sw.ts
(최소 설정 + 캐시 기본 구조)
1const CACHE_NAME = 'app-shell-v1';
2const STATIC_ASSETS = [
3 '/',
4 '/index.html',
5 '/styles.css',
6 '/main.js',
7 '/fallback.png'
8];
9
10self.addEventListener('install', (event: ExtendableEvent) => {
11 // Pre-cache application shell
12 event.waitUntil(
13 caches.open(CACHE_NAME)
14 .then(cache => cache.addAll(STATIC_ASSETS))
15 // Activate immediately (optional: coordinate with client)
16 .then(() => self.skipWaiting())
17 );
18});
install
이벤트 중에 앱의 **정적 자원(App Shell)**이 미리 캐시됩니다.self.skipWaiting()
을 호출하면 새로운Service Worker
가 즉시 활성화되어, 다음 접근을 기다리지 않고 최신 캐시를 사용할 수 있습니다.
1self.addEventListener('activate', (event: ExtendableEvent) => {
2 // Clean up old caches and take control of clients immediately
3 event.waitUntil(
4 caches.keys().then(keys =>
5 Promise.all(keys
6 .filter(key => key !== CACHE_NAME)
7 .map(key => caches.delete(key)))
8 ).then(() => self.clients.claim())
9 );
10});
activate
이벤트에서는 이전 버전의 캐시가 삭제되어Service Worker
가 최신 상태로 유지됩니다. 또한,self.clients.claim()
을 호출하면 새Service Worker
가 페이지 새로고침을 기다리지 않고 모든 클라이언트를 제어할 수 있습니다.
1self.addEventListener('fetch', (event: FetchEvent) => {
2 const request = event.request;
3 const url = new URL(request.url);
4
5 // Navigation requests (SPA) -> network-first with fallback to cached index.html
6 if (request.mode === 'navigate') {
7 event.respondWith(
8 fetch(request).catch(() => caches.match('/index.html') as Promise<Response>)
9 );
10 return;
11 }
12
13 // Simple API routing: network-first for /api/
14 if (url.pathname.startsWith('/api/')) {
15 event.respondWith(networkFirst(request));
16 return;
17 }
18
19 // Static assets: cache-first
20 event.respondWith(cacheFirst(request));
21});
fetch
에서 요청을 가로채고 응답을 제어할 수 있습니다. 캐시 우선 및 네트워크 우선과 같은 전략을 구현할 수 있으며, 이는 오프라인 지원 및 성능 향상에 유용합니다.
1self.addEventListener('message', (event: ExtendableMessageEvent) => {
2 const data = (event as any).data;
3 if (!data) return;
4
5 if (data.type === 'SKIP_WAITING') {
6 // Force the waiting service worker to become active
7 self.skipWaiting();
8 }
9});
SKIP_WAITING
메시지를 받으면,self.skipWaiting()
을 호출하여 대기 중인 Service Worker를 즉시 활성화할 수 있습니다. 이로써 페이지를 새로 고침하지 않아도 다음 요청부터 새로운 버전이 적용됩니다.
실전 캐시 전략 개요
cache-first
**캐시 우선(cache-first)**은 먼저 캐시를 확인하고, 존재한다면 즉시 응답합니다. 캐시에 없다면 네트워크에서 가져와 결과를 캐시에 저장합니다. 이 방식은 정적 파일에 적합합니다.
1async function cacheFirst(request: Request): Promise<Response> {
2 const cache = await caches.open(CACHE_NAME);
3 const cached = await cache.match(request);
4 if (cached) {
5 return cached;
6 }
7 const response = await fetch(request);
8 if (response && response.ok) {
9 cache.put(request, response.clone());
10 }
11 return response;
12}
- 이 코드는 캐시 우선 방식의 구현을 보여줍니다. 캐시가 있다면 바로 반환하고, 없으면 네트워크에서 받아와 캐시에 저장합니다. 이미지나 CSS와 같이 변경이 적은 정적 리소스에 적합합니다.
network-first
네트워크 우선(network-first) 전략은 먼저 네트워크를 시도하고 실패하면 캐시로 대체합니다. 신선도가 중요한 API에 적합합니다.
1async function networkFirst(request: Request): Promise<Response> {
2 const cache = await caches.open(CACHE_NAME);
3 try {
4 const response = await fetch(request);
5 if (response && response.ok) {
6 cache.put(request, response.clone());
7 }
8 return response;
9 } catch (err) {
10 const cached = await cache.match(request);
11 if (cached) return cached;
12 return new Response(JSON.stringify({ error: 'offline' }), {
13 status: 503,
14 headers: { 'Content-Type': 'application/json' }
15 });
16 }
17}
- 이 코드는 네트워크 우선 방식의 구현 예시입니다. 네트워크 응답을 받으면 캐시에 저장하고, 실패하면 캐시된 버전을 반환합니다. 뉴스 기사나 API 응답처럼 최신 데이터가 필요한 리소스에 적합합니다.
stale-while-revalidate
stale-while-revalidate는 먼저 캐시를 반환하는 동시에, 백그라운드에서 네트워크를 통해 캐시를 업데이트합니다. 응답 속도와 신선도의 균형을 맞춰줍니다.
1async function staleWhileRevalidate(request: Request, cacheName = CACHE_NAME): Promise<Response> {
2 const cache = await caches.open(cacheName);
3 const cachedResponse = await cache.match(request);
4 const networkFetch = fetch(request).then(networkResponse => {
5 if (networkResponse && networkResponse.ok) {
6 cache.put(request, networkResponse.clone());
7 }
8 return networkResponse;
9 }).catch(() => undefined);
10
11 // Return cached immediately if exists, otherwise wait network
12 return cachedResponse || (await networkFetch) || new Response('offline', { status: 503 });
13}
- 이 코드는 캐시에 데이터가 있으면 즉시 반환하고, 백그라운드에서 새로운 데이터를 네트워크로 받아 캐시를 갱신합니다. 사용자에게 빠른 응답을 제공하고, 다음 접근 시 최신 컨텐츠를 사용하므로 UI나 경량 데이터 전송에 적합합니다.
업데이트 플로우 최적화 (업데이트 알림과 안전한 리로드)
Service Worker
업데이트는 즉시 적용되지 않으며, 기존 탭이 닫힐 때까지 새로운 버전은 대기 상태로 남아있게 됩니다.
여기서는 새 버전이 준비되었을 때 클라이언트에 알리고, 사용자의 동의로 안전하게 페이지를 다시 로드하는 시스템을 구현합니다.
새 버전이 준비되었을 때, Service Worker
측에서 클라이언트에 알립니다.
1// In sw.ts: after 'activate' or when new version is ready, broadcast a message
2async function notifyClientsUpdated() {
3 const all = await self.clients.matchAll({ type: 'window' });
4 for (const client of all) {
5 client.postMessage({ type: 'SW_UPDATED' });
6 }
7}
8
9// e.g., call this at the end of 'activate'
10self.addEventListener('activate', (event) => {
11 event.waitUntil((async () => {
12 if ('navigationPreload' in self.registration) {
13 await self.registration.navigationPreload.enable();
14 }
15 // cache cleanup
16 const cacheNames = await caches.keys();
17 await Promise.all(
18 cacheNames.map((name) => {
19 if (name !== CACHE_NAME) {
20 return caches.delete(name);
21 }
22 })
23 );
24
25 await self.clients.claim();
26 await notifyClientsUpdated();
27 })());
28});
- 이 코드에서는
activate
이벤트 마지막에notifyClientsUpdated
를 호출하여 연결된 모든 클라이언트에 새 버전 준비 완료를 알립니다.clients.claim()
은 새로 활성화된 Service Worker가 현재 열려 있는 페이지(클라이언트)를 즉시 제어하도록 하는 메서드입니다. 일반적으로Service Worker
는 다음 로드 시부터 페이지를 제어하지만,clients.claim()
을 사용하면 리로드 없이 즉시 제어가 가능합니다.
클라이언트에서 업데이트 UI를 표시하고, 사용자 동작으로 리로드
1// in app startup
2navigator.serviceWorker.addEventListener('message', (e) => {
3 if (e.data?.type === 'SW_UPDATED') {
4 // Show a non-intrusive toast or banner: "New version available"
5 // When user clicks "Reload", call:
6 window.location.reload();
7 }
8});
- 클라이언트는
message
이벤트로SW_UPDATED
를 받아 UI에 업데이트 알림을 표시합니다. 사용자가 새로고침을 선택하면,window.location.reload()
가 실행되어 이전 HTML, CSS 및 기타 리소스가 최신 버전으로 업데이트됩니다. 이렇게 하면clients.claim()
으로 제어가 전환된Service Worker
의 캐시와 통제가 페이지 전체에 반영됩니다.
오프라인 폴백(대체 페이지)
중요한 네비게이션을 위해 /offline.html
을 준비하고, 이미지나 폰트 없이도 의미가 전달되는 최소 UI를 제공합니다. API 호출이 실패하면 가능하다면 마지막 캐시 상태를 보여주고, UX 향상을 위해 백그라운드에서 재시도를 시도하세요.
구현 예시
1// sw.ts
2const CACHE_NAME = 'app-cache-v1';
3
4// Cache offline.html during install
5self.addEventListener('install', (event) => {
6 event.waitUntil((async () => {
7 const cache = await caches.open(CACHE_NAME);
8 await cache.addAll(['/offline.html']);
9 })());
10});
11
12// Handle fetch requests
13self.addEventListener('fetch', (event) => {
14 const request = event.request;
15
16 // Navigation requests (e.g., page transitions)
17 if (request.mode === 'navigate') {
18 event.respondWith((async () => {
19 try {
20 // Try to fetch from the network as usual
21 return await fetch(request);
22 } catch (err) {
23 // On failure, return offline fallback page
24 const cache = await caches.open(CACHE_NAME);
25 return await cache.match('/offline.html') as Response;
26 }
27 })());
28 }
29});
install
이벤트 중에/offline.html
을 프리캐싱하여 네트워크가 불가할 때 최소한의 페이지를 반환할 수 있도록 합니다.fetch
이벤트에서는request.mode === 'navigate'
조건을 통해 네비게이션 요청을 감지하고, 페이지 전환을 타겟팅할 수 있습니다.- 네트워크 오류 시
/offline.html
로 폴백되어, 오프라인 환경에서도 페이지가 표시되게 할 수 있습니다.
클라이언트와 Service Worker
간의 메시지 통신
Service Worker
는 페이지의 라이프사이클과 독립적으로 동작하기 때문에, 상태 알림이나 명령 실행을 위한 양방향 메시징이 중요합니다. 메시지에 타입을 지정하면 잘못된 메시지 전송을 방지하고, 코드 자동 완성 기능을 사용할 수 있으며, 구현이 더 견고해집니다.
코드 예시
- 메시지 타입 정의
1type SwToClient =
2 | { type: 'SW_READY' }
3 | { type: 'SW_UPDATED' }
4 | { type: 'CACHE_CLEARED' }
5 | { type: 'PING'; ts: number };
6
7type ClientToSw =
8 | { type: 'CLEAR_CACHE' }
9 | { type: 'PING'; ts: number };
SwToClient
는 서비스 워커에서 클라이언트로 보내는 메시지 타입입니다.ClientToSw
는 클라이언트에서 서비스 워커로 보내는 메시지 타입입니다.- 이로써 양방향 통신을 통해 주고받을 수 있는 이벤트의 종류를 명확히 할 수 있습니다.
- 서비스 워커 측 처리
1self.addEventListener('message', (event) => {
2 const data = event.data as ClientToSw;
3 if (data?.type === 'CLEAR_CACHE') {
4 event.waitUntil((async () => {
5 const keys = await caches.keys();
6 await Promise.all(keys.map((k) => caches.delete(k)));
7 await broadcast({ type: 'CACHE_CLEARED' });
8 })());
9 } else if (data?.type === 'PING') {
10 event.source?.postMessage({ type: 'PING', ts: data.ts } as SwToClient);
11 }
12});
- 서비스 워커는 클라이언트로부터 메시지를 받아 타입에 따라 분기 처리합니다.
CLEAR_CACHE
의 경우 캐시를 삭제하고,CACHE_CLEARED
로 모든 클라이언트에 알립니다.PING
의 경우 타임스탬프를 포함한PING
메시지로 원래 클라이언트에 응답합니다.
- 서비스 워커에서 모든 클라이언트에 알리기
1async function broadcast(msg: SwToClient) {
2 const clients = await self.clients.matchAll({ includeUncontrolled: true });
3 for (const c of clients) c.postMessage(msg);
4}
clients.matchAll
을 사용하여 모든 윈도우 탭을 가져옵니다.- 각각에
postMessage
를 보내서 메시지를 브로드캐스트할 수 있습니다. - 이 방식은 업데이트 알림(
SW_UPDATED
등)이나 에러 알림 등에 사용할 수 있습니다.
- 클라이언트 측 처리
1navigator.serviceWorker.controller?.postMessage({
2 type: 'PING',
3 ts: Date.now()
4} as ClientToSw);
- 클라이언트에서
PING
을 보내고Service Worker
로부터 응답을 받으면, 양방향 통신이 올바르게 동작하는지 확인할 수 있습니다. 이렇게 하면 연결 상태와 메시지 처리를 더 쉽게 테스트할 수 있습니다.
1navigator.serviceWorker.addEventListener('message', (e) => {
2 const msg = e.data as SwToClient;
3 switch (msg.type) {
4 case 'SW_READY':
5 console.log('Service Worker is ready');
6 // Example: hide loading spinner or enable offline UI
7 break;
8 case 'SW_UPDATED':
9 console.log('A new version of the Service Worker is available');
10 // Example: show update notification or reload prompt
11 const shouldReload = confirm('A new version is available. Reload now?');
12 if (shouldReload) {
13 window.location.reload();
14 }
15 break;
16 case 'CACHE_CLEARED':
17 console.log('Cache cleared');
18 // Example: show confirmation message to user
19 alert('Cache has been successfully cleared.');
20 break;
21 case 'PING':
22 console.log(`Received PING response, ts=${msg.ts}`);
23 break;
24 }
25});
- {^ i18n_speak
クライアント側では
Service Worker
から送信されるメッセージを受信し、種類に応じて処理を分岐します。SW_READY
は初期化完了、SW_UPDATED
は新バージョン検出、CACHE_CLEARED
はキャッシュ削除完了、PING
は通信確認を示します。各メッセージに応じて、UI の更新やリロード、通知表示などを行います。^}
타입 메시지의 이점
- 타입 메시지를 사용하면 주고받을 수 있는 메시지가 명확해지고, 자동 완성과 타입 체크로 안전성이 향상됩니다.
postMessage
는 일대일 통신,broadcast
는 일대다 통신을 구현할 수 있습니다.- 업데이트 알림(
SW_UPDATED
), 캐시 관리(CACHE_CLEARED
), 헬스 체크(PING
) 등 핵심 기능을 쉽게 구현할 수 있습니다.
요약
- 타입스크립트를 사용하면 서비스 워커 API 호출과 메시지 송수신에 타입 안정성이 추가되어 개발 효율성과 유지 보수성이 크게 향상됩니다.
install
,activate
,fetch
등의 라이프사이클 이벤트를 이해하고, 상황에 맞는 적절한 캐시 전략(캐시 우선 또는 네트워크 우선 등)을 선택하면 더 나은 사용자 경험을 제공할 수 있습니다.- 운영 측면에서는 캐시 버전 관리와 업데이트 플로우(
updatefound
,waiting
,SKIP_WAITING
등)를 이해하는 것이 중요합니다. - 클라이언트와
Service Worker
간의 통신에 타입 메시징을 도입하면, 잘못된 구현을 방지하고 장기적으로 확장 및 유지보수가 쉬운 시스템을 만들 수 있습니다.
위의 기사를 보면서 Visual Studio Code를 사용해 우리 유튜브 채널에서 함께 따라할 수 있습니다. 유튜브 채널도 확인해 주세요.