웹 워커와 서비스 워커
Web Worker로 무거운 계산을 메인 스레드에서 분리하고 Service Worker의 생명주기와 캐시·오프라인 응답을 익힙니다.
9장에서는 Fetch API로 서버와 통신하고, 웹 스토리지로 데이터를 저장하고, History API로 SPA 라우팅을 만들고, Canvas API로 그래픽을 그리는 방법을 익혔습니다. 마지막으로 웹 애플리케이션의 성능과 사용 경험을 한 단계 더 끌어올리는 웹 워커(web worker)와 서비스 워커(service worker)를 살펴봅니다.
페이지의 자바스크립트, 즉 이벤트 핸들러와 DOM을 바꾸는 코드는 메인 스레드(main thread)라는 하나의 실행 흐름에서 처리됩니다. 이 흐름에서 대용량 데이터 처리나 이미지 필터처럼 오래 걸리는 계산을 하면, 그 계산이 끝날 때까지 다음 이벤트 처리와 화면 갱신이 미뤄져 화면이 멈춘 것처럼 보입니다. 브라우저 내부의 모든 작업이 이 한 스레드에서 실행된다는 뜻은 아니지만, 페이지의 자바스크립트가 오래 붙잡고 있으면 사용자는 반응이 없는 화면을 보게 됩니다.
웹 워커는 이런 계산을 별도의 스레드에서 실행해 메인 스레드를 비워 줍니다. 서비스 워커는 페이지와 네트워크 사이에서 요청을 가로채, 캐시로 응답하거나 오프라인에서도 동작하게 해 주는 백그라운드 실행 단위입니다. 둘 다 프로그레시브 웹 앱(progressive web app, PWA)에서 자주 쓰이지만 역할이 다르므로, 웹 워커를 쓰는 페이지에 서비스 워커가 꼭 있어야 하는 것은 아닙니다.
웹 워커
웹 워커는 페이지의 메인 스크립트와 따로 떨어진 백그라운드 스레드에서 스크립트를 실행하게 해 줍니다. 무거운 계산을 워커에 맡기면 메인 스레드는 그동안에도 클릭과 입력에 반응하고 화면을 갱신할 수 있습니다.
Main thread와 Web Worker 책임 경계 — 상태 추적
본문 소수 요청/배열 결과의 두 메시지와 terminate가 만드는 실행 상태가 다르다. 메인/worker/메시지를 고정해 계산 중 응답 가능성과 종료 뒤 결과 처리 경계를 추적한다. error/messageerror는 별도 경로로 유지한다.
ARCHITECTURE
Main thread와 Web Worker 책임 경계
메인 스레드는 DOM과 UI를 갱신하고 Worker는 별도 실행 환경에서 계산한다. postMessage로 데이터를 복제하거나 Transferable의 소유권을 넘겨 결과를 주고받는다.
숫자 요청과 소수 배열 결과는 원 코드의 두 메시지입니다. 원 main.js는 error를 처리하고 messageerror 리스너는 확장 설계입니다. transfer는 메시지 데이터 소유권 옵션입니다. 오류·terminate는 정상 결과 뒤에 항상 이어지는 단계가 아닌 별도 경로이며, 브라우저 내부의 모든 렌더링 작업을 메인이 독점한다는 뜻은 아닙니다.
- postMessage 요청
- 결과 전송·실행 종료
- error·messageerror
Transferable은 메시지를 보낼 때 선택하는 소유권 옵션이다. 요청 화살표 하나가 한 메시지를 뜻한다. error와 terminate는 정상 결과 뒤의 단계가 아니라 별도 예외·수명 경로다.
이 예의 조건
- 원도표의 전체 구조와 설명은 위 관계도와 표에서 유지합니다. 아래 인쇄 표에도 원도표의 관계·조건·라벨·설명·주석을 보존합니다.
- 두 관찰 칸의 위치는 고정입니다. 관찰 상태는 해당 순간의 값이며, 동작·이유는 그 조건에서의 판정과 변화 원인입니다. 독립 비교와 실패 분기는 별도 조건으로 읽고 모든 분기를 한 요청의 필수 실행 순서로 이어 읽지 않습니다.
| 장면 | 현재 관찰값 | 설명 | 변화 이유 |
|---|---|---|---|
| 작업 요청 | main: UI 작업 / worker: 계산 요청 | main이 소수 계산 입력을 worker에 보내고 DOM을 유지한다 | 메시지 전달은 공유 DOM 접근이 아니다 |
| 계산과 응답 | worker: primes 배열 / main: 응답 대기 | worker가 소수 배열을 계산해 postMessage한다 | worker 결과는 메시지 경계를 통과한다 |
| 반영 또는 종료 | 결과: 현재 요청에 반영 / 종료: worker 중단 | main이 결과를 반영하거나 소유자 종료로 terminate한다 | error/messageerror는 별도 확장 실패 경로로 보존한다 |
Main thread와 Web Worker 책임 경계 — 상태 추적
본문 소수 요청/배열 결과의 두 메시지와 terminate가 만드는 실행 상태가 다르다. 메인/worker/메시지를 고정해 계산 중 응답 가능성과 종료 뒤 결과 처리 경계를 추적한다. error/messageerror는 별도 경로로 유지한다.
ARCHITECTURE
Main thread와 Web Worker 책임 경계
메인 스레드는 DOM과 UI를 갱신하고 Worker는 별도 실행 환경에서 계산한다. postMessage로 데이터를 복제하거나 Transferable의 소유권을 넘겨 결과를 주고받는다.
숫자 요청과 소수 배열 결과는 원 코드의 두 메시지입니다. 원 main.js는 error를 처리하고 messageerror 리스너는 확장 설계입니다. transfer는 메시지 데이터 소유권 옵션입니다. 오류·terminate는 정상 결과 뒤에 항상 이어지는 단계가 아닌 별도 경로이며, 브라우저 내부의 모든 렌더링 작업을 메인이 독점한다는 뜻은 아닙니다.
- postMessage 요청
- 결과 전송·실행 종료
- error·messageerror
Transferable은 메시지를 보낼 때 선택하는 소유권 옵션이다. 요청 화살표 하나가 한 메시지를 뜻한다. error와 terminate는 정상 결과 뒤의 단계가 아니라 별도 예외·수명 경로다.
이 예의 조건
- 원도표의 전체 구조와 설명은 위 관계도와 표에서 유지합니다. 아래 인쇄 표에도 원도표의 관계·조건·라벨·설명·주석을 보존합니다.
- 두 관찰 칸의 위치는 고정입니다. 관찰 상태는 해당 순간의 값이며, 동작·이유는 그 조건에서의 판정과 변화 원인입니다. 독립 비교와 실패 분기는 별도 조건으로 읽고 모든 분기를 한 요청의 필수 실행 순서로 이어 읽지 않습니다.
| 장면 | 현재 관찰값 | 설명 | 변화 이유 |
|---|---|---|---|
| 작업 요청 | main: UI 작업 / worker: 계산 요청 | main이 소수 계산 입력을 worker에 보내고 DOM을 유지한다 | 메시지 전달은 공유 DOM 접근이 아니다 |
| 계산과 응답 | worker: primes 배열 / main: 응답 대기 | worker가 소수 배열을 계산해 postMessage한다 | worker 결과는 메시지 경계를 통과한다 |
| 반영 또는 종료 | 결과: 현재 요청에 반영 / 종료: worker 중단 | main이 결과를 반영하거나 소유자 종료로 terminate한다 | error/messageerror는 별도 확장 실패 경로로 보존한다 |
위 그림은 메인 스레드와 워커가 맡는 일을 나눠 보여 줍니다. 메인 스레드는 DOM과 화면을 다루고, 워커는 별도의 실행 환경에서 계산만 합니다. 메인 스레드의 이벤트가 postMessage()로 입력 숫자를 워커에 보내면, 워커는 DOM에 손대지 않고 소수 배열을 계산해 돌려줍니다. 메인 스레드의 핸들러가 그 배열을 받아 개수를 화면에 표시합니다. 요청과 결과는 각각 하나의 메시지입니다.
메시지의 데이터는 기본으로 구조화 복제(structured clone)되어 상대에게 복사본이 전달되고, ArrayBuffer 같은 전송 가능 객체(transferable object)는 복사하는 대신 소유권을 넘길 수도 있습니다. 그림의 오른쪽 아래는 정상 흐름 뒤에 늘 이어지는 단계가 아닌 별도의 경로들입니다. 워커 안에서 예외가 나면 메인 스레드에 error 이벤트가 발생하고, 받은 메시지를 복원하지 못하면 messageerror 이벤트가 발생하는데, 이 글의 예제는 error만 처리하고 messageerror는 확장할 때 더하는 부분입니다. terminate()는 결과를 받은 뒤나 다시 시작할 때 메인 스레드가 내리는 수명 명령으로, 실행 중인 워커를 멈추지만 이미 처리한 결과에는 영향을 주지 않습니다.
웹 워커의 특징
워커는 메인 스레드와 별개의 실행 환경을 가지며, HTML 문서의 DOM에 직접 접근할 수 없습니다. 그래서 워커가 화면을 직접 바꿀 수는 없고, 결과를 메시지로 보내 메인 스레드가 화면을 갱신하게 해야 합니다. 메인 스레드와 워커는 postMessage()로 메시지를 보내고, message 이벤트로 받습니다.
워커 안에서는 alert()나 confirm() 같은 화면 관련 API를 쓸 수 없지만, fetch(), XMLHttpRequest, IndexedDB, setTimeout(), setInterval()은 쓸 수 있습니다. 워커 스크립트는 페이지와 같은 출처에서 제공되어야 하며, 파일을 직접 여는 file: 주소에서는 워커를 만들 수 없을 수 있으므로 로컬 HTTP 서버로 실습하세요.
웹 워커 사용법
먼저 워커 스레드에서 실행할 스크립트 파일 worker.js를 만듭니다. 이 파일은 메시지로 숫자 하나를 받으면 2부터 그 수까지의 소수를 모두 구해 배열로 돌려보냅니다. 수가 클수록 이중 반복문이 오래 걸리므로, 메인 스레드에서 실행하면 그동안 화면이 멈추는 종류의 작업입니다.
// worker.js
// 이 스크립트는 웹 워커 스레드에서 실행됩니다.
// 메인 스크립트로부터 메시지를 받으면 실행되는 이벤트 핸들러
self.onmessage = function(event) {
const number = event.data; // 메인 스크립트로부터 전달받은 데이터
console.log(`워커: ${number}까지의 소수 계산을 시작합니다.`);
// 복잡하고 시간이 오래 걸리는 연산 (예시: 소수 계산)
let primes = [];
for (let i = 2; i <= number; i++) {
let isPrime = true;
for (let j = 2; j * j <= i; j++) {
if (i % j === 0) {
isPrime = false;
break;
}
}
if (isPrime) {
primes.push(i);
}
}
console.log(`워커: ${number}까지의 소수 계산 완료.`);
// 계산 결과를 다시 메인 스크립트로 전송
self.postMessage(primes);
};워커에서 self는 워커 자신의 전역 객체입니다. self.onmessage로 메인 스크립트가 보낸 메시지를 받고, 계산이 끝나면 self.postMessage()로 결과를 돌려보냅니다.
메인 스크립트 main.js에서는 Worker 객체를 만들고 메시지를 주고받습니다. 같은 폴더의 index.html에 <button id="startButton">계산 시작</button><p id="status">대기</p><p id="result"></p><script src="main.js"></script>를 준비합니다.
// main.js (또는 HTML <script>)
const startButton = document.getElementById('startButton');
const resultDiv = document.getElementById('result');
const statusDiv = document.getElementById('status');
let myWorker;
startButton.addEventListener('click', () => {
statusDiv.textContent = "워커 시작 중... 잠시 기다려 주세요.";
// 이전에 워커가 있다면 종료
if (myWorker) {
myWorker.terminate();
myWorker = null;
}
// 새 워커 생성 (워커 스크립트 파일 경로 지정)
myWorker = new Worker('worker.js');
// 워커로부터 메시지를 받을 때 실행될 함수
myWorker.onmessage = function(event) {
const primes = event.data; // 워커로부터 전달받은 소수 배열
resultDiv.textContent = `계산된 소수 개수: ${primes.length}`;
statusDiv.textContent = "계산 완료! (메인 스레드는 블로킹되지 않았습니다.)";
myWorker.terminate(); // 작업이 끝나면 워커 종료
myWorker = null;
};
// 워커에서 에러 발생 시
myWorker.onerror = function(error) {
statusDiv.textContent = `워커 오류: ${error.message}`;
console.error("워커 오류:", error);
};
// 워커에게 계산을 시작하도록 메시지 전송
const numberToCalculate = 500000; // 큰 숫자
myWorker.postMessage(numberToCalculate);
statusDiv.textContent = `워커에게 ${numberToCalculate}까지의 소수 계산 명령을 보냈습니다. (UI는 반응 중)`;
});
// HTML에 버튼과 결과 표시 영역 추가
/*
<button id="startButton">복잡한 계산 시작 (워커 사용)</button>
<div id="status"></div>
<div id="result"></div>
*/버튼을 누르면 이전 워커가 있으면 종료하고, new Worker('worker.js')로 새 워커를 만든 뒤 계산할 숫자를 보냅니다. 소수 계산은 워커 스레드에서 실행되므로 그동안에도 메인 스레드는 다른 버튼이나 입력에 반응합니다. 다만 메시지를 복제하는 비용과 결과를 처리하는 비용은 메인 스레드에 남고, 워커도 같은 CPU를 나눠 쓰므로 계산 자체가 공짜가 되는 것은 아닙니다.
500000처럼 큰 숫자는 결과가 나오기까지 시간이 걸리므로, 처음에는 사본에서 numberToCalculate를 10으로 바꿔 흐름부터 확인하세요. 2부터 10까지의 소수는 2, 3, 5, 7이므로 워커는 [2, 3, 5, 7]을 돌려주고, 메인 스크립트는 배열의 length인 4를 표시한 뒤 워커를 종료합니다. 워커에서 document로 화면을 바꾸려 하면 워커에는 document가 없어 오류가 납니다. 이 예제는 버튼을 비활성화하지 않으므로, 계산 중에 다시 누르면 이전 워커를 terminate()하고 새 워커를 만듭니다.
워커 종료
worker.terminate()는 워커 스레드를 즉시 종료합니다. 워커는 직접 종료하지 않으면 계속 남아 메모리를 차지하므로, 할 일이 끝났거나 더 필요 없어지면 종료해 자원을 돌려줘야 합니다. 워커가 스스로 끝내려면 워커 안에서 self.close()를 호출합니다.
더 깊이: 워커와 메모리를 함께 쓰려면
postMessage()는 데이터를 복사하거나 소유권을 넘기므로, 메인 스레드와 워커가 같은 메모리를 동시에 보는 일은 없습니다. 같은 메모리를 함께 쓰려면 SharedArrayBuffer를 씁니다. 이 객체를 워커에 보내면 받는 쪽에 새 SharedArrayBuffer 객체가 생기지만, 두 객체는 같은 데이터 블록을 가리키므로 한쪽에서 바꾼 내용이 다른 쪽에서도 보입니다. 다만 다른 스레드의 변경이 언제 보일지는 정해져 있지 않으므로, 값을 안전하게 주고받으려면 Atomics 객체의 원자적 연산으로 순서를 맞춥니다.
SharedArrayBuffer는 아무 페이지에서나 쓸 수 없습니다. 2018년 초 Spectre 취약점이 알려지면서 공유 메모리와 고해상도 타이머가 브라우저에서 사실상 꺼졌기 때문입니다. 2020년에 안전하게 다시 쓰는 방법이 표준이 되었는데, 문서가 HTTPS 같은 보안 컨텍스트에 있고 교차 출처 격리(cross-origin isolated) 상태여야 합니다. 교차 출처 격리는 서버가 문서 응답에 Cross-Origin-Opener-Policy: same-origin과 Cross-Origin-Embedder-Policy: require-corp 같은 헤더를 보내 다른 출처의 창이나 자원과 섞이지 않게 만드는 상태입니다.
페이지가 이 조건을 갖췄는지는 전역의 crossOriginIsolated 값으로 확인합니다. 격리를 켜면 CORS나 별도 헤더로 허용하지 않은 다른 출처의 이미지나 스크립트를 불러오지 못할 수 있으므로, 외부 자원을 많이 쓰는 페이지라면 먼저 영향을 확인해야 합니다. 조건은 MDN의 SharedArrayBuffer 설명에 정리되어 있습니다.
서비스 워커
서비스 워커는 페이지와 네트워크 사이에서 프록시(proxy)처럼 동작하는 워커입니다. 페이지와 독립된 백그라운드에서 실행되며, 네트워크 요청을 가로채 캐시에서 응답해 로딩을 빠르게 하거나, 네트워크가 끊겨도 일부 기능을 쓸 수 있게 합니다. 브라우저와 운영체제가 지원하고 사용자가 허락하면, 페이지가 열려 있지 않아도 푸시 알림을 받거나 연결이 돌아왔을 때 데이터를 동기화하는 데도 쓰입니다.
서비스 워커는 네트워크 요청을 가로챌 수 있는 강한 권한을 가지므로, HTTPS처럼 안전한 컨텍스트(secure context)에서만 등록할 수 있습니다. 개발할 때는 http://localhost 같은 로컬 주소도 안전한 출처로 취급되므로 로컬 서버로 실습할 수 있습니다. window.isSecureContext와 navigator.serviceWorker가 있는지로 지금 환경에서 쓸 수 있는지 확인할 수 있습니다.
서비스 워커의 생명주기
Service Worker 설치·활성·제어 수명 — 상태 추적
A/B worker의 installing/waiting/activating/activated와 client controller는 별개다. 고정 registration·버전·client에서 takeover 전후를 보여 주되 idle 실행과 redundant를 구분한다.
STATE · REGISTRATION & VERSION TAKEOVER
Service Worker 설치·활성·제어 수명
설치·대기·활성 상태와 실제 client 제어를 구분한다. 설치 실패·교체에 따른 redundant와 일시적인 실행 종료도 다르다.
실선은 버전 설치·활성 상태, 점선은 별도 client 제어 관계입니다. 설치 성공 뒤에도 A의 client가 남으면 B는 waiting일 수 있습니다. claim은 activate 처리 중에도 요청할 수 있고, 실행이 쉬는 것은 redundant 상태가 아닙니다.
installed state와 registration의 waiting slot, activated state와 실제 client control을 구분해야 업데이트 적용 시점을 판단한다. 이벤트가 없을 때 실행이 종료되는 것은 redundant 상태가 아니며 필요할 때 다시 실행된다.
이 예의 조건
- 원도표의 전체 구조와 설명은 위 관계도와 표에서 유지합니다. 아래 인쇄 표에도 원도표의 관계·조건·라벨·설명·주석을 보존합니다.
- 두 관찰 칸의 위치는 고정입니다. 관찰 상태는 해당 순간의 값이며, 동작·이유는 그 조건에서의 판정과 변화 원인입니다. 독립 비교와 실패 분기는 별도 조건으로 읽고 모든 분기를 한 요청의 필수 실행 순서로 이어 읽지 않습니다.
| 장면 | 현재 관찰값 | 설명 | 변화 이유 |
|---|---|---|---|
| 기존 controller | A: 현재 controller / B: installing | client가 A를 사용하고 새 B를 설치한다 | 등록의 worker state와 client 제어를 분리한다 |
| B 대기 | A: client 제어 / B: waiting | 설치 후 B는 waiting이고 client는 A를 유지할 수 있다 | idle인 worker를 redundant로 해석하지 않는다 |
| 새 제어 | B: activated / client: 정책에 따라 B | 조건에 따라 B activate 및 client takeover가 일어난다 | 활성 worker 존재가 모든 client 즉시 제어와 같은 뜻은 아니다 |
Service Worker 설치·활성·제어 수명 — 상태 추적
A/B worker의 installing/waiting/activating/activated와 client controller는 별개다. 고정 registration·버전·client에서 takeover 전후를 보여 주되 idle 실행과 redundant를 구분한다.
STATE · REGISTRATION & VERSION TAKEOVER
Service Worker 설치·활성·제어 수명
설치·대기·활성 상태와 실제 client 제어를 구분한다. 설치 실패·교체에 따른 redundant와 일시적인 실행 종료도 다르다.
실선은 버전 설치·활성 상태, 점선은 별도 client 제어 관계입니다. 설치 성공 뒤에도 A의 client가 남으면 B는 waiting일 수 있습니다. claim은 activate 처리 중에도 요청할 수 있고, 실행이 쉬는 것은 redundant 상태가 아닙니다.
installed state와 registration의 waiting slot, activated state와 실제 client control을 구분해야 업데이트 적용 시점을 판단한다. 이벤트가 없을 때 실행이 종료되는 것은 redundant 상태가 아니며 필요할 때 다시 실행된다.
이 예의 조건
- 원도표의 전체 구조와 설명은 위 관계도와 표에서 유지합니다. 아래 인쇄 표에도 원도표의 관계·조건·라벨·설명·주석을 보존합니다.
- 두 관찰 칸의 위치는 고정입니다. 관찰 상태는 해당 순간의 값이며, 동작·이유는 그 조건에서의 판정과 변화 원인입니다. 독립 비교와 실패 분기는 별도 조건으로 읽고 모든 분기를 한 요청의 필수 실행 순서로 이어 읽지 않습니다.
| 장면 | 현재 관찰값 | 설명 | 변화 이유 |
|---|---|---|---|
| 기존 controller | A: 현재 controller / B: installing | client가 A를 사용하고 새 B를 설치한다 | 등록의 worker state와 client 제어를 분리한다 |
| B 대기 | A: client 제어 / B: waiting | 설치 후 B는 waiting이고 client는 A를 유지할 수 있다 | idle인 worker를 redundant로 해석하지 않는다 |
| 새 제어 | B: activated / client: 정책에 따라 B | 조건에 따라 B activate 및 client takeover가 일어난다 | 활성 worker 존재가 모든 client 즉시 제어와 같은 뜻은 아니다 |
위 그림은 서비스 워커의 한 버전이 거치는 상태입니다. 페이지가 범위(scope)와 스크립트 주소로 서비스 워커를 등록(registration)하면, 브라우저는 스크립트를 내려받아 installing 상태로 install 이벤트를 보냅니다. 보통 여기서 필요한 파일을 미리 캐시(precache)하며, event.waitUntil()에 넘긴 작업이 성공해야 설치가 끝납니다.
설치가 끝나면 installed 상태가 되어 대기 자리(waiting)에 들어갑니다. 이전 버전 A가 제어하는 페이지가 아직 열려 있으면 새 버전은 여기서 기다립니다. 활성화 조건이 갖춰지면 activating 상태로 activate 이벤트를 받아 오래된 캐시를 정리하고, 끝나면 activated 상태가 되어 이벤트를 처리할 준비를 마칩니다.
그림의 점선은 버전의 상태와 별개인 페이지 제어 관계입니다. 활성화된 서비스 워커가 실제로 페이지의 요청을 가로채려면 그 페이지의 제어자(controller)가 되어야 하는데, 새로 탐색해 열린 페이지이거나 clients.claim()을 호출해야 이미 열린 페이지를 제어하기 시작합니다. 설치에 실패하거나 새 버전으로 교체되면 그 버전은 redundant 상태가 되어 쓰이지 않습니다. 그림 아래의 설명처럼 처리할 이벤트가 없어 서비스 워커의 실행이 잠시 멈추는 것은 redundant와 다르며, 이벤트가 오면 다시 실행됩니다.
서비스 워커 사용법
먼저 서비스 워커 스크립트 sw.js를 만듭니다. 이 파일에는 install, activate, fetch 세 이벤트의 리스너가 있습니다. 각각 앱에 필요한 파일을 미리 캐시하고, 이전 버전의 캐시를 지우고, 요청이 오면 캐시에 있는 응답을 먼저 돌려주는 일을 합니다.
// sw.js
// 이 스크립트는 서비스 워커에서 실행됩니다.
const CACHE_PREFIX = 'my-pwa-cache-';
const CACHE_NAME = `${CACHE_PREFIX}v1`; // 캐시 이름 정의
const urlsToCache = [ // 캐시할 파일 목록
'/',
'/index.html',
'/styles.css',
'/main.js',
'/logo.png' // 예시 이미지
];
// install 이벤트: 서비스 워커 설치 시점에 발생
self.addEventListener('install', (event) => {
console.log('[Service Worker] 설치 중...', event);
event.waitUntil(
caches.open(CACHE_NAME) // 캐시 저장소 열기
.then((cache) => {
console.log('[Service Worker] 모든 리소스를 캐시합니다.');
return cache.addAll(urlsToCache); // 파일들을 캐시에 추가
})
);
});
// activate 이벤트: 활성화 시점에 발생 (이전 버전의 캐시 정리 등)
self.addEventListener('activate', (event) => {
console.log('[Service Worker] 활성화 중...', event);
event.waitUntil(
caches.keys().then((cacheNames) => {
return Promise.all(
cacheNames
.filter((cacheName) => cacheName.startsWith(CACHE_PREFIX) && cacheName !== CACHE_NAME)
.map((cacheName) => {
console.log('[Service Worker] 오래된 캐시 삭제:', cacheName);
return caches.delete(cacheName);
})
);
})
);
// 이미 열려 있는 페이지도 바로 제어하도록 요청
event.waitUntil(self.clients.claim());
});
// fetch 이벤트: 범위 안의 페이지가 보내는 네트워크 요청을 가로챔
self.addEventListener('fetch', (event) => {
console.log('[Service Worker] Fetch 요청 가로챔:', event.request.url);
event.respondWith(
caches.match(event.request) // 요청이 캐시에 있는지 확인
.then((response) => {
if (response) { // 캐시에 있으면 캐시된 응답 반환
console.log('[Service Worker] 캐시에서 응답 반환:', event.request.url);
return response;
}
console.log('[Service Worker] 네트워크 요청:', event.request.url); // 없으면 네트워크로
return fetch(event.request);
})
);
});install에서는 cache.addAll()로 목록의 파일을 모두 캐시합니다. 목록 가운데 하나라도 성공 응답을 받지 못하면 addAll()이 실패하고 설치도 끝나지 않으므로, 등록하기 전에 다섯 파일이 모두 성공 응답을 돌려주는지 확인하세요. activate에서는 이 앱의 접두사 my-pwa-cache-로 시작하는 캐시 가운데 지금 버전이 아닌 것만 지웁니다. 같은 출처의 다른 기능이 만든 캐시까지 지우지 않도록 자기 이름으로 범위를 좁힌 것입니다.
fetch에서는 먼저 캐시를 찾고, 없을 때만 네트워크로 요청하는 캐시 우선(cache-first) 방식으로 응답합니다. 실제로는 요청의 메서드와 출처, 종류(destination)를 보고 캐시와 네트워크 가운데 어느 쪽을 쓸지 정하는 규칙을 따로 세웁니다.
다음으로 메인 스크립트에서 서비스 워커를 등록합니다. 페이지의 첫 로딩을 방해하지 않도록 load 이벤트 뒤에 등록하는 경우가 많습니다.
// main.js (또는 HTML <script>)
if ('serviceWorker' in navigator) { // 브라우저가 서비스 워커를 지원하는지 확인
window.addEventListener('load', () => {
navigator.serviceWorker.register('/sw.js') // 서비스 워커 파일 경로 지정
.then((registration) => {
console.log('Service Worker 등록 성공:', registration.scope);
})
.catch((error) => {
console.error('Service Worker 등록 실패:', error);
});
});
} else {
console.warn('이 브라우저는 Service Worker를 지원하지 않습니다.');
}이렇게 배포하면 사용자가 처음 접속할 때 서비스 워커가 설치되고, 그 뒤로는 서비스 워커가 범위 안의 요청을 가로채 캐시에서 응답하거나 네트워크로 요청합니다. 반복 방문할 때 로딩이 빨라지고, 네트워크가 끊겨도 캐시해 둔 화면은 보여 줄 수 있습니다. 다만 캐시하지 않은 요청은 오프라인에서 실패합니다. 이 예제는 캐시 목록의 파일이 모두 있는 학습용 출처를 가정합니다.
등록과 제어를 구별하기
등록의 Promise가 성공했다고 해서 지금 보고 있는 페이지가 서비스 워커의 제어를 받고 있다는 뜻은 아닙니다. 다음 코드를 등록하는 페이지에 넣으면 제어자가 바뀌는 순간을 확인할 수 있습니다.
function showController() {
console.log('현재 controller:', navigator.serviceWorker.controller?.scriptURL ?? null);
}
if ('serviceWorker' in navigator) {
showController();
navigator.serviceWorker.addEventListener('controllerchange', showController);
}navigator.serviceWorker.controller는 지금 페이지를 제어하는 서비스 워커이고, 아직 없다면 null입니다. 첫 방문에서는 등록이 끝나도 처음에는 null이 출력되고, activate의 clients.claim()이 처리되면 controllerchange 이벤트가 발생해 스크립트 주소가 출력됩니다. 등록 완료, 활성화 완료, 제어 시작은 각각 다른 시점입니다.
제어가 시작된 것을 확인한 뒤 개발자 도구에서 네트워크를 끊고 미리 캐시한 index.html을 다시 요청해 보세요. 캐시 우선 방식이므로 캐시한 문서는 오프라인에서도 응답합니다. 서비스 워커는 동작을 확인하기 까다로우므로, Chrome 개발자 도구의 Application 탭에서 등록 상태와 대기 중인 버전, 캐시 내용을 함께 확인하는 것이 좋습니다.
정리
- 페이지의 자바스크립트는 메인 스레드에서 실행되므로, 오래 걸리는 계산은 웹 워커로 옮겨야 화면이 멈추지 않습니다.
- 웹 워커는 DOM에 접근할 수 없고
postMessage()와message이벤트로 메인 스레드와 데이터를 주고받으며, 데이터는 복제되거나 전송 가능 객체로 소유권이 넘어갑니다. - 다 쓴 워커는
terminate()로 종료하고, 워커의 예외는 메인 스레드의error이벤트로 받습니다. - 서비스 워커는 페이지와 네트워크 사이에서 요청을 가로채 캐시로 응답하며, 안전한 컨텍스트에서만 등록할 수 있습니다.
- 서비스 워커는 등록, 설치, 대기, 활성화를 거치며, 활성화된 것과 지금 페이지를 제어하는 것은 별개입니다.
install의 미리 캐시가 하나라도 실패하면 설치가 끝나지 않고, 캐시하지 않은 요청은 오프라인에서 실패합니다.
연습 문제
1. 입력을 30으로 바꾸면
같은 소수 알고리즘에서 입력을 30으로 바꿨습니다. 워커가 돌려줄 배열과 메인 쪽에 표시될 개수, 메인 핸들러의 처리 순서를 적어 보세요. 또 따로 실험해서, 입력 데이터를 ArrayBuffer로 만들어 postMessage(buffer, [buffer])처럼 전송 목록에 넣어 보냈다면, 보낸 뒤에도 메인 쪽에서 원래 buffer를 읽을 수 있을까요?
해설 보기
워커는 [2, 3, 5, 7, 11, 13, 17, 19, 23, 29]를 돌려주고, 메인 쪽에는 length인 10이 표시됩니다. 메인의 onmessage는 결과 문장을 바꾸고, 상태 문장을 바꾼 뒤, terminate()로 워커를 종료하고 myWorker를 null로 만듭니다. 버튼을 비활성화하거나 다시 활성화하는 처리는 이 코드에 없습니다.
ArrayBuffer를 전송 목록에 넣어 보내면 소유권이 워커로 넘어가므로, 메인 쪽의 buffer는 분리된(detached) 상태가 되어 길이가 0이 되고 원래 데이터를 읽을 수 없습니다. 결과를 계산하는 일과 데이터를 복사할지 넘길지 고르는 일은 서로 다른 문제입니다.
2. 설치된 새 버전과 실제 제어자
이미 버전 A가 페이지를 제어하고 있고, 새 버전 B의 미리 캐시는 성공했습니다. A가 제어하는 페이지는 아직 열려 있고, B는 skipWaiting()을 호출하지 않았습니다. B가 installed 상태이고 registration.waiting에 들어 있는 것을 확인했다면, 지금 페이지의 제어자가 B로 바뀌었다고 말할 수 있을까요? 또 따로 실험해서, B의 미리 캐시 목록에 없는 파일이 하나 들어가 install의 waitUntil()이 거부되었다면, B를 정상적인 대기 버전으로 볼 수 있을까요?
해설 보기
말할 수 없습니다. B는 대기 중이고 지금 페이지의 제어자는 여전히 A입니다. 설치가 끝났다는 것만으로는 제어를 단정할 수 없으며, A가 제어하는 페이지가 모두 닫히는 등 활성화 조건이 갖춰지고 새 제어 관계가 생겼는지 확인해야 합니다.
두 번째 경우는 설치에 실패한 것이므로 B를 대기 버전으로 볼 수 없고, B는 redundant가 됩니다. A가 계속 페이지를 제어하는 것은 B의 설치 실패와는 별개의 사실입니다. 또 서비스 워커가 이벤트 사이에 잠시 멈추는 것은 버전이 redundant가 되는 것과 다릅니다.
3. 캐시하지 않은 페이지
서비스 워커가 페이지를 제어하는 상태에서 네트워크를 끊고, 미리 캐시하지 않은 /about.html로 이동했습니다. 예제의 fetch 핸들러는 어떻게 동작하고, 사용자는 무엇을 보게 될까요?
해설 보기
caches.match()가 /about.html을 찾지 못해 undefined를 돌려주므로, 핸들러는 fetch(event.request)로 네트워크에 요청합니다. 네트워크가 끊겨 이 fetch()가 거부되고, respondWith()에 넘긴 Promise도 거부되므로 브라우저는 네트워크 오류 화면을 보여 줍니다.
오프라인에서도 안내 화면을 보여 주려면 미리 offline.html 같은 페이지를 캐시해 두고, 문서 요청의 fetch()가 실패했을 때 .catch(() => caches.match('/offline.html'))처럼 그 페이지로 응답하는 규칙을 더해야 합니다.
워커의 메시지와 종료 규칙은 HTML 표준의 Web Workers에서, 서비스 워커의 상태와 페이지 제어 규칙은 Service Workers 명세의 수명에서, 안전한 출처의 조건은 Secure Contexts 명세에서 확인할 수 있습니다.
이것으로 9장을 마칩니다. 9장에서는 Fetch API로 서버와 통신하고, 웹 스토리지에 데이터를 저장하고, History API로 라우팅을 만들고, Canvas API로 그래픽을 그리고, 웹 워커와 서비스 워커로 계산과 네트워크를 메인 스레드 밖으로 옮기는 방법까지 브라우저가 제공하는 기능을 두루 살펴봤습니다. 다음 장의 단위 테스트 (Jest 사용)부터는 지금까지 만든 코드가 의도대로 동작하는지 확인하는 테스트와, 문제를 찾아 고치는 디버깅과 성능 분석을 다룹니다.