Ray Book
웹 워커와 병렬 처리

Web Worker: 메인 스레드를 해방시켜라

JavaScript의 싱글 스레드 한계를 극복하는 방법, Web Worker로 무거운 연산을 별도 스레드에 오프로드합니다

javascriptweb-workerconcurrencymultithreading

메인 스레드 차단 문제

JavaScript는 싱글 스레드 언어입니다. 이벤트 루프 시리즈에서 살펴봤듯이, 콜스택은 하나이며 한 번에 하나의 작업만 처리합니다.

이것은 CPU 집약적 작업에서 문제가 됩니다.

// 이 코드가 실행되는 동안 UI는 완전히 멈춥니다
function heavyCompute(size) {
  let sum = 0;
  for (let i = 0; i < size; i++) {
    sum += Math.sqrt(i) * Math.sin(i);
  }
  return sum;
}

heavyCompute(100_000_000); // ~1-2초 동안 UI 프리즈

네트워크 요청은 브라우저가 별도로 처리하지만, 순수 CPU 작업은 setTimeout이나 requestAnimationFrame으로 나눠도 결국 같은 UI 스레드에서 실행됩니다. 이때 Web Worker가 필요합니다.

Web Worker란

Web Worker는 메인 스레드와 별도로 동작하는 JavaScript 실행 환경입니다. 실제 OS 스레드 위에서 실행되므로 진정한 병렬 처리가 가능합니다.

메인 스레드:    DOM 조작, 이벤트 처리, UI 업데이트
Worker 스레드:  CPU 집약적 연산, 데이터 처리, 파싱

두 스레드는 공유 메모리 없이 postMessage로 통신합니다. 기본적으로 데이터는 구조적 복제로 복사되어 전달됩니다.

기본 사용법

Worker는 별도의 스크립트 파일로 정의합니다.

// worker.js
self.onmessage = (e) => {
  const { nums } = e.data;
  const sum = nums.reduce((acc, n) => acc + n, 0);
  self.postMessage({ sum });
};

메인 스레드에서 Worker를 생성하고 메시지를 주고받습니다.

// main.js
const worker = new Worker('worker.js');

worker.onmessage = (e) => {
  console.log('결과:', e.data.sum);
};

worker.postMessage({ nums: [1, 2, 3, 4, 5] });

아래 시각화로 전체 흐름을 확인하세요.

메인 스레드 (main.js)대기
1const worker = new Worker('worker.js');
2 
3worker.onmessage = (e) => {
4 console.log('합계:', e.data.sum);
5};
6 
7worker.postMessage({ nums: bigArray });
8 
9// 워커 실행 중에도 계속 동작
10updateUI();
채널
워커 스레드 (worker.js)대기
1self.onmessage = (e) => {
2 const { nums } = e.data;
3 
4 // 무거운 연산
5 const sum = nums.reduce(
6 (acc, n) => acc + n, 0
7 );
8 
9 self.postMessage({ sum });
10};
초기 상태. 메인 스레드만 실행 중이며 Web Worker는 아직 생성되지 않았습니다. 모든 JavaScript는 이 단일 스레드에서 처리됩니다.

Worker의 전역 스코프

Worker는 브라우저 환경이지만 DOM에 접근할 수 없습니다. Worker 내부의 전역 객체는 DedicatedWorkerGlobalScope이며, self 키워드로 접근합니다.

Worker에서 사용 가능한 API (MDN Web Workers API 기준):

  • fetch, XMLHttpRequest
  • setTimeout, setInterval (단, requestAnimationFrame은 Window 전용이라 Worker에서는 쓸 수 없습니다)
  • WebSocket
  • IndexedDB, Cache API
  • crypto, navigator
  • importScripts() (classic worker) / import (module worker)

Worker에서 접근 불가능한 것들:

  • document, window, parent
  • 모든 DOM API (querySelector, createElement 등)
  • localStorage, sessionStorage (대신 IndexedDB 사용)
  • alert, confirm, prompt

Module Worker

ES 모듈을 Worker에서도 사용할 수 있습니다.

// module-worker.js
import { heavyCompute } from './utils.js';

self.onmessage = (e) => {
  const result = heavyCompute(e.data.input);
  self.postMessage(result);
};
// main.js
const worker = new Worker('./module-worker.js', { type: 'module' });

