콘텐츠 대표 이미지 - 타입스크립트 PWA와 Service Worker 타입을 활용한 오프라인 기능 구현
TypeScript × PWA × Service Worker

타입스크립트 PWA와 Service Worker 타입을 활용한 오프라인 기능 구현

인터넷 없어도 앱이 돌아간다고?! 🤯 그 비밀, 지금 파헤쳐봄

🚀 📶 💻 🔧 ✨

야 솔직히 말해서, 지하철 타다가 인터넷 끊겼을 때 앱이 그냥 하얗게 뻗어버리면 진짜 현타 오지 않음?ㅋㅋㅋ 근데 요즘 잘 만든 웹앱들은 오프라인에서도 멀쩡히 돌아가잖아. 그게 바로 PWA(Progressive Web App)의 힘이고, 그 핵심엔 Service Worker가 있음.

오늘은 TypeScript로 PWA 만들면서 Service Worker 타입을 어떻게 다루는지, 오프라인 기능을 어떻게 구현하는지 제대로 뜯어볼 거임. 코드도 실제로 쓸 수 있는 거 위주로 가져왔으니까 집중해줘 👀


🌐 PWA가 뭔지 일단 짚고 가자

PWA는 Progressive Web App의 약자로, 웹 기술(HTML/CSS/JS)로 만들었지만 네이티브 앱처럼 동작하는 웹앱임. 설치도 되고, 푸시 알림도 되고, 오프라인도 됨. 진짜 앱이랑 거의 구분 안 됨ㅋㅋ

PWA 핵심 3요소 Service Worker 오프라인·캐싱 Web App Manifest 설치·아이콘 HTTPS 보안 안전한 통신 세 가지가 합쳐져야 진짜 PWA! 🎯

PWA의 핵심 특징을 표로 정리해보면 이럼:

특징설명관련 기술
🔌 오프라인 지원네트워크 없어도 동작Service Worker + Cache API
📲 설치 가능홈 화면에 추가 가능Web App Manifest
🔔 푸시 알림백그라운드 알림 발송Push API + Notification API
⚡ 빠른 로딩캐시로 즉시 응답Cache Storage
🔒 보안HTTPS 필수TLS/SSL

⚙️ Service Worker가 뭔지 제대로 이해하자

Service Worker는 브라우저 백그라운드에서 돌아가는 JavaScript 워커 스크립트임. 웹 페이지와 완전히 분리된 별도 스레드에서 실행되고, DOM에 직접 접근 못 함. 대신 네트워크 요청을 가로채서 캐시에서 응답하거나, 백그라운드 동기화 같은 걸 처리함.

💡 Service Worker = 웹앱과 네트워크 사이의 프록시 서버 역할! 요청을 가로채서 캐시에서 줄지, 네트워크에서 가져올지 결정함.
Service Worker 생명주기
Service Worker 생명주기 🔄 등록(Register) 설치(Install) 활성화(Activate) 실행(Running) 종료 SW 파일 등록 캐시 초기화 이전 캐시 정리 요청 가로채기 유휴 상태 주요 이벤트: install → activate → fetch 업데이트 시: 새 SW 설치 → 기존 SW 종료 후 활성화 ⚠️ HTTPS 환경 또는 localhost에서만 동작함!

생명주기 이해가 진짜 중요함. 특히 업데이트할 때 기존 SW가 살아있으면 새 SW가 waiting 상태로 대기하는 거 모르면 나중에 "왜 업데이트가 안 돼?!" 하고 멘붕 옴ㅋㅋㅋ


🔷 TypeScript에서 Service Worker 타입 설정하기

자 이제 본론임. TypeScript로 Service Worker 작성할 때 제일 먼저 막히는 게 타입 설정임. self가 뭔지, ServiceWorkerGlobalScope가 뭔지 모르면 타입 에러 폭탄 맞음ㅋㅋ

tsconfig.json 설정

Service Worker는 브라우저 메인 스레드가 아니라 별도 워커 스레드에서 실행되기 때문에, lib 설정에 "WebWorker"를 추가해야 함:

