타입스크립트에서의 서비스 워커

타입스크립트에서의 서비스 워커

이 글은 타입스크립트에서의 서비스 워커에 대해 설명합니다.

타입스크립트에서의 서비스 워커와 실제 예제를 포함하여 설명하겠습니다.

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와 같은 타입을 사용할 수 있습니다.
  • DOMWebWorker는 서로 다른 타입을 가지고 있기 때문에, 브라우저(메인 앱)와 Service Workertsconfig.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를 등록합니다. scopeService 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 WorkerpostMessage({ 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는 페이지의 라이프사이클과 독립적으로 동작하기 때문에, 상태 알림이나 명령 실행을 위한 양방향 메시징이 중요합니다. 메시지에 타입을 지정하면 잘못된 메시지 전송을 방지하고, 코드 자동 완성 기능을 사용할 수 있으며, 구현이 더 견고해집니다.

코드 예시

  1. 메시지 타입 정의
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는 클라이언트에서 서비스 워커로 보내는 메시지 타입입니다.
  • 이로써 양방향 통신을 통해 주고받을 수 있는 이벤트의 종류를 명확히 할 수 있습니다.
  1. 서비스 워커 측 처리
 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 메시지로 원래 클라이언트에 응답합니다.
  1. 서비스 워커에서 모든 클라이언트에 알리기
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 등)이나 에러 알림 등에 사용할 수 있습니다.
  1. 클라이언트 측 처리
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를 사용해 우리 유튜브 채널에서 함께 따라할 수 있습니다. 유튜브 채널도 확인해 주세요.

YouTube Video