Skip to content

필사 모드: PWA 완전 가이드 2025: 네이티브 앱 수준의 웹 경험

한국어
0%
정확도 0%
💡 왼쪽 원문을 읽으면서 오른쪽에 따라 써보세요. Tab 키로 힌트를 받을 수 있습니다.
원문 렌더가 준비되기 전까지 텍스트 가이드로 표시합니다.

도입

2025년, PWA(Progressive Web App)는 더 이상 실험적 기술이 아닙니다. Twitter(X), Starbucks, Pinterest, Spotify 등 수많은 대형 서비스가 PWA를 도입하여 네이티브 앱에 버금가는 사용자 경험을 제공하고 있습니다. Google Play Store와 Microsoft Store에서는 PWA를 직접 등록할 수 있으며, iOS도 점진적으로 PWA 지원을 확대하고 있습니다.

이 가이드에서는 Service Worker의 생명주기부터 5가지 캐싱 전략, Workbox 활용, 푸시 알림, 오프라인 우선 아키텍처, Next.js PWA 통합, 그리고 앱스토어 배포까지 체계적으로 다룹니다.

> **핵심 표현 정리**

>

> | 표현 | 의미 |

> |------|------|

> | **Service Worker** | 브라우저 백그라운드에서 실행되는 JavaScript 프록시. 네트워크 요청 가로채기, 캐싱, 푸시 알림 처리 |

> | **Cache-First Strategy** | 캐시에서 먼저 응답하고, 없으면 네트워크에서 가져오는 전략. 오프라인 지원의 핵심 |

> | **Web App Manifest** | PWA의 메타데이터를 정의하는 JSON 파일. 앱 이름, 아이콘, 시작 URL, 디스플레이 모드 등 |

> | **Workbox** | Google이 만든 Service Worker 라이브러리. 캐싱 전략, 프리캐싱, 라우팅을 쉽게 구현 |

1. PWA란 무엇인가?

1.1 PWA의 핵심 특성

PWA는 웹 기술로 구축되면서도 네이티브 앱과 같은 경험을 제공하는 애플리케이션입니다.

PWA의 3가지 핵심 요소:

┌─────────────────────────────────────────┐

│ Progressive Web App │

├─────────────┬─────────────┬─────────────┤

│ Reliable │ Fast │ Engaging │

│ (안정적) │ (빠른) │ (몰입감) │

│ │ │ │

│ - 오프라인 │ - 즉시 로드 │ - 홈화면 │

│ - 안정적 │ - 부드러운 │ - 전체화면 │

│ 네트워크 │ 스크롤 │ - 푸시 알림 │

│ │ - 60fps │ - 설치 가능 │

└─────────────┴─────────────┴─────────────┘

1.2 PWA vs 네이티브 앱 vs 일반 웹

| 기능 | 일반 웹 | PWA | 네이티브 앱 |

|------|---------|-----|-------------|

| 설치 필요 | 없음 | 선택적 | 필수 |

| 오프라인 지원 | 없음 | 있음 | 있음 |

| 푸시 알림 | 없음 | 있음 | 있음 |

| 앱스토어 배포 | 불가 | 가능 (TWA/PWABuilder) | 기본 |

| 업데이트 | 즉시 | 즉시/백그라운드 | 스토어 심사 필요 |

| 파일 크기 | 0 (URL 접근) | 수 KB ~ 수 MB | 수십~수백 MB |

| 하드웨어 접근 | 제한적 | 확대 중 | 완전 |

| 개발 비용 | 낮음 | 낮음 | 높음 (플랫폼별) |

| SEO | 우수 | 우수 | 불가 |

| 크로스 플랫폼 | 브라우저 | 브라우저 | 플랫폼별 개발 |

1.3 PWA 도입 성과 사례

주요 기업 PWA 성과:

Twitter Lite:

- 페이지당 데이터 소비 70% 감소

- 트윗 전송 75% 증가

- 이탈률 20% 감소

Pinterest:

- 광고 수익 44% 증가

- 핵심 참여 지표 60% 증가

- 로그인 사용자 40% 증가

Starbucks:

- 네이티브 앱 대비 99.84% 작은 크기

- 일일 활성 사용자 2배 증가

Uber:

- 3G에서 3초 이내 로드

- 핵심 앱 크기 50KB

2. Service Worker 생명주기

