[번역] 우리는 왜 서비스 워커를 사용하지 않을까요?

Chanhee Kim·2026년 7월 1일

FE 글 번역

목록 보기
36/36
post-thumbnail

원문: Why are we not using Service Workers?

지난 몇 달 동안 바르셀로나, 파리, 클루지, 런던, 코임브라, 취리히에서 열린 컨퍼런스와 미트업에서 규모가 큰 기업의 개발자들에게 질문을 던졌습니다. "여러분 팀은 서비스 워커(Service Worker)를 어떻게 활용하고 계시나요?"

수백만 명의 사용자가 사용하는 앱을 개발하는 프론트엔드 리드, 스태프 엔지니어, 앱 개발자들과 이야기를 나눴습니다.

압도적인 대답은 "사용하지 않는다"였습니다.

이 사실은 저를 꽤나 곤혹스럽게 만들었습니다. 서비스 워커 API는 2018년부터 모든 주요 브라우저에서 사용할 수 있었고, 저 역시 직접 사용해 보며 그 이점을 똑똑히 체감했기 때문입니다.

구글, 마이크로소프트, 캔바(Canva) 같은 대기업들은 서비스 워커를 활발하게 사용하지만, 이는 그들이 만드는 제품의 특성 때문이기도 합니다.

그래서 저는 왜 중소기업들이 서비스 워커를 사용하지 않는지, 특히 제품에 꼭 필요한 상황에서도 왜 도입하지 않는지 알아보고 싶었습니다.

가장 유력한 가설은 서비스 워커가 제공하는 이점을 제대로 이해하지 못하고 있기 때문이라는 것입니다. 그렇다면 서비스 워커가 무엇인지, 여러분의 비즈니스에 어떤 이점을 주는지, 그리고 다른 대기업들은 프로덕션 환경에서 이를 어떻게 활용하고 있는지 함께 살펴보겠습니다.

서비스 워커란 정확히 무엇일까요?

서비스 워커는 브라우저가 웹 페이지와는 별개의 스레드에서 실행하는 자바스크립트 파일입니다.

등록은 단 한 번만 해두면 됩니다.

if ('serviceWorker' in navigator) {
  navigator.serviceWorker.register('/sw.js');
}

이 시점부터 서비스 워커는 애플리케이션과 네트워크 사이의 길목에 자리를 잡습니다. 페이지가 요청하는 모든 리소스(스크립트, 스타일시트, API 호출, 이미지 등)는 서비스 워커의 fetch 핸들러를 거쳐 가게 되며, 이 핸들러가 무엇으로 응답할지 결정합니다. 실제 네트워크 응답을 그대로 전달할 수도 있고, 캐시된 복사본을 내어줄 수도 있으며, 그 자리에서 직접 만들어낸 응답을 내려줄 수도 있습니다.

self.addEventListener('fetch', (event) => {
  event.respondWith(
    caches.match(event.request).then((hit) => hit || fetch(event.request))
  );
});

이 세 가지 특징이 서비스 워커를 독특하게 만듭니다.

  • 네트워크 경로 상에 위치합니다.
    서비스 워커를 거치지 않고서는 그 어떤 요청도 페이지를 빠져나갈 수 없습니다. 즉, 애플리케이션 코드는 아무것도 모르는 상태에서 서비스 워커가 요청을 재작성하거나, 응답을 새로 생성하거나, 헤더를 추가하거나, 디스크 캐시를 통해 응답할 수 있습니다.
  • 페이지보다 오래 살아남습니다.
    브라우저는 탭이 닫힌 후에도 서비스 워커를 깨울 수 있습니다. 푸시 알림(push notifications)과 백그라운드 동기화(background sync)가 오직 서비스 워커를 통해서만 가능한 이유가 바로 이 때문입니다. 사용자가 떠난 후 다시 부활할 수 있는 브라우저 컨텍스트는 서비스 워커가 유일합니다.
  • DOM에 접근할 수 없습니다.
    서비스 워커는 웹 페이지와 postMessage를 주고받거나 응답을 제공하는 방식으로만 소통합니다. 또한 서비스 워커는 페이지와 독립된 자체 라이프사이클을 가집니다. 설치(install)되고, 대기(wait)하고, 활성화(activate)되며, 브라우저가 필요 없다고 판단하면 언제든 종료(terminate)됩니다. 오직 Cache API나 IndexedDB에 명시적으로 보존한 데이터만 유지됩니다.

