Claude Code로 PWA 구현하기: Manifest부터 오프라인 캐시까지
Claude Code로 PWA를 구현하는 실전 가이드. manifest, 아이콘, Service Worker, 오프라인 fallback, 검증까지 다룹니다.
PWA(Progressive Web App)는 웹사이트를 설치 가능한 앱처럼 사용할 수 있게 만드는 구현 방식입니다. 사용자는 홈 화면이나 데스크톱에 추가할 수 있고, 브라우저는 앱 이름과 아이콘을 이해하며, 네트워크가 끊겨도 준비한 오프라인 화면을 보여줄 수 있습니다.
하지만 PWA는 manifest.webmanifest 하나로 끝나지 않습니다. 아이콘, Service Worker, 오프라인 fallback, 캐시 전략, 설치 가능성 확인, Chrome DevTools와 Lighthouse 검증까지 같이 다뤄야 합니다. 작은 경로 오류 하나가 설치 버튼을 숨기거나, 오래된 HTML 캐시가 배포 후 오류를 만들 수 있습니다.
이 글은 Claude Code를 사용해 초보자도 복사해 실험할 수 있는 PWA 구현 흐름을 정리합니다. Claude Code 사용이 처음이라면 먼저 Claude Code 시작 가이드를 확인하세요. 공식 자료는 web.dev PWA 학습 가이드, MDN 설치 가능한 PWA 문서, MDN PWA 베스트 프랙티스, Chrome PWA 설치 기준 업데이트, Claude Code 공식 문서를 기준으로 삼으면 됩니다.
PWA의 전체 구조
PWA는 여러 파일이 함께 작동하는 작은 시스템입니다. HTML은 manifest를 연결하고, 앱 진입점은 Service Worker를 등록합니다. Service Worker는 자신이 제어하는 범위의 요청을 가로채서 네트워크와 캐시 중 어떤 응답을 줄지 결정합니다.
사용자가 사이트를 연다
-> index.html이 manifest.webmanifest를 연결한다
-> register-sw.js가 /sw.js를 등록한다
-> sw.js가 app shell을 미리 캐시한다
-> fetch 이벤트가 요청별 캐시 전략을 선택한다
-> 오프라인 navigation은 offline.html을 받는다
구현 전에 세 가지를 정하세요.
| 결정 항목 | 예시 | 중요한 이유 |
|---|---|---|
| 시작 URL과 scope | / 또는 /app/ | 범위가 틀리면 Service Worker가 페이지를 제어하지 못함 |
| 캐시 대상 | HTML, CSS, JS, 이미지, offline.html | 잘못 캐시하면 오래된 파일이나 404가 남음 |
| 오프라인 동작 | 오프라인 페이지, 최근 페이지, API 오류 | 사용자가 현재 상태를 이해해야 함 |
콘텐츠 사이트나 강의 사이트라면 HTML navigation은 Network First, 이미지는 Cache First, CSS/JS/font는 Stale While Revalidate부터 시작하는 편이 안전합니다. 캐시 설계를 더 깊게 보고 싶다면 Claude Code 캐싱 전략 글도 함께 읽을 수 있습니다.
Claude Code에 줄 프롬프트
Claude Code에는 “PWA로 만들어줘”보다 파일과 검증 기준을 명확히 전달하는 편이 좋습니다.
기존 Vite/React 앱을 PWA로 바꿔 주세요.
요구사항:
- public/manifest.webmanifest 추가
- 192x192, 512x512, maskable 512x512 PNG 아이콘 참조
- public/offline.html 추가
- public/sw.js Service Worker 추가
- src/register-sw.js에서 Service Worker 등록
- HTML navigation은 Network First
- 이미지는 Cache First
- CSS, JS, font는 Stale While Revalidate
- POST 요청과 cross-origin 요청은 캐시하지 않음
- 새 Service Worker가 설치되면 새로고침 안내 표시
- 마지막에 DevTools와 Lighthouse 확인 목록 작성
주의:
- 설치 가능성만 맞추기 위한 빈 fetch handler를 넣지 말 것
- 변경 파일을 모두 설명할 것
- 배포 base path에 의존하는 경로를 표시할 것
이 프롬프트는 코드 생성뿐 아니라 검토까지 강제합니다. Masa가 작은 강의 랜딩 페이지에서 테스트했을 때 가장 먼저 걸린 문제도 문법이 아니라 start_url과 scope 불일치였습니다. 이런 전제를 미리 말하게 하면 재작업이 줄어듭니다.
Manifest와 아이콘 만들기
public/manifest.webmanifest를 만듭니다. name은 앱의 정식 이름, short_name은 좁은 공간에서 쓰는 이름, start_url은 설치된 앱이 시작할 URL, scope는 앱이 제어하는 URL 범위입니다.
{
"id": "/",
"name": "ClaudeCodeLab PWA Demo",
"short_name": "CCLab",
"description": "Claude Code로 만든 오프라인 대응 PWA 데모",
"start_url": "/?source=pwa",
"scope": "/",
"display": "standalone",
"background_color": "#ffffff",
"theme_color": "#0f766e",
"orientation": "portrait-primary",
"prefer_related_applications": false,
"icons": [
{
"src": "/icons/icon-192.png",
"sizes": "192x192",
"type": "image/png"
},
{
"src": "/icons/icon-512.png",
"sizes": "512x512",
"type": "image/png"
},
{
"src": "/icons/icon-maskable-512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "any maskable"
}
]
}
HTML head에는 manifest와 테마 색상을 연결합니다.
<link rel="manifest" href="/manifest.webmanifest" />
<meta name="theme-color" content="#0f766e" />
<link rel="apple-touch-icon" href="/icons/apple-touch-icon.png" />
아이콘은 실제 PNG 파일이어야 합니다. 최소 192x192와 512x512를 준비하고, Android 런처에서 잘리지 않도록 maskable 512x512 아이콘에는 로고 주변 여백을 둡니다. Claude Code가 경로를 추가해도 브라우저에서 직접 아이콘 URL을 열어 200 응답인지 확인해야 합니다.
Service Worker 구현
다음 public/sw.js는 시작점으로 쓰기 좋은 구현입니다. app shell을 미리 캐시하고, 활성화 시 오래된 캐시를 삭제하며, GET 및 같은 origin 요청만 처리합니다.
const VERSION = "2026-06-02";
const STATIC_CACHE = `static-${VERSION}`;
const RUNTIME_CACHE = `runtime-${VERSION}`;
const APP_SHELL = [
"/",
"/offline.html",
"/manifest.webmanifest",
"/icons/icon-192.png",
"/icons/icon-512.png",
"/icons/icon-maskable-512.png"
];
self.addEventListener("install", (event) => {
event.waitUntil(
caches
.open(STATIC_CACHE)
.then((cache) => cache.addAll(APP_SHELL))
.then(() => self.skipWaiting())
);
});
self.addEventListener("activate", (event) => {
const allowedCaches = [STATIC_CACHE, RUNTIME_CACHE];
event.waitUntil(
caches
.keys()
.then((keys) =>
Promise.all(
keys
.filter((key) => !allowedCaches.includes(key))
.map((key) => caches.delete(key))
)
)
.then(() => self.clients.claim())
);
});
self.addEventListener("fetch", (event) => {
const { request } = event;
if (request.method !== "GET") return;
const url = new URL(request.url);
if (url.origin !== self.location.origin) return;
if (request.mode === "navigate") {
event.respondWith(networkFirstPage(request));
return;
}
if (request.destination === "image") {
event.respondWith(cacheFirst(request));
return;
}
if (["style", "script", "font"].includes(request.destination)) {
event.respondWith(staleWhileRevalidate(request));
}
});
async function networkFirstPage(request) {
const cache = await caches.open(RUNTIME_CACHE);
try {
const response = await fetch(request);
if (response.ok) await cache.put(request, response.clone());
return response;
} catch {
const cached = await cache.match(request);
return cached || (await caches.match("/offline.html")) || new Response("Offline", { status: 503 });
}
}
async function cacheFirst(request) {
const cached = await caches.match(request);
if (cached) return cached;
const response = await fetch(request);
if (response.ok) {
const cache = await caches.open(RUNTIME_CACHE);
await cache.put(request, response.clone());
}
return response;
}
async function staleWhileRevalidate(request) {
const cache = await caches.open(RUNTIME_CACHE);
const cached = await cache.match(request);
const networkPromise = fetch(request)
.then((response) => {
if (response.ok) cache.put(request, response.clone());
return response;
})
.catch(() => undefined);
if (cached) return cached;
return (await networkPromise) || new Response("Network error", { status: 504 });
}
이 코드는 API 응답, 결제, 로그인 정보, 장바구니, 권한 데이터를 캐시하지 않습니다. PWA 캐시는 성능 기능이지만 동시에 저장소입니다. 사용자의 개인 정보나 최신성이 중요한 데이터는 명시적인 서버 캐시 정책과 인증 설계 안에서 다뤄야 합니다.
오프라인 페이지와 등록 코드
public/offline.html은 단순해야 합니다. 네트워크가 없는 상황에서 외부 폰트나 분석 스크립트에 의존하면 fallback 자체가 실패할 수 있습니다.
<!doctype html>
<html lang="ko">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>오프라인 상태입니다</title>
</head>
<body>
<main>
<h1>오프라인 상태입니다</h1>
<p>네트워크가 복구되면 새로고침해 주세요. 최근에 연 페이지는 계속 볼 수 있을 수 있습니다.</p>
<p><a href="/">홈으로 돌아가기</a></p>
</main>
</body>
</html>
등록 파일은 src/register-sw.js로 둡니다.
export async function registerServiceWorker() {
if (!("serviceWorker" in navigator)) return;
window.addEventListener("load", async () => {
try {
const registration = await navigator.serviceWorker.register("/sw.js", {
scope: "/"
});
registration.addEventListener("updatefound", () => {
const worker = registration.installing;
if (!worker) return;
worker.addEventListener("statechange", () => {
if (worker.state === "installed" && navigator.serviceWorker.controller) {
document.querySelector("[data-refresh-app]")?.removeAttribute("hidden");
}
});
});
} catch (error) {
console.error("Service Worker registration failed:", error);
}
});
}
앱 진입점에서 한 번만 호출합니다.
import { registerServiceWorker } from "./register-sw.js";
registerServiceWorker();
새 버전 안내는 운영에서 특히 중요합니다. Service Worker는 즉시 모든 탭을 바꾸지 않습니다. 오래 열린 탭이 있으면 새 worker가 waiting 상태로 남을 수 있으므로, 사용자가 직접 새로고침할 수 있는 버튼을 제공하는 것이 안전합니다.
설치 버튼과 검증
일부 Chromium 기반 브라우저는 설치 조건을 만족하면 beforeinstallprompt 이벤트를 발생시킵니다. 모든 브라우저의 표준 신호는 아니므로, 설치 버튼은 점진적 개선으로만 다룹니다.
let deferredPrompt = null;
window.addEventListener("beforeinstallprompt", (event) => {
event.preventDefault();
deferredPrompt = event;
document.querySelector("[data-install-app]")?.removeAttribute("hidden");
});
document.querySelector("[data-install-app]")?.addEventListener("click", async () => {
if (!deferredPrompt) return;
deferredPrompt.prompt();
const choice = await deferredPrompt.userChoice;
console.info("Install result:", choice.outcome);
deferredPrompt = null;
});
검증은 Chrome DevTools Application 패널과 Lighthouse를 함께 사용합니다. 현재는 오래된 “PWA 점수” 하나에 기대기보다 Manifest, Service Worker, Cache Storage, Offline 동작을 직접 확인하고, Lighthouse는 성능, 접근성, Best Practices, SEO 확인에 쓰는 편이 현실적입니다.
npm run build
npx serve dist -l 4173
npx lighthouse http://localhost:4173 --view --only-categories=performance,accessibility,best-practices,seo
| 확인 항목 | 위치 | 통과 기준 |
|---|---|---|
| Manifest | Application > Manifest | 이름, start_url, 아이콘 오류 없음 |
| Service Worker | Application > Service Workers | /sw.js가 activated 상태 |
| 오프라인 navigation | Network Offline 후 새로고침 | offline.html 또는 최근 페이지 표시 |
| Cache Storage | Application > Cache Storage | static/runtime 캐시가 예상대로 분리 |
| Lighthouse | Lighthouse report | 성능, 접근성, SEO가 나빠지지 않음 |
사용 사례, CTA, 함정
PWA는 재방문이 많고, 네트워크가 불안정할 수 있으며, 사용자가 홈 화면에 둘 이유가 있는 서비스에 효과적입니다.
| 사용 사례 | PWA의 가치 | 수익화 또는 업무 CTA |
|---|---|---|
| 기술 블로그와 강의 라이브러리 | 이동 중에도 계속 읽기 | 유료 템플릿, 강의, 컨설팅 |
| 내부 대시보드 | 매일 여는 작업공간을 빠르게 실행 | 팀 교육과 도입 지원 |
| 이벤트 또는 장소 안내 | 혼잡한 네트워크에서도 일정 확인 | 스폰서, 자료 다운로드, 리드 수집 |
| 커머스와 예약 페이지 | 이미지와 최근 조회가 빠르게 표시 | 회원가입, 장바구니 복귀 |
ClaudeCodeLab에서는 단순히 “설치 가능”보다 “재방문, 설치 클릭, 오프라인 fallback, 구매 CTA를 측정할 수 있는가”가 더 중요합니다. 구현 템플릿과 Claude Code 프롬프트가 필요하면 제품 라이브러리를 확인하세요. 팀 프로젝트라면 캐시 리뷰, 배포 경로 검증, 분석 이벤트까지 묶어서 설계하는 것이 좋습니다.
자주 나오는 함정은 scope와 start_url 불일치, HTML Cache First로 인한 오래된 배포, 개인 API 응답 캐시, 아이콘 404, 오래된 Service Worker를 지우지 않고 디버깅하는 문제입니다. 문제가 생기면 Application 패널에서 Unregister, Cache Storage 삭제, 새로고침 순서로 첫 방문부터 다시 확인하세요.
테스트 결과
Masa가 작은 Vite 강의 랜딩 페이지에서 테스트했을 때, manifest와 등록 코드, 오프라인 페이지는 Claude Code로 빠르게 추가할 수 있었습니다. 시간이 걸린 부분은 아이콘 URL 확인, Offline 상태에서의 새로고침, 새 버전 배포 후 오래된 캐시 제거였습니다. 결론은 명확합니다. 먼저 가장 작은 오프라인 fallback을 안정적으로 만들고, 실제로 가치가 있는 페이지와 자산만 단계적으로 캐시하는 편이 안전합니다.
무료 PDF: Claude Code 치트시트
이메일을 입력하면 명령, 리뷰 습관, 안전한 워크플로를 정리한 PDF를 받을 수 있습니다.
개인정보를 안전하게 관리하며 스팸을 보내지 않습니다.
작성자 소개
Masa
Claude Code 실무 워크플로와 팀 도입을 검증하는 엔지니어입니다.
관련 글
Obsidian 메모를 CLAUDE.md로 바꾸는 Claude Code 워크플로
Obsidian 작업 메모를 CLAUDE.md 운영 노트로 정리해 Claude Code 세션의 문맥 반복을 줄입니다.
Claude Code Revenue CTA Routing: 글에서 PDF, Gumroad, 상담으로 보내기
독자 의도에 따라 무료 PDF, Gumroad 상품, 상담으로 나누는 Claude Code CTA 설계입니다.
Claude Code 팀 인계 규칙: 리뷰 증거, 권한, 롤백, 수익 경로까지 넘기는 법
Claude Code 작업을 팀에 넘길 때 필요한 증거, 권한 규칙, 롤백, 무료 PDF, Gumroad, 상담 경로 체크리스트.