본문으로 건너뛰기
Tech Blog

Service Worker 는 네트워크 프록시예요

글 복사 완료!

같은 워커 이름인데 둘은 사는 곳이 달랐거든요.

·13분·

워커 이름이 들어간 표준이 둘이라서 처음엔 같은 거라고 생각했어요. Web Worker 가 무거운 계산을 옆 스레드로 보낸다는 건 메인 스레드가 멈추면 클릭도 멈춰요 글에서 정리했는데, Service Worker 도 그 친척이려니 했거든요. 근데 코드를 한 줄 들여다보니 둘은 완전히 다른 자리에 사는 워커였어요.

같은 워커인데 왜 이렇게 다를까

W3C 명세는 한 줄로 못박아 둬요.

"A service worker is a type of web worker." - W3C Service Workers spec

스펙이 그렇게 말하니까 둘은 분명 친척이에요. DOM 접근이 안 되고 메시지로만 소통한다는 점도 똑같고요. 다만 친척이라고 같은 일을 하는 건 아니에요.

차이는 어디에 앉아 있느냐예요. Web Worker 는 페이지 옆에 앉아서 계산을 받아줘요. 메인 스레드가 무거운 정렬에 묶이지 않게 그 일을 떠받아 가는 구조예요. Service Worker 는 그게 아니라 페이지와 네트워크 사이에 끼어 있어요. 페이지가 보내는 fetch 요청이 네트워크로 나가기 전에 Service Worker 를 한 번 거쳐 가요.

1

Web Worker 의 자리

페이지가 무거운 작업을 워커에 보내요. 메인 스레드는 풀려있고, 워커가 옆 스레드에서 계산만 처리해요. 결과는 메시지로 돌려받아요.

2

Service Worker 의 자리

페이지가 fetch 를 호출하면 요청이 Service Worker 를 먼저 거쳐요. SW 가 캐시에서 줄지, 네트워크에 보낼지, 직접 만든 응답을 줄지 결정해요.

같은 '워커' 라는 단어를 써도 한 명은 계산을 떠받는 자리고, 한 명은 네트워크를 가로채는 자리예요.

네트워크 프록시라는 정체성

MDN 도 비슷한 표현으로 정리해요.

"Service workers essentially act as proxy servers that sit between web applications, the browser, and the network." - MDN Service Worker API

웹 앱과 네트워크 사이에 앉은 프록시예요. web.dev 는 이걸 '미들웨어' 라고도 부르고요. 어느 쪽이든 fetch 를 가로채는 자리라는 뜻이에요.

등록은 한 줄이에요. 페이지 쪽에서 SW 파일을 가리키면 돼요.

// 페이지의 main.js
if ('serviceWorker' in navigator) {
  navigator.serviceWorker.register('/sw.js');
}

이 한 줄이 등록되면 그 다음부터 페이지가 만드는 모든 fetch 요청이 SW 의 fetch 이벤트를 거쳐요.

// sw.js
self.addEventListener('fetch', (event) => {
  event.respondWith(
    caches.match(event.request).then((cached) => {
      return cached || fetch(event.request);
    })
  );
});

event.respondWith 가 핵심이에요. SW 가 응답을 직접 만들어서 돌려주면 네트워크에는 안 가요. 캐시에 같은 요청의 응답이 있으면 그걸 주고, 없으면 진짜 네트워크에 보내고요. 이 결정 권한이 페이지 코드가 아니라 SW 한테 있다는 게 '프록시' 라는 표현의 진짜 뜻이에요.

Page 가 부르는 fetch 가 SW 를 거쳐 Cache 또는 Network 로 갈라져요

scope 는 SW 파일이 놓인 디렉토리가 기본값이에요. /sw.js 면 사이트 루트 전체, /app/sw.js/app/ 아래만 가로채요. 이 한 줄이 SW 가 페이지의 어디까지 책임지는지를 결정해요.

install 과 activate 사이의 기다림

여기서 한 가지 짚고 갈 점은 SW 가 React 같은 페이지 JS 번들과 함께 묶이지 않는다는 거예요. /sw.js 라는 별도 파일로 떠 있고, 브라우저는 페이지에 올 때마다 그 파일을 따로 받아 바이트 단위로 새 버전인지 확인해요.

그래서 새 SW 를 배포해도 사용자에게 즉시 적용되지 않아요. 브라우저가 새 파일을 발견하면 install 이벤트로 설치를 먼저 돌리고, 이전 SW 가 통제하던 페이지가 다 닫힐 때까지 기다렸다가 activate 이벤트로 활성화해요. 처음엔 이게 버그인 줄 알았거든요. 근데 이건 라이프사이클이 그렇게 설계돼 있어서 그래요.

1

installing

새 sw.js 파일이 바이트 단위로 다른 걸 발견하면 브라우저가 install 이벤트를 보내요. 초기 캐시 자산을 여기서 채워요.

2

installed (waiting)

설치는 끝났지만 아직 활성화는 안 됐어요. 이전 SW 가 통제하던 페이지가 다 닫힐 때까지 기다려요.

3

activating

이전 SW 의 통제가 풀린 순간 activate 이벤트가 발화해요. 옛 캐시를 정리하기 좋은 자리예요.

4

activated

이제부터 새 SW 가 페이지의 fetch 요청을 가로채요. 이전 버전과 새 버전이 동시에 페이지를 통제하지 않도록 보장돼요.

기다림의 이유가 일관성이에요. 이전 버전 SW 가 만든 캐시 키와 새 버전이 만들 캐시 키가 다를 수 있어요. 같은 페이지를 보고 있는 두 개 탭에서 한쪽은 이전 SW, 한쪽은 새 SW 가 응답하면 데이터가 어긋날 수 있고요. 그래서 새 SW 는 옛 SW 의 통제가 모두 풀린 뒤에야 일을 시작해요.

