본문으로 건너뛰기
2022. 12. 4·약 18분

Web Worker와 Service Worker는 어떻게 다를까요?

겹쳐 쌓인 창에서 분홍 메시지가 나가고, 톱니가 도는 창에서 결과가 점선을 따라 돌아오는 그림

웹뷰에서 입력한 내용을 다른 웹뷰로 전달하려고 BroadcastChannel을 살펴보고 있었습니다. 서로 다른 JavaScript 실행 환경이 어떻게 메시지를 주고받는지 따라가다 보니 Web Worker와 Service Worker가 함께 등장했습니다.

처음에는 모두 "백그라운드에서 돌아가는 무언가"처럼 보였습니다. 그런데 문서를 분석하는 일, 탭 사이에 변경을 알리는 일, 오프라인에서 화면을 여는 일은 필요한 조건부터 달랐습니다.

어떤 작업을 워커에 맡길 수 있고, 맡긴 뒤에는 무엇을 직접 관리해야 할까요?


이 글에서 다루는 내용​

문서 파싱, 이미지 분석, 대용량 데이터 검증처럼 화면을 멈추게 하는 작업에서 출발합니다. 작업을 다른 실행 환경으로 옮겼을 때 생기는 데이터 전달 비용과 취소 문제를 먼저 다루고, 여러 탭의 통신과 Service Worker의 캐시·업데이트로 이어갑니다.

아래 코드는 파일 검증과 탭 간 통신, 정적 자산 캐싱을 예로 들어 구성했습니다.



실행 환경과 통신 수단의 구분​

브라우저의 메인 스레드에서는 JavaScript 실행과 DOM 변경, 스타일 계산·레이아웃 등 화면을 갱신하는 일이 맞물려 진행됩니다. 긴 동기 연산이 메인 스레드를 오래 붙잡고 있으면 클릭 이벤트의 처리나 다음 화면 갱신도 늦어집니다. 네트워크 응답이 빨리 왔는데도 화면이 굼뜰 수 있는 이유입니다.

Web Worker는 메인 스레드와 별도의 실행 환경에서 JavaScript를 실행합니다. 일반적인 전용 워커(dedicated worker)는 new Worker()로 만들고 postMessage()로 작업과 결과를 주고받습니다. DOM에는 직접 접근할 수 없지만 계산이나 fetch 같은 작업은 수행할 수 있습니다. MDN의 Web Worker 가이드가 이 실행 모델을 설명합니다.

이름에 Worker가 붙었다고 수명이나 사용 목적까지 같지는 않습니다.

도구맡기는 일연결과 수명에서 볼 점
Dedicated Worker특정 화면·작업의 CPU 연산생성한 쪽이 메시지를 보내고 terminate()로 종료할 수 있음
SharedWorker여러 탭이 공유하는 계산·연결같은 오리진에서 스크립트 URL·이름 등을 맞추고 포트로 연결. 탭과 무관한 영구 서버는 아님
Service Worker요청 가로채기, 캐시 응답, 지원 환경의 푸시·동기화등록은 남지만 실행 중인 워커는 브라우저가 종료·재시작할 수 있음
BroadcastChannel탭·웹뷰 등에 변경 사실 전달실행 환경을 만들지 않는 메시지 통신 API

BroadcastChannel을 쓴다고 워커가 생성되지는 않습니다. 메시지를 받은 탭이 무거운 정렬을 실행하면 그 탭의 메인 스레드는 그대로 바빠집니다. 반대로 Dedicated Worker 하나에 계산을 맡겼다고 다른 탭까지 그 결과를 자동으로 공유하는 것도 아닙니다.



메인 스레드에서 옮길 작업 고르기​

비동기 함수 안에서도 멈추는 화면​

파일을 읽은 뒤 데이터를 검증하는 코드를 생각해 보겠습니다.

async function inspectFile(file) {
const text = await file.text();
const rows = JSON.parse(text);
return validateRows(rows);
}
비동기 실행과 다른 스레드에서의 실행

파일을 기다리는 동안에는 다른 일을 할 수 있습니다. 하지만 JSON.parse()와 validateRows()가 실행되는 동안에는 그 함수가 실행 중인 스레드를 사용합니다. async를 붙이거나 Promise.resolve().then(...)으로 감싸도 계산이 다른 스레드로 옮겨지지는 않습니다.

여기서 구분할 것은 서버를 기다리는 시간과 데이터를 처리하는 시간입니다. 서버 응답이 느리다면 요청을 워커로 보내도 서버가 빨라지지 않습니다. 응답 이후 파싱·변환이 길다면 워커로 분리할 후보가 됩니다.