2.1 Service Worker란?

Service Worker는 브라우저와 네트워크 사이에서 동작하는 프록시 스크립트입니다. 메인 스레드와 별도의 스레드에서 실행되며, DOM에 직접 접근할 수 없습니다.

┌─────────────┐ ┌──────────────┐ ┌─────────────┐

│ Web App │────▶│Service Worker│────▶│ Network │

│ (Main │ │ (Proxy) │ │ (Server) │

│ Thread) │◀────│ │◀────│ │

└─────────────┘ │ ┌──────┐ │ └─────────────┘

│ │Cache │ │

│ │ API │ │

│ └──────┘ │

└──────────────┘

2.2 생명주기 단계

Service Worker 생명주기:

1. Registration (등록)

└─▶ navigator.serviceWorker.register('/sw.js')

2. Installation (설치)

└─▶ 'install' 이벤트 발생

└─▶ 정적 자원 프리캐싱

3. Waiting (대기)

└─▶ 이전 SW가 활성 상태면 대기

4. Activation (활성화)

└─▶ 'activate' 이벤트 발생

└─▶ 오래된 캐시 정리

5. Fetch (요청 가로채기)

└─▶ 'fetch' 이벤트로 네트워크 요청 제어

6. Idle / Terminated (유휴/종료)

└─▶ 이벤트 없으면 브라우저가 종료 가능

2.3 기본 Service Worker 구현

// sw.js - Service Worker 파일

const CACHE_NAME = 'my-pwa-v1';

const STATIC_ASSETS = [

'/',

'/index.html',

'/styles/main.css',

'/scripts/app.js',

'/images/logo.png',

'/offline.html',

];

// Install 이벤트: 정적 자원 프리캐싱

self.addEventListener('install', (event) => {

console.log('[SW] Installing...');

event.waitUntil(

caches.open(CACHE_NAME)

.then((cache) => {

console.log('[SW] Pre-caching static assets');

return cache.addAll(STATIC_ASSETS);

})

.then(() => self.skipWaiting()) // 대기 건너뛰기

);

});

// Activate 이벤트: 오래된 캐시 정리

self.addEventListener('activate', (event) => {

console.log('[SW] Activating...');

event.waitUntil(

caches.keys().then((cacheNames) => {

return Promise.all(

cacheNames

.filter((name) => name !== CACHE_NAME)

.map((name) => {

console.log('[SW] Deleting old cache:', name);

return caches.delete(name);

})

);

}).then(() => self.clients.claim()) // 즉시 제어 획득

);

});

// Fetch 이벤트: 요청 가로채기

self.addEventListener('fetch', (event) => {

event.respondWith(

caches.match(event.request)

.then((cachedResponse) => {

if (cachedResponse) {

return cachedResponse;

}

return fetch(event.request);

})

.catch(() => {

// 네트워크 실패 시 오프라인 페이지

if (event.request.mode === 'navigate') {

return caches.match('/offline.html');

}

})

);

});

2.4 등록 코드

// main.js - 앱 진입점

if ('serviceWorker' in navigator) {

window.addEventListener('load', async () => {

try {

const registration = await navigator.serviceWorker.register('/sw.js', {

scope: '/',

});

console.log('SW registered:', registration.scope);

// 업데이트 확인

registration.addEventListener('updatefound', () => {

const newWorker = registration.installing;

newWorker.addEventListener('statechange', () => {

if (newWorker.state === 'installed') {

if (navigator.serviceWorker.controller) {

// 새 버전 사용 가능

showUpdateNotification();

}

}

});

});

} catch (error) {

console.error('SW registration failed:', error);

}

});

}

function showUpdateNotification() {

const banner = document.createElement('div');

banner.innerHTML = '새 버전이 있습니다. <button id="reload">업데이트</button>';

banner.className = 'update-banner';

document.body.appendChild(banner);

document.getElementById('reload').addEventListener('click', () => {

window.location.reload();

});

}

3. 캐싱 전략 5가지

3.1 Cache First (캐시 우선)

캐시에 있으면 캐시에서, 없으면 네트워크에서 가져옵니다. 정적 자원에 최적입니다.

// Cache First 전략

self.addEventListener('fetch', (event) => {

event.respondWith(

caches.match(event.request).then((cached) => {

return cached || fetch(event.request).then((response) => {

const responseClone = response.clone();

caches.open(CACHE_NAME).then((cache) => {

cache.put(event.request, responseClone);

});

return response;

});

})

);

});

