웹 애플리케이션에서 중요하게 여겨지는 점 중 하나는 네트워크 최적화입니다. 네트워크와 관련해서 다양한 요청을 캐싱하고, 캐싱한 데이터를 적절한 타이밍에 업데이트하고, 인터넷 연결이 불안정한 상황에서도 최소한의 확실한 동작을 보장하는 것이 중요한 문제입니다. AXIOS, Tanstack Query 등 네트워크와 관련한 다양한 라이브러리가 인기있는 이유이기도 합니다. 이번에는 백그라운드에서 네트워크 요청을 다루며, 다양하게 활용할 수 있는 Service Worekr에 대해 다루어보겠습니다.
Service Worekr
Servie Workeer는 다른 Worker들과 마찬가지로 메인 쓰레드가 아닌 별도의 쓰레드에서 관리되는 Worker입니다. 특히, 네트워크 요청 가로채기(intercept), 요청 캐싱 등을 처리하는 데에 최적화 되어있습니다. AXIOS나 Tanstack Query를 써본 신 분들은 아시겠지만 요청 가로채기나 캐싱 등을 메인쓰레드에서 처리하는 라이브러리가 있습니다. Service Worker의 장점은 방금 언급한 비슷한 작업을 백그라운드 쓰레드에서 처리하여 메인 쓰레드의 부담을 줄여주는 것에 있습니다.
사실 Service Worker 메인 쓰레드에서 직접 네트워크를 처리하고 캐싱을 하는 것보다 더 복잡하고 어렵기 때문에 흔하게 사용되는 방식은 아닙니다. 특히, 일반적으로 자바스크립트에서 네트워크 요청은 비동기로 처리되기 때문에 굳이 백그라운드 쓰레드까지 활용할 필요성이 적기도 하고, SSR같은 렌더링 방식은 서버에서 대부분의 HTML을 완성해서 주기 때문에 필요성이 더 적어집니다. 게다가 브라우저 자체 캐싱도 있기 때문에 Service Worker를 사용하였을 때의 이점이 작은 경우도 많습니다.
그러나 특정한 상황에는 빛을 발하는 도구가 됩니다. 먼저 메인 쓰레드도 할 수 있지만 Service Worker가 조금 더 효율적으로 하는 작업이 있습니다. 바로 캐싱인데요. Tanstack Query 등을 이용하거나 직접 캐싱 로직을 구현한다면 메인 쓰레드가 캐싱된 데이터를 관리하게 됩니다. 더 자세하게는 현재 캐시된 데이터가 최신인지 확인하고, 필요한 경우에만 새로 요청을 보내는 방식으로 캐싱이 동작합니다.
반면 Service Worker가 캐싱을 관리하게 하면 메인 쓰레드는 모든 요청을 단순히 fetching
하기만 하면 됩니다. 이후는 애플리케이션이 아닌 브라우저의 네트워크 스택에에서 독립적으로 동작하는 Service Woerker가 캐싱을 처리할 수 있습니다. origin이 같은 모든 탭에 대해서 작동하며 해당 네트워크 요청을 가로채서 이전에 Tanstack Query가 하던 일을 하는 것입니다. origin이 같은 모든 탭이 같은 캐시를 사용할 수 있습니다.
그리고 Service Worker를 사용해야만 가능한 기능들이 있습니다. 브라우저가 제공하는 푸시 알림을 사용하기 위해서 Service Worker는 필수 요소입니다. 알림을 받기 위해서는 Service Worker가 존재해야만 합니다. 웹 애플리케이션이 열려있지 않아도 푸시 알림을 받으려면 반드시 백그라운드 프로세스로 Service Worker를 가지고 있어야 합니다.
인터넷이 끊겼을 때, Service Worker만이 가능한 일이 더 있습니다. 만약 유저가 오프라인 상황에서 이런 저런 작업을 하다가 앱을 종료하면 그동안의 작업은 서버에 도달할 수 없습니다. 하지만 Service Worker가 있다면 백그라운드에 상주하면서 다시 온라인 상태가 되었을 때 그동안의 작업을 서버와 동기화하도록 할 수 있습니다. 물론 브라우저 종료에도 대응하려면 localStorage 등을 이용해야겠지만요. 중요한 점은 이 작업은 유저가 다시 우리의 웹사이트에 접속하지 않아도 백그라운드에서 실행된다는 것입니다.
사용법
Service Worekr는 Shared Worker와 마찬가지로 동일한 출처(origin)에서 실행되는 네트워크 요청에 대해서만 반응할 수 있습니다. 여기에 한가지 제약이 더 존재하는데요. Service Worker는 HTTPS 환경에서만 동작한다는 점입니다. 이는 보안과 관련된 중요한 제약입니다. Service Worker는 사용자의 데이터를 처리하고 네트워크 요청을 가로채며, 캐싱을 관리하는 역할을 하기 때문에, 안전하지 않은 환경에서 이 기능이 악용될 수 있습니다. 따라서 Service Worker는 HTTPS 프로토콜을 사용하는 페이지에서만 활성화될 수 있습니다. 물론 localhost에서는 Service Worker가 동작할 수 있습니다.
예제
다음은 Service Worker를 사용하여 데이터를 캐싱하는 방법입니다. 랜덤한 여우 사진을 응답으로 보내 주는 open api에 대한 요청을 가로채서 데이터를 캐싱합니다. 데이터 캐싱 시간은 3초입니다.
// main.js
// 브라우저에서 Service Worker 지원 여부 확인
if ("serviceWorker" in navigator) {
window.addEventListener("DOMContentLoaded", () => {
navigator.serviceWorker
.register("/serviceWorker.js", { type: "module" })
.then((registration) => {
console.log("Service Worker 등록 성공:", registration);
// ServiceWorker가 완전히 활성화 된 후 웹 페이지가 작동하도록 함
init();
})
.catch((error) => {
console.log("Service Worker 등록 실패:", error);
});
});
} else {
console.log("Service Worker는 이 브라우저에서 지원되지 않습니다.");
}
const init = () => {
const li = document.getElementById("images")!;
document.getElementById("offlineButton")!.addEventListener("click", () => {
fetch("https://randomfox.ca/floof/")
.then((response) => response.json())
.then((data) => {
const newImg = document.createElement("img");
newImg.src = data.image;
const newLi = document.createElement("li");
newLi.appendChild(newImg);
li.appendChild(newImg);
})
.catch((err) => {
console.log("네트워크 요청 실패:", err);
});
});
main.js
는 Service Worker
를 등록합니다. 각 요청에 대한 응답이 오면 새로운 사진을 추가하게 되는 코드도 입습니다. 여기서 주의해야 할 점은 Service Worker
는 비동기로 등록되므로 다른 코드를 실행하기 전 Service Worker
가 등록됨을 보장해주어야 Service Worker
가 작동하지 않는 상황을 없앨 수 있습니다. 등록된 Service Worker
는 개발자 도구(F12)의 Application - Service workers 항목에서 확인할 수 있습니다.
/// serviceWorker.ts
declare const self: ServiceWorkerGlobalScope;
export {};
const CACHE_NAME = "my-cache-v1";
const urlsToCache = [
"https://randomfox.ca/floof/", // 캐시할 URL들
];
const TTL_TIME = 3 * 1000;
const ttlObj = {
[urlsToCache[0]]: 0,
};
// 설치 이벤트: 캐시할 URL들을 캐시
self.addEventListener("install", (event: ExtendableEvent) => {
console.log("서비스 워커 설치 중...");
event.waitUntil(
caches.open(CACHE_NAME).then((cache) => {
return cache.addAll(urlsToCache); // 캐시 저장
})
);
});
self.addEventListener("activate", (event: ExtendableEvent) => {
event.waitUntil(Promise.resolve()); // 비어있는 Promise를 반환해서 활성화 완료 처리
});
// fetch 이벤트: 캐시에서 응답을 반환하거나, 네트워크 요청 후 캐시 저장
self.addEventListener("fetch", (event: FetchEvent) => {
event.respondWith(
caches.match(event.request).then((cachedResponse) => {
console.log("fetch 이벤트 발생:", event.request.url);
if (cachedResponse) {
const currentTime = Date.now();
if (currentTime - ttlObj[event.request.url] > TTL_TIME) {
console.log("캐시 만료, 삭제 후 새로 요청", event.request.url);
caches.delete(event.request.url);
} else {
console.log("캐시에서 응답 반환:", event.request.url);
return cachedResponse;
}
}
// 네트워크 요청을 통해 응답을 받아 캐시 저장
return fetchAndCache(event.request);
})
);
});
async function fetchAndCache(request: Request) {
const networkResponse = await fetch(request);
if (networkResponse && urlsToCache.includes(request.url)) {
console.log("네트워크에서 응답 받아 캐시 저장:", request.url);
const clonedResponse = networkResponse.clone();
caches.open(CACHE_NAME).then((cache) => {
ttlObj[request.url] = Date.now();
cache.put(request, clonedResponse);
});
} else {
console.log("네트워크 응답이 없거나 캐싱하는 URL이 아님");
}
return networkResponse;
}
CACHE_NAME
은 Service Worker
가 어떤 이름으로 캐시를 저장할 지 정하기 위한 변수입니다. 이렇게 정한 이름은 개발자 도구의 Application - Cashe storage에서 확인할 수 있고 해당 이름으로 저장된 데이터 또한 여기서 확인할 수 있습니다.
urlsToCache
는 Service Worker
를 등록하는 과정에서 각 캐시 이름에 대해 어떤 url에 대한 데이터를 캐싱할 것인지에 대한 정보를 주기 위해 필요합니다. 아래의 TTL
은 캐싱되는 시간을 정하게 됩니다.
Service Worker의 이벤트
Installing(설치중)
새로운 서비스 워커를 등록하는 경우 발생하는 이벤트입니다. 예시 코드에선 main.js
에 의해 등록이 시작되었습니다.
일반적으로 캐시를 등록하고 초기화 작업을 수행하는데, 모든 작업이 완료된 뒤 설치중 이벤트가 끝날 수 있도록 event.waitUntil()
를 사용하게 됩니다.
activate(활성화)
서비스 워커가 설치된 후, 활성화되기 전의 상태입니다. 이 단계에서 기존 캐시를 정리하거나, 서비스 워커의 업데이트 작업을 할 수 있습니다. 기존에 등록되었던 서비스 워커를 삭제하고 새로운버전의 서비스 워커를 등록하는 등의 작업이 여기서 발생합니다. activate 이벤트도 event.waitUntil()
을 사용하여 비동기 작업이 완료될 때까지 대기할 수 있습니다.
다음의 코드는 Service Worker가 새롭게 활성화 될 때 일부 캐시는 그대로 사용하고 나머지 캐시는 삭제하는 코드입니다.
self.addEventListener("activate", (event: ExtendableEvent) => {
const cacheWhitelist = [CACHE_NAME];
event.waitUntil(
caches.keys().then((cacheNames) => {
return Promise.all(
cacheNames.map((cacheName) => {
console.log("캐시 이름:", cacheName);
if (!cacheWhitelist.includes(cacheName)) {
console.log("이전 캐시 삭제:", cacheName);
return caches.delete(cacheName);
}
})
);
})
);
});
fetch
서비스 워커가 활성화되고 난 후에, 네트워크 요청이 발생한 경우 시작되는 이벤트입니다. 네트워크 요청을 가로채서 캐시에서 응답을 반환하거나 네트워크 요청을 처리할 수 있습니다. 이 단계에서 캐시된 데이터를 반환하거나, 네트워크에서 데이터를 요청하고 응답을 캐시에 저장하는 등의 작업을 할 수 있습니다. 예시에서는 캐싱된 데이터를 저장하고 관리하고 있습니다.
이 이벤트가 반환하는 값에 따라 기존 fetch
가 어떻게 이어질 지 결정됩니다. 만약 event.request
를 반환한다면 메인 쓰레드의 request
가 계속 진행됩니다. 따라서 서버에 요청이 전달되게 됩니다. 반면 cachedResponse
와 같이 기존에 저장해 두었던 응답 객체를 반환하면 네트워크 요청은 일어나지 않습니다.
만약 새로운 응답 객체를 만들면 어떻게 될까요?
// ... fetch 이벤트 내부
if (event.request.url === "https://서비스워커응답답") {
event.respondWith(
new Response(
JSON.stringify({
message: "서비스 워커가 만들어서 준 응답, 서버는 없어도 됩니다.",
})
)
);
}
// ...
이제 새롭게 만들어진 응답 객체가 메인 쓰레드에서 호출된 fetch
의 반환값이 됩니다. 테스트 환경에서 널리 사용되는 MSW(Mock Service Worker) 라이브러리가 이런 방식으로 작동합니다. 캐시된 객체나 기존 요청을 반환하지 않고 원하는 응답을 돌려 줄수도 있기 때문입니다.
원래는 이 방식으로 사용하는 것에 대해 다루려 했지만 카카오 기술 블로그에 좋은 글이 있어 해당 글을 공유하는 것으로 대신하겠습니다. 서비스 워커에 대해 알아보고 Mock Response 만들기
주의점
이번 글을 작성하면서 Service Worker가 강력 새로고침(hard refresh)에서 이상하게 반응한다는 것을 알았습니다. 강력 새로고침의 상황에선 Service Worker가 올바르게 활성화 되지 않았습니다. 개발자 도구에서조차도 정상적으로 작동한다고 나오지만 실제로 Service Worker는 전혀 작동하지 않는 상태였습니다. 참고
다음은 스택 오버플로우 답변을 참고하여 웹 페이지가 로딩된 후 Service Worker가 없다면 새로 등록하도록 하는 코드입니다.
navigator.serviceWorker.getRegistration().then(function (reg) {
// There's an active SW, but no controller for this tab.
if (reg?.active && !navigator.serviceWorker.controller) {
// Perform a soft reload to load everything from the SW and get
// a consistent set of resources.
window.location.reload();
}
});
참고 자료
chrome for developers,서비스 워커 개요
kakao FE 기술블로그, 서비스 워커에 대해 알아보고 Mock Response 만들기
stackoverflow, Register service worker after hard refresh
'개발 > JavaScript' 카테고리의 다른 글
워커를 더 효율적으로 사용하는 워커풀(Worker Pool) 개발기 (0) | 2025.04.13 |
---|---|
자바스크립트와 멀티 쓰레드 3 - 다른 형태의 워커(사실 의미가 적은 글) (0) | 2025.02.23 |
자바스크립트와 멀티 쓰레드 2 - Shared Worker로 여러 탭과 창의 데이터를 공유하기(+추상화하여 메모리 관리하기) (0) | 2025.01.28 |
자바스크립트와 멀티 쓰레드 1 - 자바스크립트가 싱글 쓰레드라고 불리는 이유와 Worker로 여러 개의 쓰레드 사용하기 (0) | 2025.01.11 |
자바스크립트 await는 이벤트 루프 내에서 어떻게 동작할까 (0) | 2024.10.23 |