Service Worker로 오프라인 캐시 구축

현대 웹 애플리케이션에서 오프라인 지원은 사용자 경험의 핵심 요소가 되었습니다. 네트워크 연결이 불안정하거나 완전히 끊어진 상황에서도 앱이 동작해야 합니다. **Service Worker**는 브라우저와 네트워크 사이의 프록시 역할을 하여 강력한 캐싱 전략을 구현할 수 있게 해줍니다. 이번 글에서는 정적 리소스부터 API 응답까지 체계적으로 캐싱하여 완전한 오프라인 웹앱을 만드는 방법을 소개하겠습니다.

1. Service Worker 기본 설정

Service Worker는 메인 스레드와 독립적으로 실행되는 백그라운드 스크립트입니다. 앱의 생명주기와 관계없이 네트워크 요청을 가로채고 캐시를 관리할 수 있습니다. 등록과 업데이트 로직을 신중하게 구현해야 사용자에게 혼란을 주지 않습니다.


// main.js - Service Worker 등록
const registerServiceWorker = async () => {
  if ('serviceWorker' in navigator) {
    try {
      const registration = await navigator.serviceWorker.register('/sw.js');
      
      registration.addEventListener('updatefound', () => {
        const newWorker = registration.installing;
        
        newWorker.addEventListener('statechange', () => {
          if (newWorker.state === 'installed') {
            if (navigator.serviceWorker.controller) {
              // 새 버전 사용 가능 알림
              showUpdateNotification();
            }
          }
        });
      });
      
      console.log('Service Worker 등록 성공');
    } catch (error) {
      console.error('Service Worker 등록 실패:', error);
    }
  }
};

const showUpdateNotification = () => {
  if (confirm('앱의 새 버전이 있습니다. 업데이트하시겠습니까?')) {
    window.location.reload();
  }
};

registerServiceWorker();
      

Service Worker의 업데이트는 24시간마다 자동으로 확인되지만, 수동으로 체크하는 기능도 제공하는 것이 좋습니다. 특히 중요한 업데이트가 있을 때는 사용자에게 명확히 알리고 선택권을 주어야 합니다.

2. 캐시 전략별 구현

효과적인 오프라인 지원을 위해서는 리소스 타입별로 다른 캐시 전략을 적용해야 합니다. 정적 리소스는 Cache First, API는 Network First, 이미지는 Stale While Revalidate 전략을 사용합니다. 각 전략의 특성을 이해하고 앱의 요구사항에 맞게 선택하는 것이 중요합니다.


// sw.js - 다양한 캐시 전략 구현
const CACHE_NAME = 'my-app-v1';
const STATIC_CACHE = 'static-v1';
const API_CACHE = 'api-v1';
const IMAGE_CACHE = 'images-v1';

// Cache First - 정적 리소스용
const cacheFirst = async (request) => {
  const cachedResponse = await caches.match(request);
  if (cachedResponse) {
    return cachedResponse;
  }
  
  const networkResponse = await fetch(request);
  const cache = await caches.open(STATIC_CACHE);
  cache.put(request, networkResponse.clone());
  
  return networkResponse;
};

// Network First - API 요청용
const networkFirst = async (request) => {
  try {
    const networkResponse = await fetch(request);
    const cache = await caches.open(API_CACHE);
    cache.put(request, networkResponse.clone());
    return networkResponse;
  } catch (error) {
    const cachedResponse = await caches.match(request);
    if (cachedResponse) {
      return cachedResponse;
    }
    throw error;
  }
};

// Stale While Revalidate - 이미지용
const staleWhileRevalidate = async (request) => {
  const cache = await caches.open(IMAGE_CACHE);
  const cachedResponse = await cache.match(request);
  
  const fetchPromise = fetch(request).then(networkResponse => {
    cache.put(request, networkResponse.clone());
    return networkResponse;
  });
  
  return cachedResponse || fetchPromise;
};

self.addEventListener('fetch', event => {
  const { request } = event;
  
  if (request.url.includes('/api/')) {
    event.respondWith(networkFirst(request));
  } else if (request.destination === 'image') {
    event.respondWith(staleWhileRevalidate(request));
  } else if (request.method === 'GET') {
    event.respondWith(cacheFirst(request));
  }
});
      

3. 워크박스로 고급 캐싱

Google의 Workbox 라이브러리를 사용하면 복잡한 캐시 로직을 쉽게 구현할 수 있습니다. 사전 캐싱, 런타임 캐싱, 백그라운드 동기화 등의 고급 기능을 제공합니다. 빌드 프로세스와 통합하여 자동으로 캐시 매니페스트를 생성할 수도 있습니다.


// workbox-config.js
module.exports = {
  globDirectory: 'dist/',
  globPatterns: [
    '**/*.{html,js,css,png,jpg,gif,svg,woff2}'
  ],
  swDest: 'dist/sw.js',
  runtimeCaching: [
    {
      urlPattern: /^https:\/\/api\.example\.com\//,
      handler: 'NetworkFirst',
      options: {
        cacheName: 'api-cache',
        networkTimeoutSeconds: 3,
        cacheableResponse: {
          statuses: [0, 200]
        }
      }
    },
    {
      urlPattern: /\.(?:png|gif|jpg|jpeg|svg)$/,
      handler: 'CacheFirst',
      options: {
        cacheName: 'image-cache',
        expiration: {
          maxEntries: 100,
          maxAgeSeconds: 30 * 24 * 60 * 60 // 30일
        }
      }
    }
  ]
};