요청 흐름:

Client ──▶ Cache ──(hit)──▶ 응답 반환

(miss)

Network ──▶ 응답 반환 + 캐시 저장

3.2 Network First (네트워크 우선)

네트워크를 먼저 시도하고, 실패하면 캐시에서 가져옵니다. API 데이터에 적합합니다.

// Network First 전략

self.addEventListener('fetch', (event) => {

event.respondWith(

fetch(event.request)

.then((response) => {

const responseClone = response.clone();

caches.open(CACHE_NAME).then((cache) => {

cache.put(event.request, responseClone);

});

return response;

})

.catch(() => caches.match(event.request))

);

});

3.3 Stale While Revalidate (오래된 것 사용 후 갱신)

캐시에서 즉시 응답하면서, 백그라운드에서 네트워크로 캐시를 갱신합니다.

// Stale While Revalidate 전략

self.addEventListener('fetch', (event) => {

event.respondWith(

caches.open(CACHE_NAME).then((cache) => {

return cache.match(event.request).then((cached) => {

const fetchPromise = fetch(event.request).then((networkResponse) => {

cache.put(event.request, networkResponse.clone());

return networkResponse;

});

return cached || fetchPromise;

});

})

);

});

3.4 Cache Only / Network Only

// Cache Only - 프리캐싱된 자원에만 사용

self.addEventListener('fetch', (event) => {

event.respondWith(caches.match(event.request));

});

// Network Only - 캐싱이 불필요한 요청 (예: 분석 데이터)

self.addEventListener('fetch', (event) => {

event.respondWith(fetch(event.request));

});

3.5 전략 선택 가이드

| 전략 | 사용 사례 | 장점 | 단점 |

|------|-----------|------|------|

| Cache First | 정적 자원 (CSS, JS, 이미지) | 빠른 응답, 오프라인 | 업데이트 지연 |

| Network First | API 데이터, 뉴스 피드 | 항상 최신 | 느린 네트워크에서 지연 |

| Stale While Revalidate | 자주 변하는 정적 자원 | 빠르면서 최신 | 첫 요청은 오래된 데이터 |

| Cache Only | 버전화된 자원 | 완전한 오프라인 | 업데이트 불가 |

| Network Only | 결제, 인증 | 항상 최신 | 오프라인 불가 |

4. Web App Manifest

4.1 Manifest 작성

{

"name": "My Progressive Web App",

"short_name": "MyPWA",

"description": "An awesome PWA that works offline",

"start_url": "/?source=pwa",

"display": "standalone",

"background_color": "#ffffff",

"theme_color": "#2196F3",

"orientation": "portrait-primary",

"scope": "/",

"lang": "ko",

"dir": "ltr",

"categories": ["productivity", "utilities"],

"icons": [

{

"src": "/icons/icon-72x72.png",

"sizes": "72x72",

"type": "image/png"

},

{

"src": "/icons/icon-96x96.png",

"sizes": "96x96",

"type": "image/png"

},

{

"src": "/icons/icon-128x128.png",

"sizes": "128x128",

"type": "image/png"

},

{

"src": "/icons/icon-144x144.png",

"sizes": "144x144",

"type": "image/png"

},

{

"src": "/icons/icon-152x152.png",

"sizes": "152x152",

"type": "image/png"

},

{

"src": "/icons/icon-192x192.png",

"sizes": "192x192",

"type": "image/png",

"purpose": "any maskable"

},

{

"src": "/icons/icon-384x384.png",

"sizes": "384x384",

"type": "image/png"

},

{

"src": "/icons/icon-512x512.png",

"sizes": "512x512",

"type": "image/png",

"purpose": "any maskable"

}

],

"screenshots": [

{

"src": "/screenshots/desktop.png",

"sizes": "1280x720",

"type": "image/png",

"form_factor": "wide",

"label": "Desktop view"

},

{

"src": "/screenshots/mobile.png",

"sizes": "750x1334",

"type": "image/png",

"form_factor": "narrow",

"label": "Mobile view"

}

],

"shortcuts": [

{

"name": "New Task",

"short_name": "Task",

"url": "/tasks/new",

"icons": [{ "src": "/icons/task.png", "sizes": "96x96" }]

}

]

}

