본문 바로가기

React

React에서 Service Worker로 PWA 기능 추가하기

Service Worker를 추가하면 React 앱에 오프라인 지원, 설치 가능한 앱(웹 앱 설치 배너), 백그라운드 업데이트 등 PWA 핵심 기능을 빠르게 도입할 수 있습니다. 실무에서 바로 적용 가능한 최소 설정부터 업데이트 UX, 배포 체크리스트까지 정리했습니다.

1. PWA 핵심과 배치 위치

필수는 두 가지입니다. 1) 웹 앱 매니페스트(manifest.json), 2) 루트 스코프의 Service Worker입니다. Service Worker는 사이트 루트(/)를 제어해야 하므로 빌드 시 /service-worker.js가 공개 루트에 배치되어야 합니다. React(Vite/CRA)는 보통 public 폴더에 넣으면 빌드시 루트로 복사됩니다.

개발 모드에서는 Service Worker가 제대로 동작하지 않을 수 있으니, 실제 동작 검증은 반드시 빌드 후 프리뷰/배포 환경에서 진행합니다.

2. manifest.json 추가

앱 이름, 아이콘, 색상을 정의합니다. public/manifest.json을 만들고 index.html에 링크합니다.

// public/manifest.json
{
  'name': 'React PWA Demo',
  'short_name': 'PWA Demo',
  'start_url': '/',
  'display': 'standalone',
  'background_color': '#ffffff',
  'theme_color': '#0ea5e9',
  'icons': [
    { 'src': '/icons/icon-192.png', 'sizes': '192x192', 'type': 'image/png' },
    { 'src': '/icons/icon-512.png', 'sizes': '512x512', 'type': 'image/png' }
  ]
}
// public/index.html <head> 내부
<link rel='manifest' href='/manifest.json' />
<meta name='theme-color' content='#0ea5e9' />

3. Service Worker 작성(캐싱 + 오프라인)

public/service-worker.js를 생성합니다. 앱 셸(기본 HTML, 폴백 페이지 등)은 사전 캐싱하고, 정적 자산과 API는 런타임 캐싱 전략을 씁니다. 빌드 자산 경로는 프로젝트에 맞게 수정합니다(Vite는 /assets, CRA는 /static 등).

// public/service-worker.js
const CACHE_NAME = 'app-cache-v1';
const APP_SHELL = [
  '/',
  '/index.html',
  '/offline.html',
  '/favicon.ico'
  // 필요 시 빌드 결과 정적 파일 경로 추가(예: '/static/js/main.xxx.js')
];

self.addEventListener('install', (event) => {
  event.waitUntil(
    caches.open(CACHE_NAME)
      .then((cache) => cache.addAll(APP_SHELL))
      .then(() => self.skipWaiting())
  );
});

self.addEventListener('activate', (event) => {
  event.waitUntil(
    caches.keys().then((keys) => Promise.all(
      keys.filter((k) => k !== CACHE_NAME).map((k) => caches.delete(k))
    )).then(() => self.clients.claim())
  );
});

self.addEventListener('fetch', (event) => {
  const req = event.request;
  if (req.method !== 'GET') return; // POST 등은 네트워크 우선

  const url = new URL(req.url);

  // 동일 오리진 정적 자산: Stale-While-Revalidate 비슷한 전략
  if (url.origin === location.origin) {
    // 빌드 정적 자산 경로에 맞춰 수정(Vite: '/assets/', CRA: '/static/')
    if (url.pathname.startsWith('/assets/') || url.pathname.startsWith('/static/')) {
      event.respondWith(
        caches.match(req).then((cached) => {
          const fetchPromise = fetch(req).then((res) => {
            const copy = res.clone();
            caches.open(CACHE_NAME).then((c) => c.put(req, copy));
            return res;
          });
          return cached || fetchPromise;
        }).catch(() => caches.match('/offline.html'))
      );
      return;
    }

    // 앱 셸 및 기타 동일 오리진: Cache First, 네트워크 실패 시 폴백
    event.respondWith(
      caches.match(req).then((cached) => {
        return cached || fetch(req).catch(() => caches.match('/offline.html'));
      })
    );
    return;
  }

  // 외부 API: Network First, 실패 시 폴백
  event.respondWith(
    fetch(req)
      .then((res) => {
        const copy = res.clone();
        caches.open(CACHE_NAME).then((c) => c.put(req, copy));
        return res;
      })
      .catch(() => caches.match(req).then((c) => c || caches.match('/offline.html')))
  );
});

// 업데이트 트리거(UX를 위해 앱에서 메시지 수신)
self.addEventListener('message', (event) => {
  if (event.data && event.data.type === 'SKIP_WAITING') {
    self.skipWaiting();
  }
});

