목차6개 항목
출처와 참고
web.dev의 「Learn Performance」 강좌 중 「An overview of web workers」, 「A concrete web worker use case」 모듈을 바탕으로 재구성하고, MDN 「Web Workers API」로 워커 종류를 보강한 노트다.
메인 스레드와 워커
「JavaScript는 싱글 스레드다」라는 말은 메인 스레드를 가리킨다. 메인 스레드 하나가 스크립트 실행, HTML·CSS 파싱, 스타일·레이아웃·페인트 대부분, 입력 처리를 맡는다(브라우저 렌더링 과정, 이벤트 루프). 브라우저 안에는 GPU 스레드 같은 다른 스레드도 있지만 개발자가 직접 쓸 수는 없다.
웹 워커(Web Worker) 는 개발자가 쓸 수 있는 추가 스레드다. 무거운 계산을 워커로 옮기면 메인 스레드에 긴 작업이 생기지 않아, 사용자 입력에 바로 반응할 수 있다(INP 최적화).
만들기와 메시지 주고받기
// main.js: 메인 스레드
const worker = new Worker('/js/my-worker.js') // 클래식 워커
// const worker = new Worker('/js/my-worker.js', { type: 'module' }) // 모듈 워커
worker.addEventListener('message', ({ data }) => {
console.log(data) // "Hello, window!"
})
worker.postMessage('hi')// my-worker.js: 워커 스레드. 전역은 window가 아니라 self
self.addEventListener('message', () => {
self.postMessage('Hello, window!')
})| 항목 | 워커에서 |
|---|---|
| 전역 객체 | self(WorkerGlobalScope). window가 아니다 |
| DOM | 접근할 수 없다. 화면을 바꾸려면 결과를 메인 스레드로 보낸다 |
| 쓸 수 있는 것 | 언어 기본 기능(배열·객체 등), fetch, IndexedDB, FileReader, 타이머 등 워커용 API |
| 대화 | postMessage와 message 이벤트. 데이터는 구조화된 복제로 복사된다 |
| 라이브러리 불러오기 | 클래식 워커는 importScripts(), 모듈 워커(type: 'module')는 정적 import |
- 메시지 하나로 끝나는 단순한 일이면 원시 API로 충분하다. 함수 호출처럼 쓰고 싶으면 Comlink 같은 추상화가
postMessage를 감싸 준다. - 큰
ArrayBuffer는 복사 대신 이전(transfer) 해서 비용을 없앨 수 있다. 스레드·복사 모델은 동시성 모델에 있다.
예: 이미지 메타데이터 읽기
이미지 URL을 받아 Exif 메타데이터(카메라 기종, 색 깊이 등)를 화면에 보여 주는 기능을 생각해 보자.
flowchart TB a["1. 이미지를 가져온다(fetch)"] --> b["2. 응답을 ArrayBuffer로 바꾼다"] b --> c["3. Exif 파서 라이브러리로 메타데이터를 뽑는다"] c --> d["4. 메타데이터로 HTML 문자열을 만든다"] d --> e["5. 화면에 넣는다<br/>(이것만 DOM이 필요하다)"]
1~4를 워커가 하고, 메인 스레드는 URL을 보내고 받은 HTML을 넣기만 한다.
// exif-worker.js
importScripts('/js/exifreader.js')
self.addEventListener('message', async ({ data: imageUrl }) => {
// 메타데이터는 파일 앞부분에 있으니 64KiB만 받는다
const res = await fetch(imageUrl, { headers: { Range: `bytes=0-${64 * 1024}` } })
const buffer = await res.arrayBuffer()
const tags = ExifReader.load(buffer, { expanded: true })
self.postMessage(toMarkup(tags))
})DevTools Performance 패널로 비교하면 차이가 보인다.
| 메인 스레드에서 | 워커에서 | |
|---|---|---|
| 메인 스레드 긴 작업 | 가져오기·디코딩, 메타데이터 추출 두 덩이 | 크게 짧아진다. 메시지 주고받기와 DOM 갱신만 남는다 |
| 파서 라이브러리 | 메인 스레드에서 받고 컴파일 | 다운로드·파싱·컴파일까지 워커에서 |
Range요청 헤더로 필요한 앞부분만 받는 것도 같이 쓸 만한 기법이다. 서버가 범위 요청을 지원해야 한다.- 실전 코드에서는 실패한 응답(
!res.ok)과 예외를 메시지로 돌려보내 처리한다.
무엇을 워커로 보내나
| 맞는 일 | 맞지 않는 일 |
|---|---|
| 응답 가져오기와 큰 JSON·바이너리 파싱 | DOM을 읽고 쓰는 일 |
| 대용량 데이터 정렬·필터·집계 | 아주 짧은 계산(메시지 복사 비용이 더 크다) |
| 이미지·오디오 처리, 압축, 암호화 | 매 프레임 화면과 맞물려야 하는 일(OffscreenCanvas로 옮길 수 있는 그리기는 예외) |
| 문법 강조, 검색 색인 같은 백그라운드 작업 |
워커로 옮길 수 없는 렌더링 관련 작업은 INP 최적화의 양보(scheduler.yield())로 쪼갠다.
워커의 종류
| 종류 | 만들기 | 전역 스코프 | 특징 |
|---|---|---|---|
| 전용 워커(dedicated) | new Worker(url) | DedicatedWorkerGlobalScope | 만든 스크립트 하나만 쓴다. 이 노트의 대부분 |
| 공유 워커(shared) | new SharedWorker(url) | SharedWorkerGlobalScope | 같은 출처의 여러 탭·iframe·워커가 함께 쓴다. worker.port(MessagePort)로 통신하고 워커는 connect 이벤트로 접속을 받는다. 탭 사이에 WebSocket 하나를 나눠 쓰는 식이다 |
| 서비스 워커 | navigator.serviceWorker.register(url) | ServiceWorkerGlobalScope | 페이지와 네트워크 사이의 프록시. 페이지가 없어도 이벤트로 깨어난다(서비스 워커 등록·스코프·생명주기와 업데이트) |
- 셋 다
Window는 없지만fetch,setTimeout같은 타이머, IndexedDB,crypto같은 공용 기능(WindowOrWorkerGlobalScope)은 쓴다. 워커 안에서XMLHttpRequest의responseXML은 늘null이다(DOM 파서가 없다). - 워커는 같은 출처 안에서 다시 워커를 만들 수 있다.
- 메시지 데이터는 공유되지 않고 구조화된 복제(structured clone) 로 복사된다. 함수·DOM 노드는 보낼 수 없다.
- 오디오 처리(
AudioWorklet)나 CSS 페인트(paintWorklet)처럼 렌더링 파이프라인 안의 정해진 지점에서 도는 가벼운 스크립트는 워클릿(worklet) 이라고 따로 부른다.
정리
- 웹 워커는 메인 스레드 밖에서 JS를 돌리는 스레드다. DOM·
window는 없고self와postMessage만 있다. - DOM이 필요 없는 무거운 일(가져오기·파싱·계산)을 옮기면 메인 스레드의 긴 작업이 줄어 INP가 좋아진다.
- 그 일에 쓰는 라이브러리의 다운로드·컴파일 비용도 메인 스레드에서 빠진다.
- 메시지는 복사되므로 작은 일에는 오히려 손해다. 큰 버퍼는 이전한다.
관련 노트: SharedArrayBuffer와 Atomics, INP 최적화, 이벤트 루프, 동시성·병렬·비동기·논블로킹과 동시성 모델, 브라우저 렌더링 과정, PWA와 서비스 워커 캐시 전략, PWA 오프라인 데이터와 저장소 관리