4.2 HTML에 Manifest 연결

<!DOCTYPE html>

<!-- Manifest 연결 -->

<!-- iOS 지원 -->

<!-- 테마 색상 -->

<!-- 앱 콘텐츠 -->

4.3 Display 모드 비교

| 모드 | 설명 | 브라우저 UI |

|------|------|-------------|

| `fullscreen` | 전체 화면, 상태바 숨김 | 없음 |

| `standalone` | 독립 앱처럼 표시 | 상태바만 |

| `minimal-ui` | 최소 브라우저 UI | 뒤로가기/새로고침 |

| `browser` | 일반 브라우저 탭 | 전체 브라우저 UI |

5. Workbox를 활용한 Service Worker

5.1 Workbox 소개

Workbox는 Google이 만든 Service Worker 라이브러리로, 캐싱 전략을 선언적으로 구현할 수 있습니다.

Workbox CLI 설치

npm install workbox-cli --save-dev

또는 Webpack 플러그인

npm install workbox-webpack-plugin --save-dev

5.2 Workbox로 캐싱 전략 구현

// sw.js with Workbox

CacheFirst,

NetworkFirst,

StaleWhileRevalidate,

} from 'workbox-strategies';

// 프리캐싱 (빌드 시 주입)

precacheAndRoute(self.__WB_MANIFEST);

// 이미지: Cache First + 만료 설정

registerRoute(

({ request }) => request.destination === 'image',

new CacheFirst({

cacheName: 'images-cache',

plugins: [

new CacheableResponsePlugin({ statuses: [0, 200] }),

new ExpirationPlugin({

maxEntries: 100,

maxAgeSeconds: 30 * 24 * 60 * 60, // 30일

}),

],

})

);

// CSS/JS: Stale While Revalidate

registerRoute(

({ request }) =>

request.destination === 'style' ||

request.destination === 'script',

new StaleWhileRevalidate({

cacheName: 'static-resources',

plugins: [

new CacheableResponsePlugin({ statuses: [0, 200] }),

],

})

);

// API 호출: Network First

registerRoute(

({ url }) => url.pathname.startsWith('/api/'),

new NetworkFirst({

cacheName: 'api-cache',

networkTimeoutSeconds: 5,

plugins: [

new CacheableResponsePlugin({ statuses: [0, 200] }),

new ExpirationPlugin({

maxEntries: 50,

maxAgeSeconds: 5 * 60, // 5분

}),

],

})

);

// Google Fonts: Cache First

registerRoute(

({ url }) => url.origin === 'https://fonts.googleapis.com' ||

url.origin === 'https://fonts.gstatic.com',

new CacheFirst({

cacheName: 'google-fonts',

plugins: [

new CacheableResponsePlugin({ statuses: [0, 200] }),

new ExpirationPlugin({

maxEntries: 30,

maxAgeSeconds: 365 * 24 * 60 * 60, // 1년

}),

],

})

);

5.3 Workbox 빌드 설정

// workbox-config.js

module.exports = {

globDirectory: 'dist/',

globPatterns: [

'**/*.{html,js,css,png,jpg,svg,woff2}'

],

globIgnores: [

'admin/**/*',

'node_modules/**/*'

],

swDest: 'dist/sw.js',

swSrc: 'src/sw.js',

maximumFileSizeToCacheInBytes: 5 * 1024 * 1024, // 5MB

};

5.4 백그라운드 동기화

// Background Sync로 오프라인 폼 제출

const bgSyncPlugin = new BackgroundSyncPlugin('formQueue', {

maxRetentionTime: 24 * 60, // 24시간 (분 단위)

onSync: async ({ queue }) => {

let entry;

while ((entry = await queue.shiftRequest())) {

try {

await fetch(entry.request);

console.log('[BG Sync] Replayed:', entry.request.url);

} catch (error) {

console.error('[BG Sync] Failed:', error);

await queue.unshiftRequest(entry);

throw error;

}

}

},

});

// POST 요청에 Background Sync 적용

registerRoute(

({ url, request }) =>

url.pathname === '/api/submit' && request.method === 'POST',

new NetworkOnly({

plugins: [bgSyncPlugin],

}),

'POST'

);

6. 푸시 알림 (Push Notification)

6.1 푸시 알림 아키텍처