// tsconfig.json (Service Worker 전용)
{
  "compilerOptions": {
    "target": "ES2017",
    "module": "ESNext",
    "lib": ["ES2017", "WebWorker"],
    "strict": true,
    "outDir": "./dist",
    "rootDir": "./src"
  }
}
💡 메인 앱과 Service Worker의 tsconfig를 분리하는 게 좋음. 메인 앱은 "lib": ["DOM", "ES2017"], SW는 "lib": ["WebWorker", "ES2017"]으로 따로 관리하면 타입 충돌 없음!
ServiceWorkerGlobalScope 타입 선언

Service Worker 파일 최상단에 이걸 선언해줘야 self의 타입이 제대로 잡힘:

// service-worker.ts
declare const self: ServiceWorkerGlobalScope;

// 이제 self.addEventListener 등에서 타입 자동완성 됨!
export {}; // 모듈로 인식시키기 위해 필요

이거 안 하면 selfWindow & typeof globalThis로 잡혀서 SW 전용 API들 쓸 때 타입 에러 남. 진짜 흔한 실수임ㅋㅋ

주요 Service Worker 타입들
타입/인터페이스설명
ServiceWorkerGlobalScopeSW의 전역 스코프 타입
ExtendableEventinstall, activate 이벤트 타입
FetchEventfetch 이벤트 타입 (요청 가로채기)
Cache캐시 스토리지 인터페이스
CacheStoragecaches 전역 객체 타입
RequestHTTP 요청 타입
ResponseHTTP 응답 타입
PushEvent푸시 알림 이벤트 타입

📦 캐싱 전략 구현하기 (TypeScript 코드)

오프라인 기능의 핵심은 캐싱 전략임. 어떤 리소스를 언제 캐시에서 줄지, 네트워크에서 가져올지 결정하는 게 전략의 핵심임. 대표적인 전략 5가지 알아보자.

1. Cache First (캐시 우선)

캐시에 있으면 캐시에서 주고, 없으면 네트워크에서 가져옴. 정적 리소스(이미지, CSS, JS)에 적합함:

declare const self: ServiceWorkerGlobalScope;

const CACHE_NAME = 'my-pwa-v1';
const STATIC_ASSETS = [
  '/',
  '/index.html',
  '/styles.css',
  '/app.js',
  '/icons/icon-192.png'
];

// install 이벤트: 정적 리소스 사전 캐싱
self.addEventListener('install', (event: ExtendableEvent) => {
  event.waitUntil(
    caches.open(CACHE_NAME).then((cache: Cache) => {
      console.log('캐시 열기 성공!');
      return cache.addAll(STATIC_ASSETS);
    })
  );
  // 즉시 활성화 (waiting 스킵)
  self.skipWaiting();
});

// activate 이벤트: 오래된 캐시 정리
self.addEventListener('activate', (event: ExtendableEvent) => {
  event.waitUntil(
    caches.keys().then((cacheNames: string[]) => {
      return Promise.all(
        cacheNames
          .filter((name: string) => name !== CACHE_NAME)
          .map((name: string) => caches.delete(name))
      );
    })
  );
  // 모든 클라이언트 즉시 제어
  self.clients.claim();
});

// fetch 이벤트: Cache First 전략
self.addEventListener('fetch', (event: FetchEvent) => {
  event.respondWith(
    caches.match(event.request).then((cachedResponse: Response | undefined) => {
      // 캐시에 있으면 캐시 반환
      if (cachedResponse) {
        return cachedResponse;
      }
      // 없으면 네트워크 요청
      return fetch(event.request);
    })
  );
});
2. Network First (네트워크 우선)

항상 최신 데이터가 필요한 API 요청에 적합함. 네트워크 실패 시 캐시로 폴백:

// Network First 전략
function networkFirst(request: Request): Promise<Response> {
  return fetch(request)
    .then((networkResponse: Response) => {
      // 네트워크 성공 시 캐시에도 저장
      const responseClone = networkResponse.clone();
      caches.open(CACHE_NAME).then((cache: Cache) => {
        cache.put(request, responseClone);
      });
      return networkResponse;
    })
    .catch(() => {
      // 네트워크 실패 시 캐시에서 반환
      return caches.match(request).then((cachedResponse: Response | undefined) => {
        if (cachedResponse) return cachedResponse;
        // 캐시도 없으면 오프라인 페이지 반환
        return caches.match('/offline.html') as Promise<Response>;
      });
    });
}
3. Stale While Revalidate (캐시 반환 후 백그라운드 업데이트)

