Use Cases (업데이트: 2026. 6. 2.)

Claude Code로 PWA 구현하기: Manifest부터 오프라인 캐시까지

Claude Code로 PWA를 구현하는 실전 가이드. manifest, 아이콘, Service Worker, 오프라인 fallback, 검증까지 다룹니다.

Claude Code로 PWA 구현하기: Manifest부터 오프라인 캐시까지

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_urlscope 불일치였습니다. 이런 전제를 미리 말하게 하면 재작업이 줄어듭니다.

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
확인 항목위치통과 기준
ManifestApplication > Manifest이름, start_url, 아이콘 오류 없음
Service WorkerApplication > Service Workers/sw.js가 activated 상태
오프라인 navigationNetwork Offline 후 새로고침offline.html 또는 최근 페이지 표시
Cache StorageApplication > Cache Storagestatic/runtime 캐시가 예상대로 분리
LighthouseLighthouse report성능, 접근성, SEO가 나빠지지 않음

사용 사례, CTA, 함정

PWA는 재방문이 많고, 네트워크가 불안정할 수 있으며, 사용자가 홈 화면에 둘 이유가 있는 서비스에 효과적입니다.

사용 사례PWA의 가치수익화 또는 업무 CTA
기술 블로그와 강의 라이브러리이동 중에도 계속 읽기유료 템플릿, 강의, 컨설팅
내부 대시보드매일 여는 작업공간을 빠르게 실행팀 교육과 도입 지원
이벤트 또는 장소 안내혼잡한 네트워크에서도 일정 확인스폰서, 자료 다운로드, 리드 수집
커머스와 예약 페이지이미지와 최근 조회가 빠르게 표시회원가입, 장바구니 복귀

ClaudeCodeLab에서는 단순히 “설치 가능”보다 “재방문, 설치 클릭, 오프라인 fallback, 구매 CTA를 측정할 수 있는가”가 더 중요합니다. 구현 템플릿과 Claude Code 프롬프트가 필요하면 제품 라이브러리를 확인하세요. 팀 프로젝트라면 캐시 리뷰, 배포 경로 검증, 분석 이벤트까지 묶어서 설계하는 것이 좋습니다.

자주 나오는 함정은 scopestart_url 불일치, HTML Cache First로 인한 오래된 배포, 개인 API 응답 캐시, 아이콘 404, 오래된 Service Worker를 지우지 않고 디버깅하는 문제입니다. 문제가 생기면 Application 패널에서 Unregister, Cache Storage 삭제, 새로고침 순서로 첫 방문부터 다시 확인하세요.

테스트 결과

Masa가 작은 Vite 강의 랜딩 페이지에서 테스트했을 때, manifest와 등록 코드, 오프라인 페이지는 Claude Code로 빠르게 추가할 수 있었습니다. 시간이 걸린 부분은 아이콘 URL 확인, Offline 상태에서의 새로고침, 새 버전 배포 후 오래된 캐시 제거였습니다. 결론은 명확합니다. 먼저 가장 작은 오프라인 fallback을 안정적으로 만들고, 실제로 가치가 있는 페이지와 자산만 단계적으로 캐시하는 편이 안전합니다.

#Claude Code #PWA #Service Worker #offline #mobile
무료

무료 PDF: Claude Code 치트시트

이메일을 입력하면 명령, 리뷰 습관, 안전한 워크플로를 정리한 PDF를 받을 수 있습니다.

개인정보를 안전하게 관리하며 스팸을 보내지 않습니다.

Masa

작성자 소개

Masa

Claude Code 실무 워크플로와 팀 도입을 검증하는 엔지니어입니다.