화면에서 하는 일워커로 분리할 후보메인 스레드에 남는 일
PDF 미리보기파싱·디코딩 등 라이브러리가 워커 실행을 지원하는 처리페이지 배치, 입력 UI, 해당 렌더러가 메인에서 수행하는 그리기
이미지·서명 검증픽셀 순회, 내용 영역 계산, 유효성 분석포인터 입력, DOM 상태 반영, 분석용 픽셀 추출 비용
파일 가져오기파싱, 값 정규화, 중복·필수값 검증오류 위치 안내와 사용자의 수정
큰 표의 정렬·필터데이터 순서·포함 여부 계산실제 행 렌더링과 포커스·스크롤 관리
실시간 대시보드이벤트 파싱·집계, 표시할 데이터 계산그래프 갱신과 사용자 조작

표의 행을 가상화하면 DOM 개수는 줄어듭니다. 그러나 전체 데이터에서 중복을 찾는 순회까지 줄어들지는 않습니다. 반대로 검증을 워커에 옮겨도 수만 개 행을 한꺼번에 DOM에 넣는 비용은 남습니다. 어느 단계가 느린지에 따라 두 방법을 함께 적용할 수 있습니다.

보내는 데이터와 돌려받는 데이터​

postMessage()는 보통 구조화된 복제(structured clone) 방식으로 데이터를 전달합니다. 함수나 DOM 노드를 그대로 넘길 수 없으므로 화면 컴포넌트와 작업 함수가 공유하던 값을 메시지에 담을 데이터로 정리해야 합니다.

큰 데이터를 주고받을 때는 복제 비용도 살펴야 합니다. 메인 스레드에서 파일을 전부 파싱하고 큰 객체 배열을 워커에 복제한 뒤 결과 배열 전체를 다시 돌려받으면, 계산을 옮기느라 양쪽에 데이터 사본만 늘어날 수 있습니다.

파일의 바이트부터 워커가 소유하게 하는 방법이 있습니다.

const bytes = await file.arrayBuffer();

worker.postMessage(
{ type: "validate", requestId: 1, bytes },
[bytes],
);

// 전송 이후에는 소유권을 넘겼으므로 원래 버퍼를 다시 읽을 수 없다.
console.log(bytes.byteLength); // 0
전송한 버퍼를 다시 읽을 수 있을까요?

두 번째 인자의 전송 목록(transfer list)은 ArrayBuffer의 소유권을 넘깁니다. 버퍼는 메시지 본문에도 포함해야 수신 측에서 꺼낼 수 있습니다. Uint8Array 같은 뷰를 사용한다면 전송 대상은 보통 그 뷰의 .buffer입니다. 같은 버퍼를 참조하던 다른 뷰도 영향을 받습니다. Transferable 객체 설명에 이 소유권 변경이 정리돼 있습니다.

돌려받는 값도 화면에서 필요한 크기로 제한합니다. 오류가 10만 개라면 우선 전체 오류 건수와 첫 화면에 표시할 일부를 받습니다. 정렬이라면 원본 행 전체를 왕복시키기보다 행 ID나 인덱스 배열만 돌려받는 방법을 검토합니다. 데이터가 수정됐다면 인덱스가 어느 버전의 원본을 가리키는지도 함께 확인해야 합니다.

공유 메모리까지 필요한가​

SharedArrayBuffer는 여러 실행 환경이 같은 메모리를 보게 하는 선택지입니다. 소유권을 한쪽에 넘기는 전송과 달리 동시 접근을 고려해야 하며 필요에 따라 Atomics로 동기화합니다. 사용하려면 교차 출처 격리(cross-origin isolation) 조건과 브라우저 지원을 확인해야 합니다. MDN의 SharedArrayBuffer 설명을 참고할 수 있습니다.

이를 위해 COOP·COEP 헤더를 바꾸면 외부 리소스 로딩과 팝업 연동도 영향을 받을 수 있습니다. 파일을 한 번 넘겨 분석하는 정도라면 우선 전송과 작은 결과 메시지로 충분한지 보겠습니다. 공유 메모리를 도입한 뒤에는 값을 쓰고 읽는 순서까지 디버깅해야 합니다.

Recap​

비동기 함수 안의 동기 연산도 화면을 막을 수 있습니다. 워커로 옮길 때는 파싱 전 원본을 보낼지, 계산된 객체를 보낼지부터 정해야 합니다. 전송할 데이터와 반환할 결과를 줄이면 복제와 메모리 비용을 함께 줄일 수 있습니다.



작업의 시작부터 취소까지​

결과를 기다리는 사이 바뀐 화면​

사용자가 파일 A를 선택하고 곧바로 파일 B를 선택할 수 있습니다. A의 검증이 늦게 끝나면 B를 보고 있는 화면에 A의 오류를 표시하게 됩니다. 워커가 결과를 보냈다는 사실만으로 그 결과가 지금도 유효하다고 판단할 수는 없습니다.

