메인 콘텐츠로 바로가기

Progressive Web App (PWA) 이해하기

개요

Progressive Web App(PWA)은 웹 기술로 만들어진 애플리케이션이 네이티브 앱과 유사한 경험을 제공할 수 있도록 하는 웹 앱 개발 방식입니다. PWA를 사용하면 웹사이트를 사용자의 홈 화면에 설치할 수 있고, 오프라인에서도 작동하며, 푸시 알림을 보낼 수 있습니다.

PWA는 기존 웹사이트에 점진적으로 적용할 수 있으며, 별도의 앱스토어 배포 없이 사용자에게 네이티브 앱과 유사한 경험을 제공합니다.


배경

왜 필요한가?

웹 애플리케이션은 다음과 같은 한계가 있었습니다:

  • 플랫폼 의존성: iOS와 Android에서 각각 다른 네이티브 앱을 개발해야 했습니다
  • 네트워크 의존성: 인터넷 연결이 없으면 앱을 사용할 수 없었습니다
  • 설치의 어려움: 앱스토어를 거쳐야만 사용자 기기에 설치할 수 있었습니다
  • 사용자 재참여: 푸시 알림 같은 네이티브 기능을 사용할 수 없었습니다

PWA는 이러한 문제를 해결하여 웹 앱이 네이티브 앱의 장점을 누릴 수 있게 합니다.

등장 이전의 방식

PWA가 등장하기 전에는:

  1. 하이브리드 앱: Cordova, Ionic 같은 프레임워크로 웹뷰를 네이티브 앱으로 감쌌습니다
  2. 반응형 웹: 모바일 브라우저에 최적화된 웹사이트를 만들었지만, 오프라인 기능이나 설치는 불가능했습니다
  3. 네이티브 앱: iOS와 Android 각각 개발해야 했고, 개발 비용과 시간이 많이 들었습니다

핵심 구성 요소

PWA는 세 가지 핵심 기술로 구성됩니다:

1. Service Worker

Service Worker는 브라우저가 백그라운드에서 실행하는 JavaScript 파일입니다. 네트워크 요청을 가로채고 캐싱을 관리하여 오프라인 기능을 제공합니다.

주요 역할:

  • 네트워크 요청 캐싱
  • 오프라인 콘텐츠 제공
  • 백그라운드 동기화
  • 푸시 알림 수신

2. Web App Manifest

Web App Manifest는 JSON 파일로, 앱의 메타데이터를 정의합니다. 이를 통해 브라우저가 앱을 설치하는 방법을 알 수 있습니다.

주요 정보:

  • 앱 이름과 설명
  • 아이콘
  • 시작 URL
  • 테마 색상
  • 표시 모드 (전체 화면, 독립 실행형 등)

3. HTTPS

PWA는 보안을 위해 HTTPS에서만 작동합니다. Service Worker가 네트워크 요청을 가로채기 때문에 중간자 공격을 방지하기 위해 필수입니다.

예외:

  • localhost에서는 개발 목적으로 HTTP 허용

동작 원리

Service Worker 생명주기

1. 등록 (Register) 2. 설치 (Install) 3. 활성화 (Activate) 4. 대기 (Idle/Fetch/Message) 5. 종료 (Terminated)

캐싱 전략

PWA는 다양한 캐싱 전략을 사용합니다:

1. Cache First (캐시 우선)

요청 → 캐시 확인 → 있으면 반환 ↓ 없으면 네트워크 요청 → 캐시 저장 → 반환

2. Network First (네트워크 우선)

요청 → 네트워크 요청 → 성공하면 반환 ↓ 실패하면 캐시 확인 → 반환

3. Stale While Revalidate (캐시 반환 후 갱신)

요청 → 캐시 즉시 반환 동시에 네트워크 요청 → 캐시 업데이트

코드로 이해하기

