Next.js
배포

PWA 전제 조건과 Offline-first를 깨닫기까지

중년개발자
중년개발자

@loxo

26일 전

30

PWA 전제 조건과 Offline-first를 깨닫기까지

알림을 위해 FCM을 붙이면서 처음 접한 것은 수년 전이지만, 그때까지만 해도 PWA는 그냥 “웹에 알림 붙이는 정도의 기술”이라고 생각했다. 그런데 실제로 적용해보니, PWA는 생각보다 훨씬 강력한 웹앱 전략이었다.

모든 배포를 PWA로 구성하면 배포 방식이 단순해진다. 웹앱이기 때문에 별도 설치 과정을 거치지 않아도 되고, 브라우저에서 자동으로 설치가 가능하다. 보통은 홈 화면에 설치되며, 주소창이나 브라우저 UI 없이 앱처럼 실행된다.

또한 PWA는 스토어 배포도 가능하다.

  • Microsoft Store
  • Google Play Store (Android)

Apple의 경우 App Store 등록 없이도 iPhone, iPad, Mac에서 홈 화면 설치 및 실행이 가능하다. 즉, 알림·오프라인·설치 경험 정도면 충분한 서비스라면 굳이 네이티브 앱이 필요하지 않은 경우가 대부분이라는 결론에 도달했다. 이 관점에서 보면 PWA는 꽤나 현실적인 “최적의 전략”이다.


가장 중요한 전제 조건은 오프라인 지원

PWA의 필수 전제 조건 중 가장 중요한 것은 오프라인 지원이다. 문서에서는 HTTPS, Manifest, Service Worker를 이야기하지만, 실제로 설치 버튼이 보이느냐 마느냐를 가르는 결정적 요소는 이거였다.

“오프라인 상태에서도 최소한의 화면이 유지되는가?”

이 조건을 만족하지 않으면,

  • Manifest가 있어도
  • Service Worker가 등록되어 있어도

브라우저는 PWA로 인정하지 않는다.

다행히 오프라인 지원 자체가 복잡한 것은 아니다. 핵심은 단순하다.

Service Worker에서 캐시 전략이 실제로 동작해야 한다


pwabuilder.com에서 깨달은 결정적 포인트

배포 앱을 만들기 위해 pwabuilder.com을 사용해보면 이 사실이 명확해진다. 서비스 중인 URL만 입력하면 다음을 바로 확인할 수 있다.

  • PWA 점수
  • 필수 항목 누락 여부
  • 추천 설정
  • 스토어 배포용 패키지 생성 가능 여부

특히 점수 분석 과정에서 오프라인 관련 항목이 가장 큰 비중을 차지한다는 점이 인상적이었다.


삽질의 역사: React Native → Electron → 현타

처음에는 React Native, Electron으로 시작했다. 여러 삽질 끝에 배포 파일은 만들 수 있었지만, 단순히 알림 하나 연결하려고:

  • 네이티브 브릿지 코드 추가
  • 불필요한 라이브러리 설치
  • 플랫폼별 분기 처리

이런 작업들이 점점 쌓이면서 “이게 맞는 건가?”라는 현타가 오기 시작했다.

결국 이 방향은 접고, 원리를 이해한 뒤 다시 적용하자고 판단했다.

그러다 홈 화면 설치된 웹앱이 생각보다 잘 동작하는 걸 보고, “이걸 버튼으로 제어할 수는 없을까?”를 찾아보다가 비로소 제대로 동작하는 PWA의 전제 조건을 이해하게 되었다.


결론적으로 깨달은 핵심

다시 정리하면, 가장 중요한 것은 역시 오프라인 지원을 포함한 Service Worker 등록이다. 말을 어렵게 했지만 결국 요약하면 이거다.

필요한 리소스를 cache에 저장하면 된다

나머지는 대부분 문서에 잘 나와 있다. Next.js 문서도 잘 정리되어 있고, Manifest 작성도 크게 어렵지 않다.

