서비스 워커(Service Worker)로 리소스 캐싱하기

김민재·2026년 2월 27일

혹시 이런 형태로 Cache-Control 옵션이 설정되어 있는 응답 헤더를 보신적이 있나요?

Cache-Control: no-cache, no-store, max-age=0, must-revalidate, max-age=604800, public

제가 공공데이터를 활용한 프로젝트를 진행하면서 마주했던 상황입니다.

구체적인 문제 상황은 다음과 같았습니다.

브라우저는 본래 이미지 같은 리소스들을 다시 다운로드하지 않기 위해 캐싱 작업을 하는데, 지금은 페이지를 이동할 때마다 포스터 이미지들을 전부 새로 받아오고 있었습니다.

이유가 궁금해서 네트워크 탭에서 응답 헤더를 확인해보니, 위에 언급한 것과 같은 Cache-Control 옵션이 설정되어 있었죠.

옵션들의 의미를 살펴보면 다음과 같습니다.

  • no-store: 응답 결과를 어디에도 캐싱하지 않는다.
  • no-cache: 캐싱을 허용하되, 캐싱된 데이터를 사용할 때는 서버에 물어보고 사용한다.
  • max-age=0: 데이터를 받자마자 상한 데이터(stale)로 취급한다.
  • must-revalidate: 캐시된 데이터가 유효기간이 지났다면, 네트워크가 좋지 않거나 오프라인일 때 이 데이터를 사용하지 않고 무조건 서버에 요청한다.
  • max-age=604800: 일주일(604800초) 동안은 fresh한 데이터로 간주하고, 서버에 묻지 않고 캐시된 데이터를 사용한다.
  • public: 브라우저, CDN, 프록시 서버 등 거치는 모든 곳에서 캐싱이 가능하다.

public, max-age-604800과 같이 캐싱을 허용하는 옵션과, no-store과 같이 캐싱을 금지하는 옵션이 혼재되어 있었습니다. 아마 서버 측에서 다양한 상황에 대비하기 위해서, 혹은 알수 없지만 어떤 특정한 기술적 구조 때문인 것으로 보입니다.

이렇게 다양한 옵션이 혼재된 상황에서, 브라우저는 보통 데이터에 대한 보안과 무결성을 위해 가장 엄격한 옵션을 따릅니다.

결국 'no-store'옵션을 따르게 되면서 캐싱이 일어나지 않게 되었고, 페이지를 이동하거나 새로고침할 때마다 리소스를 새로 받아오고 있는 것이었습니다.

공공데이터를 사용하고 있었기 때문에 헤더의 Cache-Control을 바꿔달라고 요청할 수는 없었으므로, 이미지를 재요청하지 않는 다른 방법을 찾아봐야 했습니다.

우선 네트워크에 요청에 의한 브라우저의 캐싱이 어떤식으로 이루어지는지부터 파악하면 좋을 것 같습니다.



✔️ HTTP 캐시

브라우저는 효율을 위해 네트워크 요청을 통해 받아온 데이터에 대한 캐시(HTTP 캐시)를 크게 두 곳에 저장합니다. 바로 네트워크 프로세스가 관리하는 디스크 캐시(SSD), 그리고 렌더러 프로세스가 관리하는 메모리 캐시(RAM)입니다.

1. 디스크 캐시

디스크 캐시는 메모리 캐시보다 상대적으로 데이터를 불러오는 속도가 느리지만 전원이 꺼져도 데이터가 유지된다는 장점이 있습니다. 브라우저를 껐다 켜도 다운로드 없이 빠르게 이미지를 렌더링할 수 있는 것이 바로 디스크 캐시 덕분이죠.

파일 형태로 저장되기 때문에 다음과 같이 사용자의 컴퓨터에서 캐시 데이터를 확인할 수 있습니다.

2. 메모리 캐시

반면 메모리 캐시는 RAM이기 때문에 데이터를 불러오는 속도가 빠르지만, 렌더러 프로세스의 관리 하에 있어 탭이 종료되면 휘발된다는 단점이 있습니다.