// Service Worker 등록 (main.js) if ('serviceWorker' in navigator) { window.addEventListener('load', () => { navigator.serviceWorker .register('/sw.js') .then(registration => { console.log('Service Worker 등록 성공:', registration.scope); }) .catch(error => { console.error('Service Worker 등록 실패:', error); }); }); } // Service Worker 파일 (sw.js) const CACHE_NAME = 'my-pwa-cache-v1'; const urlsToCache = [ '/', '/styles/main.css', '/scripts/main.js', '/images/logo.png' ]; // 설치 단계: 필요한 파일을 캐시에 저장 self.addEventListener('install', event => { event.waitUntil( caches.open(CACHE_NAME) .then(cache => { console.log('캐시 열기'); return cache.addAll(urlsToCache); }) ); }); // Fetch 단계: 네트워크 요청 가로채기 self.addEventListener('fetch', event => { event.respondWith( caches.match(event.request) .then(response => { // 캐시에 있으면 캐시 반환, 없으면 네트워크 요청 return response || fetch(event.request); }) ); });

주요 기능

기능 1: 오프라인 지원

PWA는 Service Worker를 통해 오프라인에서도 작동합니다.

사용자 경험:

  • 인터넷 연결이 끊겨도 이전에 방문한 페이지를 볼 수 있습니다
  • 오프라인 전용 페이지를 표시할 수 있습니다
  • 백그라운드에서 데이터를 동기화합니다

구현 예시:

// 오프라인 폴백 페이지 self.addEventListener('fetch', event => { event.respondWith( caches.match(event.request) .then(response => { return response || fetch(event.request); }) .catch(() => { // 네트워크 요청 실패 시 오프라인 페이지 반환 return caches.match('/offline.html'); }) ); });

기능 2: 설치 가능 (Install to Home Screen)

사용자가 PWA를 홈 화면에 추가하여 네이티브 앱처럼 실행할 수 있습니다.

manifest.json 예시:

{ "name": "My Progressive Web App", "short_name": "MyPWA", "description": "A sample PWA demonstrating core features", "start_url": "/", "display": "standalone", "background_color": "#ffffff", "theme_color": "#000000", "icons": [ { "src": "/icons/icon-192x192.png", "sizes": "192x192", "type": "image/png" }, { "src": "/icons/icon-512x512.png", "sizes": "512x512", "type": "image/png" } ] }

설치 프롬프트 제어:

