Claude Code로 Service Worker 구현하기: 캐시, 업데이트, 오프라인 UX
Service Worker 구조, 캐시 무효화, 업데이트 흐름, 오프라인 UX를 Claude Code 예제로 정리합니다.
Service Worker는 PWA와 오프라인 지원을 만들 때 매우 강력하지만, 잘못 넣으면 운영 버그가 되기 쉽습니다. Claude Code에 범위 없이 “캐시 추가해 줘”라고 맡기면 오래된 HTML이 남거나, 개인화된 응답이 브라우저에 저장되거나, 배포 후 새 버전이 보이지 않는 문제가 생길 수 있습니다.
Service Worker는 브라우저와 서버 사이에 있는 작은 프록시라고 이해하면 쉽습니다. 네트워크 요청이 나갈 때 개입해서 네트워크를 우선할지, Cache API에서 돌려줄지, 오프라인 페이지를 보여줄지 판단합니다. 이 글은 Claude Code에 구현을 맡기기 전에 정해야 할 것, 바로 실행 가능한 최소 코드, 캐시 무효화, 업데이트 라이프사이클, 오프라인 UX, 실패 사례를 함께 정리합니다.
공식 문서는 MDN Service Worker API, web.dev Service Worker 가이드, web.dev 캐싱 가이드, Chrome Workbox docs를 기준으로 확인하세요. 함께 읽을 내부 글은 Claude Code PWA 가이드, 캐싱 전략, IndexedDB 구현입니다.
Service Worker의 역할
Service Worker는 페이지 밖에서 실행되는 JavaScript입니다. DOM을 직접 만질 수 없으므로 버튼, 폼, React state를 직접 바꾸지는 못합니다. 대신 조건에 맞는 요청을 가로채서 네트워크 응답, 캐시 응답, 오프라인 fallback 중 하나를 선택합니다.
일반 페이지 스크립트는 탭이 닫히면 끝나지만, Service Worker는 이벤트가 올 때만 깨어나는 방식입니다. install, activate, fetch, push 같은 이벤트가 있고, 대부분의 앱에서는 fetch 처리, Cache API, 업데이트 흐름부터 제대로 잡는 것이 우선입니다.
sequenceDiagram
participant User as 사용자
participant Page as 페이지
participant SW as Service Worker
participant Cache as Cache API
participant Net as 서버
User->>Page: 사이트 열기
Page->>SW: /sw.js 등록
Page->>SW: fetch 요청
SW->>Cache: 캐시 확인
alt 캐시 있음
Cache-->>SW: 저장된 응답
else 캐시 없음
SW->>Net: 최신 응답 요청
Net-->>SW: 새 응답
end
SW-->>Page: 렌더링할 응답
즉 Service Worker는 성능 마법이 아니라 요청 교통정리입니다. 무엇을 저장하고, 언제 지우고, 실패했을 때 무엇을 보여줄지 결정해야 품질이 나옵니다.
현실적인 사용 사례
| 사용 사례 | 효과 | 주의점 |
|---|---|---|
| 문서 사이트나 블로그 | 재방문 시 글, CSS, 이미지, 폰트가 빠르게 보임 | HTML을 오래 캐시하면 수정이 늦게 반영됨 |
| SaaS 대시보드 | 약한 네트워크에서도 내비게이션 뼈대가 보임 | 청구, 계정, 권한 응답은 캐시하지 않음 |
| 현장 입력 앱 | 오프라인에서도 초안과 대기 작업을 유지 | POST는 Cache API가 아니라 IndexedDB 큐로 처리 |
| 커머스나 미디어 카탈로그 | 썸네일과 정적 리소스 재다운로드 감소 | 가격, 재고, 보호 이미지의 신선도를 관리 |
Masa가 작은 학습 사이트에서 테스트했을 때, 이미지와 폰트만 캐시해도 재방문 체감 속도는 좋아졌습니다. 반대로 글 HTML까지 무조건 Cache First로 둔 버전은 오탈자 수정이 독자에게 늦게 보였습니다. Claude Code에는 “전부 캐시”가 아니라 리소스별 소유권과 수명을 전달해야 합니다.
Claude Code에 줄 요청
Service Worker 요청에는 금지사항과 검증 방법을 함께 넣어야 합니다.
기존 Vite 앱에 Service Worker를 추가해 주세요.
요구사항:
- /sw.js를 public 루트에 두고 scope는 / 로 설정
- GET 정적 리소스만 캐시
- HTML navigation은 Network First 사용
- 오프라인 navigation 실패 시 /offline.html 반환
- API, POST, 인증 페이지, 외부 origin은 캐시하지 않음
- 캐시 이름에 날짜 또는 버전 포함
- activate 단계에서 오래된 캐시 삭제
- 새 worker가 waiting 상태가 되면 새로고침 확인 표시
검증:
- Chrome DevTools > Application > Service Workers에서 등록 확인
- Network를 Offline으로 바꾸고 /offline.html 표시 확인
- CACHE_VERSION을 바꾼 뒤 이전 캐시 삭제 확인
API, POST, 인증 페이지를 제외한다는 문장은 꼭 필요합니다. Service Worker의 실수는 단순 UI 버그가 아니라 데이터 노출이나 오래된 데이터 문제로 이어질 수 있습니다.
바로 실행 가능한 최소 구현
아래 네 파일을 sw-demo 같은 빈 디렉터리에 두고 로컬 서버를 실행합니다. Service Worker는 HTTPS 또는 localhost에서만 동작하므로 HTML 파일을 직접 열면 등록되지 않습니다.
python -m http.server 5173
브라우저에서 http://localhost:5173을 엽니다.
<!-- index.html -->
<!doctype html>
<html lang="ko">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Service Worker Demo</title>
<style>
body {
font-family: system-ui, sans-serif;
margin: 2rem;
line-height: 1.7;
}
button {
padding: 0.7rem 1rem;
}
</style>
</head>
<body>
<h1>Service Worker Demo</h1>
<p id="status">등록을 기다리는 중입니다.</p>
<button type="button" onclick="location.reload()">새로고침</button>
<script src="/register-sw.js"></script>
</body>
</html>
// register-sw.js
const status = document.querySelector("#status");
let reloadRequested = false;
let updatePromptShown = false;
function setStatus(message) {
if (status) status.textContent = message;
}
function askToReload(worker) {
if (updatePromptShown) return;
updatePromptShown = true;
const ok = window.confirm("새 버전이 있습니다. 지금 새로고침할까요?");
if (ok) {
reloadRequested = true;
worker.postMessage({ type: "SKIP_WAITING" });
}
}
async function registerServiceWorker() {
if (!("serviceWorker" in navigator)) {
setStatus("이 브라우저는 Service Worker를 지원하지 않습니다.");
return;
}
try {
const registration = await navigator.serviceWorker.register("/sw.js", {
scope: "/",
});
setStatus(`Service Worker 등록됨: ${registration.scope}`);
if (registration.waiting && navigator.serviceWorker.controller) {
askToReload(registration.waiting);
}
registration.addEventListener("updatefound", () => {
const worker = registration.installing;
if (!worker) return;
worker.addEventListener("statechange", () => {
const hasOldController = Boolean(navigator.serviceWorker.controller);
if (worker.state === "installed" && hasOldController) {
askToReload(worker);
}
});
});
} catch (error) {
console.error(error);
setStatus("Service Worker 등록에 실패했습니다.");
}
}
navigator.serviceWorker?.addEventListener("controllerchange", () => {
if (!reloadRequested) return;
window.location.reload();
});
registerServiceWorker();
// sw.js
const CACHE_VERSION = "2026-06-02-v1";
const CACHE_PREFIX = "claude-sw-demo";
const CACHE_NAME = `${CACHE_PREFIX}-${CACHE_VERSION}`;
const APP_SHELL = [
"/",
"/index.html",
"/offline.html",
"/register-sw.js",
];
self.addEventListener("install", (event) => {
event.waitUntil(
caches.open(CACHE_NAME).then((cache) => cache.addAll(APP_SHELL)),
);
});
self.addEventListener("activate", (event) => {
event.waitUntil(
caches.keys().then((names) =>
Promise.all(
names
.filter((name) => name.startsWith(CACHE_PREFIX))
.filter((name) => name !== CACHE_NAME)
.map((name) => caches.delete(name)),
),
),
);
self.clients.claim();
});
self.addEventListener("message", (event) => {
if (event.data?.type === "SKIP_WAITING") {
self.skipWaiting();
}
});
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(networkFirstNavigation(request));
return;
}
if (["style", "script", "font", "image"].includes(request.destination)) {
event.respondWith(staleWhileRevalidate(request));
}
});
async function networkFirstNavigation(request) {
const cache = await caches.open(CACHE_NAME);
try {
const response = await fetch(request);
if (response.ok) cache.put(request, response.clone());
return response;
} catch {
return (
(await cache.match(request)) ||
(await cache.match("/offline.html")) ||
new Response("Offline", { status: 503 })
);
}
}
async function staleWhileRevalidate(request) {
const cache = await caches.open(CACHE_NAME);
const cached = await cache.match(request);
const fetched = fetch(request)
.then((response) => {
if (response.ok) cache.put(request, response.clone());
return response;
})
.catch(() => cached || new Response("Offline", { status: 503 }));
return cached || fetched;
}
<!-- offline.html -->
<!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>
<button type="button" onclick="location.reload()">다시 시도</button>
</main>
</body>
</html>
이 예제는 페이지 navigation에는 Network First를, CSS, JS, 폰트, 이미지에는 Stale While Revalidate를 사용합니다. Network First는 서버를 먼저 보고 실패할 때만 캐시나 offline 페이지로 돌아갑니다. Stale While Revalidate는 캐시를 바로 보여주면서 뒤에서 새 응답을 받아옵니다. 뉴스, 가격, 재고, 로그인 후 화면에는 무조건 적용하면 안 됩니다.
업데이트와 캐시 무효화
가장 많이 놓치는 부분은 업데이트입니다. sw.js가 바뀌면 브라우저는 새 worker를 설치하지만, 기존 페이지가 열려 있으면 새 worker가 waiting 상태로 멈추는 일이 많습니다. 위 등록 코드는 이 상태를 감지하고 사용자에게 확인을 받은 뒤 SKIP_WAITING을 보냅니다.
worker는 메시지를 받으면 self.skipWaiting()을 호출하고 activate 단계에서 오래된 캐시를 삭제합니다. 이 흐름이 없으면 사용자는 배포 후에도 오래된 app-cache-v1을 계속 사용할 수 있습니다.
캐시 이름에는 날짜, 릴리스 번호, commit ID를 넣으세요. 빌드 파일명에 hash가 붙는 환경에서는 precache 목록도 빌드 결과와 맞춰야 합니다. 수동 목록 관리가 어려워지면 Workbox를 검토할 수 있지만, Workbox도 어떤 비즈니스 데이터를 캐시해도 되는지는 대신 판단하지 않습니다.
오프라인 UX까지 설계하기
오프라인 지원은 Cache API에서 응답이 나온다고 끝나지 않습니다. 사용자는 작업이 저장됐는지, 동기화를 기다리는지, 실패했는지 알아야 합니다. 폼 제출은 Cache API가 아니라 페이지에서 IndexedDB에 초안이나 대기 작업으로 저장하고, 온라인 복귀 시 재시도합니다. Background Sync는 유용하지만 브라우저 차이가 있으므로 중요한 흐름에는 online 이벤트와 보이는 재시도 버튼을 함께 둡니다.
Claude Code에 요청할 때는 offline 페이지뿐 아니라 문구, 재시도 버튼, 초안 상태, 동기화 실패 메시지도 넣으세요. 현장 앱이라면 “전송됨”, “이 기기에 저장됨”, “동기화 실패” 세 상태를 분리하는 것만으로 문의가 줄어듭니다.
흔한 실패 사례
첫째는 scope 불일치입니다. /app/sw.js에 둔 worker는 기본적으로 /app/만 제어합니다. 전체 사이트를 제어하려면 /sw.js에 두고 scope /로 등록합니다.
둘째는 cache.addAll() 목록의 404입니다. 하나라도 없는 파일이 있으면 install 전체가 실패합니다. Claude Code가 파일을 추가한 뒤에는 DevTools Application 패널에서 등록 상태와 Cache Storage를 확인하세요.
셋째는 개인 데이터 캐시입니다. /api/me, 청구 페이지, 관리자 HTML, 사용자별 JSON은 명확한 삭제 전략이 없다면 캐시하지 않습니다. 브라우저 캐시는 사용자 기기의 저장소이고, 공용 PC와 계정 전환을 고려해야 합니다.
넷째는 업데이트 UX 생략입니다. 오래된 worker가 오래된 JS와 CSS를 계속 들고 있을 수 있습니다. 캐시 이름을 버전 관리하고, activate에서 삭제하고, 새 worker가 waiting일 때 새로고침 선택권을 주세요.
마지막으로 Service Worker는 영구 저장소가 아닙니다. 브라우저는 저장 공간이 부족하면 캐시를 지울 수 있습니다. cross-origin opaque 응답은 크기와 디버깅이 어렵습니다. DOM에 접근할 수 없고 HTTPS 또는 localhost에서만 동작합니다. 실제 흐름을 검증하기 전에는 “완전 오프라인”이라고 말하지 않는 편이 안전합니다.
정리와 상담
Service Worker는 재방문 속도, 오프라인 경험, PWA 품질을 개선합니다. 안전하게 쓰려면 Claude Code가 파일을 고치기 전에 캐시 소유권, 업데이트 흐름, 개인 데이터 규칙, 오프라인 화면을 정해야 합니다.
ClaudeCodeLab은 PWA 전환, Service Worker 캐시 설계, 오프라인 폼, Workbox 마이그레이션, Claude Code 구현 리뷰를 도와드립니다. 더 빠른 사이트를 만들면서 오래된 데이터와 개인정보 사고를 피하고 싶다면 Claude Code 교육 및 상담에서 시작하세요.
이 최소 예제를 로컬 Chrome에서 테스트하면 첫 로드 후 Application 패널에 claude-sw-demo-2026-06-02-v1이 보입니다. Network를 Offline으로 바꾸고 새로고침하면 offline.html이 표시됩니다. CACHE_VERSION을 바꾸면 activate 단계에서 이전 캐시가 삭제되어 릴리스 검증의 출발점으로 쓸 수 있습니다.
무료 PDF: Claude Code 치트시트
이메일을 입력하면 명령, 리뷰 습관, 안전한 워크플로를 정리한 PDF를 받을 수 있습니다.
개인정보를 안전하게 관리하며 스팸을 보내지 않습니다.
작성자 소개
Masa
Claude Code 실무 워크플로와 팀 도입을 검증하는 엔지니어입니다.
관련 글
Claude Code 권한 세이프티 래더: 통제력을 잃지 않고 allow 넓히기
read-only에서 제한 편집, 검증 명령, deploy 확인까지 권한을 단계적으로 넓히는 방법.
Claude Code Small PR Proof Pack: 작은 PR을 리뷰 가능한 상태로 만드는 증거 세트
Claude Code의 작은 PR에 diff, 검증, 공개 URL, CTA 경로, rollback을 붙이는 실무 체크리스트.
Claude Code 커밋 전 리뷰 게이트: diff, 테스트, 공개 URL, CTA 확인
Claude Code 작업을 커밋하기 전에 diff 범위, build, 공개 URL, Gumroad 링크, 상담 CTA, 테스트 누락과 무관한 파일을 확인하는 방법입니다.