브라우저가 캐시 데이터를 확인할 때는 가장 빠른 메모리 캐시부터 확인하고, 그 다음 디스크 캐시를 확인합니다.


✔️ 캐싱 메커니즘

캐시 저장소가 두 군데나 있다고 해서 브라우저가 데이터를 마음대로 저장하는 것은 아니고, Cache-Control에 명시된 서버의 명령 하에 캐싱을 하게 됩니다.

후술한 옵션 말고도 다양한 옵션들이 존재하지만, 대표적인 것을 기준으로 설명하겠습니다.

1. 서버에서 캐싱을 금지하는 경우(no-store)

현재 상황과 같이 서버가 보내주는 Cache-Control 헤더에 no-store같이 캐싱을 금지하는 옵션이 있는 경우에는, 디스크 캐시와 메모리 캐시에 모두 데이터가 저장되지 않습니다.


2. 서버에서 캐싱을 허용하는 경우

반면 캐싱을 허용하는 경우에는, max-age 옵션에 따라 데이터를 다루게 됩니다.

max-age가 설정된 경우

max-age=3600인 경우를 예시로 들어보겠습니다.

요청이 왔을 때, 캐시 저장소에서는 3600초(1시간) 동안은 fresh한 데이터로 간주하고 서버에 가지 않고 캐싱된 데이터를 즉시 반환합니다. 하지만 1시간이 지나면 데이터를 stale 상태로 간주하게 되고, 서버에 작은 신호를 요청해서 바뀌었는지 여부를 확인합니다.

이때 데이터가 바뀌지 않았다면 서버는 304 Not Modified 응답을 보내며, 브라우저는 저장된 데이터를 그대로 재사용합니다.

max-age가 설정되지 않은 경우

max-age가 설정되어있지 않다면, '휴리스틱 캐싱'이라고 하여 max-age를 특정한 알고리즘에 따라 어림짐작합니다.

보통 (요청 시각 - 최종 수정 시각(Last-Modified)) x 0.1을 사용합니다.



✔️ 이미지 캐싱 전략 세우기

이미지 리소스를 캐싱하기 위해, 브라우저가 가진 저장소들을 살펴보며 여러가지 방법을 고려해볼 수 있었습니다.

1. IndexedDB

이미지 바이너리 데이터와 함께 메타데이터를 구조화하여 저장하거나, 특정 조건에 따른 쿼리가 필요한 경우 유용합니다.

하지만 캐시된 데이터를 활용할 때 매번 Response 객체로 변환하는 과정이 필요하며, fetch 이벤트와의 직관적인 연결성이 떨어져 구현 복잡도가 높다는 단점이 있습니다.

2. Cache Storage

HTTP 요청(Request)과 응답(Response) 객체를 쌍으로 저장하는 방식이므로, 네트워크 레이어와의 정합성이 가장 뛰어납니다.

특히 서비스 워커(Service Worker)와 결합했을 때 별도의 변환 과정 없이 즉시 응답을 반환할 수 있고, 저장 용량이 상대적으로 넉넉하여 고화질 이미지 자산을 관리하기에 가장 적합한 선택지입니다.

3. 상태관리 도구

서버에서 받아온 이미지를 Blob 또는 Base64 형태로 변환하여 자바스크립트 힙 메모리에 유지하고, 이를 전역 상태 관리 도구로 공유하는 방식입니다.

그러나 자바스크립트 힙을 직접 활용한다는 것은 브라우저의 RAM 점유율을 높이는 결과를 초래합니다. 이는 특히 고화질 이미지나 대용량 데이터를 다룰 때 GC 부하를 가중시키고, 런타임 성능 저하를 유발할 위험이 있어 지양해야 할 방식입니다.


결과적으로 대용량의 데이터를 저장하고 네트워크 요청을 실시간으로 인터셉트하여 캐시된 리소스를 즉각 응답하는 것이 핵심이었기에, 서비스 워커와 Cache Storage의 조합을 선택했습니다.



🔎 서비스 워커(Service Worker)란?

