PWA 2026 실전 가이드 — 설치형 웹앱 구축의 현실적 접근

프론트엔드

PWAService Worker오프라인웹 앱Next.js

이 글은 누구를 위한 것인가

  • 앱스토어 배포 없이 설치형 앱 경험을 제공하고 싶은 팀
  • Service Worker를 썼지만 캐싱이 생각대로 작동하지 않아 고생하는 개발자
  • PWA 기능을 Next.js 프로젝트에 어떻게 통합하는지 모르는 엔지니어

2026년 PWA의 현실

PWA는 2015년 등장 이후 꾸준히 발전했다. 2026년 현재:

  • iOS Safari PWA 지원이 크게 향상 (홈 화면 추가, 독립 실행 모드)
  • Web Push 알림 iOS 지원 (iOS 16.4+)
  • Badging API, Share Target API 등 앱 수준 기능 확대
  • 여전히 네이티브 앱 대비 일부 하드웨어 접근 제한

PWA가 맞는 경우: 콘텐츠 중심 서비스, 간단한 도구, 비용 절감이 필요한 스타트업 PWA가 맞지 않는 경우: 고성능 게임, 블루투스/NFC 집약적 앱, 앱스토어 노출이 필수인 서비스


1. Web App Manifest

// public/manifest.json
{
  "name": "My App",
  "short_name": "MyApp",
  "description": "앱 설명",
  "start_url": "/",
  "display": "standalone",
  "orientation": "portrait",
  "background_color": "#ffffff",
  "theme_color": "#2563eb",
  "icons": [
    {
      "src": "/icons/icon-192.png",
      "sizes": "192x192",
      "type": "image/png",
      "purpose": "any"
    },
    {
      "src": "/icons/icon-512.png",
      "sizes": "512x512",
      "type": "image/png",
      "purpose": "any maskable"
    }
  ],
  "screenshots": [
    {
      "src": "/screenshots/mobile.png",
      "sizes": "390x844",
      "type": "image/png",
      "form_factor": "narrow"
    }
  ],
  "share_target": {
    "action": "/share",
    "method": "POST",
    "enctype": "multipart/form-data",
    "params": {
      "title": "title",
      "text": "text",
      "url": "url"
    }
  }
}

maskable 아이콘: 안드로이드의 어댑티브 아이콘에 사용. 아이콘 내용을 중앙 80% 영역에 배치해야 잘린다.

Next.js에서 Manifest 설정

// app/manifest.ts (Next.js 14+)
import type { MetadataRoute } from 'next';

export default function manifest(): MetadataRoute.Manifest {
  return {
    name: 'My App',
    short_name: 'MyApp',
    description: '앱 설명',
    start_url: '/',
    display: 'standalone',
    background_color: '#ffffff',
    theme_color: '#2563eb',
    icons: [
      { src: '/icons/icon-192.png', sizes: '192x192', type: 'image/png' },
      { src: '/icons/icon-512.png', sizes: '512x512', type: 'image/png' },
    ],
  };
}

2. Service Worker 캐싱 전략

Service Worker의 핵심은 무엇을 어떻게 캐싱할지 전략이다. 잘못된 캐싱 전략은 오래된 콘텐츠를 계속 보여주거나, 캐싱의 이점이 없어진다.

주요 캐싱 전략

Cache First (캐시 우선): 캐시가 있으면 캐시에서, 없으면 네트워크. 자주 변하지 않는 정적 에셋(폰트, 아이콘, 라이브러리)에 적합.

Network First (네트워크 우선): 네트워크 먼저, 실패하면 캐시. API 응답, 자주 변하는 HTML에 적합.

Stale While Revalidate: 캐시된 버전을 즉시 반환하면서 백그라운드에서 최신 버전을 가져와 캐시 업데이트. 빠른 응답과 최신성의 균형.

Workbox로 전략 적용 (next-pwa)

// next.config.js
const withPWA = require('next-pwa')({
  dest: 'public',
  disable: process.env.NODE_ENV === 'development',
  runtimeCaching: [
    {
      // API 응답: Network First
      urlPattern: /^https:\/\/api\.example\.com\/.*/i,
      handler: 'NetworkFirst',
      options: {
        cacheName: 'api-cache',
        expiration: {
          maxAgeSeconds: 60 * 60,  // 1시간
          maxEntries: 100,
        },
        networkTimeoutSeconds: 10,
      },
    },
    {
      // 이미지: Cache First
      urlPattern: /\.(?:png|jpg|jpeg|webp|avif|svg|ico)$/i,
      handler: 'CacheFirst',
      options: {
        cacheName: 'image-cache',
        expiration: {
          maxAgeSeconds: 60 * 60 * 24 * 30,  // 30일
          maxEntries: 200,
        },
      },
    },
    {
      // 폰트: Cache First (장기)
      urlPattern: /^https:\/\/fonts\.(googleapis|gstatic)\.com\/.*/i,
      handler: 'CacheFirst',
      options: {
        cacheName: 'font-cache',
        expiration: { maxAgeSeconds: 60 * 60 * 24 * 365 },
      },
    },
    {
      // 페이지: Stale While Revalidate
      urlPattern: /\/_next\/static\/.*/i,
      handler: 'StaleWhileRevalidate',
      options: { cacheName: 'next-static-cache' },
    },
  ],
});

