Next.js와 TypeScript로 구축하는 Progressive Web App 실전 가이드
Next.js와 TypeScript를 활용하여 PWA를 구축하는 실전 방법을 단계별로 알아보고, Service Worker와 Manifest 설정부터 오프라인 지원까지 완벽히 구현해보세요.
Next.js와 TypeScript로 구축하는 Progressive Web App 실전 가이드
Progressive Web App(PWA)은 웹과 네이티브 앱의 장점을 결합한 혁신적인 기술입니다. 오늘날 모바일 중심의 디지털 환경에서 PWA는 사용자 경험을 크게 향상시키며, 개발자에게는 하나의 코드베이스로 다양한 플랫폼을 지원할 수 있는 효율성을 제공합니다. 이 글에서는 Next.js와 TypeScript를 활용하여 실제 운영 환경에서 사용할 수 있는 PWA를 단계별로 구축하는 방법을 알아보겠습니다.
PWA 핵심 개념과 구성 요소
Progressive Web App은 세 가지 핵심 기술로 구성됩니다. 첫째, Service Worker를 통한 백그라운드 처리와 오프라인 지원, 둘째, Web App Manifest를 통한 앱과 같은 설치 경험, 셋째, HTTPS를 통한 보안 연결입니다.
PWA의 주요 특징으로는 반응형 디자인, 오프라인 작동, 앱과 같은 인터페이스, 푸시 알림 지원, 자동 업데이트 등이 있습니다. 이러한 특징들은 사용자 참여도를 높이고 재방문률을 증가시키는 데 크게 기여합니다.
Next.js 프로젝트 PWA 설정
먼저 Next.js 프로젝트에 PWA 기능을 추가하기 위해 next-pwa 패키지를 설치합니다.
npm install next-pwa
npm install -D @types/serviceworker
next.config.js 파일을 다음과 같이 설정합니다:
const withPWA = require('next-pwa')({
dest: 'public',
register: true,
skipWaiting: true,
disable: process.env.NODE_ENV === 'development',
runtimeCaching: [
{
urlPattern: /^https:\/\/fonts\.googleapis\.com\/.*/i,
handler: 'CacheFirst',
options: {
cacheName: 'google-fonts',
expiration: {
maxEntries: 4,
maxAgeSeconds: 365 * 24 * 60 * 60 // 1 year
}
}
},
{
urlPattern: /^https:\/\/api\.example\.com\/.*/i,
handler: 'NetworkFirst',
options: {
cacheName: 'api-cache',
networkTimeoutSeconds: 10,
expiration: {
maxEntries: 50,
maxAgeSeconds: 5 * 60 // 5 minutes
}
}
}
]
});
module.exports = withPWA({
// 기존 Next.js 설정
});
Web App Manifest 구성
public/manifest.json 파일을 생성하여 앱의 메타데이터를 정의합니다:
{
"name": "My PWA Application",
"short_name": "MyPWA",
"description": "A Progressive Web App built with Next.js and TypeScript",
"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"
}
]
}
TypeScript를 활용한 Service Worker 커스터마이징
고급 기능을 위해 커스텀 Service Worker를 TypeScript로 작성할 수 있습니다. public/sw.ts 파일을 생성합니다:
/// <reference lib="webworker" />
declare const self: ServiceWorkerGlobalScope;
const CACHE_NAME = 'my-pwa-cache-v1';
const urlsToCache = [
'/',
'/static/js/bundle.js',
'/static/css/main.css',
'/offline'
];
// 설치 이벤트
self.addEventListener('install', (event: ExtendableEvent) => {
event.waitUntil(
caches.open(CACHE_NAME)
.then((cache) => {
return cache.addAll(urlsToCache);
})
);
});
// 활성화 이벤트
self.addEventListener('activate', (event: ExtendableEvent) => {
event.waitUntil(
caches.keys().then((cacheNames) => {
return Promise.all(
cacheNames.map((cacheName) => {
if (cacheName !== CACHE_NAME) {
return caches.delete(cacheName);
}
})
);
})
);
});
// 네트워크 요청 가로채기
self.addEventListener('fetch', (event: FetchEvent) => {
event.respondWith(
caches.match(event.request)
.then((response) => {
// 캐시에서 발견되면 반환
if (response) {
return response;
}
return fetch(event.request).catch(() => {
// 네트워크 실패 시 오프라인 페이지 반환
if (event.request.destination === 'document') {
return caches.match('/offline');
}
});
})
);
});
// 푸시 알림 처리
self.addEventListener('push', (event: PushEvent) => {
const options: NotificationOptions = {
body: event.data?.text() || 'New notification',
icon: '/icons/icon-192x192.png',
badge: '/icons/badge-72x72.png',
vibrate: [100, 50, 100],
data: {
dateOfArrival: Date.now(),
primaryKey: '2'
},
actions: [
{
action: 'explore',
title: 'Explore this new world',
icon: '/icons/checkmark.png'
},
{
action: 'close',
title: 'Close',
icon: '/icons/xmark.png'
}
]
};
event.waitUntil(
self.registration.showNotification('PWA Notification', options)
);
});
React 컴포넌트에서 PWA 기능 활용
PWA 설치 프롬프트와 오프라인 상태를 관리하는 React 컴포넌트를 TypeScript로 구현합니다:
import React, { useState, useEffect } from 'react';
interface BeforeInstallPromptEvent extends Event {
readonly platforms: string[];
readonly userChoice: Promise<{
outcome: 'accepted' | 'dismissed';
platform: string;
}>;
prompt(): Promise<void>;
}
const PWAInstallPrompt: React.FC = () => {
const [deferredPrompt, setDeferredPrompt] = useState<BeforeInstallPromptEvent | null>(null);
const [isInstallable, setIsInstallable] = useState(false);
const [isOnline, setIsOnline] = useState(true);
useEffect(() => {
const handleBeforeInstallPrompt = (e: Event) => {
e.preventDefault();
setDeferredPrompt(e as BeforeInstallPromptEvent);
setIsInstallable(true);
};
const handleOnline = () => setIsOnline(true);
const handleOffline = () => setIsOnline(false);
window.addEventListener('beforeinstallprompt', handleBeforeInstallPrompt);
window.addEventListener('online', handleOnline);
window.addEventListener('offline', handleOffline);
// 초기 온라인 상태 설정
setIsOnline(navigator.onLine);
return () => {
window.removeEventListener('beforeinstallprompt', handleBeforeInstallPrompt);
window.removeEventListener('online', handleOnline);
window.removeEventListener('offline', handleOffline);
};
}, []);
const handleInstallClick = async () => {
if (!deferredPrompt) return;
deferredPrompt.prompt();
const { outcome } = await deferredPrompt.userChoice;
if (outcome === 'accepted') {
setDeferredPrompt(null);
setIsInstallable(false);
}
};
return (
<div className="pwa-controls">
{!isOnline && (
<div className="offline-indicator">
<span>🔴 오프라인 모드</span>
</div>
)}
{isInstallable && (
<button
onClick={handleInstallClick}
className="install-button"
>
📱 앱 설치하기
</button>
)}
</div>
);
};
export default PWAInstallPrompt;
오프라인 지원과 캐싱 전략
효과적인 캐싱 전략을 구현하기 위해 다양한 리소스 타입별로 적절한 캐싱 방식을 적용합니다:
// utils/cacheStrategies.ts
export const cacheStrategies = {
// 정적 리소스: Cache First
staticAssets: {
handler: 'CacheFirst',
options: {
cacheName: 'static-assets',
expiration: {
maxEntries: 100,
maxAgeSeconds: 30 * 24 * 60 * 60 // 30일
}
}
},
// API 호출: Network First
apiCalls: {
handler: 'NetworkFirst',
options: {
cacheName: 'api-calls',
networkTimeoutSeconds: 5,
expiration: {
maxEntries: 50,
maxAgeSeconds: 5 * 60 // 5분
}
}
},
// 이미지: Stale While Revalidate
images: {
handler: 'StaleWhileRevalidate',
options: {
cacheName: 'images',
expiration: {
maxEntries: 200,
maxAgeSeconds: 7 * 24 * 60 * 60 // 7일
}
}
}
};
푸시 알림 구현
서버와 클라이언트 간 푸시 알림을 구현합니다:
// hooks/usePushNotification.ts
import { useState, useEffect } from 'react';
export const usePushNotification = () => {
const [subscription, setSubscription] = useState<PushSubscription | null>(null);
const [isSupported, setIsSupported] = useState(false);
useEffect(() => {
setIsSupported('serviceWorker' in navigator && 'PushManager' in window);
}, []);
const subscribeUser = async (): Promise<PushSubscription | null> => {
if (!isSupported) return null;
try {
const registration = await navigator.serviceWorker.ready;
const sub = await registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: process.env.NEXT_PUBLIC_VAPID_PUBLIC_KEY
});
setSubscription(sub);
// 서버에 구독 정보 전송
await fetch('/api/subscribe', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(sub)
});
return sub;
} catch (error) {
console.error('푸시 구독 실패:', error);
return null;
}
};
const unsubscribeUser = async (): Promise<boolean> => {
if (!subscription) return false;
try {
await subscription.unsubscribe();
setSubscription(null);
return true;
} catch (error) {
console.error('푸시 구독 해제 실패:', error);
return false;
}
};
return {
subscription,
isSupported,
subscribeUser,
unsubscribeUser
};
};
성능 최적화와 모니터링
PWA의 성능을 지속적으로 모니터링하고 최적화하는 것이 중요합니다. Web Vitals를 측정하고 개선하는 방법을 구현합니다:
// utils/webVitals.ts
import { getCLS, getFID, getFCP, getLCP, getTTFB } from 'web-vitals';
export const reportWebVitals = (onPerfEntry?: (metric: any) => void) => {
if (onPerfEntry && onPerfEntry instanceof Function) {
getCLS(onPerfEntry);
getFID(onPerfEntry);
getFCP(onPerfEntry);
getLCP(onPerfEntry);
getTTFB(onPerfEntry);
}
};
// _app.tsx에서 사용
export function reportWebVitals(metric: any) {
// Google Analytics나 다른 분석 도구로 전송
if (typeof window !== 'undefined' && window.gtag) {
window.gtag('event', metric.name, {
event_category: 'Web Vitals',
value: Math.round(metric.value),
event_label: metric.id,
non_interaction: true,
});
}
}
마무리
Next.js와 TypeScript를 활용한 PWA 구축은 현대적인 웹 애플리케이션 개발의 필수 요소가 되었습니다. Service Worker를 통한 오프라인 지원, Web App Manifest를 통한 네이티브 앱과 같은 경험, 그리고 푸시 알림까지 구현함으로써 사용자 경험을 크게 향상시킬 수 있습니다. 지속적인 성능 모니터링과 최적화를 통해 더욱 완성도 높은 PWA를 만들어 나가시기 바랍니다.
관련 게시글
Vite Build Tool: Fast Frontend Development Guide
Vite는 현대적인 프론트엔드 개발을 위한 빠르고 효율적인 빌드 도구입니다. 이 가이드에서는 Vite의 핵심 기능, React 및 TypeScript 프로젝트 설정, 플러그인 활용법, 그리고 빌드 최적화 전략까지 완벽하게 다룹니다.
React Server Components (RSC) 심층 가이드: Next.js와 함께하는 Full-stack React
React Server Components (RSC)의 개념, 등장 배경, 동작 원리, 그리고 Next.js 13+ App Router에서의 활용법을 심층적으로 다룹니다. 클라이언트/서버 컴포넌트 분리 전략과 실전 코드 예제를 통해 RSC의 강력한 이점을 이해하고 웹 애플리케이션 성능을 최적화하는 방법을 알아봅니다.
Next.js Middleware: 강력한 요청 처리 활용법
Next.js Middleware를 활용하여 사용자 인증, 국제화, A/B 테스트 등 다양한 요청 처리 로직을 효율적으로 구현하는 방법을 심층적으로 알아봅니다. 실전 코드 예제를 통해 Next.js 애플리케이션의 프론트엔드 기능을 강화하세요.