일단 캐시에서 빠르게 주고, 백그라운드에서 네트워크 요청해서 캐시 업데이트함. 속도와 최신성 둘 다 챙기는 전략임:

// Stale While Revalidate 전략
function staleWhileRevalidate(request: Request): Promise<Response> {
  const fetchPromise = fetch(request).then((networkResponse: Response) => {
    caches.open(CACHE_NAME).then((cache: Cache) => {
      cache.put(request, networkResponse.clone());
    });
    return networkResponse;
  });

  return caches.match(request).then((cachedResponse: Response | undefined) => {
    // 캐시 있으면 즉시 반환, 없으면 네트워크 응답 대기
    return cachedResponse || fetchPromise;
  });
}
🎯 전략 선택 가이드:
• 정적 파일(CSS/JS/이미지) → Cache First
• API 데이터 → Network First 또는 Stale While Revalidate
• 폰트, 아이콘 → Cache First (변경 거의 없음)
• 실시간 데이터 → Network Only

🏗️ TypeScript PWA 프로젝트 구조 잡기

실제 프로젝트에서 어떻게 구조를 잡는지 보여줄게. Vite + TypeScript 조합이 요즘 제일 핫함ㅋㅋ

프로젝트 구조
my-pwa/
├── src/
│   ├── main.ts              # 메인 앱 진입점
│   ├── sw.ts                # Service Worker
│   ├── sw-register.ts       # SW 등록 로직
│   └── types/
│       └── sw.d.ts          # SW 커스텀 타입 선언
├── public/
│   ├── manifest.json        # Web App Manifest
│   └── icons/               # PWA 아이콘들
├── tsconfig.json            # 메인 앱 TS 설정
├── tsconfig.sw.json         # SW 전용 TS 설정
└── vite.config.ts
SW 등록 로직 (TypeScript)
// src/sw-register.ts

interface ServiceWorkerRegistrationResult {
  registration: ServiceWorkerRegistration | null;
  error: Error | null;
}

export async function registerServiceWorker(
  swPath: string = '/sw.js'
): Promise<ServiceWorkerRegistrationResult> {
  // Service Worker 지원 여부 확인
  if (!('serviceWorker' in navigator)) {
    console.warn('이 브라우저는 Service Worker를 지원하지 않아요 😢');
    return { registration: null, error: null };
  }

  try {
    const registration = await navigator.serviceWorker.register(swPath, {
      scope: '/'
    });

    // 업데이트 감지
    registration.addEventListener('updatefound', () => {
      const newWorker = registration.installing;
      if (!newWorker) return;

      newWorker.addEventListener('statechange', () => {
        if (
          newWorker.state === 'installed' &&
          navigator.serviceWorker.controller
        ) {
          // 새 버전 설치됨 - 사용자에게 알림
          console.log('새 버전이 준비됐어요! 새로고침하면 적용돼요 🎉');
          notifyUpdate();
        }
      });
    });

    console.log('Service Worker 등록 성공!', registration.scope);
    return { registration, error: null };
  } catch (error) {
    console.error('Service Worker 등록 실패:', error);
    return { registration: null, error: error as Error };
  }
}

function notifyUpdate(): void {
  // 실제 앱에서는 토스트 알림 등으로 사용자에게 알려줌
  const shouldUpdate = confirm('새 버전이 있어요! 지금 업데이트할까요?');
  if (shouldUpdate) {
    window.location.reload();
  }
}
Web App Manifest 설정
// public/manifest.json
{
  "name": "My TypeScript PWA",
  "short_name": "MyPWA",
  "description": "TypeScript로 만든 오프라인 지원 PWA",
  "start_url": "/",
  "display": "standalone",
  "background_color": "#f8f7f4",
  "theme_color": "#5a3fa0",
  "orientation": "portrait-primary",
  "icons": [
    {
      "src": "/icons/icon-72.png",
      "sizes": "72x72",
      "type": "image/png"
    },
    {
      "src": "/icons/icon-192.png",
      "sizes": "192x192",
      "type": "image/png",
      "purpose": "any maskable"
    },
    {
      "src": "/icons/icon-512.png",
      "sizes": "512x512",
      "type": "image/png"
    }
  ],
  "categories": ["productivity", "utilities"]
}