하지만 이상하게도 정작 중요한 오프라인 지원에 대한 설명은 한눈에 들어오지 않았다. pwabuilder.com에서 실제 점수 평가를 해보면서 “아, 이게 진짜 핵심이구나”를 체감하게 되었다.


웹앱을 진짜 PWA로 만들기 위한 최소 조건 3가지

진정으로 “웹앱”이 아니라 “설치 가능한 앱”으로 만들고 싶다면, 다음 3가지만 프로젝트에 제대로 적용하면 된다.

1️⃣ localhost 또는 HTTPS

  • 개발 환경은 localhost
  • 운영 환경은 HTTPS

(이건 너무 당연해서 패스)

2️⃣ Manifest 작성

  • manifest.json 또는 manifest.ts
  • 아이콘, start_url, display 등 꼼꼼히 작성
  • pwabuilder.com에서 점수와 필수 항목 체크
json
{ "name": "postgresql.co.kr", "short_name": "postgresql.co.kr", "start_url": "/?source=pwa&ver=1.0.0", "id": "/", "description": "PostgreSQL, Spring Boot, Next.js를 아우르는 대한민국 대표 풀스택 개발자 커뮤니티. 깊이 있는 기술 공유와 성장의 공간.", "lang": "ko-KR", "dir": "ltr", "orientation": "any", "categories": ["education", "productivity", "social"], "icons": [ { "src": "/web-app-manifest-192x192.png", "sizes": "192x192", "type": "image/png", "purpose": "any maskable" }, { "src": "/web-app-manifest-512x512.png", "sizes": "512x512", "type": "image/png", "purpose": "any maskable" } ], "screenshots": [ { "src": "https://cdn.postgresql.co.kr/cdn-cgi/image/width=900,height=900,fit=scale-down,format=auto,anim=false/download/desktop_edit.png", "sizes": "1920x1080", "type": "image/png", "form_factor": "wide" }, { "src": "https://cdn.postgresql.co.kr/cdn-cgi/image/width=900,height=900,fit=scale-down,format=auto,anim=false/download/desktop_list.png", "sizes": "1920x1080", "type": "image/png", "form_factor": "wide" }, { "src": "https://cdn.postgresql.co.kr/cdn-cgi/image/width=900,height=900,fit=scale-down,format=auto,anim=false/download/mobile_detail.png", "sizes": "1080x1920", "type": "image/png", "form_factor": "narrow" }, { "src": "https://cdn.postgresql.co.kr/cdn-cgi/image/width=900,height=900,fit=scale-down,format=auto,anim=false/download/mobile_home.png", "sizes": "1080x1920", "type": "image/png", "form_factor": "narrow" }, { "src": "https://cdn.postgresql.co.kr/cdn-cgi/image/width=900,height=900,fit=scale-down,format=auto,anim=false/download/mobile_list.png", "sizes": "1080x1920", "type": "image/png", "form_factor": "narrow" }, { "src": "https://cdn.postgresql.co.kr/cdn-cgi/image/width=900,height=900,fit=scale-down,format=auto,anim=false/download/mobile_login.png", "sizes": "1080x1920", "type": "image/png", "form_factor": "narrow" }, { "src": "https://cdn.postgresql.co.kr/cdn-cgi/image/width=900,height=900,fit=scale-down,format=auto,anim=false/download/mobile_mypage.png", "sizes": "1080x1920", "type": "image/png", "form_factor": "narrow" }, { "src": "https://cdn.postgresql.co.kr/cdn-cgi/image/width=900,height=900,fit=scale-down,format=auto,anim=false/download/mobile_wite.png", "sizes": "1080x1920", "type": "image/png", "form_factor": "narrow" } ], "theme_color": "#ffffff", "background_color": "#ffffff", "display": "standalone" }

3️⃣ Service Worker 등록 (핵심)

  • public/sw.js
  • Firebase FCM 연동
  • App Shell 및 최소 리소스 캐시