한마디로, 애플리케이션보다 수명이 긴 프록시(Proxy)라고 생각하면 됩니다.

유스케이스 1: 초기 로딩 성능 및 오프라인 지원

먼저 슬랙(Slack)의 이야기로 시작해 보겠습니다.

2019년 당시 슬랙 웹 클라이언트는 워크스페이스가 한두 개뿐인 사용자 기준으로도 로딩(boot)하는 데 약 5초가 걸렸습니다.

그 원인을 분석한 결과, 네트워크가 지연 시간(latency)과 변동성(variability)의 가장 큰 주범이었습니다. 매번 앱을 실행할 때마다 에셋을 새로 불러왔고, 접속 상태에 따라 불러오는 시간은 천차만별이었습니다.

하지만 이 에셋 모음 중 앱을 켤 때마다 바뀌는 부분은 거의 없었습니다.

화요일 아침에 슬랙을 켠 사용자는 월요일 아침에 다운로드했던 것과 똑같은 자바스크립트를 다시 다운로드하고 있었습니다. 그래서 슬랙 팀은 이를 개선하기로 했습니다.

첫 로딩 시, 클라이언트는 전체 에셋(HTML, 자바스크립트, CSS, 폰트, 오디오 파일 등)을 다운로드하여 서비스 워커의 Cache API에 저장합니다. 이와 동시에 메모리에 있던 Redux 스토어의 복사본을 IndexedDB에 저장합니다.

다음 로딩 때 클라이언트는 캐시가 존재하는지 확인합니다.

캐시가 존재한다면 캐시된 HTML, 캐시된 번들 파일, IndexedDB에서 복원(hydrate)한 Redux 스토어 등 오직 로컬 데이터만을 기반으로 실행됩니다. 단 하나의 네트워크 요청도 완료되기 전에 화면에 UI가 그려지며, 그 이후 백그라운드에서 최신 데이터를 불러와 캐시 스냅샷을 갱신합니다.

슬랙은 이를 ‘웜 부트(warm boot)’라고 부릅니다. 캐시된 것이 아무것도 없는 첫 방문 상태는 ‘콜드 부트(cold boot)’가 됩니다.

슬랙이 공개한 서비스 워커 핸들러 코드는 당황스러울 정도로 짤막합니다. (물론 프로덕션 코드에는 앱 전용 로직이 더 녹아있겠지만, 뼈대는 동일합니다.)

구현 자체는 꽤나 단순했습니다.

self.addEventListener('fetch', (e) => {
  if (assetManifest.includes(e.request.url)) {
    e.respondWith(
      caches
        .open(cacheKey)
        .then((cache) => cache.match(e.request))
        .then((response) => response || fetch(e.request))
    );
  } else {
    e.respondWith(fetch(e.request));
  }
});

진짜 어려운 부분은 버전 관리(versioning)였습니다. 슬랙은 하루에도 여러 번 배포를 진행하는데, 서비스 워커가 기존 에셋만 제공한다면 사용자는 이전 배포본으로 앱을 로딩하게 되기 때문입니다.

이 문제를 해결하기 위해 슬랙 팀은 3가지 레이어의 해결책을 마련했습니다.

  1. 배포할 때마다 커스텀 Webpack 플러그인이 파일의 콘텐츠 해시가 포함된 에셋 매니페스트를 생성합니다. 이 매니페스트를 서비스 워커 파일 자체에 직접 내장합니다. 이렇게 하면 에셋이 하나라도 바뀌는 즉시 서비스 워커의 바이트 단위 데이터가 변경되고, 브라우저가 서비스 워커의 업데이트 흐름을 자동으로 시작합니다.
  2. 캐시 버킷은 배포 타임스탬프를 키로 사용합니다. 배포 X에서 생성된 HTML 파일은 캐시나 네트워크 중 어느 곳에서 가져오든 오직 버킷 X의 에셋만 로드합니다. 배포 X 버전의 HTML이 배포 Y 버전의 자바스크립트를 로드하는 불일치 현상이 결코 발생하지 않습니다.
  3. 7일이 지난 오래된 캐시 버킷은 서비스 워커의 activate 이벤트 단계에서 일괄 삭제합니다.

또한 정의상 '웜 부트'는 이전 서비스 워커 등록 시점에 가져온 에셋들을 보여주게 됩니다. 슬랙은 매일 여러 번 배포하고 사용자는 보통 아침에 한 번 로그인하므로, 클라이언트가 항상 하루 정도 뒤처진(과거 버전의) 상태로 영구히 머무를 위험이 있었습니다.

