웹앱 오프라인 대응하기(2) - Service Worker

김소연·2025년 8월 21일
post-thumbnail

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

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

Service Worker

  • 브라우저와 서버 사이에서 동작하는 프록시 레이어
  • 네트워크 요청을 가로채서 캐시로 응답하거나, 네트워크로 전달하거나, 둘을 조합하하여 응답 가능
  • 오프라인 대응, 푸시 알림, 백그라운드 동기화 기능 구현에 사용
  • 브라우저의 Worker API를 통해 별도의 스레드에서 실행됨 (백그라운드 스레드에서 돌게됨)
  • 메인 스레드와 독립적이기 때문에 DOM에 접근할 수 없고, 메시지 기반 통신으로 메인 스레드와 통신

Service Worker의 라이프사이클

  1. 설치(Install)
  • 브라우저가 새로운 Service Worker 파일을 등록하거나 기존 파일이 바뀌었을 때 실행
  • 주로 캐시 초기화가 이루어짐

2. 대기 (Waiting)

  • 기존 Service Worker가 실행 중이면 대기
  • 사용자가 페이지를 닫거나 새로고침하면 활성화됨
  • self.skipWaiting() 호출 시 즉시 활성화 가능
  1. 활성화 (Activate)
  • 새 버전의 Service Worker가 활성화됨
  • 이 시점에 주로 불필요한 캐시를 정리
  1. 동작 (Idle & Events)
  • fetch, push, sync 같은 이벤트 처리
  • 이벤트가 발생하지 않으면 idle 상태가 되고, 이벤트가 발생하면 깨어나서 실행
  1. 종료 (Terminate)
  • idle 상태로 일정 시간이 지나면 terminate되어 리소스를 절약
  • 다시 필요하면 브라우저가 새로 실행

캐싱 전략

  1. Cache-Only
  • 캐시로만 응답
  • 이 전략을 사용하려면 반드시 사전에 캐시되어야 함
  1. Network-Only
  • 캐시를 사용하지 않고 네트워크로 이동
  • 항상 최신 데이터를 유지할 수 있음
  • 오프라인 대응 불가

3. Cache-first

  • 캐시가 있으면 캐시를 리턴
  • 없으면 네트워크로 이동하고, 요청이 완료되면 캐시에 추가하고 응답 반환
  • 정적 asset(CSS, JS, 이미지, 폰트)에 적합한 전략
  1. Network-First
  • 네트워크로 우선적으로 이동하여 요청하고 응답을 캐시에 추가
  • 오프라인 상황에서는 캐시를 사용
  • HTML 또는 API 요청에 적합. 오프라인에서도 가장 최신 버전의 자원을 사용 가능
  1. Stale-While-Revalidate
  • 처음 요청에 대해서는 네트워크로부터 자원을 fetch하고 캐시에 추가한 뒤 반환
  • 그 다음 요청부터는 캐시를 반환하고, 백그라운드에서 다시 네트워크 요청 후 캐싱
    • 백그라운드에서 네트워크로부터 받은 자원은 다음 요청에서 반환됨
  • 최신의 데이터를 유지하는게 중요하지만 필수적이지 않은 경우에 적합한 전략
    • ex: SNS의 사용자 프로필 사진

캐싱 적용하기

위 내용을 기반으로 리소스의 종류에 따른 캐싱 전략을 적용해보기로 했다.

  1. 정적 자원(JS/CSS/이미지/폰트) → Cache First
    • 잘 바뀌지 않는 파일이므로 미리 캐시해두고 사용
  2. HTML 문서 → Stale-While-Revalidate
    • 빠르게 페이지를 보여주기 위해 캐시된 HTML을 바로 보여주고 백그라운드에서 최신 버전으로 갱신
  3. API 응답 → Network-First
    • 최신 데이터 확보를 위해 네트워크 요청을 우선적으로 실행하고, 실패 시 캐시 사용

기본 API 사용

다음은 기본 Service Worker API를 사용해 캐싱하는 코드이다.

  1. Service Worker 캐싱
// 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());
}
  1. 서비스워커 등록하는 컴포넌트
// 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;
  1. layout에 컴포넌트 추가
// 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}`;

지금 코드는 이렇게 버전이 하드코딩되어 있는데, 배포할 때마다 수동으로 직접 버전을 수정해주어야 캐시가 교체될 수 있다.

그래서 일반적으로는 라이브러리를 사용한다고 한다.

Serwist 사용하기

처음엔 대표적인 라이브러리 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.

코드

  1. nextConfig 감싸기
// 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로 설정해야 한다
  1. Service Worker 코드 작성
// 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이었다.

  • mode: no-cors, response type: opaque, status code: 0

원인

교차 출처 요청에 대해 CORS 허용없이(no-cors) 서버가 응답을 보내면, 브라우저는 보안상 해당 응답을 js 코드에서 볼 수 없게 차단한다. 그래서 opaque, status 0인 응답이 표시되는 것이다.

하지만 브라우저는 응답 내용을 알고 있으므로, img 태그 같은 것에서는 화면에 이미지를 제대로 렌더링할 수 있다!

결과

결과적으로 0인 응답에 대해서도 캐싱을 허용하니, 오프라인에서도 이미지가 깨지지 않고 페이지가 제대로 표시되었다.

new CacheableResponsePlugin({ statuses: [0, 200] }), // 후

참고 자료

0개의 댓글