요청에는 requestId를 붙입니다. 편집 중인 데이터라면 datasetVersion도 별도로 둡니다. 요청 ID는 어떤 작업의 응답인지 구분하고 데이터 버전은 어떤 원본을 대상으로 계산했는지 구분합니다. 계정 전환이 있는 화면이라면 해당 세션에 속한 작업인지도 확인해야 합니다.

파일 검증 예제​

다음 예제는 한 번에 파일 하나를 검증하고 새 파일을 고르면 이전 작업을 버리는 화면입니다. 파일마다 워커를 생성하므로 취소 시 다른 작업을 중단시킬 위험이 없습니다. 대신 워커 시작 비용을 매번 지불합니다.

메인 코드의 onState는 화면이 구현하는 상태 수신 함수입니다. done에서는 결과를 표시하고 error에서는 실패 안내와 재시도 버튼을 표시한다고 가정합니다. 예제 파일은 JSON 배열이며 각 행에 문자열 id와 name이 있어야 합니다. 행마다 첫 오류 하나를 기록하므로 errorCount는 오류가 있는 행의 수입니다.

file-validator.js: 메인 스레드
export function createFileValidator(onState, timeoutMs = 30000) {
let currentId = 0;
let active = null;

function stopActive() {
if (!active) return;
clearTimeout(active.timer);
if (active.worker) active.worker.terminate();
active = null;
}

function finish(requestId, state) {
if (requestId !== currentId || !active) return;
stopActive();
onState(state);
}

return {
async validate(file) {
const requestId = ++currentId;
stopActive();
active = {
worker: null,
timer: setTimeout(() => {
finish(requestId, { status: "error", code: "TIMEOUT" });
}, timeoutMs),
};
onState({ status: "running" });

try {
const bytes = await file.arrayBuffer();
// 파일 읽기 중 취소되거나 다른 파일이 선택됐을 수 있다.
if (requestId !== currentId || !active) return;

// 브라우저 간 호환성을 고려해 classic worker로 생성한다.
const worker = new Worker("/workers/validate-file.js");
active.worker = worker;

worker.onmessage = ({ data }) => {
if (data.requestId !== requestId) return;
if (data.type === "result") {
finish(requestId, { status: "done", result: data.result });
} else if (data.type === "error") {
finish(requestId, { status: "error", code: data.code });
}
};
worker.onerror = () => {
finish(requestId, { status: "error", code: "WORKER_ERROR" });
};
worker.onmessageerror = () => {
finish(requestId, { status: "error", code: "MESSAGE_ERROR" });
};
worker.postMessage({ type: "validate", requestId, bytes }, [bytes]);
} catch {
finish(requestId, { status: "error", code: "READ_OR_START_ERROR" });
}
},

cancel() {
++currentId;
stopActive();
onState({ status: "idle" });
},

dispose() {
++currentId;
stopActive();
},
};
}
/workers/validate-file.js: 워커
self.onmessage = ({ data }) => {
if (data.type !== "validate") return;
const { requestId, bytes } = data;

try {
const rows = JSON.parse(new TextDecoder().decode(bytes));
if (!Array.isArray(rows)) throw new Error("Expected an array");

const seen = new Set();
const errors = [];
let errorCount = 0;

for (let index = 0; index < rows.length; index += 1) {
const row = rows[index];
const id = typeof row?.id === "string" ? row.id.trim() : "";
const name = typeof row?.name === "string" ? row.name.trim() : "";
const code = !id || !name
? "REQUIRED_FIELD"
: seen.has(id) ? "DUPLICATE_ID" : null;

if (id) seen.add(id);
if (!code) continue;
errorCount += 1;
// 미리보기는 100개로 제한하고 원본 개인정보는 반환하지 않는다.
if (errors.length < 100) errors.push({ row: index + 1, code });
}

self.postMessage({
type: "result",
requestId,
result: { rowCount: rows.length, errorCount, errors },
});
} catch {
self.postMessage({ type: "error", requestId, code: "VALIDATION_FAILED" });
}
};

30초는 예제의 종료 정책입니다. 실제 제한은 파일 크기와 지원 기기에서 측정해 정합니다. 파일 크기 상한도 읽기 전에 검사해야 합니다. 워커로 옮겨도 파일 바이트, 디코딩한 문자열, 파싱된 객체가 동시에 존재하는 메모리 부담은 남기 때문입니다. 상한을 넘는 파일은 스트리밍 가능한 포맷·파서나 서버 작업으로 처리하는 편이 낫습니다.

화면을 떠날 때는 dispose()를 호출합니다. 컴포넌트 렌더링마다 이 객체를 다시 만들면 워커를 여러 개 생성할 수 있으므로 화면의 수명에 맞춰 한 인스턴스를 유지합니다. File.arrayBuffer() 자체는 이 코드에서 취소되지 않지만 읽기가 끝나도 폐기된 작업을 다시 시작하지는 않습니다.