이에 슬랙 팀은 앱이 열려 있는 동안 무작위 지터(jitter)가 포함된 주기적인 간격으로 서비스 워커를 재등록(re-register)하도록 조치했습니다. 이렇게 재등록을 유도하면 브라우저가 바이트 단위로 변경된 서비스 워커가 있는지 확인하고, 다음 실행 때 사용할 최신 에셋들을 미리 캐싱(prefetch)하게 됩니다.

이를 통해 로딩 시점에 에셋이 생성된 평균 경과 시간(age)을 절반으로 줄일 수 있었습니다. 게다가 다른 어디에서도 다루는 것을 본 적이 없는 독특한 트릭이 하나 더 있습니다.

슬랙은 보통 새로운 기능 배포와 그에 대응하는 API 변경 작업을 동시에 진행합니다. 따라서 한 버전 뒤처진 프론트엔드가 백엔드와 불일치(desync)할 위험이 있었습니다. 이를 해결하기 위해, 서비스 워커는 에셋과 동일하게 배포 타임스탬프를 키로 하는 버킷에 엄선된 일부 API 응답(피처 플래그, 실험군 할당 등)을 함께 캐싱합니다.

웜 부트를 수행하면 프론트엔드와 플래그 설정이 항상 배포 시점에 짝을 맞춘 형태로 로드됩니다. 잠재적으로 오래된 데이터일 수는 있지만, 항상 내부 일관성을 유지할 수 있으며, 이 일관성이 훨씬 더 중요합니다.

그 결과, 레거시 클라이언트보다 로딩 속도가 대략 50% 빨라졌고, 웜 부트는 콜드 부트에 비해 약 25% 빨랐습니다. 또한 출시 한 달 만에 수백만 개의 설치된 서비스 워커를 통해 하루에 수천만 건의 요청이 처리되기 시작했습니다.

또한, 오프라인 지원이라는 확실한 이점도 곧바로 따라왔습니다. 네트워크 없이도 앱을 시작할 수 있게 되면, 인터넷 연결이 유실되어도 계속해서 동작할 수 있게 됩니다.

슬랙 사용자들은 오프라인 읽기 기능과 메시지를 읽지 않음으로 마크하는 기능을 사용할 수 있게 되었고, 네트워크가 재연결되면 자동으로 동기화가 이루어졌습니다. 서비스 워커를 사용한 덕분에, 사용자의 요청이 가장 많았던 기능 중 하나를 자연스럽게 제공할 수 있게 되었습니다.

유스케이스 2: 무엇이든 수정할 수 있는 프록시

비디오 스트리밍을 예로 들어보겠습니다. 유튜브 같은 곳에서 동영상을 시청할 때 인터넷 상태가 나쁘면 비디오 화질이 자동으로 낮아집니다.

이를 위해 HLS 비디오 스트림은 일반 텍스트 매니페스트 파일에 정의됩니다.

플레이어는 비디오의 모든 가능한 렌디션(해상도, 코덱 등)이 나열된 멀티베리언트(multivariant) 플레이리스트를 다운로드하고, 측정된 대역폭을 기준으로 하나를 골라 해당 렌디션의 세그먼트들을 다운로드하기 시작합니다.

먹스(Mux)의 고객 중 화면 녹화(screencast) 영상을 스트리밍하는 고객사가 있었는데, 어댑티브 비트레이트(adaptive bitrate) 기능이 너무 과하게 작동하는 문제가 있었습니다. 느린 연결 환경에서 시청자 화질이 240p까지 떨어졌던 것입니다. 240px 해상도에서는 화면에 나타난 글씨가 깨져서 읽을 수 없었기 때문에 이는 매우 불만족스러웠습니다. 느린 네트워크 환경의 사용자들도 240px로 화질이 뭉개지는 것보다는 차라리 버퍼링을 조금 더 기다리는 쪽을 원했습니다.

먹스의 자체 플레이어에는 렌디션 필터링 기능이 기본 탑재되어 있었지만, 플레이어를 포크하거나, 고객별로 매니페스트를 재작성하는 서버 측 프록시를 띄우거나, 고객에게 불가능하다고 말하는 등 그들이 고민한 대안들은 모두 적절한 대안이 되지 못했습니다.

