들어가며
JavaScript는 브라우저 탭당 단일 스레드로 동작합니다. 렌더링, JS 실행, 사용자 입력 처리가 모두 하나의 스레드에서 일어나기 때문에, 무거운 연산이 끼어들면 UI가 멈춥니다. Web Workers는 이 문제를 해결하는 브라우저 내장 API입니다. 메인 스레드는 왜 막히는가 하면, Chrome 기준으로 50ms를 초과하는 작업은 Long Task로 분류됩니다. 60fps 렌더링의 한 프레임이 약 16ms이므로, 50ms 이상의 작업은 최소 3프레임을 누락시킵니다. 스크롤이 끊기고, 버튼 클릭에 반응이 없고, 애니메이션이 뚝뚝 끊기는 현상이 바로 이 때문입니다.
Web Worker 유형
| 항목 | Dedicated Worker | Shared Worker | Service Worker |
|---|---|---|---|
| 접근 범위 | 생성한 스크립트에서만 | 같은 origin의 여러 window/iframe에서 공유 | 네트워크 프록시 역할 |
| 통신 | postMessage 직접 사용 | MessagePort를 통해 통신 | 이벤트 기반 (FetchEvent 등) |
| 용도 | CPU 집약적 작업 오프로드 | 탭 간 상태 공유, 공유 WebSocket | 오프라인 캐시, 푸시 알림, PWA |
| 상태 | 페이지 닫으면 종료 | 모든 연결 닫히면 종료 | 브라우저가 관리 (비상태적) |
이 글에서는 가장 범용적인 Dedicated Worker를 중심으로 다룹니다.
핵심 API: postMessage와 Structured Clone
메인 스레드와 워커는 postMessage로 통신합니다. 이때 데이터는 Structured Clone Algorithm으로 깊은 복사됩니다.
// worker.ts
self.onmessage = (e: MessageEvent<{ data: number[] }>) => {
const { data } = e.data;
const sorted = [...data].sort((a, b) => a - b);
self.postMessage({ sorted });
};
// main.ts
const worker = new Worker(new URL('./worker.ts', import.meta.url), {
type: 'module',
});
worker.postMessage({ data: hugeArray });
worker.onmessage = (e: MessageEvent<{ sorted: number[] }>) => {
renderList(e.data.sorted);
};복사 가능한 것과 불가능한 것
Structured Clone으로 복사할 수 있는 타입은 다양합니다.
- 가능: 원시 타입, Array, Map, Set, Date, RegExp, ArrayBuffer, TypedArray, Blob, File, Error
- 불가능: Function, DOM 노드, Symbol, 프로토타입 체인, getter/setter
DataCloneError가 발생하면 보통 함수나 DOM 노드를 넘기려 한 경우입니다. 워커에서는 DOM에 접근할 수 없다는 점을 항상 기억해야 합니다.
postMessage 오버헤드
Structured Clone은 공짜가 아닙니다. Chrome 기준 벤치마크입니다.
| 객체 키 수 | 왕복 시간 |
|---|---|
| 1,000 이하 | 1ms 미만 |
| 10,000 | ~2.5ms |
| 100,000 | ~35ms |
| 1,000,000 | ~550ms |
수천 개 이하의 프로퍼티라면 오버헤드가 무시할 수준이지만, 대용량 데이터에서는 이 비용 자체가 문제가 됩니다. 이때 Transferable Objects가 해법입니다.
Transferable Objects: 복사 대신 소유권 이전
Transferable은 데이터를 복사하지 않고 메모리 소유권을 이전합니다. Zero-copy 연산이라 크기와 무관하게 거의 일정한 전송 비용을 가집니다.
// 32MB ArrayBuffer 전송 비교
const buffer = new ArrayBuffer(32 * 1024 * 1024);
// Clone: ~302ms
worker.postMessage({ buffer });
// Transfer: ~6.6ms (약 45배 빠름)
worker.postMessage({ buffer }, [buffer]);
// 전송 후 buffer.byteLength === 0 (detached)전송 가능한 대표적인 객체는 ArrayBuffer, MessagePort, ReadableStream, OffscreenCanvas, ImageBitmap 등입니다.
주의할 점이 있었습니다. 대량의 작은 Transferable 객체를 한 번에 전송하면 오히려 역전 현상이 발생합니다.
| 항목 수 (100바이트씩) | Clone | Transfer |
|---|---|---|
| 10,000 | 5ms | 2,015ms |
| 100,000 | 65ms | 7,609ms |
Chrome/Edge에서 수만 개의 개별 ArrayBuffer를 transfer할 때 매핑 오버헤드로 지수적으로 느려졌습니다. 결론: 적은 수의 큰 객체에는 Transfer, 많은 수의 작은 객체에는 Clone이 유리합니다.
실전 사용 사례
1) 대용량 데이터 파싱/필터링
가장 빈번하게 쓰이는 패턴입니다. 10MB 이상의 CSV/JSON 파싱, 수만 건 리스트의 퍼지 검색, 복잡한 필터 조건 적용을 워커에서 처리합니다.
// filter-worker.ts
interface FilterParams {
records: Record<string, string | number>[];
query: string;
fields: string[];
}
self.onmessage = (e: MessageEvent<FilterParams>) => {
const { records, query, fields } = e.data;
const lowerQuery = query.toLowerCase();
const filtered = records.filter((record) =>
fields.some((field) => {
const value = record[field];
return typeof value === 'string' && value.toLowerCase().includes(lowerQuery);
}),
);
self.postMessage({ filtered, total: records.length, matched: filtered.length });
};2) 이미지 처리
Canvas pixel 데이터를 워커로 전송하여 필터를 적용합니다. OffscreenCanvas를 transfer하면 워커에서 직접 렌더링도 가능합니다.
// image-worker.ts
self.onmessage = (e: MessageEvent<{ imageData: ImageData }>) => {
const { imageData } = e.data;
const pixels = imageData.data;
// 그레이스케일 변환
for (let i = 0; i < pixels.length; i += 4) {
const avg = (pixels[i] + pixels[i + 1] + pixels[i + 2]) / 3;
pixels[i] = avg; // R
pixels[i + 1] = avg; // G
pixels[i + 2] = avg; // B
}
self.postMessage({ imageData }, [imageData.data.buffer]);
};3) 암호화/해시 연산
Web Crypto API는 워커 내에서도 self.crypto.subtle로 접근 가능합니다.
// hash-worker.ts
self.onmessage = async (e: MessageEvent<{ data: ArrayBuffer }>) => {
const hashBuffer = await crypto.subtle.digest('SHA-256', e.data.data);
const hashArray = Array.from(new Uint8Array(hashBuffer));
const hashHex = hashArray.map((b) => b.toString(16).padStart(2, '0')).join('');
self.postMessage({ hash: hashHex });
};Comlink으로 통신 단순화
postMessage 기반의 통신은 코드가 금방 복잡해집니다. Google Chrome Labs의 Comlink(~1.1KB)을 사용하면 워커 함수를 마치 로컬 async 함수처럼 호출할 수 있습니다.
// math-worker.ts
import * as Comlink from 'comlink';
const api = {
calculatePrimes(limit: number): number[] {
const primes: number[] = [];
for (let i = 2; i <= limit; i++) {
let isPrime = true;
for (let j = 2; j * j <= i; j++) {
if (i % j === 0) {
isPrime = false;
break;
}
}
if (isPrime) primes.push(i);
}
return primes;
},
};
Comlink.expose(api);// main.ts
import * as Comlink from 'comlink';
const worker = new Worker(new URL('./math-worker.ts', import.meta.url), {
type: 'module',
});
const api = Comlink.wrap<{ calculatePrimes(limit: number): number[] }>(worker);
// 마치 로컬 함수처럼 호출
const primes = await api.calculatePrimes(1_000_000);postMessage/onmessage 보일러플레이트가 사라지고, 타입 안전성도 유지됩니다. Transferable 전송도 Comlink.transfer()로 지원합니다.
SharedArrayBuffer와 Atomics
postMessage는 데이터를 복사하거나 소유권을 이전합니다. SharedArrayBuffer는 여러 스레드가 같은 메모리를 직접 공유합니다.
보안 요구사항
2018년 Spectre 취약점 이후, SharedArrayBuffer는 Cross-Origin Isolation이 적용된 환경에서만 사용 가능합니다.
Cross-Origin-Opener-Policy: same-origin
Cross-Origin-Embedder-Policy: require-corp
window.crossOriginIsolated가 true인지 확인한 후 사용해야 합니다. 이 헤더가 서드파티 iframe이나 이미지와 호환성 문제를 일으킬 수 있으므로, 도입 전 반드시 영향 범위를 확인해야 합니다.
Atomics로 스레드 간 동기화
SharedArrayBuffer에 여러 스레드가 동시에 접근하면 race condition이 발생합니다. Atomics는 이를 방지하는 원자적 연산을 제공합니다.
// 워커에서 진행률을 메인 스레드로 공유하는 예시
// main.ts
const sab = new SharedArrayBuffer(Int32Array.BYTES_PER_ELEMENT);
const progress = new Int32Array(sab);
const worker = new Worker(new URL('./heavy-worker.ts', import.meta.url), {
type: 'module',
});
worker.postMessage({ progress });
// 메인 스레드에서 진행률 폴링
const interval = setInterval(() => {
const current = Atomics.load(progress, 0);
updateProgressBar(current);
if (current >= 100) clearInterval(interval);
}, 100);// heavy-worker.ts
self.onmessage = (e: MessageEvent<{ progress: Int32Array }>) => {
const { progress } = e.data;
for (let i = 0; i <= 100; i++) {
performChunk(i);
Atomics.store(progress, 0, i);
Atomics.notify(progress, 0);
}
};주요 Atomics 메서드를 정리하면 다음과 같습니다.
| 메서드 | 설명 |
|---|---|
Atomics.load(ta, idx) | 원자적 읽기 |
Atomics.store(ta, idx, val) | 원자적 쓰기 |
Atomics.add / sub | 원자적 덧셈/뺄셈 |
Atomics.compareExchange | CAS(Compare-And-Swap) 연산 |
Atomics.wait(ta, idx, val) | 값이 같으면 블로킹 대기 (워커에서만 사용 가능) |
Atomics.waitAsync(ta, idx, val) | 비블로킹 대기, Promise 반환 (메인 스레드에서도 사용 가능) |
Atomics.notify(ta, idx, count) | 대기 중인 스레드 깨움 |
SharedArrayBuffer는 강력하지만, 단순 데이터 전달에는 postMessage가 훨씬 간단하고 안전합니다. 동시성 버그(race condition, deadlock)의 디버깅이 매우 어렵기 때문에, 진짜 공유 메모리가 필요한 경우에만 사용하는 것이 좋습니다.
번들러 통합
Webpack 5
const worker = new Worker(new URL('./worker.ts', import.meta.url));Webpack이 이 패턴을 인식하여 워커 파일을 별도 entry point로 번들링합니다.
Vite
const worker = new Worker(new URL('./worker.ts', import.meta.url), {
type: 'module',
});또는 쿼리 스트링 방식으로도 가능합니다.
import MyWorker from './worker?worker';
const worker = new MyWorker();React/Next.js에서 사용할 때
워커 초기화는 반드시 useEffect 안에서 해야 합니다. SSR 환경에서는 Worker API가 존재하지 않기 때문입니다.
function DataProcessor({ data }: { data: number[] }) {
const workerRef = useRef<Worker | null>(null);
const [result, setResult] = useState<number[]>([]);
useEffect(() => {
workerRef.current = new Worker(new URL('../workers/processor.ts', import.meta.url), {
type: 'module',
});
workerRef.current.onmessage = (e: MessageEvent<{ processed: number[] }>) => {
setResult(e.data.processed);
};
return () => {
workerRef.current?.terminate();
};
}, []);
useEffect(() => {
workerRef.current?.postMessage({ data });
}, [data]);
return <List items={result} />;
}워커 사용 판단 기준
모든 작업을 워커로 보내면 좋은 것은 아닙니다. 워커 생성 비용(약 40ms)과 postMessage 오버헤드를 고려해야 합니다.
| 기준 | 설명 |
|---|---|
| 16ms 초과 | 60fps 한 프레임을 넘기는 작업. 워커 후보 |
| 50ms 초과 | Long Task 임계값. 사용자 경험에 직접 영향. 워커 강력 권장 |
| 통신 > 연산 | 작업 자체가 수 ms 이내라면 워커로 보내는 것이 오히려 느림 |
측정 방법은 performance.now()로 작업 시간을 찍거나, PerformanceObserver로 Long Task를 모니터링하는 것입니다.
const observer = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
if (entry.duration > 50) {
reportLongTask(entry);
}
}
});
observer.observe({ type: 'longtask', buffered: true });Chrome DevTools Performance 탭에서도 Long Tasks를 시각적으로 확인할 수 있습니다.
주의점 정리
1) DOM에 접근할 수 없습니다
document, window, localStorage 등은 워커 내에서 사용 불가합니다. 대신 self가 워커의 글로벌 스코프입니다. 다만 fetch, IndexedDB, WebSocket, crypto.subtle 등은 사용할 수 있습니다.
2) 워커 생성 비용이 있습니다
워커 인스턴스 생성에 약 40ms, 별도 JS 파일 로드/파싱 시간이 추가됩니다. 작업마다 워커를 생성/종료하면 비용이 누적되므로, Worker Pool 패턴으로 미리 생성한 워커를 재사용하는 것이 좋습니다. 풀 크기는 navigator.hardwareConcurrency로 논리 코어 수를 참고하여 결정합니다.
3) 메모리 오버헤드를 고려해야 합니다
각 워커는 별도 JS 엔진 인스턴스와 별도 GC를 가집니다. 워커를 과도하게 생성하면 메모리 사용량이 급증할 수 있습니다.
4) Module Workers 지원을 확인해야 합니다
{ type: 'module' } 옵션은 Chrome 80+, Firefox 114+, Safari 15+에서 지원됩니다. 전체 사용자의 약 97%를 커버하지만, 레거시 환경을 지원해야 한다면 classic worker를 사용해야 합니다.
결론
Web Workers는 메인 스레드의 응답성을 지키면서 무거운 작업을 처리하는 가장 직접적인 방법이었습니다.
제가 현재 쓰는 기준은 단순합니다.
- 50ms 이상의 연산: 반드시 워커로 분리
- 대용량 바이너리 데이터: Transferable로 전송
- 단순 JSON 데이터: Structured Clone (postMessage 기본 동작)
- 복잡한 워커 통신: Comlink으로 추상화
- 진짜 공유 메모리가 필요한 경우만: SharedArrayBuffer + Atomics
INP가 Core Web Vitals 지표로 자리 잡으면서, 메인 스레드 최적화의 중요성은 더 커질 것입니다. Web Workers를 단순한 성능 최적화가 아닌, UI 응답성을 보장하는 아키텍처 패턴으로 접근하면 적용 시점을 판단하기가 훨씬 수월했습니다.
읽어주셔서 감사합니다.
참고 자료
- web.dev - Use web workers to run JavaScript off the browser's main thread
- web.dev - A concrete web worker use case
- MDN - Using Web Workers
- MDN - Transferable objects
- MDN - Structured clone algorithm
- MDN - SharedArrayBuffer
- MDN - Atomics
- Chrome Blog - Transferable objects: Lightning fast
- web.dev - Making your website cross-origin isolated using COOP and COEP
- GitHub - GoogleChromeLabs/comlink
- James Milner - Examining Web Worker Performance
- Performance issue of using massive transferable objects in Web Worker
- V8 - Atomics.wait, Atomics.notify, Atomics.waitAsync