취소 메시지가 바로 처리되지 않는 이유​

워커 하나를 재사용한다면 cancel 메시지를 보내는 방식이 필요할 수 있습니다. 다만 워커도 긴 동기 루프를 실행하는 동안에는 다음 메시지를 처리하지 못합니다. 취소 메시지는 그 루프가 끝날 때까지 기다립니다.

작업을 작은 묶음으로 나누고 묶음 사이에 이벤트 루프로 제어를 돌려주면 취소 여부를 확인할 기회가 생깁니다. setTimeout 등으로 다음 태스크에 이어서 실행하는 방법을 사용할 수 있습니다.

await를 넣으면 취소 메시지를 받을 수 있을까요?

await Promise.resolve()만 반복하면 마이크로태스크가 계속 이어집니다. 다음 메시지를 처리하는 태스크로 넘어가지 못하므로 취소 요청도 기다리게 됩니다. 계산을 나누는 것과 다음 메시지를 처리할 기회를 주는 것을 함께 고려해야 합니다.

terminate()는 더 단호합니다. 워커를 즉시 종료하므로 내부 finally나 마지막 결과 전송을 기대할 수 없습니다. 따라서 위 예제처럼 메인 스레드가 타이머와 화면 상태를 정리해야 합니다. 워커를 공유하는 설계라면 그 워커를 기다리던 다른 요청도 모두 실패 처리해야 합니다.

Recap​

작업이 끝났어도 현재 화면이 기다리는 결과인지 확인해야 합니다. 요청 ID와 데이터 버전은 이 판단에 쓰이고, 실제 계산 중단은 별도의 취소 정책으로 처리합니다. 워커 종료·시간 초과·화면 이탈에서도 기다리던 UI가 끝없이 로딩하지 않도록 작업을 닫아야 합니다.



여러 탭과 웹뷰의 연결​

BroadcastChannel로 알릴 내용​

한 탭에서 문서를 저장했을 때 다른 탭의 목록도 갱신하고 싶다면 BroadcastChannel로 변경 사실을 알릴 수 있습니다. 워커가 필요한 일은 아닙니다.

const channel = new BroadcastChannel("document-events-v1");

// 저장 API의 성공을 확인한 다음 발행한다.
channel.postMessage({ type: "document-saved", documentId: "doc-42" });

channel.onmessage = ({ data }) => {
if (data.type !== "document-saved") return;
// 화면에 필요한 문서를 현재 세션의 권한으로 다시 조회한다.
};

// 화면에서 더 이상 채널을 사용하지 않을 때 호출한다.
// channel.close();

이벤트에는 "다시 확인할 대상"을 담았습니다. 문서 내용 전체를 브로드캐스트하면 받는 탭마다 큰 데이터를 복제하고, 이미 계정이 전환된 탭에 이전 계정의 내용을 전달할 수도 있습니다. 알림을 받은 쪽이 자신의 세션과 권한으로 서버를 다시 조회하면 상태 복구와 권한 확인을 함께 처리할 수 있습니다.

이 채널에는 과거 메시지를 재생하는 기능이 없습니다. 나중에 열린 탭은 이미 지나간 저장 이벤트를 받지 못하므로 초기 조회가 필요합니다. 이벤트 유실에 대비해 화면 복귀 시에도 상태를 다시 확인할 수 있어야 합니다.

오리진은 스킴·호스트·포트의 조합입니다. 같은 오리진이어도 저장소 파티션이 다르면 통신이 제한될 수 있습니다. 서로 다른 앱 웹뷰에서는 네이티브가 구성한 데이터 저장소와 실제 메시지 왕복도 확인해야 합니다. URL이 같다는 이유로 연결을 가정하면 웹뷰 사이의 통신 문제를 놓치게 됩니다. BroadcastChannel의 통신 범위부터 확인합니다.

SharedWorker로 연결을 공유한다면​

여러 탭이 같은 서버 알림을 각각 구독한다면 연결 수와 중복 처리가 늘어납니다. 지원 환경에서는 SharedWorker 한 곳이 SSE나 WebSocket 연결을 관리하고 각 탭이 MessagePort로 구독을 요청하는 구조를 검토할 수 있습니다.

공유 연결을 만들면 탭마다 필요로 하는 구독 대상을 관리해야 합니다. A가 문서 X를 닫았어도 B가 X를 보고 있다면 서버 구독은 유지해야 합니다. 탭 종료 알림만으로 정리를 보장하기 어려우므로 연결 재수립 때 구독 목록을 복원하고, 필요하면 포트별 생존 확인과 만료 정책을 둡니다.