우선 서비스 워커가 무엇인지 알아보겠습니다.

서비스 워커는 브라우저와 네트워크 사이에 존재하는 중간 관리자로서, 백그라운드에서 실행하는 스크립트 파일입니다.


📌 등장 배경

서비스 워커가 생기기 전까지 웹은 인터넷이 끊기면 아무것도 할 수 없었습니다. 반면 앱은 오프라인에서도 예전 메시지를 볼 수 있는 등의 작업이 가능했죠.

이처럼 인터넷 연결 여부와 상관없이 항상 작동하는 웹(PWA)을 만들기 위해 등장했습니다.


📌 서비스 워커의 특징

1. 독립된 실행 환경(Worker Context)

서비스 워커는 위 그림에서 볼 수 있듯이 우리가 흔히 아는 웹페이지(메인 스레드)와 완전히 분리된 별도의 스레드에서 돌아갑니다.

따라서 DOM 접근이 불가능합니다. 대신 postMessage 와 같은 IPC를 통해 메인 스레드와 통신하며 필요한 정보를 주고받을 수 있습니다.

2. 가로채기 (Network Proxy)

앞서 이야기했듯이, 브라우저와 네트워크 사이에서 요청을 가로챌 수 있습니다. 서버로 갈 요청을 캐시로 돌리거나, 아예 가짜 응답(Mock Response)를 만들어 보내는 등 네트워크의 흐름을 재설계할 수 있습니다.

이 밖에도 푸시 알림, 백그라운드 동기화 등 네트워크에 관한 다양한 권한을 가집니다.

3. 이벤트 기반의 생명주기(Event-driven & Asynchronous)

서비스 워커는 24시간 깨어있는 게 아니라, 필요할 때만 잠시 깨어납니다. 사용하지 않을 때는 브라우저가 서비스 워커를 종료시켜 메모리를 아끼고, fetchpush와 같이 등록된 이벤트가 트리거되면 다시 실행되어 일을 처리합니다.

4. HTTPS Only

서비스 워커는 네트워크를 조작할 수 있는 강력한 권한을 가졌으므로, 만일 해커가 악성 서비스 워커를 심는다면 데이터를 빼앗길 수 있습니다. 이를 막기 위해서 반드시 HTTPS 환경에서만 설치 및 작동이 가능합니다.

하지만 개발 서버(localhost)에서는 안전하다고 판단하고 HTTP 방식을 사용하더라도 예외적으로 서비스 워커 작동을 허용합니다.


📌 VS. 웹 워커(Web Worker)

서비스 워커는 웹 워커와는 다릅니다. 웹 워커는 워커 스레드라고도 불리며, 메인 스레드가 속한 렌더러 프로세스 내에서 돌아가며 메인 스레드가 처리하기 복잡한 연산을 보조해주는 역할을 합니다.

하지만 서비스 워커는 앞서 이야기했듯 웹페이지와 분리된 별도의 서비스 워커 프로세스라는 곳에서 별도의 생명주기를 가지며 작동합니다.

따라서 탭이 닫히면 메인 스레드와 함께 죽는 웹 워커와는 달리, 서비스 워커는 백그라운드에서 실행이 가능합니다.


📌 서비스 워커의 생명주기

서비스 워커의 생명주기는 크게 5단계로 구성됩니다.

1. 등록(registration)

등록은 브라우저에게 서비스 워커 자바스크립트 파일이 어디에 위치해 있는지 알려주는 과정입니다. 이는 보통 웹 애플리케이션의 메인 자바스크립트 파일에서 navigator.serviceWorker.register(파일경로) 를 사용해 수행됩니다.

2. 설치(Installation)

설치 단계는 서비스 워커가 처음 등록되거나 업데이트된 서비스 워커 파일이 감지될 때 발생합니다. 여기서 서비스 워커의 install 이벤트가 트리거됩니다.

self.addEventListener('install', event => {
  // Installation logic goes here
  console.log('Service Worker installed');
});

유의할 점은, 설치 콜백에서 에러가 발생하더라도 서비스 워커는 활성화 단계로 넘어갈 수 있습니다.

