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

타입스크립트 PWA와 Service Worker 타입을 활용한 오프라인 기능 구현
인터넷 없어도 앱이 돌아간다고?! 🤯 그 비밀, 지금 파헤쳐봄
야 솔직히 말해서, 지하철 타다가 인터넷 끊겼을 때 앱이 그냥 하얗게 뻗어버리면 진짜 현타 오지 않음?ㅋㅋㅋ 근데 요즘 잘 만든 웹앱들은 오프라인에서도 멀쩡히 돌아가잖아. 그게 바로 PWA(Progressive Web App)의 힘이고, 그 핵심엔 Service Worker가 있음.
오늘은 TypeScript로 PWA 만들면서 Service Worker 타입을 어떻게 다루는지, 오프라인 기능을 어떻게 구현하는지 제대로 뜯어볼 거임. 코드도 실제로 쓸 수 있는 거 위주로 가져왔으니까 집중해줘 👀
PWA는 Progressive Web App의 약자로, 웹 기술(HTML/CSS/JS)로 만들었지만 네이티브 앱처럼 동작하는 웹앱임. 설치도 되고, 푸시 알림도 되고, 오프라인도 됨. 진짜 앱이랑 거의 구분 안 됨ㅋㅋ
PWA의 핵심 특징을 표로 정리해보면 이럼:
| 특징 | 설명 | 관련 기술 |
|---|---|---|
| 🔌 오프라인 지원 | 네트워크 없어도 동작 | Service Worker + Cache API |
| 📲 설치 가능 | 홈 화면에 추가 가능 | Web App Manifest |
| 🔔 푸시 알림 | 백그라운드 알림 발송 | Push API + Notification API |
| ⚡ 빠른 로딩 | 캐시로 즉시 응답 | Cache Storage |
| 🔒 보안 | HTTPS 필수 | TLS/SSL |
Service Worker는 브라우저 백그라운드에서 돌아가는 JavaScript 워커 스크립트임. 웹 페이지와 완전히 분리된 별도 스레드에서 실행되고, DOM에 직접 접근 못 함. 대신 네트워크 요청을 가로채서 캐시에서 응답하거나, 백그라운드 동기화 같은 걸 처리함.
생명주기 이해가 진짜 중요함. 특히 업데이트할 때 기존 SW가 살아있으면 새 SW가 waiting 상태로 대기하는 거 모르면 나중에 "왜 업데이트가 안 돼?!" 하고 멘붕 옴ㅋㅋㅋ
자 이제 본론임. TypeScript로 Service Worker 작성할 때 제일 먼저 막히는 게 타입 설정임. self가 뭔지, ServiceWorkerGlobalScope가 뭔지 모르면 타입 에러 폭탄 맞음ㅋㅋ
Service Worker는 브라우저 메인 스레드가 아니라 별도 워커 스레드에서 실행되기 때문에, lib 설정에 "WebWorker"를 추가해야 함:
// tsconfig.json (Service Worker 전용)
{
"compilerOptions": {
"target": "ES2017",
"module": "ESNext",
"lib": ["ES2017", "WebWorker"],
"strict": true,
"outDir": "./dist",
"rootDir": "./src"
}
}
"lib": ["DOM", "ES2017"], SW는 "lib": ["WebWorker", "ES2017"]으로 따로 관리하면 타입 충돌 없음!Service Worker 파일 최상단에 이걸 선언해줘야 self의 타입이 제대로 잡힘:
// service-worker.ts
declare const self: ServiceWorkerGlobalScope;
// 이제 self.addEventListener 등에서 타입 자동완성 됨!
export {}; // 모듈로 인식시키기 위해 필요
이거 안 하면 self가 Window & typeof globalThis로 잡혀서 SW 전용 API들 쓸 때 타입 에러 남. 진짜 흔한 실수임ㅋㅋ
| 타입/인터페이스 | 설명 |
|---|---|
ServiceWorkerGlobalScope | SW의 전역 스코프 타입 |
ExtendableEvent | install, activate 이벤트 타입 |
FetchEvent | fetch 이벤트 타입 (요청 가로채기) |
Cache | 캐시 스토리지 인터페이스 |
CacheStorage | caches 전역 객체 타입 |
Request | HTTP 요청 타입 |
Response | HTTP 응답 타입 |
PushEvent | 푸시 알림 이벤트 타입 |
오프라인 기능의 핵심은 캐싱 전략임. 어떤 리소스를 언제 캐시에서 줄지, 네트워크에서 가져올지 결정하는 게 전략의 핵심임. 대표적인 전략 5가지 알아보자.
캐시에 있으면 캐시에서 주고, 없으면 네트워크에서 가져옴. 정적 리소스(이미지, 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);
})
);
});
항상 최신 데이터가 필요한 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>;
});
});
}
일단 캐시에서 빠르게 주고, 백그라운드에서 네트워크 요청해서 캐시 업데이트함. 속도와 최신성 둘 다 챙기는 전략임:
// 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
실제 프로젝트에서 어떻게 구조를 잡는지 보여줄게. 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
// 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();
}
}
// 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"]
}
캐시 API는 HTTP 요청/응답 캐싱용이고, 앱 데이터(사용자 입력, 폼 데이터 등)는 IndexedDB에 저장해야 함. TypeScript로 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();
npm install idb 하면 Promise 기반의 타입 안전한 IndexedDB 래퍼를 쓸 수 있음. 실무에서는 이걸 더 많이 씀.오프라인 상태에서 사용자가 데이터를 입력했을 때, 온라인 복구 시 자동으로 서버에 동기화하는 기능임. 이게 진짜 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 등록 완료! 🔄');
}
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);
})
);
});
매번 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 라이브러리로, 캐싱 전략을 선언적으로 설정할 수 있어서 진짜 편함!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의 Application 탭이 진짜 유용함:
| DevTools 기능 | 용도 |
|---|---|
| Application → Service Workers | SW 상태 확인, 강제 업데이트, 오프라인 시뮬레이션 |
| Application → Cache Storage | 캐시된 리소스 목록 확인 및 삭제 |
| Application → IndexedDB | 저장된 데이터 확인 |
| Network → Offline 체크박스 | 오프라인 상태 시뮬레이션 |
| Lighthouse | PWA 점수 및 개선 사항 확인 |
실제로 PWA 개발하다가 많이 걸리는 함정들 정리해봄ㅋㅋ 나도 다 겪어봤음:
새 SW가 설치됐는데 기존 SW가 페이지를 제어하고 있으면 새 SW는 waiting 상태로 대기함.
self.skipWaiting()을 install 이벤트에서 호출하고, self.clients.claim()을 activate에서 호출해야 즉시 적용됨.
캐시 용량 제한 없이 계속 쌓으면 스토리지 꽉 참.
expiration 설정으로 최대 항목 수와 만료 시간 꼭 설정해줘야 함.
Cache API는 기본적으로 GET 요청만 캐시 가능함. POST 요청 캐싱은 IndexedDB + Background Sync 조합으로 처리해야 함.
SW 파일 최상단에
declare const self: ServiceWorkerGlobalScope; 선언 안 하면 타입 에러 폭탄 맞음. 이거 꼭 넣어줘!
외부 도메인 리소스는 CORS 설정이 없으면 캐시에 저장 안 됨.
mode: 'no-cors'로 요청하면 opaque response가 오는데, 이건 캐시는 되지만 상태 코드 확인 불가능함.
그리고 재능넷 같은 플랫폼에서 프론트엔드 개발 재능을 거래할 때도 PWA 구현 능력은 진짜 고부가가치 스킬임! 오프라인 지원 웹앱 만들 수 있다고 하면 클라이언트들이 눈 반짝임ㅋㅋ
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);
@types 설치 없이도 자동완성이 완벽하게 됨. 진짜 개발 경험이 좋음!사용자에게 "앱 설치할래요?" 프롬프트 띄우는 것도 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 만드는 거 대충 감 잡혔을 거임ㅋㅋ 핵심만 다시 정리해보면:
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
지식인의 숲 - 지적 재산권 보호 고지
지적 재산권 보호 고지
- 저작권 및 소유권: 본 컨텐츠는 재능넷의 독점 AI 기술로 생성되었으며, 대한민국 저작권법 및 국제 저작권 협약에 의해 보호됩니다.
- AI 생성 컨텐츠의 법적 지위: 본 AI 생성 컨텐츠는 재능넷의 지적 창작물로 인정되며, 관련 법규에 따라 저작권 보호를 받습니다.
- 사용 제한: 재능넷의 명시적 서면 동의 없이 본 컨텐츠를 복제, 수정, 배포, 또는 상업적으로 활용하는 행위는 엄격히 금지됩니다.
- 데이터 수집 금지: 본 컨텐츠에 대한 무단 스크래핑, 크롤링, 및 자동화된 데이터 수집은 법적 제재의 대상이 됩니다.
- AI 학습 제한: 재능넷의 AI 생성 컨텐츠를 타 AI 모델 학습에 무단 사용하는 행위는 금지되며, 이는 지적 재산권 침해로 간주됩니다.

댓글 작성
이 글에 대한 여러분의 생각을 들려주세요
로그인이 필요합니다
댓글을 작성하려면 먼저 로그인해주세요.