로그아웃이나 계정 전환도 공유 연결에 전달해야 합니다. 워커가 오리진 단위로 공유된다고 해서 모든 탭의 사용자 상태가 같다는 보장은 없습니다. 인증 정보가 바뀌면 기존 연결과 구독을 정리하고 새 인증 맥락으로 연결하는 절차가 필요합니다. 서버의 권한 검증은 이 절차와 별도로 수행합니다.

단순한 상태 변경 알림이라면 BroadcastChannel과 탭별 조회로 시작하겠습니다. SharedWorker는 연결 수를 실제로 줄일 필요가 있고, 지원 브라우저와 재연결 동작을 운영할 수 있을 때 선택할 만합니다.

쌓이는 이벤트와 화면의 갱신 속도​

공유 연결에서 받은 이벤트를 모두 그대로 탭에 전달하면 메인 스레드에 메시지가 쌓일 수 있습니다. 일반적인 WebSocket API에는 수신 속도에 맞춰 생산자를 자동으로 늦추는 백프레셔(backpressure)가 없습니다. MDN의 WebSocket 설명은 수신 처리량을 넘을 때 메모리와 CPU 부담이 커지는 문제를 짚습니다.

현재 상태를 나타내는 대시보드라면 항목별 최신 값만 모아 일정 간격으로 전달할 수 있습니다. 하지만 "수량을 1 늘림" 같은 증분 이벤트를 중간에서 버리면 값이 틀립니다. 이벤트를 모두 집계한 뒤 화면에 보낼 결과를 합치거나, 누락을 감지했을 때 서버의 전체 상태를 다시 받아야 합니다.

탭이 백그라운드에서 돌아왔을 때도 쌓인 화면 갱신을 전부 재생할 필요는 없습니다. 현재 상태를 다시 조회할지, 서버가 제공하는 이벤트 ID로 재개할지는 데이터의 성격에 따라 정합니다. 연결을 공유하는 설계에는 이 복구 절차까지 포함됩니다.

Recap​

BroadcastChannel은 변경 사실을 알리는 데 사용할 수 있지만 저장소나 재생 가능한 이벤트 로그가 아닙니다. SharedWorker로 연결을 공유하면 구독·인증·재연결 관리를 한곳에 모으는 대신 그 관리 코드를 직접 운영해야 합니다. 이벤트를 합칠 때는 최신 값과 빠짐없이 적용해야 하는 증분을 구분해야 합니다.



Service Worker의 생명 주기와 캐시​

등록이 남아 있다는 의미​

Service Worker는 페이지의 리소스 요청을 가로채 응답을 만들 수 있습니다. 이 기능으로 캐시된 화면을 열거나 네트워크 실패 시 오프라인 안내를 제공할 수 있습니다. HTTPS 같은 보안 컨텍스트가 필요하며 로컬 개발에서는 localhost가 허용됩니다. Service Worker 사용 가이드를 참고할 수 있습니다.

if ("serviceWorker" in navigator) {
navigator.serviceWorker.register("/sw.js", { scope: "/" })
.catch((error) => {
console.error("Service Worker registration failed", error);
});
}

기본 스코프는 워커 스크립트가 위치한 디렉터리입니다. /app/sw.js를 등록해 곧바로 사이트 전체를 제어할 수는 없습니다. 더 넓은 범위를 허용하려면 서버의 Service-Worker-Allowed 헤더 같은 조건도 맞아야 합니다. 스코프는 제어할 페이지의 범위이며 캐시할 API URL의 허용 목록을 대신하지 않습니다.

등록의 수명과 실행 중인 워커의 수명

등록(registration)은 브라우저에 남지만 워커의 전역 변수와 연결까지 영구 보존되는 것은 아닙니다. 브라우저는 워커 실행을 종료하고 다음 이벤트에 다시 시작할 수 있습니다. 필요한 상태는 저장소에서 복원하거나 클라이언트·서버에서 다시 받아야 합니다. 이 원칙은 2019년 Service Workers 1 명세의 수명 절에 명시돼 있습니다.

따라서 Service Worker를 "탭을 닫아도 SSE가 계속 살아 있는 서버"로 설계하지는 않겠습니다. waitUntil()도 이벤트에 연결된 비동기 작업의 수명을 연장하는 수단이며 무기한 실행을 보장하지 않습니다. 푸시는 지원 환경에서 브라우저가 워커를 깨우는 별도의 이벤트 경로입니다.

어떤 응답을 저장할 것인가​

캐시 전략은 요청 종류에 따라 정합니다. 문서 사이트의 로고와 로그인한 사용자의 문서를 같은 규칙에 넣을 이유는 없습니다.