대신 먹스 팀은 플레이어 앞단에 서비스 워커를 배치해 단 한 가지 작업만 맡겼습니다. 매니페스트 요청을 가로채서, 플레이어가 읽기 전에 텍스트 내용을 수정하는 것이었습니다.

const MIN_RESOLUTION = 720;

self.addEventListener('fetch', (event) => {
  const url = new URL(event.request.url);
  if (url.hostname === 'stream.mux.com' && url.pathname.endsWith('.m3u8')) {
    event.respondWith(fetchAndFilterPlaylist(event.request));
  }
});

async function fetchAndFilterPlaylist(request) {
  const response = await fetch(request);
  const text = await response.text();
  return new Response(filterPlaylist(text), { headers: response.headers });
}

filterPlaylist 함수는 매니페스트를 줄 단위로 훑으며 720p 미만의 모든 렌디션을 도려내고, 나머지 HLS 태그는 손대지 않고 그대로 둡니다. 플레이어는 저해상도 옵션이 아예 존재하지 않는 플레이리스트를 받기 때문에, 화질을 떨어뜨려야 하는 상황에서도 화질을 떨어뜨릴 수 없습니다.

먹스의 글 내용 중 특히 흥미로웠던 디테일이 있었습니다. Cloudflare Workers 같은 에지 런타임(edge runtime) 역시 브라우저와 동일한 fetch 이벤트 API를 사용하기 때문에, 매니페스트를 수정하는 이 워커 코드를 아무런 수정 없이 그대로 클라우드플레어에 배포하여 정상 작동하는 URL을 얻을 수 있었다는 점입니다.

HTTP를 통해 텍스트로 오가는 모든 데이터는 사용자의 기기에서 실행되는 개발자가 작성한 코드를 통해 전송 중에 실시간으로 재작성될 수 있습니다.

필자의 유스케이스: 배포만 하면 깨지던 지연 로딩(Lazy-loaded) 라우트

저 역시 Vite 프로덕션 장애를 겪으며 서비스 워커를 본격적으로 사용하게 되었습니다.

Vite는 빌드 결과물의 파일명에 콘텐츠 해시를 붙여 고유하게 식별합니다. 예를 들어 지연 로딩되는 라우트 파일의 이름이 Settings-a3f8b2.js라고 해봅시다. 배포를 새로 하면 이 파일은 Settings-c91d44.js로 바뀌고, CDN에서 기존 파일은 사라집니다.

자, 배포 전에 앱을 열어둔 사용자가 있다고 상상해 봅시다. 그들의 index.html과 메인 번들은 여전히 이전 해시들을 가리키고 있습니다.

오전 내내 작업하던 사용자가 세션 진행 중 어느 시점에 처음으로 '설정' 메뉴를 클릭합니다. 브라우저는 Settings-a3f8b2.js를 요청하겠으나, CDN은 이미 404를 반환할 것이고, 결국 다이내믹 임포트가 에러를 발생시킵니다. (Sentry에서 흔히 보는 Failed to fetch dynamically imported module 에러가 바로 이것입니다.)

에러 화면이 노출됩니다. 점심시간 동안 탭을 열어둔 것 말고는 아무런 잘못도 없는 사용자에게 말이죠.

우리의 첫 대처는 아주 뻔했습니다. Vite는 프리로드 실패 시 이벤트를 발생시키므로, 이를 가로채서 페이지를 새로고침했습니다.

window.addEventListener('vite:preloadError', () => {
  window.location.reload();
});

문제는 해결되었지만 UX는 끔찍했습니다. 페이지를 이동할 때마다 새로고침을 겪고 싶어 하는 사용자는 없으니까요.

게다가 사용자의 index.html이 전달되는 경로 중 어디서든(브라우저 캐시, 아직 갱신되지 않은 CDN 에지, 잘못 설정된 Cache-Control 헤더 등 무엇이든 좋습니다) 캐싱되어 있었다면, 새로고침을 해도 똑같이 만료된 HTML을 불러오고, 지워진 파일 코드를 다시 요청하여 에러가 발생하고, 또다시 새로고침이 발생하는 무한 새로고침 루프에 빠지게 됩니다.

두 번째 임시방편은 세션당 단 한 번만 새로고침을 허용하도록 안전장치를 거는 것이었습니다.

window.addEventListener('vite:preloadError', () => {
  if (sessionStorage.getItem('chunk-reloaded')) return;
  sessionStorage.setItem('chunk-reloaded', '1');
  window.location.reload();
});