💾 IndexedDB로 오프라인 데이터 저장하기

캐시 API는 HTTP 요청/응답 캐싱용이고, 앱 데이터(사용자 입력, 폼 데이터 등)는 IndexedDB에 저장해야 함. TypeScript로 IndexedDB 쓸 때 타입 잡는 법 알아보자.

타입 안전한 IndexedDB 래퍼
// src/db.ts

interface UserData {
  id: string;
  name: string;
  email: string;
  createdAt: Date;
  synced: boolean; // 서버 동기화 여부
}

class OfflineDB {
  private db: IDBDatabase | null = null;
  private readonly DB_NAME = 'offline-store';
  private readonly DB_VERSION = 1;

  async open(): Promise<void> {
    return new Promise((resolve, reject) => {
      const request: IDBOpenDBRequest = indexedDB.open(
        this.DB_NAME,
        this.DB_VERSION
      );

      request.onupgradeneeded = (event: IDBVersionChangeEvent) => {
        const db = (event.target as IDBOpenDBRequest).result;
        
        // 오브젝트 스토어 생성
        if (!db.objectStoreNames.contains('users')) {
          const store = db.createObjectStore('users', { keyPath: 'id' });
          store.createIndex('synced', 'synced', { unique: false });
        }

        // 오프라인 큐 스토어 (동기화 대기 중인 작업들)
        if (!db.objectStoreNames.contains('syncQueue')) {
          db.createObjectStore('syncQueue', { 
            keyPath: 'id', 
            autoIncrement: true 
          });
        }
      };

      request.onsuccess = (event: Event) => {
        this.db = (event.target as IDBOpenDBRequest).result;
        resolve();
      };

      request.onerror = () => reject(request.error);
    });
  }

  async saveUser(user: UserData): Promise<void> {
    if (!this.db) throw new Error('DB가 열려있지 않아요!');
    
    return new Promise((resolve, reject) => {
      const transaction = this.db!.transaction(['users'], 'readwrite');
      const store = transaction.objectStore('users');
      const request = store.put(user);
      
      request.onsuccess = () => resolve();
      request.onerror = () => reject(request.error);
    });
  }

  async getUnsyncedUsers(): Promise<UserData[]> {
    if (!this.db) throw new Error('DB가 열려있지 않아요!');
    
    return new Promise((resolve, reject) => {
      const transaction = this.db!.transaction(['users'], 'readonly');
      const store = transaction.objectStore('users');
      const index = store.index('synced');
      const request = index.getAll(IDBKeyRange.only(false));
      
      request.onsuccess = () => resolve(request.result as UserData[]);
      request.onerror = () => reject(request.error);
    });
  }
}

export const offlineDB = new OfflineDB();
💡 idb 라이브러리 쓰면 훨씬 편함! npm install idb 하면 Promise 기반의 타입 안전한 IndexedDB 래퍼를 쓸 수 있음. 실무에서는 이걸 더 많이 씀.

🔄 Background Sync로 오프라인 작업 동기화

오프라인 상태에서 사용자가 데이터를 입력했을 때, 온라인 복구 시 자동으로 서버에 동기화하는 기능임. 이게 진짜 UX 혁명임ㅋㅋ

// src/sw.ts (Background Sync 추가)
declare const self: ServiceWorkerGlobalScope;

// Background Sync 이벤트 타입 (TypeScript에서 별도 선언 필요)
interface SyncEvent extends ExtendableEvent {
  tag: string;
  lastChance: boolean;
}

self.addEventListener('sync', (event: Event) => {
  const syncEvent = event as SyncEvent;
  
  if (syncEvent.tag === 'sync-user-data') {
    syncEvent.waitUntil(syncUserData());
  }
});

