스마트폰 사용자가 앱 스토어에서 새 앱을 다운받는 빈도는 해마다 감소하고 있습니다. 반면 브라우저로 접근할 수 있는 웹 서비스 이용은 꾸준히 늘고 있죠. PWA(Progressive Web App)는 이 두 세계의 장점을 결합한 기술입니다. 설치 없이도 앱처럼 동작하고, 오프라인에서도 콘텐츠를 볼 수 있으며, 홈 화면에 아이콘을 추가할 수 있습니다.

이 글에서는 PWA를 구성하는 핵심 기술인 Web App Manifest, Service Worker, 캐싱 전략을 처음부터 실전 코드와 함께 살펴봅니다.

PWA의 세 가지 핵심 요건

구글이 정의한 PWA의 핵심 요건은 다음 세 가지입니다.

  • 신뢰성(Reliable) — 네트워크 상태와 무관하게 즉시 로드됩니다.
  • 빠름(Fast) — 스크롤과 애니메이션이 부드럽고 사용자 입력에 즉각 반응합니다.
  • 몰입감(Engaging) — 앱처럼 설치하고, 전체 화면으로 실행하며, 홈 화면에 추가할 수 있습니다.

1단계 — Web App Manifest 설정

Manifest는 앱의 이름, 아이콘, 시작 URL, 화면 방향 등을 정의하는 JSON 파일입니다. 브라우저가 이 파일을 읽어 "홈 화면에 추가" 프롬프트를 표시합니다.

// manifest.json
{
  "name": "My 매일이슈",
  "short_name": "매일이슈",
  "description": "웹 개발 기술 블로그",
  "start_url": "/index.html",
  "display": "standalone",
  "background_color": "#ffffff",
  "theme_color": "#4f46e5",
  "orientation": "portrait-primary",
  "icons": [
    {
      "src": "/icons/icon-192.png",
      "sizes": "192x192",
      "type": "image/png",
      "purpose": "any maskable"
    },
    {
      "src": "/icons/icon-512.png",
      "sizes": "512x512",
      "type": "image/png"
    }
  ]
}

HTML <head>에 아래 한 줄을 추가하면 브라우저가 Manifest를 인식합니다.

<link rel="manifest" href="/manifest.json">

2단계 — Service Worker 등록

Service Worker는 브라우저와 네트워크 사이에서 동작하는 별도의 JavaScript 스레드입니다. 네트워크 요청을 가로채고, 캐시를 읽거나 쓰고, 백그라운드에서 푸시 알림을 받을 수 있습니다.

// main.js — Service Worker 등록
if ('serviceWorker' in navigator) {
  window.addEventListener('load', async () => {
    try {
      const registration = await navigator.serviceWorker.register('/sw.js');
      console.log('SW 등록 성공:', registration.scope);
    } catch (error) {
      console.error('SW 등록 실패:', error);
    }
  });
}

3단계 — Service Worker 구현과 캐싱 전략

Service Worker의 생명주기는 install → activate → fetch 세 단계로 이루어집니다.

// sw.js
const CACHE_NAME = 'devlog-v1';
const STATIC_ASSETS = [
  '/',
  '/index.html',
  '/style.css',
  '/main.js',
  '/icons/icon-192.png'
];

// install — 정적 자산을 캐시에 저장
self.addEventListener('install', (event) => {
  event.waitUntil(
    caches.open(CACHE_NAME).then((cache) => cache.addAll(STATIC_ASSETS))
  );
  self.skipWaiting(); // 새 SW를 즉시 활성화
});

// activate — 오래된 캐시 정리
self.addEventListener('activate', (event) => {
  event.waitUntil(
    caches.keys().then((keys) =>
      Promise.all(
        keys.filter((key) => key !== CACHE_NAME).map((key) => caches.delete(key))
      )
    )
  );
  self.clients.claim();
});

캐싱 전략 선택하기

상황에 따라 아래 네 가지 전략을 조합해 사용합니다.

Cache First (캐시 우선)

캐시에 있으면 캐시를 반환하고, 없을 때만 네트워크에 요청합니다. 폰트·이미지처럼 거의 바뀌지 않는 자산에 적합합니다.

self.addEventListener('fetch', (event) => {
  event.respondWith(
    caches.match(event.request).then((cached) =>
      cached ?? fetch(event.request)
    )
  );
});