루프는 면했지만, 그 외의 모든 것은 여전히 엉망이었습니다.

우리는 단지 겉으로 드러난 증상만 고치고 있었을 뿐입니다. 근본적인 문제는 무언가 터지기 전까지는 클라이언트가 새로운 버전이 나왔는지 알 길이 없었고, 배포하는 순간 기존 청크가 사라져 버린다는 점이었습니다.

우리가 이것이 하나가 아닌 두 개의 개별적인 문제라는 것을 깨닫기까지 부끄럽게도 꽤 많은 시간이 걸렸습니다.

  • 문제 1: 구버전을 쓰고 있는 사용자는 세션이 유지되는 동안 예전 청크 파일이 계속 존재해야 한다.
  • 문제 2: 사용자는 배포 완료 시점으로부터 그리 늦지 않게 에러 없이 신버전으로 조용히 전환(migrate)되어야 한다.

서비스 워커는 이 두 문제를 한 번에 해결할 수 있습니다. 브라우저에서 사라진 파일을 계속 쥐고 있을 수 있는 유일한 공간이자, 백그라운드에서 신규 버전을 감시할 수 있는 유일한 프로세스이기 때문입니다.

이제 우리는 배포 시 빌드 매니페스트를 읽는 간단한 Vite 플러그인을 사용하여 번들 바로 옆에 version.json 파일을 작성합니다.

{
  "version": "2026.06.04-1412",
  "assets": ["/assets/index-c91d44.js", "/assets/Settings-c91d44.js"]
}

버전은 해시 대신 빌드 타임스탬프를 사용하며, 이는 주로 디버깅을 위함입니다. 워커는 '더 최신 버전인가'가 아니라 단순히 두 버전이 '일치하지 않는가'로 비교합니다. 따라서 이전 빌드로 롤백하더라도 일반적인 배포와 마찬가지로 업데이트가 정상 작동합니다.

서비스 워커는 이 파일의 상태를 감시합니다. cache: 'no-store'를 달아 200바이트 남짓의 작은 JSON 파일을 폴링(polling)하는 비용은 무시할 수 있을 정도로 저렴하며, 이미 네트워크 경로 상에 상주하고 있는 서비스 워커가 이 작업을 하기에 가장 적합합니다.

let currentVersion = null;

async function checkVersion() {
  const res = await fetch('/version.json', { cache: 'no-store' });
  const { version, assets } = await res.json();

  if (version === currentVersion) return;

  // 신규 배포 소식을 클라이언트에 알리기 전에 에셋을 전부 미리 캐싱해 둡니다
  const cache = await caches.open(`app-${version}`);
  await cache.addAll(assets);
  currentVersion = version;

  const clients = await self.clients.matchAll();
  for (const client of clients) {
    client.postMessage({ type: 'NEW_VERSION', version });
  }
}

self.addEventListener('message', (event) => {
  if (event.data.type === 'CHECK_VERSION') checkVersion();
});

서비스 워커는 유휴 상태일 때 브라우저에 의해 자동 종료될 수 있기 때문에 서비스 워커 내부의 setInterval 만으로는 안전하게 폴링을 유지할 수 없습니다.

대신 웹 페이지가 주체가 되어 주기적으로, 그리고 visibilitychange 이벤트(예: 주말이 지나 월요일에 브라우저 탭을 다시 활성화했을 때 등)가 일어날 때마다 CHECK_VERSION 메시지를 서비스 워커로 던져 즉시 폴링하도록 지시합니다.

첫 번째 문제(구버전 사용자의 세션 보장)를 해결하는 핵심은 서비스 워커의 fetch 핸들러입니다.

해시된 에셋은 캐시 우선(cache-first)으로 제공되고, 이전 캐시 버킷은 더 이상 이를 필요로 하는 클라이언트가 없을 때까지 살아남습니다.

self.addEventListener('fetch', (event) => {
  const url = new URL(event.request.url);
  if (url.pathname.startsWith('/assets/')) {
    event.respondWith(
      caches.match(event.request).then((hit) => hit || fetch(event.request))
    );
  }
});

캐시 이름 없이 caches.match를 호출하면 구버전과 신버전의 모든 버킷을 뒤집니다. 따라서 세션 진행 중에 '설정'을 클릭한 사용자는 설령 CDN에서 몇 분 전에 삭제된 파일일지라도 서비스 워커 캐시로부터 Settings-a3f8b2.js를 온전히 제공받을 수 있습니다.