async function syncUserData(): Promise<void> {
  // IndexedDB에서 동기화 안 된 데이터 가져오기
  const unsyncedData = await getUnsyncedDataFromDB();
  
  for (const item of unsyncedData) {
    try {
      const response = await fetch('/api/users', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify(item)
      });
      
      if (response.ok) {
        // 동기화 성공 - DB에서 synced 플래그 업데이트
        await markAsSynced(item.id);
        console.log(`${item.id} 동기화 완료! ✅`);
      }
    } catch (error) {
      console.error('동기화 실패, 나중에 다시 시도:', error);
      throw error; // 재시도를 위해 에러 던지기
    }
  }
}

// 메인 앱에서 Background Sync 등록
async function registerBackgroundSync(): Promise<void> {
  if (!('serviceWorker' in navigator) || !('SyncManager' in window)) {
    console.warn('Background Sync 미지원 브라우저');
    return;
  }
  
  const registration = await navigator.serviceWorker.ready;
  await registration.sync.register('sync-user-data');
  console.log('Background Sync 등록 완료! 🔄');
}
⚠️ Background Sync는 Chrome/Edge에서는 잘 지원되지만, Safari/Firefox에서는 지원이 제한적임. 항상 폴백 처리 해줘야 함!

🔔 Push Notification 타입 처리

PWA의 킬러 기능 중 하나인 푸시 알림! TypeScript로 타입 안전하게 구현하는 법:

// src/sw.ts (Push 이벤트 추가)
declare const self: ServiceWorkerGlobalScope;

interface PushNotificationData {
  title: string;
  body: string;
  icon?: string;
  badge?: string;
  url?: string;
  tag?: string;
}

self.addEventListener('push', (event: PushEvent) => {
  if (!event.data) return;
  
  let data: PushNotificationData;
  
  try {
    data = event.data.json() as PushNotificationData;
  } catch {
    data = {
      title: '새 알림',
      body: event.data.text()
    };
  }
  
  const options: NotificationOptions = {
    body: data.body,
    icon: data.icon || '/icons/icon-192.png',
    badge: data.badge || '/icons/badge-72.png',
    tag: data.tag || 'default',
    data: { url: data.url || '/' },
    requireInteraction: false,
    silent: false
  };
  
  event.waitUntil(
    self.registration.showNotification(data.title, options)
  );
});

// 알림 클릭 처리
self.addEventListener('notificationclick', (event: NotificationEvent) => {
  event.notification.close();
  
  const url = (event.notification.data as { url: string }).url;
  
  event.waitUntil(
    self.clients.matchAll({ type: 'window' }).then((clients: WindowClient[]) => {
      // 이미 열린 탭이 있으면 포커스
      const existingClient = clients.find(
        (client: WindowClient) => client.url === url
      );
      
      if (existingClient) {
        return existingClient.focus();
      }
      // 없으면 새 탭 열기
      return self.clients.openWindow(url);
    })
  );
});

🛠️ Vite PWA 플러그인으로 자동화하기

매번 Service Worker 직접 짜는 거 귀찮으면 vite-plugin-pwa 쓰면 됨. TypeScript 타입도 자동 생성해줌ㅋㅋ 진짜 편함:

// vite.config.ts
import { defineConfig } from 'vite';
import { VitePWA } from 'vite-plugin-pwa';

export default defineConfig({
  plugins: [
    VitePWA({
      registerType: 'autoUpdate',
      // TypeScript 타입 파일 자동 생성
      injectRegister: 'auto',
      workbox: {
        // 캐싱할 파일 패턴
        globPatterns: ['**/*.{js,css,html,ico,png,svg,woff2}'],
        // 런타임 캐싱 전략
        runtimeCaching: [
          {
            urlPattern: /^https:\/\/api\.myapp\.com\/.*/i,
            handler: 'NetworkFirst',
            options: {
              cacheName: 'api-cache',
              expiration: {
                maxEntries: 100,
                maxAgeSeconds: 60 * 60 * 24 // 24시간
              },
              networkTimeoutSeconds: 10
            }
          },
          {
            urlPattern: /^https:\/\/fonts\.googleapis\.com\/.*/i,
            handler: 'CacheFirst',
            options: {
              cacheName: 'google-fonts-cache',
              expiration: {
                maxEntries: 10,
                maxAgeSeconds: 60 * 60 * 24 * 365 // 1년
              }
            }
          }
        ]
      },
      manifest: {
        name: 'My TypeScript PWA',
        short_name: 'MyPWA',
        theme_color: '#5a3fa0',
        icons: [
          { src: 'icons/icon-192.png', sizes: '192x192', type: 'image/png' },
          { src: 'icons/icon-512.png', sizes: '512x512', type: 'image/png' }
        ]
      }
    })
  ]
});
💡 vite-plugin-pwa는 내부적으로 Workbox를 사용함. Workbox는 Google이 만든 Service Worker 라이브러리로, 캐싱 전략을 선언적으로 설정할 수 있어서 진짜 편함!