이걸 우회하는 방법이 있긴 해요.

"The new service worker can call skipWaiting() to ask to be activated immediately without waiting for open pages to be closed." - MDN Using Service Workers

install 안에서 self.skipWaiting() 을 호출하면 waiting 단계를 건너뛰고 바로 activate 으로 가요. '즉시 적용' 이라 매력적으로 들리지만, 두 버전 SW 가 잠깐 공존하던 그 안전 구간을 직접 깨는 결정이에요.

skipWaiting 을 쓸지 말지는 캐시 키를 어떻게 굴리느냐에 따라 갈려요. 새 버전이 옛 캐시를 그대로 쓸 수 있는 형태라면 위험이 적고, 캐시 구조를 바꿨다면 두 버전이 동시에 응답하다가 사용자한테 혼란을 줘요. 기본값은 기다리는 쪽이고, skipWaiting 은 정말 필요할 때만 켜는 옵션이에요.

캐시는 알아서 갱신되지 않아요

마지막 함정 하나가 더 있어요. SW 가 다루는 Cache Storage 는 정말 단순한 키 값 저장소예요.

"The Cache Storage API doesn't update your assets if you change them on your server nor does it delete them. Your code should manage both situations." - web.dev Learn PWA

서버에서 자산을 바꿔도 캐시는 그대로예요. 만료도 없고, 자동 삭제도 없어요. 갱신과 삭제는 전부 SW 코드의 책임이에요.

그래서 흔히 쓰는 패턴이 캐시 이름에 버전을 박는 거예요. v1 으로 채운 캐시는 v2 가 나올 때까지 그대로 살아있고, activate 시점에 옛 버전 캐시를 정리해요.

const CACHE_NAME = 'static-v2';
const KEEP_LIST = [CACHE_NAME];
 
self.addEventListener('activate', (event) => {
  event.waitUntil(
    caches.keys().then((keys) =>
      Promise.all(
        keys
          .filter((key) => !KEEP_LIST.includes(key))
          .map((key) => caches.delete(key))
      )
    )
  );
});

KEEP_LIST 에 들어 있지 않은 캐시 이름은 다 지워요. 새 버전이 올라가면 static-v2static-v3 가 되고, 옛 v2 캐시는 다음 activate 에서 정리돼요.

이 작은 꼼수가 빠지면 캐시가 영원히 살아남아요. 사용자 브라우저 어딘가에 옛 자산이 남아 있고, 디버깅하다가 '분명 배포했는데 왜 안 바뀌지' 하는 순간이 그 자리에서 와요. 캐시는 알아서 청소되지 않으니, 청소 코드를 같이 배포해야 하는 거예요.

프록시 자리에서 한 걸음 더

같은 자리에 앉아 있다는 건 fetch 가로채기 외에도 따라오는 게 있다는 뜻이에요. 두 가지만 짚을게요.

install 시점에 미리 받아두기

페이지가 처음 로드될 때 필요한 정적 자산은 install 단계에서 한꺼번에 캐시에 채워둘 수 있어요. 사용자가 처음 페이지를 열 때 한 번 받고, 그 다음부터는 네트워크가 끊겨도 그 자산들로 화면을 그릴 수 있어요.

const PRELOAD = ['/', '/styles.css', '/app.js', '/offline.html'];
 
self.addEventListener('install', (event) => {
  event.waitUntil(
    caches.open('static-v2').then((cache) => cache.addAll(PRELOAD))
  );
});

cache.addAll 은 모든 자산이 다 받아질 때까지 기다려요. 하나라도 실패하면 install 자체가 실패하니까, '처음 로드 자산이 다 들어오면 install 통과' 라는 보장이 자연스럽게 따라와요.

네트워크가 돌아오면 다시 보내기

오프라인에서 폼을 제출했는데 네트워크가 끊겨 있던 적 있나요? Background Sync API 가 그 자리예요.

"The Background Synchronization API allows web applications to defer server synchronization work to their service worker to handle at a later time, if the device is offline." - MDN

페이지가 sync 작업을 등록만 해두면, 네트워크가 돌아온 시점에 SW 가 그걸 받아서 처리해요. 그 사이 사용자가 탭을 닫아도 SW 는 백그라운드에 살아있어요.

// 페이지의 폼 핸들러
async function submitMessage(data) {
  await saveToOutbox(data); // IndexedDB 같은 곳에 임시 저장
  const reg = await navigator.serviceWorker.ready;
  await reg.sync.register('send-messages');
}
 
// sw.js
self.addEventListener('sync', (event) => {
  if (event.tag === 'send-messages') {
    event.waitUntil(sendOutboxMessages());
  }
});

탭이 닫혀도 SW 가 살아있다는 정체성이 여기서도 그대로 적용돼요. Web Worker 라면 페이지가 닫히는 순간 같이 사라져서 이 자리를 못 만들어요. 페이지 바깥에 살고 있는 워커라야 가능한 자리예요.


Web Worker 글 끝에 이런 정리가 있었어요. '백그라운드 계산은 Web Worker, 네트워크 프록시는 Service Worker'. 두 워커가 이름은 비슷해도 사는 곳이 달랐던 거고요. Service Worker 는 페이지와 네트워크 사이에 앉아 fetch 를 가로채는 프록시예요. 그 정체성에서 라이프사이클의 기다림도, 캐시 청소의 책임도, 미리 받아두기와 나중에 다시 보내기도 같이 따라와요. '왜 새 SW 가 즉시 적용 안 되지?' 도, '분명 배포했는데 왜 옛 자산이 남아 있지?' 도 그 자리를 알면 풀려요.

참고 자료

관련 글