푸시 알림 흐름:

┌──────────┐ 구독 ┌──────────────┐

│ Client │─────────▶│ Push Server │

│ (Browser)│ │ (FCM/APNS) │

└────┬─────┘ └──────┬───────┘

│ 구독 정보 │

│ 전달 │

▼ │

┌──────────┐ 푸시 전송 ◀───┘

│ App │

│ Server │

└──────────┘

6.2 구독 구현

// 클라이언트: 푸시 알림 구독

async function subscribePush() {

// 알림 권한 요청

const permission = await Notification.requestPermission();

if (permission !== 'granted') {

console.log('Notification permission denied');

return;

}

const registration = await navigator.serviceWorker.ready;

// VAPID 공개키 변환

const vapidPublicKey = 'YOUR_VAPID_PUBLIC_KEY';

const convertedKey = urlBase64ToUint8Array(vapidPublicKey);

// 구독 생성

const subscription = await registration.pushManager.subscribe({

userVisibleOnly: true,

applicationServerKey: convertedKey,

});

// 서버에 구독 정보 전송

await fetch('/api/push/subscribe', {

method: 'POST',

headers: { 'Content-Type': 'application/json' },

body: JSON.stringify(subscription),

});

console.log('Push subscription successful');

}

function urlBase64ToUint8Array(base64String) {

const padding = '='.repeat((4 - base64String.length % 4) % 4);

const base64 = (base64String + padding)

.replace(/-/g, '+')

.replace(/_/g, '/');

const rawData = window.atob(base64);

const outputArray = new Uint8Array(rawData.length);

for (let i = 0; i < rawData.length; ++i) {

outputArray[i] = rawData.charCodeAt(i);

}

return outputArray;

}

6.3 서버에서 푸시 전송

// Node.js 서버: web-push 라이브러리

const webpush = require('web-push');

// VAPID 키 설정

webpush.setVapidDetails(

'mailto:admin@example.com',

process.env.VAPID_PUBLIC_KEY,

process.env.VAPID_PRIVATE_KEY

);

// 푸시 메시지 전송

async function sendPushNotification(subscription, payload) {

try {

await webpush.sendNotification(

subscription,

JSON.stringify({

title: '새 메시지',

body: '김철수님이 메시지를 보냈습니다.',

icon: '/icons/icon-192x192.png',

badge: '/icons/badge-72x72.png',

data: {

url: '/messages/123',

timestamp: Date.now(),

},

actions: [

{ action: 'reply', title: '답장', icon: '/icons/reply.png' },

{ action: 'dismiss', title: '닫기', icon: '/icons/close.png' },

],

})

);

console.log('Push sent successfully');

} catch (error) {

if (error.statusCode === 410) {

// 구독 만료 - DB에서 제거

await removeSubscription(subscription.endpoint);

}

console.error('Push failed:', error);

}

}

6.4 Service Worker에서 푸시 수신

// sw.js: 푸시 이벤트 처리

self.addEventListener('push', (event) => {

const data = event.data ? event.data.json() : {};

const options = {

body: data.body || 'New notification',

icon: data.icon || '/icons/icon-192x192.png',

badge: data.badge || '/icons/badge-72x72.png',

vibrate: [200, 100, 200],

data: data.data || {},

actions: data.actions || [],

tag: data.tag || 'default',

renotify: true,

};

event.waitUntil(

self.registration.showNotification(data.title || 'Notification', options)

);

});

// 알림 클릭 처리

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

event.notification.close();

const urlToOpen = event.notification.data.url || '/';

if (event.action === 'reply') {

// 답장 액션 처리

event.waitUntil(clients.openWindow(urlToOpen + '?action=reply'));

} else {

event.waitUntil(

clients.matchAll({ type: 'window' }).then((clientList) => {

// 이미 열린 창이 있으면 포커스

for (const client of clientList) {

if (client.url === urlToOpen && 'focus' in client) {

return client.focus();

}

}

// 없으면 새 창 열기

return clients.openWindow(urlToOpen);

})

);

}

});

7. 오프라인 우선 (Offline-First) 아키텍처

7.1 IndexedDB를 활용한 로컬 데이터 저장

// IndexedDB 래퍼 (idb 라이브러리 사용)