// sw.js with Workbox
import { precacheAndRoute, cleanupOutdatedCaches } from 'workbox-precaching';
import { registerRoute } from 'workbox-routing';
import { CacheFirst, NetworkFirst, StaleWhileRevalidate } from 'workbox-strategies';

// 사전 캐싱 설정
precacheAndRoute(self.__WB_MANIFEST);
cleanupOutdatedCaches();

// 동적 캐싱 라우트 등록
registerRoute(
  ({ request }) => request.destination === 'image',
  new CacheFirst({
    cacheName: 'images',
    plugins: [
      {
        cacheKeyWillBeUsed: async ({ request }) => {
          return `${request.url}?v=${Date.now()}`;
        }
      }
    ]
  })
);
      

4. 오프라인 폴백 페이지

캐시에 없는 페이지에 오프라인 상태로 접근할 때 표시할 폴백 페이지가 필요합니다. 사용자에게 현재 상태를 명확히 알리고, 가능한 대안을 제시하는 것이 좋습니다. 브랜딩과 일관된 디자인으로 자연스러운 경험을 제공해야 합니다.


// 오프라인 폴백 처리
const OFFLINE_PAGE = '/offline.html';
const FALLBACK_IMAGE = '/images/offline.svg';

self.addEventListener('install', event => {
  event.waitUntil(
    caches.open(STATIC_CACHE).then(cache => {
      return cache.addAll([OFFLINE_PAGE, FALLBACK_IMAGE]);
    })
  );
});

self.addEventListener('fetch', event => {
  if (event.request.mode === 'navigate') {
    event.respondWith(
      fetch(event.request).catch(() => {
        return caches.match(OFFLINE_PAGE);
      })
    );
  } else if (event.request.destination === 'image') {
    event.respondWith(
      fetch(event.request).catch(() => {
        return caches.match(FALLBACK_IMAGE);
      })
    );
  }
});

// 네트워크 상태 감지
const updateOnlineStatus = () => {
  document.body.classList.toggle('offline', !navigator.onLine);
};

window.addEventListener('online', updateOnlineStatus);
window.addEventListener('offline', updateOnlineStatus);
      

5. 캐시 관리와 정리

캐시가 계속 쌓이면 저장 공간 부족 문제가 발생할 수 있습니다. 적절한 만료 정책과 정리 로직을 구현하여 캐시 크기를 관리해야 합니다. 사용자가 직접 캐시를 정리할 수 있는 옵션도 제공하는 것이 좋습니다.


// 캐시 관리 유틸리티
class CacheManager {
  static async getCacheSize() {
    let totalSize = 0;
    const cacheNames = await caches.keys();
    
    for (const name of cacheNames) {
      const cache = await caches.open(name);
      const requests = await cache.keys();
      
      for (const request of requests) {
        const response = await cache.match(request);
        if (response) {
          totalSize += (await response.clone().arrayBuffer()).byteLength;
        }
      }
    }
    
    return totalSize;
  }

  static async clearOldCaches(maxAge = 7 * 24 * 60 * 60 * 1000) {
    const cacheNames = await caches.keys();
    const now = Date.now();
    
    for (const name of cacheNames) {
      if (name.includes('old-') || now - this.getCacheTimestamp(name) > maxAge) {
        await caches.delete(name);
        console.log(`캐시 삭제: ${name}`);
      }
    }
  }

  static getCacheTimestamp(cacheName) {
    const match = cacheName.match(/v(\d+)$/);
    return match ? parseInt(match[1]) : 0;
  }

  static async clearAllCaches() {
    const cacheNames = await caches.keys();
    await Promise.all(cacheNames.map(name => caches.delete(name)));
    console.log('모든 캐시 삭제 완료');
  }
}

// 사용 예시
document.getElementById('clear-cache').addEventListener('click', async () => {
  if (confirm('모든 캐시를 삭제하시겠습니까?')) {
    await CacheManager.clearAllCaches();
    location.reload();
  }
});
      

6. 백그라운드 동기화

오프라인 상태에서 사용자가 입력한 데이터는 네트워크가 복구될 때 자동으로 동기화되어야 합니다. Background Sync API를 활용하면 이러한 기능을 구현할 수 있습니다.


// 백그라운드 동기화 구현
self.addEventListener('sync', event => {
  if (event.tag === 'background-sync') {
    event.waitUntil(syncOfflineData());
  }
});

const syncOfflineData = async () => {
  const offlineData = await getOfflineData();
  
  for (const item of offlineData) {
    try {
      await fetch('/api/sync', {
        method: 'POST',
        body: JSON.stringify(item),
        headers: { 'Content-Type': 'application/json' }
      });
      
      await removeOfflineData(item.id);
    } catch (error) {
      console.error('동기화 실패:', error);
    }
  }
};

// 메인 앱에서 오프라인 데이터 저장
const saveOfflineData = async (data) => {
  const db = await openDB();
  await db.put('offline-queue', data);
  
  // 백그라운드 동기화 등록
  if ('serviceWorker' in navigator && 'sync' in window.ServiceWorkerRegistration.prototype) {
    const registration = await navigator.serviceWorker.ready;
    await registration.sync.register('background-sync');
  }
};
      

7. 마무리하며

Service Worker를 활용한 오프라인 캐시는 웹앱의 안정성과 사용자 경험을 크게 향상시킵니다. 적절한 캐시 전략을 선택하고, 정기적인 관리와 업데이트 로직을 구현하는 것이 핵심입니다. 사용자에게 투명한 오프라인 경험을 제공하고, 네트워크 복구 시 자동 동기화까지 고려한다면 네이티브 앱에 버금가는 웹앱을 만들 수 있습니다.