
1편에서는 오프라인 대응을 위해 IndexedDB를 사용하여 데이터를 저장했다면, 이번 글에서는 Service Worker를 사용하여 리소스를 캐싱한 경험을 기록해보려 한다.

데이터가 저장되었다고 해도, 페이지를 진입할 수 없다면 의미가 없다. HTML, JS, CSS, 이미지 등의 리소스는 네트워크에 의존한다. 따라서 오프라인 상황에서는 캐싱된 리소스를 사용하여 사용자에게 최소한의 화면을 보여주는 것이 중요하다.
2. 대기 (Waiting)
self.skipWaiting() 호출 시 즉시 활성화 가능

3. Cache-first



위 내용을 기반으로 리소스의 종류에 따른 캐싱 전략을 적용해보기로 했다.
다음은 기본 Service Worker API를 사용해 캐싱하는 코드이다.
// sw.js
const VERSION = "v1.0.0";
const PRECACHE = `precache-${VERSION}`;
const RUNTIME = `runtime-${VERSION}`;
const PRECACHE_URLS = ["/", "/favicon.ico"]; // 미리 캐싱할 정적 리소스
self.addEventListener("install", (event) => {
// 정적 리소스 미리 캐싱하기
event.waitUntil(
caches.open(PRECACHE).then((cache) => cache.addAll(PRECACHE_URLS))
);
});
self.addEventListener("fetch", (event) => {
const req = event.request;
const url = new URL(req.url);
const isPrecachedRequest = PRECACHE_URLS.includes(url.pathname);
// 미리 캐싱된 요청인 경우 cacheOnly
if (isPrecachedRequest) {
event.respondWith(cacheOnly(req));
return;
}
// 정적 리소스의 경우 Cache-First
if (["style", "script", "image", "font"].includes(req.destination)) {
event.respondWith(cacheFirst(req));
return;
}
// 페이지(html)인 경우 Stale-While-Revalidate
if (req.mode === "navigate") {
event.respondWith(staleWhileRevalidate(req));
return;
}
// api 요청의 경우 Network-First
if (req.url.includes("/api/")) {
event.respondWith(networkFirst(req));
return;
}
});
// 전략 별 함수
// 1. Cache-Only
async function cacheOnly(request, cacheName = PRECACHE) {
const cache = await caches.open(cacheName);
return cache.match(request.url);
}
// 2. Network-Only
async function networkOnly(request) {
return;
}
// 3. Cache-First
async function cacheFirst(request, cacheName = RUNTIME) {
const cache = await caches.open(cacheName);
const cachedResponse = await cache.match(request.url);
if (cachedResponse) {
return cachedResponse;
}
try {
const response = await fetch(request);
cache.put(request, response.clone());
return response;
} catch (err) {
return;
}
}
// 4. Network-First
async function networkFirst(request, cacheName = RUNTIME) {
const cache = await caches.open(cacheName);
try {
const response = await fetch(request.url);
if (response) {
cache.put(request, response.clone());
return response;
}
} catch (err) {
const cachedResponse = await cache.match(request.url);
return cachedResponse;
}
}
// 5. Stale-While-Revalidate
async function staleWhileRevalidate(request, cacheName = RUNTIME) {
const cache = await caches.open(cacheName);
const cachedResponse = await cache.match(request);
const fetchResponse = async () => {
const response = await fetch(request);
if (response.ok) {
cache.put(request, response.clone());
}
return response;
};
return cachedResponse || (await fetchResponse());
}
// ServiceWorkerRegister.tsx
"use client";
import { useEffect } from "react";
const ServiceWorkerRegister = () => {
useEffect(() => {
if ("serviceWorker" in navigator) {
// 서비스워커 등록
navigator.serviceWorker
.register("/serviceWorker.js")
.then((registration) => {
console.log(
"ServiceWorker successfully registered with scope:",
registration.scope
);
})
.catch((error) => {
console.log("ServiceWorker registeration failed:", error);
});
}
}, []);
return null;
};
export default ServiceWorkerRegister;
// layout.tsx
import ServiceWorkerRegister from "@/components/ServiceWorkerRegister";
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="en">
<body>
{/* ... */}
<ServiceWorkerRegister />
</body>
</html>
);
}
이렇게 기본 API를 사용하는 경우 캐시 버전 관리가 어렵다는 문제가 있다.
const VERSION = "v1.0.0";
const PRECACHE = `precache-${VERSION}`;
지금 코드는 이렇게 버전이 하드코딩되어 있는데, 배포할 때마다 수동으로 직접 버전을 수정해주어야 캐시가 교체될 수 있다.
그래서 일반적으로는 라이브러리를 사용한다고 한다.
처음엔 대표적인 라이브러리 next-pwa를 사용하였는데, 사용 중이었던 Next.js 15 버전과 호환이 되지 않는 문제가 발생했다. ("next": "15.4.6", "next-pwa": "5.6.0", "@types/next-pwa": "5.6.9")

Next.js 공식 페이지를 찾아보니 Serwist를 소개하고 있어 해당 라이브러리를 사용해보기로 했다.