4. React에서 Service Worker 등록과 업데이트 UX

앱 시작 시 Service Worker를 등록하고, 새 버전 배포 시 사용자에게 새로고침을 안내합니다.

// src/main.tsx 또는 src/index.tsx
if ('serviceWorker' in navigator) {
  window.addEventListener('load', () => {
    navigator.serviceWorker.register('/service-worker.js').then((reg) => {
      // 이미 대기 중인 새 워커가 있으면 알림
      if (reg.waiting) promptUpdate(reg);

      reg.addEventListener('updatefound', () => {
        const nw = reg.installing;
        if (!nw) return;
        nw.addEventListener('statechange', () => {
          if (nw.state === 'installed' && navigator.serviceWorker.controller) {
            promptUpdate(reg);
          }
        });
      });
    }).catch(console.error);
  });
}

function promptUpdate(reg) {
  const ok = window.confirm('새로운 버전이 배포되었습니다. 지금 업데이트할까요?');
  if (ok) reg.waiting && reg.waiting.postMessage({ type: 'SKIP_WAITING' });
}

// 새 워커가 컨트롤러로 전환되면 새로고침
navigator.serviceWorker?.addEventListener('controllerchange', () => {
  window.location.reload();
});

5. 오프라인 폴백 페이지 연결

네트워크가 완전히 끊긴 경우 보여줄 간단한 offline.html을 public에 두고, Service Worker에서 해당 페이지로 폴백합니다.

// public/offline.html
<!doctype html>
<html>
  <head>
    <meta charset='utf-8' />
    <meta name='viewport' content='width=device-width, initial-scale=1' />
    <title>오프라인입니다</title>
    <style>body{font-family:sans-serif;padding:24px} .dim{color:#666}</style>
  </head>
  <body>
    <h1>오프라인 상태</h1>
    <p class='dim'>네트워크 연결이 없어도 기본 페이지는 이용할 수 있습니다.</p>
  </body>
</html>

6. Vite 프로젝트라면 vite-plugin-pwa가 더 쉽습니다

핸드크래프트 대신 플러그인을 쓰면 프리캐시/업데이트 로직을 자동화할 수 있습니다.

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

export default defineConfig({
  plugins: [
    react(),
    VitePWA({
      registerType: 'prompt', // 업데이트 시 사용자에게 알림
      includeAssets: ['favicon.svg', 'robots.txt', 'apple-touch-icon.png'],
      manifest: {
        name: 'React PWA Demo',
        short_name: 'PWA Demo',
        start_url: '/',
        display: 'standalone',
        theme_color: '#0ea5e9',
        icons: [
          { src: 'icons/icon-192.png', sizes: '192x192', type: 'image/png' },
          { src: 'icons/icon-512.png', sizes: '512x512', type: 'image/png' }
        ]
      },
      workbox: {
        runtimeCaching: [
          {
            urlPattern: /^https:\/\/api\.example\.com\//,
            handler: 'NetworkFirst',
            options: {
              cacheName: 'api-cache',
              expiration: { maxEntries: 50, maxAgeSeconds: 60 * 60 * 24 }
            }
          }
        ]
      }
    })
  ]
});
// src/main.tsx - 업데이트 UX
import { registerSW } from 'virtual:pwa-register';

const updateSW = registerSW({
  onNeedRefresh() {
    if (confirm('업데이트가 있습니다. 새로고침할까요?')) updateSW();
  },
  onOfflineReady() {
    console.log('오프라인 준비 완료');
  }
});

7. 배포 체크리스트와 디버깅 팁

HTTPS가 필수입니다(로컬호스트 제외). service-worker.js는 캐시되지 않도록 Cache-Control: no-cache로 서빙하는 것이 업데이트 감지에 유리합니다. 정적 파일은 파일명 해시를 사용해 캐시 무효화를 자동화하세요. Service Worker 파일은 반드시 루트 경로에서 서빙되어야 전체 앱을 제어합니다. Chrome DevTools Application 탭의 Service Workers, Cache Storage에서 등록/캐시 상태를 확인하고, Update on reload 옵션으로 업데이트를 강제로 테스트할 수 있습니다.

API 캐싱은 만료 정책을 꼭 설정하고, 로그인 토큰이 필요한 요청은 캐시를 피하세요(Authorization 포함 요청은 네트워크 우선). 백엔드 CORS/캐시 헤더 설정도 함께 점검해야 합니다.

여기까지 적용하면 오프라인 동작, 설치 가능, 부드러운 업데이트까지 PWA의 핵심 가치를 React 앱에 안정적으로 넣을 수 있습니다.