전략동작예제에서 고려할 대상남는 문제
Cache First캐시가 있으면 반환하고 없으면 네트워크내용 해시가 붙은 공개 JS·CSS·이미지잘못된 버전 연결, 저장 공간
Network First네트워크를 시도하고 실패 시 캐시오프라인 열람을 허용한 공개 문서대기 시간 제한, 오래된 내용 표시
Stale While Revalidate캐시를 먼저 주고 뒤에서 갱신다소 오래돼도 되는 공개 목록이번 화면에는 이전 값이 남을 수 있음
Network Only저장된 응답을 대신 제공하지 않음인증 상태, 제출·수정 요청, 민감한 조회오프라인 실패를 화면에서 처리
Cache Storage와 HTTP 캐시의 차이

Cache API는 항목을 자동으로 만료시키지 않으며 HTTP 캐시 헤더에 따라 저장을 자동으로 거부하는 장치도 아닙니다. 서버가 Cache-Control: no-store를 보내도 코드에서 cache.put()으로 저장할 수 있습니다. 응답을 저장할지 직접 결정해야 합니다. MDN의 Cache 설명이 두 동작을 구분합니다.

다음은 빌드가 정한 공개 정적 자산만 저장하는 예제입니다. 다른 요청에는 respondWith()를 호출하지 않아 브라우저의 기본 네트워크 경로로 보냅니다. 이 경우에도 HTTP 캐시까지 꺼지는 것은 아니므로 인증·개인화 응답의 캐시 헤더는 서버에서 별도로 설정해야 합니다.

sw.js: 공개 정적 자산만 캐시하는 예제
const CACHE_NAME = "public-assets-v1";
// 실제 빌드가 생성한 파일명으로 치환한다. 쿼리 문자열까지 정확히 맞춘다.
const PUBLIC_ASSETS = new Set([
"/assets/app.7c91.js",
"/assets/app.a810.css",
]);

self.addEventListener("fetch", (event) => {
const request = event.request;
const url = new URL(request.url);
if (request.method !== "GET") return;
if (url.origin !== self.location.origin) return;
if (!PUBLIC_ASSETS.has(url.pathname + url.search)) return;

const responsePromise = (async () => {
const cache = await caches.open(CACHE_NAME);
const hit = await cache.match(request);
if (hit) return hit;

const response = await fetch(request);
if (response.ok && response.type === "basic" && !response.redirected) {
// 저장 실패가 정상 네트워크 응답까지 실패시키지는 않게 한다.
try {
await cache.put(request, response.clone());
} catch {
// 저장 용량·횟수 등 메타데이터 관측은 별도로 연결한다.
}
}
return response;
})();

event.respondWith(responsePromise);
});

이 예제는 처음 방문하기 전부터 오프라인으로 열리는 앱을 만들지 않습니다. 이미 요청한 자산을 재사용하는 코드이며 HTML의 오프라인 제공도 다루지 않습니다. install에서 미리 저장하는 프리캐시(precaching)가 필요하다면 설치 성공 조건과 실패 정책을 별도로 정해야 합니다.

사용자별 응답을 저장하는 기능을 추가한다면 사용자 식별·로그아웃 삭제·저장 중이던 요청과의 경쟁도 함께 설계해야 합니다. 캐시를 삭제한 직후 이전 계정의 요청이 끝나 다시 저장하는 상황까지 생깁니다. 오프라인 열람 요구가 없다면 개인화 응답은 이 캐시에서 제외하는 선택이 단순합니다.

오프라인 제출과 재시도​

Background Sync는 지원 환경에서 연결 복구 후 작업을 재시도하는 데 사용할 수 있습니다. 다만 모든 브라우저·웹뷰에서 지원하는 기능은 아니며 정확한 실행 시점이나 무한 재시도를 보장하지 않습니다. 오프라인·백그라운드 작업 가이드에 실행과 재시도의 제한이 설명돼 있습니다.

문서 제출에서 응답을 받지 못했다고 서버도 처리하지 못했다고 단정할 수는 없습니다. 클라이언트의 작업 ID와 서버의 멱등 처리, 처리 결과 조회가 있어야 재시도로 중복 제출하는 문제를 줄일 수 있습니다. 화면 역시 "기기에 보관됨", "전송 중", "서버 접수 완료"를 구분해야 합니다. 로컬 큐에 넣은 순간 제출 완료를 보여주면 사용자는 앱을 닫아도 접수가 끝났다고 믿게 됩니다.

Recap​

Service Worker의 등록과 실행 중인 워커의 수명은 다릅니다. 캐시는 저장할 응답을 좁게 정하고 만료·삭제 정책을 직접 관리해야 합니다. 오프라인 작업의 완료 여부는 로컬 큐가 아니라 서버가 확인한 처리 결과로 판단해야 합니다.



구버전 화면이 남아 있는 배포​

새 파일을 올렸는데 이전 코드가 실행되는 이유​

Service Worker를 도입하면 서버 배포 시점과 사용자에게 적용되는 시점이 갈릴 수 있습니다. 기존 워커가 페이지를 제어하는 동안 새 워커는 설치 후 대기(waiting)할 수 있습니다.