사용법은 공식 페이지에 아주 친절히 소개되어 있다: https://serwist.pages.dev/docs/next/getting-started.
// next.config.ts
import type { NextConfig } from "next";
import withSerwistInit from "@serwist/next";
const withSerwist = withSerwistInit({
swSrc: "src/app/sw.ts", // 내가 작성한 sw.ts 코드
swDest: "public/sw.js", // 빌드시 생성될 코드 위치
additionalPrecacheEntries: ["/"],
reloadOnOnline: false, // 오프라인->온라인 전환 시점에 reload하지 않게
});
const nextConfig: NextConfig = {
/* config options here */
};
export default withSerwist(nextConfig);
reloadOnOline: true인 경우, 오프라인 상태에서 사용자가 무언가를 입력하다가 온라인으로 전환되면 reload 되면서 입력하던 것이 날아갈 수 있다. 이를 방지하려면 false로 설정해야 한다// sw.ts
import { defaultCache } from "@serwist/next/worker";
import type {
PrecacheEntry,
RuntimeCaching,
SerwistGlobalConfig,
} from "serwist";
import {
CacheableResponsePlugin,
CacheFirst,
ExpirationPlugin,
NetworkFirst,
Serwist,
StaleWhileRevalidate,
} from "serwist";
// This declares the value of `injectionPoint` to TypeScript.
// `injectionPoint` is the string that will be replaced by the
// actual precache manifest. By default, this string is set to
// `"self.__SW_MANIFEST"`.
declare global {
interface WorkerGlobalScope extends SerwistGlobalConfig {
__SW_MANIFEST: (PrecacheEntry | string)[] | undefined;
}
}
declare const self: ServiceWorkerGlobalScope;
const cacheStrategies: RuntimeCaching[] = [
{
matcher({ request }) {
return ["style", "script", "image", "font"].includes(request.destination);
},
handler: new CacheFirst({
cacheName: "runtime",
plugins: [
new CacheableResponsePlugin({ statuses: [0, 200] }),
new ExpirationPlugin({
maxEntries: 1000,
maxAgeSeconds: 60 * 60 * 24 * 7,
}),
],
}),
},
{
matcher({ request, url: { pathname }, sameOrigin }) {
return (
request.headers.get("Content-Type")?.includes("text/html") &&
sameOrigin &&
!pathname.startsWith("/api/")
);
},
handler: new StaleWhileRevalidate({
cacheName: "page",
plugins: [new CacheableResponsePlugin({ statuses: [200] })],
}),
},
{
matcher({ url }) {
return url.pathname.startsWith("/api/");
},
handler: new NetworkFirst({
cacheName: "api",
plugins: [
new CacheableResponsePlugin({ statuses: [200] }),
new ExpirationPlugin({
maxEntries: 200,
maxAgeSeconds: 60 * 5,
}),
],
}),
},
];
const serwist = new Serwist({
precacheEntries: self.__SW_MANIFEST,
precacheOptions: {
cleanupOutdatedCaches: true,
},
skipWaiting: true,
clientsClaim: true,
navigationPreload: true,
runtimeCaching: [...cacheStrategies, ...defaultCache],
});
serwist.addEventListeners();
주요 옵션 설명
cleanupOutdatedCaches: true → 과거 버전 캐시 자동 정리skipWaiting: true + clientsClaim: true → 새 SW가 설치되면 즉시 활성화 및 기존 탭 제어navigationPreload: true → 네비게이션 요청 시 브라우저의 네트워크 프리로드 활용build & start해서 테스트를 해보았다. (빌드할 때 public 폴더 아래 sw.js 파일에 생기므로 개발환경에서 테스트할 땐 빌드 필수)
처음 페이지에 진입하면 다음과 같이 Cache Storage에 캐시가 저장된다.

직접 지정한 캐시 이름 외에도 serwist-precache, pages-rsc-prefetch, others이 생긴다.
serwist-precache

Serwist 설정할 때 precacheEntries, additionalPrecacheEntries에 적어준 값에 의한 캐시가 저장된 것을 확인할 수 있었다. 루트 html과 번들된 정적 파일들이 저장되어 있다.
// sw.ts
const serwist = new Serwist({
precacheEntries: self.__SW_MANIFEST,
});
// next.config.ts
const withSerwist = withSerwistInit({
additionalPrecacheEntries: ["/"],
});
pages-rsc-prefetch
첫 페이지에 포함된 Link 컴포넌트의 href 속성으로 지정된 경로가 들어가 있다. 페이지에 접근하기 전에 미리 React Server Component(RSC)를 프리페치하는 것으로 보인다.

pages-rsc
실제 접근한 페이지에 대해 RSC 응답을 저장해둔다.
페이지를 방문할 때마다 추가되는 것을 확인할 수 있었다.

others
처음엔 manifest 파일만 들어가 있다. matcher 조건에 해당하지 않는 요청에 대한 캐시인 것 같다.

처음엔 응답이 성공적인 이미지인 것만 캐시하면 되는 것이 아닌가 생각하여 status가 200인 응답에 대해서만 캐싱을 허용하였다.
new CacheableResponsePlugin({ statuses: [200] }), // 전
그랬더니 캐시 스토리지에 외부(cross origin) 이미지에 대한 캐시가 하나도 쌓이지 않았다.

그래서 오프라인에선 이미지가 깨져서 보였다.

Service Worker 코드에서 확인해보니 외부 이미지의 경우 status code가 0이었다.

교차 출처 요청에 대해 CORS 허용없이(no-cors) 서버가 응답을 보내면, 브라우저는 보안상 해당 응답을 js 코드에서 볼 수 없게 차단한다. 그래서 opaque, status 0인 응답이 표시되는 것이다.
하지만 브라우저는 응답 내용을 알고 있으므로, img 태그 같은 것에서는 화면에 이미지를 제대로 렌더링할 수 있다!
결과적으로 0인 응답에 대해서도 캐싱을 허용하니, 오프라인에서도 이미지가 깨지지 않고 페이지가 제대로 표시되었다.
new CacheableResponsePlugin({ statuses: [0, 200] }), // 후