class LocalStore {

constructor(dbName, storeName) {

this.dbName = dbName;

this.storeName = storeName;

}

async getDB() {

return openDB(this.dbName, 1, {

upgrade(db) {

if (!db.objectStoreNames.contains('tasks')) {

const store = db.createObjectStore('tasks', {

keyPath: 'id',

autoIncrement: true,

});

store.createIndex('status', 'status');

store.createIndex('syncStatus', 'syncStatus');

store.createIndex('updatedAt', 'updatedAt');

}

},

});

}

async getAll() {

const db = await this.getDB();

return db.getAll(this.storeName);

}

async get(id) {

const db = await this.getDB();

return db.get(this.storeName, id);

}

async add(item) {

const db = await this.getDB();

item.syncStatus = 'pending';

item.updatedAt = Date.now();

return db.add(this.storeName, item);

}

async update(item) {

const db = await this.getDB();

item.syncStatus = 'pending';

item.updatedAt = Date.now();

return db.put(this.storeName, item);

}

async delete(id) {

const db = await this.getDB();

return db.delete(this.storeName, id);

}

async getPendingSync() {

const db = await this.getDB();

const tx = db.transaction(this.storeName, 'readonly');

const index = tx.store.index('syncStatus');

return index.getAll('pending');

}

}

// 사용 예시

const taskStore = new LocalStore('myApp', 'tasks');

// 오프라인에서도 작업 추가 가능

await taskStore.add({

title: '새 할일',

status: 'active',

createdAt: Date.now(),

});

7.2 동기화 전략

// 온라인 복구 시 동기화

class SyncManager {

constructor(localStore, apiUrl) {

this.localStore = localStore;

this.apiUrl = apiUrl;

this.setupListeners();

}

setupListeners() {

window.addEventListener('online', () => this.sync());

}

async sync() {

const pendingItems = await this.localStore.getPendingSync();

for (const item of pendingItems) {

try {

const response = await fetch(this.apiUrl, {

method: 'POST',

headers: { 'Content-Type': 'application/json' },

body: JSON.stringify(item),

});

if (response.ok) {

item.syncStatus = 'synced';

await this.localStore.update(item);

}

} catch (error) {

console.error('Sync failed for item:', item.id, error);

}

}

}

}

8. Next.js PWA 통합

8.1 next-pwa 설정

npm install @ducanh2912/next-pwa

// next.config.js

const withPWA = require('@ducanh2912/next-pwa').default({

dest: 'public',

disable: process.env.NODE_ENV === 'development',

register: true,

skipWaiting: true,

cacheOnFrontEndNav: true,

aggressiveFrontEndNavCaching: true,

reloadOnOnline: true,

workboxOptions: {

runtimeCaching: [

{

urlPattern: /^https:\/\/fonts\.(?:googleapis|gstatic)\.com\/.*/i,

handler: 'CacheFirst',

options: {

cacheName: 'google-fonts',

expiration: {

maxEntries: 4,

maxAgeSeconds: 365 * 24 * 60 * 60, // 1년

},

},

},

{

urlPattern: /\.(?:eot|otf|ttc|ttf|woff|woff2|font\.css)$/i,

handler: 'StaleWhileRevalidate',

options: {

cacheName: 'static-font-assets',

expiration: {

maxEntries: 4,

maxAgeSeconds: 7 * 24 * 60 * 60, // 7일

},

},

},

{

urlPattern: /\.(?:jpg|jpeg|gif|png|svg|ico|webp)$/i,

handler: 'StaleWhileRevalidate',

options: {

cacheName: 'static-image-assets',

expiration: {

maxEntries: 64,

maxAgeSeconds: 24 * 60 * 60, // 24시간

},

},

},

{

urlPattern: /\/api\/.*$/i,

handler: 'NetworkFirst',

options: {

cacheName: 'apis',

networkTimeoutSeconds: 10,

expiration: {

maxEntries: 16,

maxAgeSeconds: 24 * 60 * 60,

},

},

},

],

},

});

module.exports = withPWA({

// Next.js 설정

reactStrictMode: true,

});

8.2 App Router에서 Manifest

// app/manifest.ts (Next.js App Router)

export default function manifest(): MetadataRoute.Manifest {

return {

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',

icons: [

{

src: '/icons/icon-192x192.png',

sizes: '192x192',

type: 'image/png',

},

{

src: '/icons/icon-512x512.png',

sizes: '512x512',

type: 'image/png',

},

],

};

}