워커가 유일하게 살아남은 파일 사본을 보관하고 있다가 네트워크를 거치지 않고 직접 제공하기 때문에, 이 모든 소동의 원인이었던 404 에러는 더 이상 발생하지 않습니다.

두 번째 문제(사용자의 자연스러운 버전 전환)는 앱 내부에서 다음과 같이 처리합니다. 서비스 워커로부터 배포 메시지를 받으면 백그라운드에서 신버전 준비가 완료되었음을 마크업해 둡니다.

let updateReady = false;

navigator.serviceWorker.addEventListener('message', (event) => {
  if (event.data.type === 'NEW_VERSION') updateReady = true;
});

// 사용자가 라우트를 이동할 때마다 호출되는 함수
function onNavigate() {
  if (updateReady) window.location.reload();
}

새로고침은 사용자가 이미 화면이 전환될 것을 예상하고 있고, 작성 중인 양식이 날아갈 걱정이 없는 라우트 변경 시점에 일어납니다.

새로고침은 새로운 index.html을 가져오고, 이 HTML은 서비스 워커가 이미 캐시해 둔 에셋들을 가리키므로 새로고침은 거의 전적으로 로컬 디스크에서 처리됩니다.

이 방식의 효과를 온전히 보려면 한 가지 조건이 필요합니다. index.html 파일 자체가 Cache-Control: no-cache로 서비스되어야 합니다. Vite 공식 문서에서도 단순 새로고침 방식에 대해 똑같이 명시하고 있으며, 우리가 이전에 겪었던 무한 새로고침 루프가 바로 이 규칙을 무시한 대가였습니다.

설령 일부 CDN 에지가 만료된 HTML을 내려주더라도 사용자는 한 사이클 더 안정적으로 이전 버전에 머무르게 되며, 서비스 워커 캐시에 이전 청크들이 남아 있어 아무것도 고장 나지 않습니다.

마지막 보루로 마련해 둔 vite:preloadError 핸들러는 아직 남아있지만, 그 이후로 단 한 번도 실행된 적이 없습니다.

어디서 많이 본 듯한 흐름이라면 맞습니다. 슬랙의 배포 타임스탬프 기반 캐시 버킷 디자인을 그대로 착안해 만든 시스템입니다.

구버전 세션에는 예전 에셋이 안전하게 서빙되어야 하고, 신버전 세션에는 새로운 에셋이 공급되어야 하며, 클라이언트들은 어떤 장애도 겪지 않고 점진적으로 최신 릴리즈에 도달해야 합니다.

배포를 거쳐 영속성을 유지하면서 애플리케이션과 네트워크 사이에 존재할 수 있는 브라우저 내 유일한 주체이기 때문입니다.

그런데 왜 아무도 서비스 워커를 안 쓸까요?

설문 조사를 마친 뒤, 저는 "왜 안 쓰시나요?"라는 후속 질문을 던지기 시작했습니다.

첫 번째 대답은 "쓸 필요가 없어서"(진짜 필요 없는 경우도 있었을 것입니다)였습니다.

대부분의 시니어 엔지니어가 내놓은 두 번째 대답은 "과거에 서비스 워커 때문에 피를 많이 봤고, 굳이 고생할 만큼 가치가 없다"였습니다.

구체적으로 서비스 워커의 라이프사이클(Lifecycle)이 원인입니다.

기본적으로 서비스 워커를 등록한 페이지는 다음 페이지 이동 전까지는 워커의 제어를 받지 못하며, clients.claim()을 사용하더라도 워커 활성화 전에 페이지가 이미 발생시킨 요청들까지 소급해서 가로챌 수는 없습니다.

서비스 워커를 건드려 본 사람이라면 누구나 새로고침을 12번씩 해도 워커가 waiting 상태에 박혀 꼼짝하지 않던 경험이나, 구버전 기준으로 동작 중인 페이지 아래에서 skipWaiting이 새 워커를 활성화해 버리는 바람에 발생한 일 등이 좋겠네요.

먹스(Mux) 역시 데모를 만들면서 이 문제에 부딪혔습니다. 비디오 플레이어는 마운트되는 즉시 데이터를 가져오기 시작하는데, 같은 페이지의 서비스 워커가 활성화되어 통제권을 쥐기 전에 요청이 출발해 버리는 현상입니다. 그래서 먹스는 인덱스 페이지에서 먼저 서비스 워커를 등록해 두고 플레이어 페이지로 링크를 걸어 넘어가는 방식을 취해야 했습니다.