따라서 install 단계에서 중요한 작업을 수행하는 경우, waitUntil() 메서드로 실행 완료 시점을 명시적으로 지정해주어야 합니다.

waitUntil()은 다음과 같이 인자로 Promise를 받으며, 프로미스가 완료될 때까지는 설치 단계를 유지합니다.

self.addEventListener('install', (event) => {
  console.log('설치 시작!');

  event.waitUntil(
    caches.open('my-cache-v1').then((cache) => {
      // 이 다운로드 작업이 모두 성공해야만 설치 완료로 인정됨
      return cache.addAll([
        '/',
        '/index.html',
        '/styles.css',
        '/app.js'
      ]);
    })
  );
});

프로미스가 resolve되면 다음 라이프사이클 단계로 이동하고, reject되면 종료(Redundant) 상태가 되어 생명주기를 곧바로 마감합니다.

installation은 보통 거의 변하지 않는 정적 자산을 캐싱하거나, 애플리케이션이 오프라인에서 작동하는 데 필요한 초기 캐시 설정과 같은 일회성 설정 작업을 수행하는 용도로 사용됩니다.

3. 대기 중(waiting)

설치가 성공적으로 끝나고, 다른 서비스 워커가 앱을 제어하고 있다면 대기 중 상태가 됩니다.

서비스 워커 스크립트가 업데이트되어 새로운 버전의 서비스 워커가 설치되었지만, 기존 서비스 워커가 여전히 페이지를 제어하고 있을 때 발생하는 단계입니다.

바뀐 버전을 바로 활성화시키지 않는 이유는, 동일한 사이트에서 서로 다른 버전의 서비스 워커가 충돌하는 것을 방지하기 위함입니다.

모든 탭(웹페이지)가 닫히거나, 코드로 skipWaiting()을 호출해야만 새 서비스 워커를 활성화시킬 수 있습니다.

4. 활성화(activation)

설치가 성공적으로 끝나고, 현재 활성화된 서비스 워커가 없다면 즉시 '활성화' 단계가 됩니다.

활성화 단계는 활성화 중(Activating), 활성화됨(Activated) 두 단계로 나눌 수 있습니다.

'활성화중'은 서비스 워커가 본격적으로 활성화되기 직전의 상태로서, activate 이벤트가 발생하며 설치 상태와 같이 waitUntil() 메서드를 활용하여 이 활성화 중 상태를 유지할 수 있습니다.

'활성화됨'은 서비스 워커가 활성화된 상태로, 이제 서비스 워커는 앱을 제어할 수 있고 fetch 이벤트와 같은 동작 이벤트를 받아서 처리할 수 있습니다.

5. 중복(Redundant)

서비스 워커가 설치 중 실패하거나 새로운 버전으로 교체되면 중복 상태가 됩니다.
이 상태의 서비스 워커는 앱에 아무런 영항을 미칠 수 없습니다.



✔️ 서비스 워커 캐싱 구현하기

이제 본격적으로 서비스 워커로 캐싱을 구현해보겠습니다.

서비스 워커 스크립트(sw.js)

우선 public 폴더 하위에 sw.js라는 이름의 파일을 생성합니다.

public 하위에 파일을 생성해야 하는 이유는, 서비스 워커는 자신이 위치한 폴더와 그 하위의 폴더의 네트워크 요청만 가로챌 수 있기 때문입니다.

만약 상위 경로의 네트워크 요청도 가로챌 수 있도록 한다면, 개인 페이지에서 공용 서버의 데이터 요청을 가로채는 것과 같은 문제가 발생할 수 있습니다.

public에 두게 되면 빌드 후 위치가 https://site.com/sw.js 가 되어 하위의 요청들을 가로챌 수 있게 됩니다.

이제 코드로 구현해보겠습니다.

캐시 네이밍 및 install 로직

브라우저가 캐시를 식별하기 위해 CACHE_NAME을 붙여줍니다.

그리고 수정 내역을 바로 반영하기 위해 self.skipWaiting()을 작성합니다.

