본문 바로가기
웹 개발

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

by xosoy 2025. 8. 22.

간단한 링크 스크랩 웹앱을 만들어보고 싶었다. 나의 경우 주로 휴대폰으로 링크를 저장하는 편이기 때문에, 웹 앱을 네이티브 앱처럼 만들어보면 좋을 것 같았다.

 

네트워크가 불안정한 상태, 오프라인 상태에서도 최소한 저장한 링크에 대한 간략한 정보가 보이려면 어떻게 해야할까 방법을 찾아보았다.

 

HTML, JS, CSS, 이미지 등의 리소스는 네트워크에 의존한다. 따라서 오프라인 상황에서는 캐싱된 리소스를 사용하여 사용자에게 최소한의 화면을 보여주는 것이 중요하다!

 

Service Worker

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

 

Service Worker의 라이프사이클

 

1. 설치(Install)

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

2. 대기 (Waiting)

  • 기존 Service Worker가 실행 중이면 대기
  • 사용자가 페이지를 닫거나 새로고침하면 활성화됨
  • self.skipWaiting() 호출 시 즉시 활성화 가능

3. 활성화 (Activate)

  • 새 버전의 Service Worker가 활성화됨
  • 이 시점에 주로 불필요한 캐시를 정리

4. 동작 (Idle & Events)

  • fetch, push, sync 같은 이벤트 처리
  • 이벤트가 발생하지 않으면 idle 상태가 되고, 이벤트가 발생하면 깨어나서 실행

종료 (Terminate)

  • idle 상태로 일정 시간이 지나면 terminate되어 리소스를 절약
  • 다시 필요하면 브라우저가 새로 실행

 

캐싱 전략

  1. Cache-Only
  • 캐시로만 응답
  • 이 전략을 사용하려면 반드시 사전에 캐시되어야 함

2. Network-Only

  • 캐시를 사용하지 않고 네트워크로 이동
  • 항상 최신 데이터를 유지할 수 있음
  • 오프라인 대응 불가

3. Cache-first

  • 캐시가 있으면 캐시를 리턴
  • 없으면 네트워크로 이동하고, 요청이 완료되면 캐시에 추가하고 응답 반환
  • 정적 asset(CSS, JS, 이미지, 폰트)에 적합한 전략

 

4. Network-First

  • 네트워크로 우선적으로 이동하여 요청하고 응답을 캐시에 추가
  • 오프라인 상황에서는 캐시를 사용
  • HTML 또는 API 요청에 적합. 오프라인에서도 가장 최신 버전의 자원을 사용 가능

5. 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());
}

 

2. 서비스워커 등록하는 컴포넌트

// 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;

 

3. 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로 설정해야 한다

2. 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-corsresponse type: opaquestatus code: 0

원인

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

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

결과

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

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

 

참고 자료