🧪 Service Worker 테스트하기

SW 테스트는 일반 유닛 테스트랑 좀 다름. 환경 설정이 필요함:

Jest + TypeScript로 SW 테스트
// jest.config.ts
import type { Config } from 'jest';

const config: Config = {
  testEnvironment: 'jsdom',
  setupFilesAfterFramework: ['./src/test/setup.ts'],
  transform: {
    '^.+\\.tsx?$': 'ts-jest'
  }
};

export default config;

// src/test/sw.test.ts
import { describe, it, expect, beforeEach, vi } from 'vitest';

// Service Worker 환경 모킹
const mockCaches = {
  open: vi.fn(),
  match: vi.fn(),
  keys: vi.fn(),
  delete: vi.fn()
};

Object.defineProperty(global, 'caches', {
  value: mockCaches,
  writable: true
});

describe('Service Worker 캐싱 전략', () => {
  beforeEach(() => {
    vi.clearAllMocks();
  });

  it('Cache First: 캐시에 있으면 캐시 반환', async () => {
    const mockResponse = new Response('cached data');
    mockCaches.match.mockResolvedValue(mockResponse);
    
    // 테스트 로직...
    expect(mockCaches.match).toHaveBeenCalled();
  });

  it('Network First: 네트워크 실패 시 캐시 폴백', async () => {
    // fetch 실패 모킹
    global.fetch = vi.fn().mockRejectedValue(new Error('Network Error'));
    
    const mockCachedResponse = new Response('cached fallback');
    mockCaches.match.mockResolvedValue(mockCachedResponse);
    
    // 테스트 로직...
    expect(mockCaches.match).toHaveBeenCalled();
  });
});
Chrome DevTools로 디버깅

실제 개발할 때는 Chrome DevTools의 Application 탭이 진짜 유용함:

DevTools 기능용도
Application → Service WorkersSW 상태 확인, 강제 업데이트, 오프라인 시뮬레이션
Application → Cache Storage캐시된 리소스 목록 확인 및 삭제
Application → IndexedDB저장된 데이터 확인
Network → Offline 체크박스오프라인 상태 시뮬레이션
LighthousePWA 점수 및 개선 사항 확인

⚡ 실전 팁: 자주 하는 실수들
🚨 ⚠️ 🔥 💀 😅

실제로 PWA 개발하다가 많이 걸리는 함정들 정리해봄ㅋㅋ 나도 다 겪어봤음:

❌ 실수 1: SW 업데이트가 안 된다?

새 SW가 설치됐는데 기존 SW가 페이지를 제어하고 있으면 새 SW는 waiting 상태로 대기함.
self.skipWaiting()을 install 이벤트에서 호출하고, self.clients.claim()을 activate에서 호출해야 즉시 적용됨.
❌ 실수 2: 캐시가 너무 커진다?

캐시 용량 제한 없이 계속 쌓으면 스토리지 꽉 참. expiration 설정으로 최대 항목 수와 만료 시간 꼭 설정해줘야 함.
❌ 실수 3: POST 요청을 캐시하려 한다?

Cache API는 기본적으로 GET 요청만 캐시 가능함. POST 요청 캐싱은 IndexedDB + Background Sync 조합으로 처리해야 함.
❌ 실수 4: TypeScript에서 self 타입 에러

SW 파일 최상단에 declare const self: ServiceWorkerGlobalScope; 선언 안 하면 타입 에러 폭탄 맞음. 이거 꼭 넣어줘!
❌ 실수 5: CORS 문제로 캐싱 실패