Network First (네트워크 우선)

네트워크에 먼저 요청하고, 실패하면 캐시를 반환합니다. 최신 데이터가 중요한 API 응답에 적합합니다.

self.addEventListener('fetch', (event) => {
  event.respondWith(
    fetch(event.request)
      .then((response) => {
        // 성공 시 캐시에도 저장
        const clone = response.clone();
        caches.open(CACHE_NAME).then((cache) => cache.put(event.request, clone));
        return response;
      })
      .catch(() => caches.match(event.request))
  );
});

Stale While Revalidate

캐시를 즉시 반환하면서 동시에 네트워크 요청을 보내 캐시를 갱신합니다. 속도와 최신성을 동시에 원할 때 최선의 선택입니다.

self.addEventListener('fetch', (event) => {
  event.respondWith(
    caches.open(CACHE_NAME).then((cache) =>
      cache.match(event.request).then((cached) => {
        const fetchPromise = fetch(event.request).then((response) => {
          cache.put(event.request, response.clone());
          return response;
        });
        return cached ?? fetchPromise;
      })
    )
  );
});

오프라인 폴백 페이지

네트워크도 없고 캐시에도 없을 때, 사용자에게 친절한 오프라인 안내 페이지를 보여줄 수 있습니다.

// install 단계에서 오프라인 페이지도 캐싱
self.addEventListener('install', (event) => {
  event.waitUntil(
    caches.open(CACHE_NAME).then((cache) =>
      cache.addAll([...STATIC_ASSETS, '/offline.html'])
    )
  );
});

// fetch 단계에서 폴백
self.addEventListener('fetch', (event) => {
  if (event.request.mode === 'navigate') {
    event.respondWith(
      fetch(event.request).catch(() => caches.match('/offline.html'))
    );
  }
});

Lighthouse로 PWA 점수 확인하기

Chrome DevTools의 Lighthouse 탭을 열고 "Progressive Web App" 항목을 체크해 감사를 실행하면 PWA 준수 여부를 항목별로 확인할 수 있습니다. 특히 다음 항목을 꼭 통과해야 합니다.

  • HTTPS로 서빙되는가
  • Web App Manifest에 필수 필드가 있는가
  • Service Worker가 등록되어 있는가
  • 오프라인에서도 200 응답을 반환하는가
핵심 정리
PWA의 시작은 manifest.json과 Service Worker 등록 두 가지입니다. 캐싱 전략은 정적 자산에는 Cache First, API에는 Network First, 블로그 콘텐츠처럼 중간 성격의 자원에는 Stale While Revalidate를 조합하면 최적의 경험을 만들 수 있습니다.

앱 설치 프롬프트 커스터마이징

브라우저가 자동으로 표시하는 설치 배너 대신, 원하는 시점에 설치를 유도하는 커스텀 UI를 만들 수 있습니다.

let installPrompt = null;

// 브라우저가 설치 가능 상태가 되면 이벤트 캡처
window.addEventListener('beforeinstallprompt', (e) => {
  e.preventDefault(); // 자동 배너 표시 차단
  installPrompt = e;

  // 커스텀 설치 버튼 표시
  document.getElementById('install-btn').style.display = 'block';
});

document.getElementById('install-btn').addEventListener('click', async () => {
  if (!installPrompt) return;

  const result = await installPrompt.prompt(); // 설치 다이얼로그 표시
  console.log(`사용자 선택: ${result.outcome}`); // 'accepted' 또는 'dismissed'
  installPrompt = null;
  document.getElementById('install-btn').style.display = 'none';
});

// 설치 완료 후 처리
window.addEventListener('appinstalled', () => {
  console.log('앱이 설치되었습니다');
});

Background Sync — 오프라인 데이터 동기화

오프라인 상태에서 작성한 글이나 제출한 폼을 네트워크가 복구되면 자동으로 전송하는 기능입니다.

// 앱에서 동기화 등록
async function submitFormOffline(data) {
  // IndexedDB에 임시 저장
  await db.pendingForms.add(data);

  // 서비스 워커에 백그라운드 동기화 요청
  const registration = await navigator.serviceWorker.ready;
  await registration.sync.register('sync-forms');
}