또한 먹스의 글은 또 다른 사소한 문제도 언급합니다. 등록 스코프(registration scope)를 /resolution-filtering/로 설정하면 잘 되지만 /resolution-filtering로 설정하면 작동하지 않는 문제였는데, 왜 그런지 설명해 주는 곳이 아무 데도 없었습니다.

모두가 알고 있는 캐시 참사담(horror story)

설문 조사 중 "2019년에 한 번 시도했다가 걷어냈다"고 한 두 사람은 세부 묘사만 다를 뿐 똑같은 이야기를 들려주었습니다. 캐싱 전략을 잘못 설계한 서비스 워커가 사용자에게 만료된 앱을 계속 서빙했고, 이를 고치기 위해 모든 기능을 차단하는 '킬스위치(killswitch)' 워커를 새로 배포한 뒤 모든 클라이언트가 이를 받아 갈 때까지 며칠을 꼬박 기다려야 했다는 것입니다. 그 망가진 워커가 업데이트 확인 여부 자체를 가로쥐고 있었기 때문입니다.

이러한 두려움은 지극히 타당합니다. 하지만 그렇기에 버전 관리에 더 신경을 써야 합니다.

슬랙의 사례에서 그들이 어디에 공을 들였는지 살펴보세요. fetch 핸들러는 십여 줄에 불과하지만, 버전 관리를 위한 장치들(매니페스트 해싱, 배포 키 기반 버킷, 지터링된 재등록, 7일 간격의 캐시 삭제 등)이 그 외의 전부였습니다.

배포를 거치는 과정에서의 캐시 무효화(Cache Invalidation)야말로 진짜 본질적인 문제입니다. 결과가 좋지 않았던 팀들은 그 외의 장치들 없이 달랑 십여 줄짜리 핸들러만 배포했던 팀들입니다.

업계에서 널리 회자되는 명언처럼, 프로그래밍에서 어려운 문제는 딱 두 가지뿐입니다. 이름 짓기(naming things)캐시 무효화(cache invalidation)입니다.

기획(Product) 단계에서 오프라인을 요구한 적이 없었다.

제가 설문 조사한 엔지니어 대다수는 어드민 대시보드, 사내 도구, B2B 웹 앱 등을 개발하고 있었습니다.

어떤 기획서에도 "지하철에서도 작동해야 함" 같은 요구사항은 적혀있지 않습니다. 지극히 타당합니다.

하지만 오프라인이 서비스 워커의 유일한 목적이었던 적은 단 한 번도 없었으며, 이 글에 소개된 다른 사례들이 이를 증명합니다. 필자가 겪은 Vite 배포 오류는 오프라인과 아무런 상관이 없었습니다. 먹스의 비디오 매니페스트 재작성도 오프라인과 무관합니다. 슬랙이 로딩 속도를 50% 단축한 웜 부트 기술은 기가비트 광랜을 쓰는 사무실 한가운데의 사용자에게도 혜택을 줍니다.

오프라인이 필요 없다고 서비스 워커를 기각하는 것은, 프록시가 할 수 있는 여러 일 중 하나가 필요 없다고 프록시 자체를 거부하는 것과 같습니다.

그리고 개인적으로는 여전히 오프라인 지원을 챙겨야 한다고 생각합니다. 늘 사용자를 먼저 생각하세요.

사실 여러분도 알게 모르게 이미 쓰고 있을 수 있습니다.

제가 가장 좋아하는 반례는 파티타운(Partytown)입니다. 이는 구글 애널리틱스, 태그 매니저, 페이스북 픽셀을 메인 스레드 밖으로 이동시키는 Builder.io의 라이브러리입니다.

서드파티 스크립트들은 메인 스레드를 선점하기 위해 애플리케이션과 경쟁하며 여러분의 Core Web Vitals 성능을 파괴합니다.

이 문제를 해결하기 위해 이들을 단순히 웹 워커(Web Worker)에서 실행하는 뻔한 방법은 단 한 가지 구체적인 이유 때문에 실패합니다. 이 스크립트들은 document.title, document.cookie, window.location.href와 같이 DOM을 동기적(sync)으로 끊임없이 읽어 들이며 즉시 반환값을 받기를 기대하는 반면, 워커와 웹 페이지 간의 통신은 기본적으로 비동기(async) 방식으로만 이루어지기 때문입니다.