Vite, webpack 같은 번들러에서는 new URL()을 사용해 번들러가 Worker 파일을 별도로 처리하게 합니다.

// 번들러 환경 (Vite, webpack 5+)
const worker = new Worker(
  new URL('./worker.js', import.meta.url),
  { type: 'module' }
);

이 방식은 번들러가 Worker 파일의 의존성을 추적하고 별도 청크로 번들링할 수 있게 합니다.

오류 처리

const worker = new Worker('worker.js');

// Worker 내부에서 잡히지 않은 예외
worker.onerror = (e) => {
  console.error('Worker 오류:', e.message, e.filename, e.lineno);
  e.preventDefault(); // 오류가 전파되지 않도록
};

// 역직렬화 불가능한 값이 전달되었을 때
worker.onmessageerror = (e) => {
  console.error('메시지 역직렬화 실패:', e);
};

Worker 내부에서 잡히지 않은 예외는 메인 스레드의 onerror 핸들러로 전파됩니다.

Worker 종료

// 메인 스레드에서 강제 종료
worker.terminate();

// Worker 내부에서 스스로 종료
self.close();

terminate()는 즉시 종료합니다. 진행 중인 작업도 중단됩니다. close()는 Worker가 자신을 종료하는 방식입니다.

Worker 재사용

Worker 생성에는 스레드 시작 비용이 있습니다. 반복적으로 사용할 작업이라면 재사용하는 것이 좋습니다.

const worker = new Worker('worker.js');

async function processData(data) {
  return new Promise((resolve) => {
    worker.onmessage = (e) => resolve(e.data);
    worker.postMessage(data);
  });
}

// 같은 Worker를 여러 번 사용
const result1 = await processData(dataset1);
const result2 = await processData(dataset2);

다만 위 패턴은 onmessage 핸들러를 매번 덮어쓰므로, 동시에 여러 요청을 보내면 이전 요청의 응답이 무시될 수 있습니다. 실무에서는 메시지에 ID를 부여하거나, Worker 풀(pool) 패턴으로 여러 Worker를 미리 생성해 작업을 분배합니다.

Worker 종류

지금까지 다룬 것은 Dedicated Worker (전용 워커)입니다. 이 외에도 두 가지가 더 있습니다.

  • Shared Worker : 같은 출처의 여러 탭/iframe이 하나의 Worker를 공유합니다. MessagePort로 통신하며, 탭 간 상태 공유에 유용합니다.
  • Service Worker : 네트워크 프록시 역할을 하는 특수한 Worker입니다. 오프라인 캐싱, 푸시 알림 등 PWA의 핵심이며, 페이지와 독립적인 생명주기를 가집니다.

이 시리즈에서는 가장 일반적인 Dedicated Worker에 집중합니다.

실무 적용 사례

Vite : 개발 서버에서 TypeScript 타입 체크를 Worker 스레드로 오프로드합니다 (vite-plugin-checker). 프로덕션 빌드에서는 Rollup의 병렬 플러그인 실행에 Worker를 활용합니다.

Comlink (Google Chrome Labs) : Worker와 메인 스레드 간 통신을 Proxy로 감싸 일반 async 함수처럼 호출할 수 있게 해줍니다. postMessage 기반의 직렬화/역직렬화를 추상화해서, Worker 함수를 마치 로컬 async 함수처럼 호출할 수 있습니다.

// worker.js
import * as Comlink from 'comlink';
const api = {
  async heavyCompute(n) { /* ... */ }
};
Comlink.expose(api);

// main.js
import * as Comlink from 'comlink';
const worker = new Worker('worker.js', { type: 'module' });
const api = Comlink.wrap(worker);
const result = await api.heavyCompute(1_000_000); // 일반 async 함수처럼!

Figma : 공식 엔지니어링 블로그에 따르면, 렌더링 엔진의 레이아웃 계산과 플러그인 코드 실행을 Worker에서 처리합니다. 메인 스레드는 WebGL 렌더링만 담당해 UI 반응성을 유지합니다.

다음 단계

Worker 간 데이터 전달은 기본적으로 복사입니다. 256MB 버퍼를 전달하면 256MB를 복사합니다. 다음 글에서는 복사 없이 데이터를 이전하는 Transferable Objects를 살펴봅니다.