외부 도메인 리소스는 CORS 설정이 없으면 캐시에 저장 안 됨. mode: 'no-cors'로 요청하면 opaque response가 오는데, 이건 캐시는 되지만 상태 코드 확인 불가능함.

그리고 재능넷 같은 플랫폼에서 프론트엔드 개발 재능을 거래할 때도 PWA 구현 능력은 진짜 고부가가치 스킬임! 오프라인 지원 웹앱 만들 수 있다고 하면 클라이언트들이 눈 반짝임ㅋㅋ


📊 캐싱 전략 비교 정리
캐싱 전략 비교 차트 📊 전략 속도 최신성 적합한 리소스 Cache First ⚡⚡⚡ CSS, JS, 이미지, 폰트 Network First ⭐⭐⭐ API 데이터, HTML Stale While Revalidate ⚡⚡ ⭐⭐ 뉴스피드, 프로필 Cache Only ⚡⚡⚡ 완전 오프라인 앱 Network Only ⭐⭐⭐ 결제, 실시간 채팅 ⚡ = 속도 / ⭐ = 최신성 / ❌ = 해당 없음 별 개수가 많을수록 해당 특성이 뛰어남

🎯 Workbox TypeScript 타입 활용

Workbox는 Google이 만든 Service Worker 라이브러리인데, TypeScript 타입 지원이 완벽함. 실무에서 직접 SW 짜는 것보다 Workbox 쓰는 경우가 훨씬 많음:

// npm install workbox-core workbox-routing workbox-strategies
// npm install -D @types/workbox-sw

import { clientsClaim } from 'workbox-core';
import { 
  registerRoute, 
  Route,
  NavigationRoute 
} from 'workbox-routing';
import { 
  CacheFirst, 
  NetworkFirst, 
  StaleWhileRevalidate,
  NetworkOnly
} from 'workbox-strategies';
import { 
  ExpirationPlugin 
} from 'workbox-expiration';
import { 
  BackgroundSyncPlugin 
} from 'workbox-background-sync';
import { precacheAndRoute } from 'workbox-precaching';

declare const self: ServiceWorkerGlobalScope;

// 즉시 활성화
clientsClaim();

// 빌드 시 자동 생성된 사전 캐시 목록 (Vite PWA 플러그인이 주입)
precacheAndRoute(self.__WB_MANIFEST);

// 이미지 캐싱 (Cache First + 만료 설정)
registerRoute(
  ({ request }: { request: Request }) => 
    request.destination === 'image',
  new CacheFirst({
    cacheName: 'images-cache',
    plugins: [
      new ExpirationPlugin({
        maxEntries: 60,
        maxAgeSeconds: 30 * 24 * 60 * 60, // 30일
        purgeOnQuotaError: true
      })
    ]
  })
);

// API 요청 (Network First + Background Sync)
const bgSyncPlugin = new BackgroundSyncPlugin('api-queue', {
  maxRetentionTime: 24 * 60 // 24시간 동안 재시도
});

registerRoute(
  ({ url }: { url: URL }) => url.pathname.startsWith('/api/'),
  new NetworkFirst({
    cacheName: 'api-cache',
    plugins: [
      bgSyncPlugin,
      new ExpirationPlugin({
        maxEntries: 100,
        maxAgeSeconds: 60 * 60 // 1시간
      })
    ],
    networkTimeoutSeconds: 5
  }),
  'POST'
);

// 폰트 캐싱 (Stale While Revalidate)
registerRoute(
  ({ url }: { url: URL }) => 
    url.origin === 'https://fonts.googleapis.com',
  new StaleWhileRevalidate({
    cacheName: 'google-fonts-stylesheets'
  })
);

// 오프라인 폴백 페이지
const navigationRoute = new NavigationRoute(
  new NetworkFirst({
    cacheName: 'pages-cache',
    plugins: [
      new ExpirationPlugin({ maxEntries: 50 })
    ]
  })
);
registerRoute(navigationRoute);
🔥 Workbox의 TypeScript 타입은 각 패키지에 내장되어 있어서 별도 @types 설치 없이도 자동완성이 완벽하게 됨. 진짜 개발 경험이 좋음!