ts
// sw.js // Import Firebase Messaging Service Worker importScripts('/firebase-messaging-sw.js'); const CACHE_NAME = 'postgresql-pwa-v2'; // Install event self.addEventListener('install', (_event) => { // Worker installed but waiting for activation console.log('[SW] Installed'); }); // Listen for the "skipWaiting" message to force activation self.addEventListener('message', (event) => { if (event.data && event.data.type === 'SKIP_WAITING') { self.skipWaiting(); } }); // Activate event - Clean up old caches and claim clients self.addEventListener('activate', (event) => { event.waitUntil( caches.keys() .then((cacheNames) => { return Promise.all( cacheNames .filter((name) => name !== CACHE_NAME) // Delete all caches except current .map((name) => { console.log('[SW] Deleting old cache:', name); return caches.delete(name); }) ); }) .then(() => { console.log('[SW] Activated and claimed clients'); return clients.claim(); }) ); }); // Fetch event - Network First, then Cache self.addEventListener('fetch', (event) => { // Only handle navigation requests (HTML pages) if (event.request.mode === 'navigate') { event.respondWith( fetch(event.request) .then((response) => { // Check if we received a valid response if (!response || response.status !== 200 || response.type !== 'basic') { return response; } // IMPORTANT: Clone the response. A response is a stream // and because we want the browser to consume the response // as well as the cache consuming the response, we need // to clone it so we have two streams. const responseToCache = response.clone(); caches.open(CACHE_NAME) .then((cache) => { cache.put(event.request, responseToCache); }); return response; }) .catch(() => { // Network failed, try to get it from the cache return caches.match(event.request); }) ); } });
  • 서비스워크 등록
ts
'use client'; import { Alert, Button, Snackbar } from '@mui/material'; import { useEffect, useState } from 'react'; export function ServiceWorkerRegister() { const [showUpdate, setShowUpdate] = useState(false); const [waitingWorker, setWaitingWorker] = useState<ServiceWorker | null>(null); useEffect(() => { // 1. Reload page when the new service worker takes control const handleControllerChange = () => { window.location.reload(); }; if ('serviceWorker' in navigator) { navigator.serviceWorker.addEventListener('controllerchange', handleControllerChange); // 2. Register the service worker navigator.serviceWorker .register('/sw.js') .then((registration) => { // Check if there is already a waiting worker (e.g. from previous load) if (registration.waiting) { setWaitingWorker(registration.waiting); setShowUpdate(true); } // Listen for new updates registration.addEventListener('updatefound', () => { const newWorker = registration.installing; if (newWorker) { newWorker.addEventListener('statechange', () => { if (newWorker.state === 'installed' && navigator.serviceWorker.controller) { // A new version is available and installed, waiting to activate setWaitingWorker(newWorker); setShowUpdate(true); } }); } }); }) .catch((error) => { console.error('Service Worker registration failed:', error); }); } return () => { if ('serviceWorker' in navigator) { navigator.serviceWorker.removeEventListener('controllerchange', handleControllerChange); } }; }, []); const handleUpdate = () => { if (waitingWorker) { waitingWorker.postMessage({ type: 'SKIP_WAITING' }); setShowUpdate(false); } }; return ( <Snackbar open={showUpdate} anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }} message="새로운 버전이 출시되었습니다." > <Alert severity="info" variant="filled" sx={{ width: '100%', alignItems: 'center' }} action={ <Button color="inherit" size="small" onClick={handleUpdate}> 새로고침 </Button> } > 새로운 버전이 있습니다. </Alert> </Snackbar> ); }

이 세 가지만 제대로 동작하면, PWA로서의 웹앱은 사실상 완성이다.


모든 조치를 마친 뒤 pwabuilder.com에서 다시 점수를 확인하면, “아, 이게 브라우저가 말하는 진짜 PWA구나”라는 감각이 온다.

전체 소스는 첨부 파일에 있습니다.

#PWA#Progressive Web Apps#오프라인 지원#Service Worker#웹앱 전략

댓글 0

Ctrl + Enter를 눌러 등록할 수 있습니다
※ AI 다듬기는 내용을 정제하는 보조 기능이며, 최종 내용은 사용자가 확인해야 합니다.