let deferredPrompt; window.addEventListener('beforeinstallprompt', event => { // 브라우저 기본 설치 배너 방지 event.preventDefault(); deferredPrompt = event; // 커스텀 설치 버튼 표시 showInstallButton(); }); // 사용자가 설치 버튼 클릭 시 installButton.addEventListener('click', async () => { if (deferredPrompt) { deferredPrompt.prompt(); const { outcome } = await deferredPrompt.userChoice; console.log(`사용자 선택: ${outcome}`); deferredPrompt = null; } });

기능 3: 푸시 알림

Service Worker를 통해 백그라운드에서 푸시 알림을 받을 수 있습니다.

알림 권한 요청:

// 푸시 알림 구독 async function subscribeToPush() { const registration = await navigator.serviceWorker.ready; try { const subscription = await registration.pushManager.subscribe({ userVisibleOnly: true, applicationServerKey: urlBase64ToUint8Array(vapidPublicKey) }); // 서버에 구독 정보 전송 await sendSubscriptionToServer(subscription); } catch (error) { console.error('푸시 구독 실패:', error); } } // Service Worker에서 푸시 이벤트 처리 self.addEventListener('push', event => { const data = event.data.json(); const options = { body: data.body, icon: '/icons/icon-192x192.png', badge: '/icons/badge-72x72.png', vibrate: [200, 100, 200] }; event.waitUntil( self.registration.showNotification(data.title, options) ); });

기능 4: 백그라운드 동기화

네트워크가 다시 연결될 때 데이터를 자동으로 동기화합니다.

사용 사례:

  • 오프라인에서 작성한 메시지를 온라인 시 전송
  • 폼 제출 데이터 저장 후 나중에 전송
  • 파일 업로드 재시도

구현 예시:

// 백그라운드 동기화 등록 async function syncData() { const registration = await navigator.serviceWorker.ready; try { await registration.sync.register('sync-messages'); console.log('동기화 등록 완료'); } catch (error) { console.error('동기화 등록 실패:', error); } } // Service Worker에서 동기화 이벤트 처리 self.addEventListener('sync', event => { if (event.tag === 'sync-messages') { event.waitUntil(sendPendingMessages()); } }); async function sendPendingMessages() { const messages = await getMessagesFromIndexedDB(); for (const message of messages) { try { await fetch('/api/messages', { method: 'POST', body: JSON.stringify(message) }); await removeMessageFromIndexedDB(message.id); } catch (error) { console.error('메시지 전송 실패:', error); } } }

실제 사용 사례

사례 1: 뉴스/미디어 앱

요구사항:

  • 오프라인에서 이전 기사 읽기
  • 새 기사 알림
  • 빠른 로딩 속도

PWA 활용:

// 기사 캐싱 전략 self.addEventListener('fetch', event => { if (event.request.url.includes('/api/articles')) { event.respondWith( caches.open('articles-cache').then(cache => { return fetch(event.request) .then(response => { // 네트워크 응답을 캐시에 저장 cache.put(event.request, response.clone()); return response; }) .catch(() => { // 네트워크 실패 시 캐시에서 반환 return cache.match(event.request); }); }) ); } });

사례 2: 전자상거래

요구사항:

  • 상품 브라우징
  • 장바구니 오프라인 저장
  • 재입고 알림

PWA 활용:

// IndexedDB를 사용한 장바구니 저장 async function addToCart(product) { const db = await openDB('shopping-cart', 1, { upgrade(db) { db.createObjectStore('cart', { keyPath: 'id' }); } }); await db.put('cart', product); // 온라인 시 서버와 동기화 if (navigator.onLine) { await syncCartWithServer(); } else { // 오프라인 시 백그라운드 동기화 등록 const registration = await navigator.serviceWorker.ready; await registration.sync.register('sync-cart'); } }

사례 3: 소셜 미디어

요구사항:

  • 오프라인 포스트 작성
  • 실시간 알림
  • 빠른 콘텐츠 로딩

PWA 활용:

// 포스트 작성 후 동기화 async function createPost(postData) { try { // 온라인이면 즉시 전송 if (navigator.onLine) { const response = await fetch('/api/posts', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(postData) }); return await response.json(); } else { // 오프라인이면 IndexedDB에 저장 await savePostToIndexedDB(postData); // 백그라운드 동기화 등록 const registration = await navigator.serviceWorker.ready; await registration.sync.register('sync-posts'); return { status: 'pending' }; } } catch (error) { console.error('포스트 생성 실패:', error); throw error; } }

장점과 한계

장점

  • 크로스 플랫폼: 하나의 코드베이스로 모든 플랫폼에서 작동
  • 빠른 로딩: 캐싱을 통해 네트워크 요청을 줄이고 로딩 속도를 개선
  • 오프라인 지원: 네트워크 연결 없이도 앱 사용 가능
  • 낮은 진입 장벽: 앱스토어 승인 없이 즉시 배포 가능
  • 비용 절감: 네이티브 앱 개발 대비 개발 비용과 시간 절약
  • 자동 업데이트: Service Worker 업데이트로 즉시 반영
  • SEO 친화적: 웹 검색 엔진에 노출 가능

한계

  • ⚠️ iOS 지원 제한: iOS Safari에서 일부 기능 제한 (예: 푸시 알림은 iOS 16.4+)
  • ⚠️ 배터리 소모: 백그라운드 동기화와 푸시 알림이 배터리를 소모
  • ⚠️ 스토리지 제한: 브라우저별로 캐시 용량 제한이 다름
  • ⚠️ 하드웨어 접근 제한: 네이티브 앱만큼 기기 하드웨어 접근이 자유롭지 않음
  • ⚠️ 앱스토어 부재: 앱스토어를 통한 자연 유입이 없음

트레이드오프

PWA를 선택해야 하는 경우:

  • 빠른 출시가 필요한 경우
  • 웹 기반 서비스가 이미 있는 경우
  • 크로스 플랫폼 지원이 중요한 경우
  • 지속적인 업데이트가 필요한 경우

네이티브 앱을 선택해야 하는 경우:

  • 복잡한 하드웨어 기능이 필요한 경우 (카메라, GPS, 센서 등)
  • 앱스토어 가시성이 중요한 경우
  • iOS에서 완전한 푸시 알림 지원이 필수인 경우
  • 오프라인에서 복잡한 데이터 처리가 필요한 경우

Next.js에서 PWA 구현하기

Next.js 14 App Router 환경에서 PWA를 구현하는 방법을 알아봅니다.

1단계: next-pwa 설치

npm install next-pwa

2단계: next.config.js 설정

// next.config.js const withPWA = require('next-pwa')({ dest: 'public', register: true, skipWaiting: true, // 개발 환경에서 Service Worker 비활성화 disable: process.env.NODE_ENV === 'development', // 캐시할 파일 패턴 설정 runtimeCaching: [ { urlPattern: /^https:\/\/fonts\.(?:googleapis|gstatic)\.com\/.*/i, handler: 'CacheFirst', options: { cacheName: 'google-fonts', expiration: { maxEntries: 4, maxAgeSeconds: 365 * 24 * 60 * 60 // 1년 } } }, { urlPattern: /^https:\/\/api\.example\.com\/.*/i, handler: 'NetworkFirst', options: { cacheName: 'api-cache', expiration: { maxEntries: 32, maxAgeSeconds: 24 * 60 * 60 // 1일 }, networkTimeoutSeconds: 10 } }, { urlPattern: /\.(?:png|jpg|jpeg|svg|gif|webp)$/i, handler: 'CacheFirst', options: { cacheName: 'image-cache', expiration: { maxEntries: 64, maxAgeSeconds: 30 * 24 * 60 * 60 // 30일 } } } ] }); /** @type {import('next').NextConfig} */ const nextConfig = { // Next.js 설정 }; module.exports = withPWA(nextConfig);

3단계: Web App Manifest 생성

// public/manifest.json { "name": "My Next.js PWA", "short_name": "NextPWA", "description": "A Progressive Web App built with Next.js", "start_url": "/", "display": "standalone", "background_color": "#ffffff", "theme_color": "#000000", "orientation": "portrait-primary", "icons": [ { "src": "/icons/icon-72x72.png", "sizes": "72x72", "type": "image/png", "purpose": "maskable any" }, { "src": "/icons/icon-96x96.png", "sizes": "96x96", "type": "image/png", "purpose": "maskable any" }, { "src": "/icons/icon-128x128.png", "sizes": "128x128", "type": "image/png", "purpose": "maskable any" }, { "src": "/icons/icon-144x144.png", "sizes": "144x144", "type": "image/png", "purpose": "maskable any" }, { "src": "/icons/icon-152x152.png", "sizes": "152x152", "type": "image/png", "purpose": "maskable any" }, { "src": "/icons/icon-192x192.png", "sizes": "192x192", "type": "image/png", "purpose": "maskable any" }, { "src": "/icons/icon-384x384.png", "sizes": "384x384", "type": "image/png", "purpose": "maskable any" }, { "src": "/icons/icon-512x512.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable any" } ] }

4단계: 메타데이터 설정 (App Router)

// app/layout.tsx import type { Metadata } from 'next'; export const metadata: Metadata = { title: 'My Next.js PWA', description: 'A Progressive Web App built with Next.js', manifest: '/manifest.json', themeColor: '#000000', appleWebApp: { capable: true, statusBarStyle: 'default', title: 'NextPWA' }, formatDetection: { telephone: false }, openGraph: { type: 'website', siteName: 'My Next.js PWA', title: 'My Next.js PWA', description: 'A Progressive Web App built with Next.js' }, twitter: { card: 'summary', title: 'My Next.js PWA', description: 'A Progressive Web App built with Next.js' } }; export default function RootLayout({ children, }: { children: React.ReactNode; }) { return ( <html lang="ko"> <head> <link rel="icon" href="/favicon.ico" /> <link rel="apple-touch-icon" href="/icons/icon-192x192.png" /> </head> <body>{children}</body> </html> ); }

5단계: 설치 프롬프트 컴포넌트 (선택사항)

// components/InstallPrompt.tsx 'use client'; import { useEffect, useState } from 'react'; interface BeforeInstallPromptEvent extends Event { prompt: () => Promise<void>; userChoice: Promise<{ outcome: 'accepted' | 'dismissed' }>; } export function InstallPrompt() { const [deferredPrompt, setDeferredPrompt] = useState<BeforeInstallPromptEvent | null>(null); const [showInstallButton, setShowInstallButton] = useState(false); useEffect(() => { const handler = (e: Event) => { // 브라우저 기본 설치 배너 방지 e.preventDefault(); setDeferredPrompt(e as BeforeInstallPromptEvent); setShowInstallButton(true); }; window.addEventListener('beforeinstallprompt', handler); return () => { window.removeEventListener('beforeinstallprompt', handler); }; }, []); const handleInstallClick = async () => { if (!deferredPrompt) return; deferredPrompt.prompt(); const { outcome } = await deferredPrompt.userChoice; console.log(`사용자 선택: ${outcome}`); setDeferredPrompt(null); setShowInstallButton(false); }; if (!showInstallButton) return null; return ( <div className="fixed bottom-4 right-4 bg-white shadow-lg rounded-lg p-4 max-w-sm"> <h3 className="text-lg font-semibold mb-2">앱 설치</h3> <p className="text-sm text-gray-600 mb-4"> 홈 화면에 추가하여 빠르게 접근하세요 </p> <div className="flex gap-2"> <button onClick={handleInstallClick} className="flex-1 bg-blue-600 text-white px-4 py-2 rounded hover:bg-blue-700" > 설치하기 </button> <button onClick={() => setShowInstallButton(false)} className="px-4 py-2 rounded border border-gray-300 hover:bg-gray-50" > 나중에 </button> </div> </div> ); }

6단계: 오프라인 페이지 생성 (선택사항)

// app/offline/page.tsx export default function OfflinePage() { return ( <div className="min-h-screen flex items-center justify-center bg-gray-50"> <div className="text-center"> <h1 className="text-4xl font-bold text-gray-900 mb-4"> 오프라인 상태입니다 </h1> <p className="text-gray-600 mb-8"> 인터넷 연결을 확인하고 다시 시도해주세요 </p> <button onClick={() => window.location.reload()} className="bg-blue-600 text-white px-6 py-3 rounded-lg hover:bg-blue-700" > 다시 시도 </button> </div> </div> ); }

7단계: 빌드 및 배포

# 프로덕션 빌드 npm run build # 로컬에서 프로덕션 빌드 테스트 npm start

배포 시 주의사항:

  • HTTPS 필수 (Vercel, Netlify 등은 자동 지원)
  • Service Worker 파일 (sw.js)이 올바르게 서빙되는지 확인
  • 아이콘 파일들이 모두 포함되어 있는지 확인

8단계: PWA 동작 확인

Chrome DevTools 확인 방법:

  1. 개발자 도구 열기 (F12)
  2. Application 탭 선택
  3. 왼쪽 메뉴에서 확인:
    • Manifest: manifest.json 설정 확인
    • Service Workers: Service Worker 등록 상태 확인
    • Cache Storage: 캐시된 리소스 확인
    • Storage: IndexedDB, Local Storage 확인

Lighthouse 점수 확인:

  1. Chrome DevTools > Lighthouse
  2. Categories에서 Progressive Web App 선택
  3. Analyze page load 클릭
  4. PWA 체크리스트 결과 확인

모바일에서 테스트:

# 로컬 네트워크에서 접근 가능하도록 실행 npm run dev -- -H 0.0.0.0

모바일 기기에서 http://[컴퓨터-IP]:3000 접속하여 설치 배너 테스트


성능 최적화 팁

1. 효율적인 캐싱 전략

// next.config.js - runtimeCaching 최적화 { urlPattern: /^https:\/\/cdn\.example\.com\/.*/i, handler: 'CacheFirst', options: { cacheName: 'cdn-cache', expiration: { maxEntries: 100, maxAgeSeconds: 7 * 24 * 60 * 60, // 1주일 // 오래된 캐시 자동 정리 purgeOnQuotaError: true }, cacheableResponse: { // 성공 응답만 캐시 statuses: [0, 200] } } }

2. 프리캐싱 전략

// public/sw.js - 중요 리소스 미리 캐싱 const PRECACHE_URLS = [ '/', '/offline', '/styles/critical.css', '/scripts/main.js' ]; self.addEventListener('install', event => { event.waitUntil( caches.open('precache-v1') .then(cache => cache.addAll(PRECACHE_URLS)) ); });

3. 네트워크 우선 전략 (동적 콘텐츠)

// API 요청은 항상 최신 데이터 우선 { urlPattern: /^https:\/\/api\.example\.com\/posts/i, handler: 'NetworkFirst', options: { cacheName: 'api-posts', networkTimeoutSeconds: 5, // 5초 후 캐시 사용 expiration: { maxEntries: 50, maxAgeSeconds: 5 * 60 // 5분 } } }

4. 리소스 크기 최적화

// next.config.js - 이미지 최적화 const nextConfig = { images: { formats: ['image/avif', 'image/webp'], deviceSizes: [640, 750, 828, 1080, 1200, 1920, 2048, 3840], imageSizes: [16, 32, 48, 64, 96, 128, 256, 384], }, };

디버깅 및 트러블슈팅

일반적인 문제

문제 1: Service Worker가 등록되지 않음

증상:

Failed to register ServiceWorker: The script has an unsupported MIME type

해결 방법:

// next.config.js에서 설정 확인 const withPWA = require('next-pwa')({ dest: 'public', // MIME type 문제 해결 buildExcludes: [/middleware-manifest\.json$/], });

문제 2: 캐시가 업데이트되지 않음

원인: Service Worker가 오래된 버전을 사용 중

해결 방법:

// Service Worker 강제 업데이트 if ('serviceWorker' in navigator) { navigator.serviceWorker.getRegistrations().then(registrations => { registrations.forEach(registration => { registration.update(); // 강제 업데이트 }); }); }

문제 3: iOS Safari에서 설치 배너가 표시되지 않음

원인: iOS는 자체 설치 방식 사용

해결 방법:

// iOS 전용 설치 안내 const isIOS = /iPad|iPhone|iPod/.test(navigator.userAgent); const isStandalone = ('standalone' in window.navigator) && (window.navigator as any).standalone; if (isIOS && !isStandalone) { // iOS 전용 설치 안내 표시 return ( <div> <p>Safari 공유 버튼을 눌러 "홈 화면에 추가"를 선택하세요</p> </div> ); }

Chrome DevTools 활용

Service Worker 디버깅:

1. Chrome DevTools > Application > Service Workers 2. "Update on reload" 체크 - 새로고침 시 항상 최신 버전 사용 3. "Bypass for network" 체크 - 캐시 우회하여 네트워크 직접 호출 4. "Unregister" - Service Worker 등록 해제

캐시 확인 및 삭제:

1. Chrome DevTools > Application > Cache Storage 2. 각 캐시 항목 확인 3. 마우스 우클릭 > Delete - 특정 캐시 삭제 4. Clear site data - 모든 캐시 삭제

관련 개념

유사 개념

  • Hybrid Apps (하이브리드 앱): Cordova, Capacitor 같은 프레임워크로 웹뷰를 네이티브 앱으로 패키징

    • PWA vs Hybrid: PWA는 설치 없이 웹에서 바로 사용 가능하지만, 하이브리드는 앱스토어 배포 필요
  • SPA (Single Page Application): 단일 페이지에서 동적으로 콘텐츠를 로드하는 웹 앱

    • PWA vs SPA: PWA는 SPA에 오프라인 기능과 설치 기능을 추가한 확장 개념

대안

  • React Native / Flutter:

    • 언제 사용: 복잡한 네이티브 기능이 필요하거나 앱스토어 가시성이 중요한 경우
    • 장단점:
      • 장점: 완전한 네이티브 경험, 모든 하드웨어 접근 가능
      • 단점: 플랫폼별 코드 유지보수, 앱스토어 승인 필요, 웹 검색 불가
  • Electron (데스크톱):

    • 언제 사용: 데스크톱 앱이 필요한 경우
    • 장단점:
      • 장점: 데스크톱 환경에서 완전한 기능
      • 단점: 큰 앱 크기, 메모리 사용량 높음

더 알아보기

심화 학습

실습 자료

커뮤니티 및 도구

모범 사례


참고 자료

공식 문서

유용한 도구

댓글

developjik
All content is licensed under CC BY-NC-SA 4.0 unless otherwise noted.