/** * 서비스 워커 - 페이지 및 리소스 캐싱 * M-Project 작업보고서 시스템 */ const CACHE_NAME = 'mproject-v1.0.0'; const STATIC_CACHE = 'mproject-static-v1.0.0'; const DYNAMIC_CACHE = 'mproject-dynamic-v1.0.0'; // 캐시할 정적 리소스 const STATIC_ASSETS = [ '/', '/index.html', '/issue-view.html', '/daily-work.html', '/project-management.html', '/admin.html', '/static/js/api.js', '/static/js/core/permissions.js', '/static/js/components/common-header.js', '/static/js/core/page-manager.js', '/static/js/core/page-preloader.js', '/static/js/date-utils.js', '/static/js/image-utils.js', 'https://cdn.tailwindcss.com', 'https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css' ]; // 캐시 전략별 URL 패턴 const CACHE_STRATEGIES = { // 네트워크 우선 (API 호출) networkFirst: [ /\/api\//, /\/auth\// ], // 캐시 우선 (정적 리소스) cacheFirst: [ /\.css$/, /\.js$/, /\.png$/, /\.jpg$/, /\.jpeg$/, /\.gif$/, /\.svg$/, /\.woff$/, /\.woff2$/, /cdn\.tailwindcss\.com/, /cdnjs\.cloudflare\.com/ ], // 스테일 허용 (HTML 페이지) staleWhileRevalidate: [ /\.html$/, /\/$/ ] }; /** * 서비스 워커 설치 */ self.addEventListener('install', (event) => { console.log('🔧 서비스 워커 설치 중...'); event.waitUntil( caches.open(STATIC_CACHE) .then((cache) => { console.log('📦 정적 리소스 캐싱 중...'); return cache.addAll(STATIC_ASSETS); }) .then(() => { console.log('✅ 서비스 워커 설치 완료'); return self.skipWaiting(); }) .catch((error) => { console.error('❌ 서비스 워커 설치 실패:', error); }) ); }); /** * 서비스 워커 활성화 */ self.addEventListener('activate', (event) => { console.log('🚀 서비스 워커 활성화 중...'); event.waitUntil( caches.keys() .then((cacheNames) => { return Promise.all( cacheNames.map((cacheName) => { // 이전 버전 캐시 삭제 if (cacheName !== STATIC_CACHE && cacheName !== DYNAMIC_CACHE && cacheName !== CACHE_NAME) { console.log('🗑️ 이전 캐시 삭제:', cacheName); return caches.delete(cacheName); } }) ); }) .then(() => { console.log('✅ 서비스 워커 활성화 완료'); return self.clients.claim(); }) ); }); /** * 네트워크 요청 가로채기 */ self.addEventListener('fetch', (event) => { const { request } = event; const url = new URL(request.url); // CORS 요청이나 외부 도메인은 기본 처리 if (url.origin !== location.origin && !isCDNResource(url)) { return; } // 캐시 전략 결정 const strategy = getCacheStrategy(request.url); event.respondWith( handleRequest(request, strategy) ); }); /** * 요청 처리 (캐시 전략별) */ async function handleRequest(request, strategy) { try { switch (strategy) { case 'networkFirst': return await networkFirst(request); case 'cacheFirst': return await cacheFirst(request); case 'staleWhileRevalidate': return await staleWhileRevalidate(request); default: return await fetch(request); } } catch (error) { console.error('요청 처리 실패:', request.url, error); return await handleOffline(request); } } /** * 네트워크 우선 전략 */ async function networkFirst(request) { try { const networkResponse = await fetch(request); // 성공적인 응답만 캐시 if (networkResponse.ok) { const cache = await caches.open(DYNAMIC_CACHE); cache.put(request, networkResponse.clone()); } return networkResponse; } catch (error) { // 네트워크 실패 시 캐시에서 반환 const cachedResponse = await caches.match(request); if (cachedResponse) { return cachedResponse; } throw error; } } /** * 캐시 우선 전략 */ async function cacheFirst(request) { const cachedResponse = await caches.match(request); if (cachedResponse) { return cachedResponse; } // 캐시에 없으면 네트워크에서 가져와서 캐시 const networkResponse = await fetch(request); if (networkResponse.ok) { const cache = await caches.open(STATIC_CACHE); cache.put(request, networkResponse.clone()); } return networkResponse; } /** * 스테일 허용 전략 */ async function staleWhileRevalidate(request) { const cachedResponse = await caches.match(request); // 백그라운드에서 업데이트 const networkResponsePromise = fetch(request) .then((networkResponse) => { if (networkResponse.ok) { const cache = caches.open(DYNAMIC_CACHE); cache.then(c => c.put(request, networkResponse.clone())); } return networkResponse; }) .catch(() => null); // 캐시된 응답이 있으면 즉시 반환, 없으면 네트워크 대기 return cachedResponse || await networkResponsePromise; } /** * 캐시 전략 결정 */ function getCacheStrategy(url) { for (const [strategy, patterns] of Object.entries(CACHE_STRATEGIES)) { if (patterns.some(pattern => pattern.test(url))) { return strategy; } } return 'networkFirst'; // 기본값 } /** * CDN 리소스 확인 */ function isCDNResource(url) { const cdnDomains = [ 'cdn.tailwindcss.com', 'cdnjs.cloudflare.com', 'fonts.googleapis.com', 'fonts.gstatic.com' ]; return cdnDomains.some(domain => url.hostname.includes(domain)); } /** * 오프라인 처리 */ async function handleOffline(request) { // HTML 요청에 대한 오프라인 페이지 if (request.destination === 'document') { const offlinePage = await caches.match('/index.html'); if (offlinePage) { return offlinePage; } } // 이미지 요청에 대한 기본 이미지 if (request.destination === 'image') { return new Response( '오프라인', { headers: { 'Content-Type': 'image/svg+xml' } } ); } // 기본 오프라인 응답 return new Response('오프라인 상태입니다.', { status: 503, statusText: 'Service Unavailable', headers: { 'Content-Type': 'text/plain; charset=utf-8' } }); } /** * 메시지 처리 (캐시 관리) */ self.addEventListener('message', (event) => { const { type, payload } = event.data; switch (type) { case 'CLEAR_CACHE': clearAllCaches().then(() => { event.ports[0].postMessage({ success: true }); }); break; case 'CACHE_PAGE': cachePage(payload.url).then(() => { event.ports[0].postMessage({ success: true }); }); break; case 'GET_CACHE_STATUS': getCacheStatus().then((status) => { event.ports[0].postMessage({ status }); }); break; } }); /** * 모든 캐시 정리 */ async function clearAllCaches() { const cacheNames = await caches.keys(); await Promise.all( cacheNames.map(cacheName => caches.delete(cacheName)) ); console.log('🗑️ 모든 캐시 정리 완료'); } /** * 특정 페이지 캐시 */ async function cachePage(url) { try { const cache = await caches.open(DYNAMIC_CACHE); await cache.add(url); console.log('📦 페이지 캐시 완료:', url); } catch (error) { console.error('페이지 캐시 실패:', url, error); } } /** * 캐시 상태 조회 */ async function getCacheStatus() { const cacheNames = await caches.keys(); const status = {}; for (const cacheName of cacheNames) { const cache = await caches.open(cacheName); const keys = await cache.keys(); status[cacheName] = keys.length; } return status; }