파티타운의 트릭은 웹 워커가 실행 흐름을 일시적으로 동기식으로 차단(block)할 수 있는 합법적인 방법이 정확히 두 가지뿐이라는 사실에서 출발합니다. 하나는 SharedArrayBuffer에서의 Atomics.wait()와, 우리가 지난 10년 동안 쓰지 않는 법을 배웠던 동기식 XMLHttpRequest API가 그것입니다.

프록시 처리된 가상 DOM을 상대로 실행 중인 워커 내 애널리틱스 스크립트가 실제 DOM 속성값을 필요로 할 때, 파티타운은 해당 요청을 직렬화한 뒤 proxytown으로 끝나는 가짜 URL로 동기식 XHR을 쏩니다. 워커 스레드는 응답이 올 때까지 대기 상태로 차단됩니다.

파티타운의 프로덕션 소스 코드에는 오픈소스 프로젝트 중 제가 가장 좋아하는 주석이 달려 있습니다.

const xhr = new XMLHttpRequest();
xhr.open('POST', partytownLibUrl('proxytown'), false);
xhr.send(JSON.stringify(accessReq));
// 보세요 엄마, 저 지금 동기식으로 일하고 있어요! (•‿•)
return JSON.parse(xhr.responseText);

이 요청은 결코 네트워크로 전송되지 않습니다.

대신 서비스 워커가 이 요청을 가로채 올바른 탭의 메인 스레드에 메시지를 전송합니다. (서비스 워커는 도메인의 모든 탭에 공유되므로 각 요청은 탭 ID를 들고 다닙니다. 대기 중인 요청들은 맵 구조에 보관되며, 응답이 없는 탭 때문에 전체 스레드가 먹통이 되는(hang) 것을 막기 위해 타임아웃 처리도 되어 있습니다.)

만약 여러분의 사이트가 파티타운을 쓰고 있다면, 여러분은 자신도 모르는 사이에 가짜 HTTP를 통해 스레드를 연결(bridge)하는 서비스 워커를 프로덕션에 올려두고 작동시키고 있는 셈입니다.

서비스 워커에 크게 의존하는 또 다른 라이브러리는 Mock Service Worker(MSW)입니다.

테스트를 위해 자바스크립트 생태계에서 매우 널리 사용되는데, 실제 요청을 보내는 대신 MSW가 이를 가로채서 모킹된 JSON 응답을 보냅니다. (제 생각에 2020년에는 모두가 이런 방식으로 E2E 테스트를 만들었던 것 같습니다. 실제로 Glovo에서 일할 때 저희도 그렇게 구현했습니다.)

서비스 워커 개발은 어렵습니다

이제 서비스 워커가 어떤 훌륭한 도움을 주는지 이해하셨을 것입니다.

  • 오프라인 지원
  • 배포 시 에셋 제공 전략
  • 전반적인 로딩 성능 개선

하지만 여전히 무수히 쏟아지는 공식 문서와 보일러플레이트 코드 앞에서 막막함(이 글을 굳이 기술적 가이드가 아닌 개괄적인 소개서 형태로 쓴 이유이기도 합니다)을 느끼실 수 있습니다.

서비스 워커를 구현할 때 올바르게 다루기 어려운 복잡한 인터랙션이 많기 때문입니다.

  • 네트워크 요청 처리
  • 캐싱 전략 설계
  • 수동 캐시 만료 관리
  • 프리캐싱(Precaching) 설정

바로 이때 구글이 제작한 워크박스(Workbox)가 큰 도움이 될 수 있습니다.

워크박스는 일반적인 서비스 워커 라우팅 및 캐싱을 단순화하는 모듈 모음입니다. 제공되는 각 모듈은 서비스 워커 개발의 특정 측면을 다루어, 서비스 워커를 더 쉽게 생성, 관리 및 사용할 수 있도록 해줍니다.

진짜 중요한 것은 서비스 워커가 정확히 무엇을 할 수 있는지 알고, 현재 혹은 미래의 필요에 맞춰 활용할 준비를 해두는 일입니다.

행운을 빕니다.

참고 자료

🚀 한국어로 된 프런트엔드 아티클을 빠르게 받아보고 싶다면 Korean FE Article(https://kofearticle.substack.com/)을 구독해주세요!

profile
FE 개발을 하고 있어요🌱

0개의 댓글