// 서비스 워커 (sw.js)
self.addEventListener('sync', (event) => {
  if (event.tag === 'sync-forms') {
    event.waitUntil(syncPendingForms());
  }
});

async function syncPendingForms() {
  const pendingForms = await db.pendingForms.getAll();
  for (const form of pendingForms) {
    await fetch('/api/submit', {
      method: 'POST',
      body: JSON.stringify(form),
    });
    await db.pendingForms.delete(form.id);
  }
}

Push Notifications 구현

사용자가 앱을 열지 않은 상태에서도 알림을 받을 수 있는 Push Notifications은 PWA의 핵심 기능 중 하나입니다.

// 1. 알림 권한 요청
async function requestNotificationPermission() {
  const permission = await Notification.requestPermission();
  if (permission !== 'granted') return;

  // 2. Push 구독 생성
  const registration = await navigator.serviceWorker.ready;
  const subscription = await registration.pushManager.subscribe({
    userVisibleOnly: true,
    applicationServerKey: VAPID_PUBLIC_KEY, // 서버에서 생성한 키
  });

  // 3. 구독 정보를 서버에 저장
  await fetch('/api/push-subscribe', {
    method: 'POST',
    body: JSON.stringify(subscription),
  });
}

// 서비스 워커에서 Push 이벤트 처리
self.addEventListener('push', (event) => {
  const data = event.data.json();
  event.waitUntil(
    self.registration.showNotification(data.title, {
      body: data.body,
      icon: '/icons/icon-192.png',
    })
  );
});

Push Notification을 남용하면 사용자가 알림을 차단하거나 앱을 삭제할 수 있습니다. 사용자가 명확히 가치를 느끼는 알림(주문 상태, 중요 공지)만 발송하는 것이 중요합니다.

PWA가 실제로 네이티브 앱을 대체할 수 있는가

PWA의 가능성에 대한 관심이 높아지면서 자연스럽게 "PWA가 네이티브 앱을 대체할 수 있는가"라는 질문이 제기됩니다. 현실적인 답은 "용도에 따라 다르다"입니다. 카메라, 마이크, GPS, 결제 등 하드웨어와 OS를 깊이 연동하는 기능이 필요하다면 여전히 네이티브 앱이 유리합니다. 그러나 콘텐츠 소비, 정보 조회, 간단한 거래 처리가 중심인 서비스라면 PWA로 충분히 훌륭한 경험을 제공할 수 있습니다. Twitter Lite, Starbucks, Trivago 같은 글로벌 서비스들이 PWA를 통해 참여율과 전환율을 크게 개선한 사례가 이를 뒷받침합니다.

국내에서도 PWA 도입이 늘어나고 있습니다. 앱 스토어 등록 심사와 업데이트 지연 없이 즉시 배포할 수 있다는 점, iOS와 Android를 동시에 지원할 수 있다는 점이 특히 소규모 팀이나 스타트업에게 매력적입니다. 단, iOS에서의 PWA 지원은 Android에 비해 여전히 제한적입니다. iOS 16.4 이후 푸시 알림이 지원되기 시작했지만, 백그라운드 동기화나 일부 Web API는 여전히 Safari에서 미지원 상태입니다. 서비스의 주요 사용자층이 iOS라면 이 점을 충분히 고려해야 합니다.

PWA 도입 시 체크해야 할 핵심 요소

PWA를 실제 서비스에 도입하기 전에 Lighthouse의 PWA 감사 항목을 기준으로 점검해보는 것이 좋습니다. HTTPS 적용, 웹 앱 매니페스트 설정, 서비스 워커 등록, 반응형 디자인, 오프라인 페이지 제공이 기본 체크리스트입니다. 특히 오프라인 경험 설계는 단순히 에러 페이지를 보여주는 수준을 넘어, 오프라인 상태에서도 캐시된 콘텐츠를 보여주거나 입력한 데이터를 로컬에 저장해뒀다가 연결이 복구되면 자동으로 서버에 전송하는 수준까지 구현하면 사용자 경험이 크게 향상됩니다. 설치 가능성도 중요합니다. 설치 배너가 표시되는 조건을 만족하고, 설치 후 앱 아이콘과 스플래시 화면이 브랜드에 맞게 설정되어 있어야 실제로 홈 화면에 추가하는 사용자가 늘어납니다.