FEInterview Prep

browser

Web Workers와 멀티스레딩 — JS의 병렬 처리

"JavaScript는 싱글스레드인데 어떻게 무거운 연산을 처리하나요?" — 이 질문의 완전한 답은 Web Workers입니다. 이미지 필터링, CSV 파싱, 암호화, 물리 시뮬레이션 같은 CPU 집약적 작업을 메인 스레드에서 실행하면 UI가 멈춥니다. Workers로 오프로드하면 60fps를 유지하면서도 복잡한 연산이 가능합니다. Figma, Google Docs, VS Code Web이 Workers를 핵심 기술로 사용합니다.

시작 전
이해도
매우 낮음

학습 개요

탄생 배경

해결하려 했던 문제

JavaScript는 싱글스레드로 설계됐습니다. 이벤트 루프가 한 번에 하나의 태스크만 처리하므로, 무거운 연산이 메인 스레드를 차지하면 UI 업데이트, 이벤트 처리, 사용자 입력이 모두 차단됩니다. "UI가 멈춘다"는 사용자 경험의 치명적 문제였습니다. 특히 SPA가 복잡해지고, 브라우저에서 수행하는 연산이 늘어나면서 이 문제가 심각해졌습니다.

역사적 맥락

Web Workers는 2010년 HTML5 명세에 포함됐습니다. 초기에는 구현이 제한적이었으나 브라우저 지원이 안정화됐습니다. Dedicated Workers, Shared Workers, Service Workers로 세분화됐고, 2017년 SharedArrayBuffer + Atomics로 Workers 간 메모리 공유가 가능해졌습니다. (Spectre 취약점으로 2018년 임시 비활성화, 2020년 COOP/COEP 헤더와 함께 재활성화). 최근 Comlink(Google)로 Worker 통신이 간소화됐습니다.

이전에는 어떻게 했나

setTimeout/requestAnimationFrame으로 작업을 분할(chunking)하는 방식이 있습니다. 큰 배열을 한 번에 처리하지 않고 청크로 나눠 이벤트 루프가 중간에 다른 태스크를 처리하도록 양보합니다. 하지만 이는 병렬 처리가 아닌 인터리빙입니다. WebAssembly threads(pthread 기반)로 진정한 네이티브 멀티스레딩도 가능합니다.

멘탈 모델

동작 원리

Web Workers 종류: 1. Dedicated Worker: 단일 스크립트에서 사용. 가장 일반적 2. Shared Worker: 여러 탭/창이 공유. BroadcastChannel과 유사 3. Service Worker: 오프라인, 캐싱, Push (별도 챕터) Worker의 특성: - 독립적인 스레드에서 실행 (OS 레벨 스레드 또는 프로세스) - DOM, window 객체에 접근 불가 - postMessage()로 메인 스레드와 통신 (구조화된 복사 알고리즘) - 직접 공유 불가 → 데이터 복사 or SharedArrayBuffer 구조화된 복사 알고리즘 (Structured Clone): - postMessage로 전송 시 데이터 깊은 복사 - Function, DOM 노드, Symbol은 복사 불가 - ArrayBuffer, Blob 등은 Transferable로 이전(복사 없음) 가능 SharedArrayBuffer + Atomics: - Workers 간 메모리 직접 공유 - Atomics.wait/notify로 동기화 (뮤텍스 역할) - COOP/COEP HTTP 헤더 필요

핵심 구성 요소

postMessage()

메인↔Worker 비동기 메시지 전달. 데이터는 깊은 복사(또는 이전)

Transferable Objects

ArrayBuffer 등을 복사 없이 소유권 이전 — 대용량 데이터에 효율적

SharedArrayBuffer

Workers 간 공유 메모리. COOP/COEP 헤더 필요

Atomics

SharedArrayBuffer 원자적 연산. 경쟁 조건 방지

Worker Pool

여러 Worker를 미리 생성해두고 재사용 — 생성 비용 상각

Comlink

Google 라이브러리. Worker 함수를 Proxy로 감싸 async/await로 호출 가능

흐름 설명


[메인 스레드 ↔ Worker 통신]

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

worker.postMessage({ type: 'PROCESS', data: largeArray });
// 데이터가 복사되어 Worker로 전송

worker.onmessage = (e) => {
  const { result } = e.data; // 처리 완료
  updateUI(result);
};

// worker.js
self.onmessage = (e) => {
  const { data } = e.data;
  const result = heavyCalculation(data); // UI 차단 없음
  self.postMessage({ result });
};

[Transferable로 대용량 데이터 이전]
// 10MB ArrayBuffer 전송 시 복사 대신 이전
worker.postMessage(buffer, [buffer]); // 이전 후 main에서 buffer 사용 불가
    

코드 예제


// ✅ Comlink를 이용한 Worker 추상화

// worker.js
import { expose } from 'comlink';

const api = {
  async processData(data) {
    // 무거운 연산
    return data.map(x => x * 2);
  },
  async parseCSV(text) {
    return text.split('
').map(line => line.split(','));
  },
};

expose(api);

// main.js
import { wrap } from 'comlink';

const worker = new Worker('./worker.js', { type: 'module' });
const api = wrap(worker);

// 마치 일반 함수 호출처럼 사용 (내부적으로 postMessage)
const result = await api.processData([1, 2, 3]);

// ✅ React에서 Worker Pool 패턴
function useWorkerPool(workerPath, poolSize = 4) {
  const [pool] = useState(() =>
    Array.from({ length: poolSize }, () => new Worker(workerPath))
  );
  const counter = useRef(0);

  const dispatch = useCallback((data) => {
    const worker = pool[counter.current++ % poolSize];
    return new Promise((resolve) => {
      worker.onmessage = (e) => resolve(e.data);
      worker.postMessage(data);
    });
  }, [pool]);

  return dispatch;
}
    

비교 분석

Web

vs

setTimeout 청킹

비교 관점
이 방식
setTimeout 청킹
방식
Worker: 진정한 병렬 처리 (별도 스레드)
청킹: 인터리빙 (메인 스레드 양보)
UI 차단
없음
청크 처리 중 일시적 차단 가능
복잡성
통신 코드 필요
단순 — for 루프 분할만으로 구현
CPU 활용
멀티코어 활용 가능
단일 코어만 사용

Web

vs

WebAssembly

비교 관점
이 방식
WebAssembly
목적
Worker: JS의 멀티스레딩
Wasm: 고성능 실행 + 다른 언어 포팅
언어
JavaScript/TypeScript 그대로 사용
C/C++/Rust 등 별도 컴파일 필요
도달 성능
JS 최대 성능 (JIT 최적화)
C에 근접한 성능
조합
독립 사용
Worker 안에서 Wasm 실행 → 최강 조합

트레이드오프

이상적인 사용 사례

면접 질문