8.3 오프라인 페이지 처리

// app/offline/page.tsx

export default function OfflinePage() {

return (

인터넷 연결을 확인해 주세요.

onClick={() => window.location.reload()}

className="px-6 py-3 bg-blue-500 text-white rounded-lg"

>

다시 시도

);

}

9. 앱스토어 배포

9.1 Google Play Store (TWA)

Trusted Web Activity(TWA)를 사용하여 PWA를 Google Play Store에 배포할 수 있습니다.

Bubblewrap으로 TWA 생성

npx @nicolo-ribaudo/bubblewrap init --manifest https://myapp.com/manifest.json

npx @nicolo-ribaudo/bubblewrap build

Google Play 배포 단계:

1. Digital Asset Links 설정

- /.well-known/assetlinks.json 파일 생성

- 앱 서명 인증서의 SHA-256 지문 포함

2. TWA 프로젝트 생성

- Bubblewrap 또는 PWABuilder 사용

- Android 프로젝트 자동 생성

3. 서명 및 빌드

- Android keystore 생성

- APK 또는 AAB 빌드

4. Play Console에 업로드

- 내부/비공개/프로덕션 트랙 선택

- 스토어 목록 작성

9.2 Microsoft Store (PWABuilder)

Microsoft Store 배포 단계:

1. PWABuilder (https://www.pwabuilder.com/) 접속

2. PWA URL 입력

3. 점수 확인 및 패키지 다운로드

4. MSIX 패키지 생성

5. Partner Center에서 앱 등록

6. MSIX 패키지 업로드

9.3 iOS (Safari 지원)

iOS PWA 제한사항 (2025년 기준):

지원:

- 홈 화면 추가

- 오프라인 캐싱

- Web App Manifest (일부)

- 기본 푸시 알림 (iOS 16.4+)

제한:

- 백그라운드 동기화 미지원

- 50MB 스토리지 제한

- 서드파티 브라우저에서 PWA 설치 불가

- 일부 Web API 미지원

10. PWA 성능 최적화

10.1 Lighthouse PWA 점검 항목

Lighthouse PWA 체크리스트:

[필수]

- HTTPS 사용

- Service Worker 등록

- 오프라인에서 200 응답

- Web App Manifest 존재

- start_url 설정

- 아이콘 (192px, 512px)

- viewport 메타 태그 설정

- theme-color 메타 태그

[권장]

- HTTP를 HTTPS로 리다이렉트

- 3G에서 빠른 로드 (10초 이내)

- 커스텀 스플래시 스크린

- 주소표시줄 테마 색상

- 뷰포트에 맞는 콘텐츠

- apple-touch-icon 설정

10.2 캐시 크기 관리

// 캐시 크기 모니터링

async function getCacheSize() {

if ('storage' in navigator && 'estimate' in navigator.storage) {

const estimate = await navigator.storage.estimate();

const usedMB = (estimate.usage / (1024 * 1024)).toFixed(2);

const quotaMB = (estimate.quota / (1024 * 1024)).toFixed(2);

console.log('Storage used:', usedMB, 'MB of', quotaMB, 'MB');

return estimate;

}

}

// 영구 스토리지 요청

async function requestPersistentStorage() {

if (navigator.storage && navigator.storage.persist) {

const granted = await navigator.storage.persist();

console.log('Persistent storage:', granted ? 'granted' : 'denied');

}

}

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

// 설치 프롬프트 제어

let deferredPrompt;

window.addEventListener('beforeinstallprompt', (e) => {

e.preventDefault();

deferredPrompt = e;

showInstallButton();

});

function showInstallButton() {

const installBtn = document.getElementById('install-btn');

installBtn.style.display = 'block';

installBtn.addEventListener('click', async () => {

if (!deferredPrompt) return;

deferredPrompt.prompt();

const result = await deferredPrompt.userChoice;

console.log('Install prompt result:', result.outcome);

deferredPrompt = null;

installBtn.style.display = 'none';

});

}

// 설치 완료 감지

window.addEventListener('appinstalled', () => {

console.log('PWA was installed');

deferredPrompt = null;

// 분석 이벤트 전송

analytics.track('pwa_installed');

});

11. 실전 퀴즈

각 문제를 풀고 답을 확인해 보세요.

**Registration - Installation - Waiting - Activation - Fetch (Idle/Terminated)**

등록(Registration) 후 설치(Installation)에서 정적 자원을 프리캐싱하고, 이전 SW가 있으면 대기(Waiting) 상태에 들어갑니다. 활성화(Activation)에서 오래된 캐시를 정리하고, 이후 Fetch 이벤트로 네트워크 요청을 제어합니다.

**정적 자원(CSS, JS 번들, 이미지, 폰트)**

변경 빈도가 낮고 버전 관리가 되는 자원에 적합합니다. 캐시에서 즉시 응답하므로 빠르고 오프라인에서도 동작합니다. API 데이터처럼 자주 변하는 데이터에는 Network First나 Stale While Revalidate가 적합합니다.

**독립 앱처럼 표시되며 브라우저 주소창이 없는 모드**

standalone 모드에서는 상태바만 표시되고, 일반 앱처럼 보입니다. fullscreen은 상태바도 없고, minimal-ui는 최소한의 브라우저 UI(뒤로가기 등)가 표시되며, browser는 일반 브라우저 탭에서 열립니다.

**오프라인 상태에서 실패한 네트워크 요청을 큐에 저장하고, 온라인 복구 시 자동 재시도하는 것**

예를 들어 오프라인 상태에서 폼 제출을 하면, 요청을 IndexedDB에 저장해두었다가 네트워크가 복구되면 자동으로 서버에 전송합니다. 이를 통해 오프라인에서도 데이터 손실 없이 작업할 수 있습니다.

**TWA (Trusted Web Activity)**

TWA는 Chrome Custom Tabs를 기반으로 PWA를 네이티브 Android 앱으로 래핑합니다. Digital Asset Links 파일(assetlinks.json)을 통해 앱과 웹사이트 간의 소유권을 증명합니다. Bubblewrap이나 PWABuilder 도구로 TWA 프로젝트를 쉽게 생성할 수 있습니다.

12. 참고 자료

1. [web.dev PWA 가이드](https://web.dev/progressive-web-apps/) - Google의 공식 PWA 가이드

2. [MDN Service Worker API](https://developer.mozilla.org/ko/docs/Web/API/Service_Worker_API) - Service Worker API 문서

3. [Workbox 공식 문서](https://developer.chrome.com/docs/workbox/) - Workbox 라이브러리 문서

4. [PWABuilder](https://www.pwabuilder.com/) - PWA 빌드 및 배포 도구

5. [Web Push Protocol](https://web.dev/push-notifications-overview/) - 웹 푸시 알림 개요

6. [Lighthouse](https://developer.chrome.com/docs/lighthouse/) - PWA 감사 도구

7. [next-pwa](https://github.com/nicolo-ribaudo/next-pwa) - Next.js PWA 플러그인

8. [idb](https://github.com/nicolo-ribaudo/idb) - IndexedDB Promise 래퍼

9. [Project Fugu](https://fugu-tracker.web.app/) - 웹 플랫폼 API 트래커

10. [Bubblewrap](https://github.com/nicolo-ribaudo/nicolo-ribaudo-nicolo-ribaudo-nicolo-ribaudo-nicolo-ribaudo-nicolo-ribaudo-nicolo-ribaudo-nicolo-ribaudo-nicolo-ribaudo-nicolo-ribaudo-nicolo-ribaudo-nicolo-ribaudo-nicolo-ribaudo-nicolo-ribaudo-nicolo-ribaudo-nicolo-ribaudo-nicolo-ribaudo-nicolo-ribaudo-nicolo-ribaudo-nicolo-ribaudo-nicolo-ribaudo-nicolo-ribaudo) - TWA 빌드 도구

11. [What PWA Can Do Today](https://whatpwacando.today/) - PWA 기능 데모

12. [Microsoft PWA 문서](https://learn.microsoft.com/en-us/microsoft-edge/progressive-web-apps-chromium/) - Microsoft Edge PWA 가이드

13. [Apple Developer - Web Apps](https://developer.apple.com/documentation/webkit/web-apps) - iOS PWA 문서

현재 단락 (1/852)

2025년, PWA(Progressive Web App)는 더 이상 실험적 기술이 아닙니다. Twitter(X), Starbucks, Pinterest, Spotify 등 수많은 ...

작성 글자: 0원문 글자: 20,725작성 단락: 0/852