module.exports = withPWA({
  // next.js 설정
});

3. 오프라인 폴백 페이지

네트워크와 캐시 모두 실패할 때 표시할 폴백 페이지.

// public/sw-custom.js (커스텀 Service Worker)
const OFFLINE_URL = '/offline';

self.addEventListener('install', (event) => {
  event.waitUntil(
    caches.open('offline-cache').then((cache) =>
      cache.add(OFFLINE_URL)
    )
  );
});

self.addEventListener('fetch', (event) => {
  if (event.request.mode === 'navigate') {
    event.respondWith(
      fetch(event.request).catch(() =>
        caches.match(OFFLINE_URL)
      )
    );
  }
});
// app/offline/page.tsx
export default function OfflinePage() {
  return (
    <div className="flex flex-col items-center justify-center min-h-screen">
      <h1 className="text-2xl font-bold">인터넷 연결이 없습니다</h1>
      <p className="text-gray-600 mt-2">연결이 복구되면 자동으로 새로고침됩니다.</p>
      <button onClick={() => window.location.reload()} className="mt-4 btn-primary">
        다시 시도
      </button>
    </div>
  );
}

4. Web Push 알림

구독 처리

// lib/push-notification.ts
const VAPID_PUBLIC_KEY = process.env.NEXT_PUBLIC_VAPID_PUBLIC_KEY!;

export async function subscribeToPush(): Promise<PushSubscription | null> {
  if (!('serviceWorker' in navigator) || !('PushManager' in window)) {
    console.warn('Push 알림이 지원되지 않습니다.');
    return null;
  }

  const permission = await Notification.requestPermission();
  if (permission !== 'granted') return null;

  const registration = await navigator.serviceWorker.ready;
  const subscription = await registration.pushManager.subscribe({
    userVisibleOnly: true,
    applicationServerKey: urlBase64ToUint8Array(VAPID_PUBLIC_KEY),
  });

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

  return subscription;
}

알림 수신 처리 (Service Worker)

// sw-push.js
self.addEventListener('push', (event) => {
  if (!event.data) return;

  const data = event.data.json();

  event.waitUntil(
    self.registration.showNotification(data.title, {
      body: data.body,
      icon: '/icons/icon-192.png',
      badge: '/icons/badge-72.png',
      data: { url: data.url },
      actions: [
        { action: 'open', title: '열기' },
        { action: 'dismiss', title: '닫기' },
      ],
    })
  );
});

self.addEventListener('notificationclick', (event) => {
  event.notification.close();

  if (event.action === 'open' || !event.action) {
    event.waitUntil(
      clients.openWindow(event.notification.data.url)
    );
  }
});

5. 설치 프롬프트 제어

브라우저의 기본 설치 프롬프트를 제어해 더 좋은 시점에 표시한다.

// hooks/useInstallPrompt.ts
export function useInstallPrompt() {
  const [prompt, setPrompt] = useState<BeforeInstallPromptEvent | null>(null);
  const [isInstalled, setIsInstalled] = useState(false);

  useEffect(() => {
    // 이미 설치된 경우
    if (window.matchMedia('(display-mode: standalone)').matches) {
      setIsInstalled(true);
      return;
    }

    const handler = (e: Event) => {
      e.preventDefault();  // 기본 프롬프트 억제
      setPrompt(e as BeforeInstallPromptEvent);
    };

    window.addEventListener('beforeinstallprompt', handler);
    return () => window.removeEventListener('beforeinstallprompt', handler);
  }, []);

  const install = async () => {
    if (!prompt) return;
    const result = await prompt.prompt();
    if (result.outcome === 'accepted') setIsInstalled(true);
    setPrompt(null);
  };

  return { canInstall: !!prompt, isInstalled, install };
}

// 사용: 특정 사용자 액션 후 설치 유도
const { canInstall, install } = useInstallPrompt();
// 예: 3번 이상 방문 후, 또는 특정 기능 사용 후 배너 표시

맺으며

PWA의 성패는 Service Worker 캐싱 전략에 달려있다. 잘못된 캐싱은 오래된 콘텐츠를 계속 보여주는 최악의 경험을 만들고, 전략 없는 캐싱은 PWA를 도입하지 않은 것과 다름없다.

오프라인 지원과 빠른 로딩이 핵심 가치라면 PWA가 충분하다. 하지만 네이티브 하드웨어 접근이나 앱스토어 배포가 필요하다면 React Native나 Flutter가 더 적합하다. PWA를 선택하는 이유가 명확할 때 투자 가치가 있다.