Javascript 코드가 실행되는 메인 스레드가 하나이기 때문에 Web이나 Javascript Engine이 싱글 스레드라고 착각하는 경우가 있어요. 하지만 실제로는 내부적으로는 멀티 스레드로 작동하고 있어요. 그렇기 때문에 fetch로 통신을 하고, setTimeout으로 타이머를 적용해 놓고 다른 작업을 할 수 있는 거에요.
Javascript는 의도적으로 메인스레드를 싱글 스레드(단일 실행 컨텍스트)로 작동하도록 만들었어요. 왜냐하면 Netscape communications는 정적인 HTML 문서를 동적으로 표현하기 위해 자바스크립트를 개발하였고,
이 제약은 의도적인 설계다. 멀티스레드 환경에서 두 스레드가 동시에 DOM을 수정하면 어떻게 될까? 한 스레드가 노드를 삭제하는 동안 다른 스레드가 그 노드에 이벤트를 붙이려 한다면? 브라우저는 이 복잡성을 피하기 위해 DOM 접근을 단일 스레드로 제한했다.
그러나 웹 애플리케이션이 복잡해지면서 이 선택은 점점 비싸졌다. 이미지 처리, 암호화, 대용량 JSON 파싱, 실시간 데이터 계산 — 이런 작업이 메인 스레드에서 실행되면 UI가 멈춘다. 사용자는 버튼이 반응하지 않는 화면을 보게 된다.
웹 표준은 이 문제를 세 가지 방식으로 풀었다.
멀티스레드 이야기를 시작하기 전에 싱글스레드가 어떻게 동작하는지 짚어야 한다. 잘못된 전제 위에서 Worker를 이해하면 나중에 반드시 삐걱거린다.
JavaScript는 이벤트 루프 위에서 실행된다. 구조는 단순하다.
while (true) {
task = taskQueue.dequeue()
execute(task)
drainMicrotasks() // Promise 체인 전부 소진
if (shouldRender) render()
}
setTimeout(fn, 0)은 "지금 당장"이 아니라 "현재 태스크가 끝난 뒤"를 의미한다. 그리고 태스크 하나가 끝나야 다음 렌더링이 실행된다. 다시 말해, 다음 코드는 버튼 클릭 후 화면을 절대 업데이트하지 않는다.
button.addEventListener('click', () => {
status.textContent = '처리 중...'; // 이 시점에 렌더링되지 않는다
const result = heavyComputation(); // 메인 스레드 점유
status.textContent = result; // 이것만 화면에 보인다
});
'처리 중...'이 화면에 나타나려면 렌더링 스텝이 실행되어야 하는데, heavyComputation이 태스크를 점유하는 동안 렌더링은 대기한다. 결국 사용자는 계산이 끝난 결과만 본다.
이것이 Web Worker가 필요한 이유의 전부다.
Web Worker는 JavaScript를 별도 스레드에서 실행하는 API다.
// main.js
const worker = new Worker('./worker.js');
worker.postMessage({ data: hugeArray });
worker.onmessage = (e) => {
console.log('결과:', e.data);
};
// worker.js
self.onmessage = (e) => {
const result = processHugeArray(e.data.data);
self.postMessage(result);
};
Worker는 메인 스레드와 완전히 격리된 실행 환경이다. window도 없고 document도 없다. 메인 스레드와 데이터를 주고받는 유일한 방법은 postMessage다.
postMessage로 전달하는 데이터는 기본적으로 복사된다. 이를 구조화된 복제(Structured Clone)라 한다. 100MB짜리 ArrayBuffer를 보내면 100MB가 Worker 메모리에 새로 만들어진다.
복사를 피하려면 소유권을 넘겨야 한다.
const buffer = new ArrayBuffer(100 1024 1024); // 100MB
// 복사 — 메인 스레드와 Worker 둘 다 메모리를 사용
worker.postMessage({ buffer });
// 이전 — 메인 스레드에서 buffer는 사라진다
worker.postMessage({ buffer }, [buffer]);
console.log(buffer.byteLength); // 0
두 번째 인자로 넘기는 배열이 Transferable 목록이다. ArrayBuffer, MessagePort, ImageBitmap, OffscreenCanvas가 이를 지원한다.
Worker를 쓰면 무조건 빨라질 것 같지만, 현실은 다르다.
postMessage 직렬화 비용이 있다. 작은 데이터를 수백 번 주고받는 구조라면 오히려 느려질 수 있다. Worker는 처음 생성할 때 스레드를 새로 만드는 비용이 있고, 이를 줄이려면 Worker Pool을 써야 한다.
또한 Worker 하나는 여전히 싱글스레드다. Worker 안에서 또 무거운 루프를 돌리면 그 Worker가 멈출 뿐이다. 진짜 병렬 처리가 필요하면 Worker를 여러 개 띄워야 한다.
const POOL_SIZE = navigator.hardwareConcurrency; // CPU 코어 수
const pool = Array.from({ length: POOL_SIZE }, () => new Worker('./worker.js'));
postMessage 기반 통신은 콜백 또는 ID 기반 응답 매핑을 직접 구현해야 해서 코드가 지저분해진다. Comlink는 이를 Proxy로 감싸 함수 호출처럼 쓸 수 있게 한다.
// worker.js
import { expose } from 'comlink';
const api = {
async processImage(imageData) {
return heavyImageProcessing(imageData);
}
};
expose(api);
// main.js
import { wrap } from 'comlink';
const worker = new Worker('./worker.js', { type: 'module' });
const api = wrap(worker);
const result = await api.processImage(imageData); // Worker에서 실행됨
Service Worker는 Web Worker와 이름이 비슷하지만 목적이 전혀 다르다.
Web Worker가 "CPU 연산을 분리"한다면, Service Worker는 "네트워크 요청을 제어"한다.
Service Worker는 페이지와 서버 사이에 앉아서 모든 fetch 요청을 가로챈다. 요청을 캐시에서 응답할 수도 있고, 네트워크로 보낼 수도 있고, 완전히 다른 응답을 만들어 돌려줄 수도 있다.
// sw.js
self.addEventListener('fetch', (event) => {
event.respondWith(
caches.match(event.request).then((cached) => {
return cached ?? fetch(event.request);
})
);
});
이 12줄이 오프라인 지원의 기본이다. 캐시에 있으면 캐시를, 없으면 네트워크를.
Service Worker는 등록(register) → 설치(install) → 대기(waiting) → 활성화(activate) 순서로 동작한다. 그리고 활성화 이후에는 fetch, push, sync 이벤트를 처리한다.
중요한 특성이 두 가지 있다.
첫째, 비활성 상태에서 브라우저가 terminate한다. 이벤트가 없으면 브라우저는 Service Worker를 종료해 자원을 회수한다. 다음 이벤트가 오면 다시 시작한다. 따라서 전역 변수에 상태를 저장하면 안 된다. 영속 상태는 IndexedDB나 Cache API에 써야 한다.
둘째, 새 버전은 기존 페이지가 닫혀야 활성화된다. 이미 열린 탭이 있으면 새 Service Worker는 설치 후 대기 상태에 머문다. skipWaiting()을 호출하면 강제로 활성화할 수 있지만, 이 경우 기존 탭에서 코드가 섞여 실행될 수 있어 주의가 필요하다.
요청 유형에 따라 전략을 다르게 가져가야 한다.
Cache First — 정적 에셋(JS, CSS, 이미지). 바뀌지 않는 것들.
// 캐시에 있으면 즉시 반환, 없으면 네트워크
caches.match(request) ?? fetch(request)
Network First — API 응답. 최신 데이터가 중요한 경우.
// 네트워크 우선, 실패하면 캐시
fetch(request).catch(() => caches.match(request))
Stale-While-Revalidate — 뉴스, 블로그 등 자주 바뀌지 않는 콘텐츠.
// 캐시를 즉시 반환하면서 백그라운드에서 갱신
const cached = caches.match(request);
const fresh = fetch(request).then((res) => {
cache.put(request, res.clone());
return res;
});
return cached ?? fresh; // 캐시 없으면 fresh 대기
실전에서는 이걸 직접 구현하기보다 Workbox를 쓰는 게 낫다. 캐시 만료, URL 변형, 오프라인 폴백 처리에서 엣지 케이스가 생각보다 많다.
Web Worker의 postMessage는 데이터를 복사하거나 소유권을 이전한다. 두 스레드가 동시에 같은 메모리를 바라보게 하려면 SharedArrayBuffer가 필요하다.
// 메인 스레드
const sab = new SharedArrayBuffer(4 * 4); // 16바이트
const shared = new Int32Array(sab);
worker.postMessage({ sab }); // 참조를 전달 — 복사 아님
shared[0] = 42;
// Worker가 shared[0]을 읽으면 42가 보인다
단, 두 스레드가 동시에 같은 인덱스를 읽고 쓰면 레이스 컨디션이 발생한다. 이를 막기 위해 Atomics API가 존재한다.
// Worker에서
const shared = new Int32Array(data.sab);
// 원자적 읽기-수정-쓰기
Atomics.add(shared, 0, 1); // shared[0]++를 안전하게
// 다른 스레드 대기
Atomics.wait(shared, 0, 0); // shared[0]이 0인 동안 대기
Atomics.notify(shared, 0, 1); // 대기 중인 스레드 하나 깨우기
Atomics.wait는 메인 스레드에서 호출할 수 없다. UI를 블로킹하는 게 원칙적으로 금지되어 있어서다. 메인 스레드에서 비동기로 기다리려면 Atomics.waitAsync를 쓴다.
SharedArrayBuffer는 Spectre 취약점으로 인해 2018년에 비활성화됐다가, 2020년에 조건부로 복구됐다. 아래 두 헤더를 서버에서 설정해야만 사용할 수 있다.
Cross-Origin-Opener-Policy: same-origin
Cross-Origin-Embedder-Policy: require-corp
이 헤더가 없으면 SharedArrayBuffer는 undefined로 노출된다. 그리고 이 헤더를 켜면 서드파티 iframe이나 리소스가 깨질 수 있다. 설정 전에 반드시 영향 범위를 점검해야 한다.
C나 C++ 코드를 WebAssembly로 컴파일하면 pthread가 포함될 수 있다. Emscripten은 이를 Worker + SharedArrayBuffer + Atomics의 조합으로 에뮬레이션한다.
pthread_create가 호출되면 내부적으로 new Worker()가 실행된다. 각 Worker는 동일한 WASM 선형 메모리(SharedArrayBuffer)를 공유한다. pthread_mutex_lock은 Atomics.wait으로 번역된다.
덕분에 수만 줄의 멀티스레드 C++ 코드를 브라우저에 거의 그대로 포팅할 수 있다. 치과용 CAD, 영상 편집기, 게임 엔진이 브라우저에서 실행되는 건 이 구조 덕분이다.
// C++ 코드 — 그대로 브라우저에서 동작
void process_in_parallel(float* data, int len) {
std::vector<std::thread> threads;
int chunk = len / std::thread::hardware_concurrency();
for (int i = 0; i < len; i += chunk) {
threads.emplace_back([=] {
for (int j = i; j < std::min(i + chunk, len); j++) {
data[j] = heavy_math(data[j]);
}
});
}
for (auto& t : threads) t.join();
}
웹에서 멀티스레드를 쓰는 방식은 세 가지 층위로 나뉜다.
Web Worker — 메인 스레드의 연산 부하를 다른 스레드로 옮긴다. DOM이 필요 없는 무거운 작업에 쓴다.
Service Worker — 네트워크 레이어를 제어한다. 캐싱, 오프라인 지원, 백그라운드 동기화. 연산보다는 I/O 제어가 목적이다.
SharedArrayBuffer + Atomics — 두 스레드가 같은 메모리를 공유한다. 복사 비용 없이 대용량 데이터를 처리하거나, WASM 기반 멀티스레드 코드를 실행할 때 쓴다.
싱글스레드 제약은 여전히 유효하다. 그 위에서 어떤 탈출구를 고를지는 문제의 성격에 달려 있다.