📱 PWA 설치 프롬프트 TypeScript 처리

사용자에게 "앱 설치할래요?" 프롬프트 띄우는 것도 TypeScript로 타입 안전하게 처리할 수 있음:

// src/pwa-install.ts

// BeforeInstallPromptEvent는 표준 타입에 없어서 직접 선언
interface BeforeInstallPromptEvent extends Event {
  readonly platforms: string[];
  readonly userChoice: Promise<{
    outcome: 'accepted' | 'dismissed';
    platform: string;
  }>;
  prompt(): Promise<void>;
}

class PWAInstallManager {
  private deferredPrompt: BeforeInstallPromptEvent | null = null;
  private isInstalled: boolean = false;

  constructor() {
    this.init();
  }

  private init(): void {
    // 설치 프롬프트 이벤트 캡처
    window.addEventListener('beforeinstallprompt', (event: Event) => {
      event.preventDefault();
      this.deferredPrompt = event as BeforeInstallPromptEvent;
      this.showInstallButton();
    });

    // 설치 완료 감지
    window.addEventListener('appinstalled', () => {
      this.isInstalled = true;
      this.deferredPrompt = null;
      this.hideInstallButton();
      console.log('PWA 설치 완료! 🎉');
    });

    // 이미 설치된 경우 감지
    if (window.matchMedia('(display-mode: standalone)').matches) {
      this.isInstalled = true;
    }
  }

  async promptInstall(): Promise<boolean> {
    if (!this.deferredPrompt) {
      console.log('설치 프롬프트를 사용할 수 없어요');
      return false;
    }

    await this.deferredPrompt.prompt();
    const { outcome } = await this.deferredPrompt.userChoice;
    
    console.log(`사용자 선택: ${outcome}`);
    this.deferredPrompt = null;
    
    return outcome === 'accepted';
  }

  get canInstall(): boolean {
    return !!this.deferredPrompt && !this.isInstalled;
  }

  private showInstallButton(): void {
    const btn = document.getElementById('install-btn');
    if (btn) btn.style.display = 'block';
  }

  private hideInstallButton(): void {
    const btn = document.getElementById('install-btn');
    if (btn) btn.style.display = 'none';
  }
}

export const pwaInstall = new PWAInstallManager();

🌟 마무리: TypeScript PWA 개발의 핵심 포인트

자 여기까지 왔으면 TypeScript로 PWA 만드는 거 대충 감 잡혔을 거임ㅋㅋ 핵심만 다시 정리해보면:

✅ TypeScript PWA 핵심 체크리스트

1️⃣ tsconfig.json"lib": ["WebWorker"] 추가 (SW 전용)
2️⃣ SW 파일 최상단에 declare const self: ServiceWorkerGlobalScope; 선언
3️⃣ 캐싱 전략은 리소스 특성에 맞게 선택 (정적→Cache First, API→Network First)
4️⃣ install 이벤트에서 사전 캐싱, activate에서 오래된 캐시 정리
5️⃣ 오프라인 데이터는 IndexedDB + Background Sync 조합
6️⃣ 실무에서는 Workbox 라이브러리 활용 강력 추천
7️⃣ Chrome DevTools Application 탭으로 디버깅
8️⃣ HTTPS 환경 필수 (localhost 제외)

재능넷에서 TypeScript 개발 재능을 공유하거나 의뢰할 때, PWA 오프라인 기능 구현 능력은 진짜 차별화 포인트가 됨. 요즘 모바일 퍼스트 시대에 오프라인 지원은 선택이 아니라 필수가 되어가고 있거든 🚀

처음엔 Service Worker 개념이 좀 낯설고 생명주기 때문에 헷갈릴 수 있는데, 한 번 제대로 이해하고 나면 진짜 강력한 무기가 됨. TypeScript의 타입 시스템이 SW 개발할 때도 엄청난 도움이 되니까, 타입 설정 제대로 해두고 시작하는 거 잊지 마! 💪

🎉 🚀 💻 ✨ 🔥
댓글 작성

이 글에 대한 여러분의 생각을 들려주세요

댓글 0