기본적으로 새 워커는 이전 워커가 제어하는 클라이언트가 없어질 때 활성화됩니다. 첫 등록도 성공 직후 현재 페이지가 반드시 제어받는 것은 아닙니다. navigator.serviceWorker.controller로 현재 페이지의 제어 여부를 구분할 수 있습니다.

워커 활성화와 페이지 새로고침의 차이

skipWaiting()은 새 워커의 대기를 건너뛰게 하고 clients.claim()은 활성 워커가 스코프 안의 아직 제어받지 않는 클라이언트를 제어하도록 합니다. 둘 다 페이지에서 이미 실행 중인 JavaScript를 새 버전으로 바꿔주지는 않습니다. 이 차이는 Service Worker 생명 주기 설명에 자세히 나옵니다.

편집 중인 화면에서의 업데이트​

구버전 HTML과 JS가 살아 있는데 새 워커가 그 페이지의 요청을 받으면 두 버전이 함께 동작하게 됩니다. 새 워커가 구버전 캐시를 바로 지워버렸고 서버에서도 예전 청크를 삭제했다면, 사용자가 아직 열지 않은 메뉴를 누르는 순간 동적 import가 실패할 수 있습니다.

이를 피하려고 다음 순서로 배포를 구성하겠습니다.

  1. 내용 해시가 붙은 새 자산을 먼저 게시하고 이전 자산을 일정 기간 보관합니다.
  2. 새 워커의 설치가 성공해 대기 중임을 화면에 알립니다.
  3. 편집 중인 내용이 있으면 저장이나 복원 가능 여부를 먼저 확인합니다.
  4. 사용자가 업데이트를 선택한 뒤 활성화하고 제어 워커 변경에 맞춰 새 문서를 로드합니다.
  5. 이전 클라이언트가 필요로 할 수 있는 캐시·서버 자산의 보존 기간을 정해 정리합니다.

한 탭의 동의가 다른 탭의 편집 종료를 뜻하지는 않습니다. 강제 활성화를 적용하려면 다른 탭에 미칠 영향과 구버전 클라이언트의 호환 기간도 정해야 합니다. "새 버전이 있습니다" 배너는 사용자가 이 절차를 시작하는 계기입니다.

오래된 캐시를 지울 때는 같은 오리진의 캐시 전체를 지우지 않고 이 앱이 관리하는 이름만 대상으로 삼습니다. 계정 캐시와 정적 자산 캐시를 구분해 두면 업데이트 작업이 로그인 상태 관리까지 건드리는 일을 줄일 수 있습니다.

잘못 배포한 워커의 복구​

문제가 생겨도 /sw.js를 서버에서 삭제하는 것만으로 이미 설치된 워커가 사라지지는 않습니다. 같은 URL에 수정된 워커를 제공하고, 요청을 네트워크로 통과시키는 복구 버전도 준비해야 합니다.

unregister() 역시 현재 열린 모든 페이지와 Cache Storage를 즉시 초기화하는 명령은 아닙니다. 기존 페이지의 제어 상태와 저장된 데이터를 각각 확인해야 합니다. 복구 검증에는 처음 방문하는 브라우저뿐 아니라 문제가 있는 버전을 실제로 설치한 프로필이 필요합니다.

Recap​

Service Worker가 있는 배포는 구버전 페이지와 새 워커가 만나는 상황을 포함합니다. 강제 활성화 전에 편집 내용과 자산 호환성을 확인하고, 오래된 청크의 보존 기간을 정해야 합니다. 복구도 기존 설치 상태에서 성공하는지 검증해야 합니다.



지원 환경과 검증 방법​

API가 존재하는 것과 제품에서 쓸 수 있는 것​

Dedicated Worker를 사용할 수 있는 브라우저라고 해서 SharedWorker나 OffscreenCanvas까지 지원하는 것은 아닙니다. 사용하는 기능마다 지원 환경을 확인해야 합니다.

기능적용 전에 확인할 점
Classic Dedicated Worker본문 코드의 기본형. 실제 워커 스크립트 로딩과 오류 처리까지 확인
Module Worker주요 최신 브라우저에서 지원. Firefox도 114부터 지원하며, 그보다 오래된 환경은 classic worker 빌드 검토
BroadcastChannelSafari 15.4에 지원이 추가됨. 구형 iOS와 앱 웹뷰는 별도 확인
SharedWorkerSafari 16.0부터 지원. 모바일 브라우저·WebView는 제품과 버전별로 확인하고 대체 경로 준비
OffscreenCanvasSafari도 16.4부터 2D 작업을 지원. 필요한 렌더링 컨텍스트와 메서드의 지원 여부를 각각 확인
SharedArrayBuffer교차 출처 격리 조건과 주변 리소스·팝업의 영향을 함께 검토
Push·Background SyncService Worker 지원 여부와 별도로 판정. iOS·iPadOS 16.4 이상 웹 푸시는 홈 화면에 추가한 웹 앱에서 사용 가능하며, Background Sync 지원과는 별개