const CACHE_NAME = "poster-cache-v1";

self.addEventListener("install", () => {
  self.skipWaiting();
});

기존 캐시 삭제하기

캐시 스토리지는 브라우저가 자동으로 관리하지 않는 영구 저장소이므로, 개발자가 직접 명시적인 삭제 로직을 구현해야 합니다.

특히 새로운 CACHE_NAME으로 업데이트될 경우, activate 이벤트를 활용해 구버전 캐시를 깨끗이 정리해주는 것이 필수적입니다.

const CACHE_NAME = "poster-cache-v1";

self.addEventListener("install", () => {
  self.skipWaiting();
});

self.addEventListener("activate", (event) => {
  event.waitUntil(
    caches
      .keys()
      .then((cacheNames) => {
        return Promise.all(
          cacheNames
            .filter((name) => name !== CACHE_NAME)
            .map((name) => caches.delete(name)),
        );
      })
      .then(() => self.clients.claim()),
  );
});

그런데 정리 로직을 처음 install이 아닌 activate 단계에 배치하는 이유는 뭘까요?

새로운 서비스 워커가 설치되는 동안에도, 화면은 여전히 기존 서비스 워커에 의해 제어되고 있을 수 있습니다.

만약 install 단계에서 캐시를 지워버리면, 현재 페이지에서 사용 중인 리소스가 갑자기 사라지는 사고가 발생합니다.

따라서 새로운 서비스 워커가 모든 준비를 마치고 실제 제어권을 넘겨받는 활성화시점, 즉 대기 중(waiting) 단계를 거친 후에 청소를 시작하는 것입니다.


fetch 이벤트 가로채기

다음으로 fetch 이벤트 발생시 이를 가로채기 위하여, 핸들러를 등록해줍니다. 이미지에 대한 캐싱을 해야 하므로, request.destination === "image" 로 요청을 확인합니다.

cache.match()를 통해 요청에 대한 값이 저장되어 있는지 확인하고, 있으면 해당 응답을 반환하고 없으면 네트워크 요청을 실행하는 로직을 구현합니다.

const CACHE_NAME = "poster-cache-v1";

self.addEventListener("install", () => {
  self.skipWaiting();
});

self.addEventListener("fetch", (event) => {
  const { request } = event;

  if (request.destination === "image") {
    event.respondWith(
      (async () => {
        const cache = await caches.open(CACHE_NAME);

        console.log(cache);

        const cachedResponse = await cache.match(request);
        if (cachedResponse) {
          return cachedResponse;
        }

        try {
          const networkResponse = await fetch(request);

          if (networkResponse && networkResponse.status === 200) {
            cache.put(request, networkResponse.clone());
          }

          return networkResponse;
        } catch (error) {
          console.error("이미지 로딩실패:", error);
          throw error;
        }
      })(),
    );
  }
});

서비스 워커 등록

다음으로는 앱의 최상위에 위와 같이 서비스 워커를 등록하는 코드를 추가합니다.

화면을 그리는 작업이 서비스 워커 등록 작업으로 인해 지연되지 않도록, load 이벤트 시점에 서비스 워커가 등록되도록 작성합니다.

// App.tsx

if ("serviceWorker" in navigator) {
  window.addEventListener("load", () => {
    navigator.serviceWorker.register("/sw.js")
      .then(() => console.log("[REGISTER_SUCCESS] Service Worker register succeeded"))
      .catch(err => console.error("[REGISTER_FAIL] Service Worker register failed", err));
  })
}

const App = () => {
  return (
    <QueryClientProvider client={queryClient}>
      <RouterProvider router={router} />
      <ReactQueryDevtools initialIsOpen={false} />
    </QueryClientProvider>
  );
};

export default App;

성공적으로 등록되었다면, 다음과 같이 브라우저 개발자 도구의 Application 탭에서 초록색 동그라미와 함께 서비스 워커가 Running 상태임을 확인할 수 있습니다.


실행

이제 캐싱이 잘 이루어지는지 확인해보겠습니다.