Safari의 변경점은 15.4 릴리스 설명과 16.0 릴리스 설명에서 확인할 수 있습니다. Firefox는 105 릴리스에서 OffscreenCanvas 지원을 알렸습니다. Firefox 114는 Module Worker를, Safari 16.4는 2D OffscreenCanvas와 홈 화면 웹 앱의 웹 푸시를 지원합니다. 모바일 웹뷰까지 같은 조건이라고 가정하지 않겠습니다.

이미지 분석의 경우 픽셀 순회만 워커로 보내고 Canvas 입력·그리기는 메인에 남기는 설계부터 시작할 수 있습니다. 그다음 OffscreenCanvas를 지원하는 환경에서 더 옮길 부분이 있는지 봅니다. 워커를 만들 수 없는 환경에서는 작은 파일만 허용하거나 서버 검증으로 넘기는 등 제품 수준의 대체 경로도 필요합니다.

성능 측정에서 나눠 볼 구간​

워커 도입 전후에 검증 완료 시간만 비교하면 화면이 얼마나 덜 멈췄는지 놓칩니다. 다음 구간을 나누어 기록합니다.

측정 대상확인할 질문
메인 스레드의 긴 태스크입력을 막던 파싱·검증이 실제로 사라졌는가
작업 시작부터 결과 수신까지워커 로딩과 데이터 전달을 포함해 얼마나 걸리는가
워커 내부 처리 시간파싱과 검증 중 어느 단계가 오래 걸리는가
결과 수신 후 화면 반영큰 상태 업데이트·DOM 렌더링으로 다시 멈추지는 않는가
메모리와 작업 개수반복 실행·취소 후 버퍼와 워커가 계속 쌓이지 않는가

performance.now()는 메인과 워커 각각에서 시작·종료의 차이를 재는 데 사용할 수 있습니다. 서로 다른 실행 환경에서 얻은 값을 그대로 빼면 시간 원점 차이를 섞을 수 있으므로, 왕복 시간은 메인에서 재고 계산 시간은 워커 내부에서 재서 따로 보고합니다.

전체 완료 시간이 조금 늘어도 사용자가 스크롤하고 취소할 수 있게 됐다면 도입 목적을 달성할 수 있습니다. 다만 복제·시작 비용만 늘고 메인 스레드의 긴 태스크가 그대로라면 분리한 작업을 다시 골라야 합니다. 개발용 노트북뿐 아니라 지원 대상의 낮은 사양 기기에서 같은 파일과 동작으로 비교합니다.

실패를 일부러 만들어 보기​

정상 파일 하나가 끝까지 처리되는 것만으로 검증을 마치지는 않습니다.

  • 파일 A를 읽는 중 B를 선택하고 A의 결과가 화면을 덮지 않는지 확인합니다.
  • 검증 중 취소·화면 이탈을 반복하고 워커와 타이머가 남는지 확인합니다.
  • 워커 파일을 404로 만들거나 CSP에서 차단해 로딩 상태가 오류 안내로 끝나는지 확인합니다.
  • 여러 탭에서 같은 대상을 구독한 뒤 일부 탭을 닫고 나머지 구독이 유지되는지 확인합니다.
  • 오프라인·복귀·계정 전환 후 이전 사용자 데이터와 구독이 재사용되는지 확인합니다.
  • 구버전 탭을 열어둔 채 배포하고 편집 내용과 지연 로딩 자산이 유지되는지 확인합니다.
  • Service Worker 개발 도구의 업데이트 강제 적용·네트워크 우회 옵션을 끈 상태에서도 다시 확인합니다.

워커 파일의 실제 URL과 MIME 타입, CSP 허용 범위도 배포 환경에서 확인합니다. Safari 15.5에는 CSP의 worker-src 지원이 추가됐으므로 구형 환경을 지원한다면 그 이전의 fallback 지시자도 확인해야 합니다. WebKit 릴리스 설명에서 이 변경을 다룹니다.

Recap​

워커 종류와 부가 API의 지원 여부는 각각 확인해야 합니다. 성능은 계산 시간과 UI가 막히는 시간을 나누어 보고, 지원 기기에서 데이터 전달·메모리 비용까지 확인합니다. 취소와 재연결, 구버전 배포를 재현하면 작업을 옮긴 뒤 직접 관리해야 할 부분이 드러납니다.



References​

실행 환경과 데이터 전달​

Service Worker와 캐시​

브라우저별 지원 현황​

좋은 사람들과 재미있는 일을 하며 열정적이고 즐겁게 살고 싶은 개발자