위가 초기 로딩, 아래가 서비스 워커를 등록한 뒤 로딩을 네트워크 탭을 통해 측정한 것입니다.

예상했던 것과 달리 데이터를 가져오는 데 초기 로딩할 때보다 더 많은 시간이 소요되고 있었습니다.

respondWith()가 실행되는 시간과 서버 응답을 기다리는 시간이 같다는 점, 콘텐츠 다운로드에 시간이 소요되는 점을 통해 캐싱이 되지 않아 네트워크 요청을 새로 받아오는 것을 확인했습니다.

그래서 response를 직접 출력해보니 다음과 같은 형태였습니다.

응답의 status가 0이었기 때문에 위 조건문이 실행되지 않았고, 캐시 스토리지에도 데이터가 저장되지 않은 것이었죠.

그렇다면 왜 Response가 이런 식으로 전달된 것일까요?

근본 원인은 CORS(Cross-Origin Resource Sharing) 정책에 있었습니다. 서비스 워커 역시 브라우저 컨텍스트 내에서 실행되는 스크립트이므로 보안 정책을 따릅니다.

서버 응답 헤더에 Access-Control-Allow-Origin이 적절히 설정되지 않은 교차 출처 리소스를 요청할 경우, 브라우저는 보안을 위해 응답의 세부 내용을 숨기는 'Opaque Response'를 반환합니다. 이 경우 상태 코드가 0으로 설정되기 때문에, 기존의 response.status === 200과 같은 일반적인 조건문으로는 캐시 로직이 실행되지 않았던 것입니다.

Opaque Response는 내부 데이터에 접근할 수는 없지만, 캐시 스토리지(Cache Storage)에 저장하는 것은 가능합니다. 따라서 캐싱 성공 조건에 status 0을 포함하도록 로직을 수정하여 캐시 적중을 정상화했습니다.

이렇게 status:0 인 응답을 캐싱하여 문제를 해결할 수 있지만, 이 방식에는 치명적인 결함이 존재합니다. 바로 응답의 성공 여부를 브라우저가 보장할 수 없다는 점입니다.

서버에서 404 Not Found500 Internal Server Error가 발생하더라도, 서비스 워커는 이를 동일하게 status: 0으로 수신합니다.

서비스 워커가 에러 페이지나 깨진 데이터를 캐시 스토리지에 저장하게 되면, 이후 사용자는 서버 상태가 정상화되더라도 계속해서 잘못된 리소스를 전달받게 됩니다.

즉, 이런 클라이언트 사이드에서의 임시방편만으로는 서비스 워커에서의 안정적인 캐싱 전략을 구축할 수 없습니다. 서비스 워커가 리소스의 유효성을 정확히 판단할 수 있도록 서버 측에서 CORS 헤더를 설정하거나, 커스텀 프록시 서버를 구축해야 합니다.



마치며..

사실 고화질 이미지로 인한 로딩 속도와 캐싱 문제는 언급했던 커스텀 프록시 서버를 구축하여 해결하는 것이 더 효율적인 아키텍처입니다. 서버 단에서 이미지를 WebP나 AVIF같은 고효율 포맷으로 변환하고 압축하여 전달한다면, 앞서 언급한 CORS 이슈나 네트워크 비용 문제를 원천적으로 최적할 수 있기 때문입니다.

하지만 저는 의도적으로 순수 브라우저 리소스만으로 이 문제를 해결해보고자 하는 제약을 두었습니다. 이를 통해 메인 스레드의 부하를 최소화하는 메모리 관리 전략부터 네트워크 요청을 직접 제어하는 인터셉션 메커니즘, 그리고 CORS 정책에 따른 Opaque Response의 보안적 특성까지 폭넓게 탐구할 수 있었습니다.

잘못된 정보나 보완해야 할 부분이 있다면 적극적으로 말씀해주시면 감사하겠습니다.



참고 자료

https://deepwiki.com/lavas-project/pwa-book/4.2-service-worker-lifecycle
https://tech.kakaoent.com/front-end/2022/221208-service-worker/

profile
넓이보다 깊이있게

0개의 댓글