Javascript 코드가 실행되는 메인 스레드가 하나이기 때문에 Web이나 Javascript Engine이 싱글 스레드라고 착각하는 경우가 있어요. 하지만 실제로는 내부적으로는 멀티 스레드로 작동하고 있어요. 그렇기 때문에 fetch로 통신을 하고, setTimeout으로 타이머를 적용해 놓고 다른 작업을 할 수 있는 거에요. (ex. SetTimeout 300ms가 지나면 Task Queue에 콜백 함수를 push => 300ms동안 Call Stack의 작업이 중지되지 않으며 메인 쓰레드는 계속 다른 작업들을 처리하고 있음.)

Javascript는 HTML 문서를 동적으로 꼬임없이 표혐하기위해 의도적으로 메인스레드를 싱글 스레드(단일 실행 컨텍스트)로 작동하도록 만들었어요. 왜냐하면 멀티스레드로 여러 스레드가 동시에 DOM을 수정하면 관리가 힘들어지기 때문이에요. 예를 들면 한 스레드가 노드를 삭제하는 동안에 다른 스레드가 그 노드에 이벤트를 붙이려하는 특수 케이스들이 생겨나면 메모리가 어떻게 누수되는지도 모르고, 관리도 복잡해져요.
문제는 웹 애플리케이션이 복잡해지면서 위 선택으로 순수 자바스크립트만으로는 성능 문제를 야기시키는 경우가 많아졌어요. 예를 들면 많은 대용량 데이터의 처리가 필요한 경우 자바스크립트만으로 구현하면 메인 스레드를 막기 때문에 사용자는 잠시 멈추는 듯한 경험을 하게 되요.
그래서 이를 해결하기 위해 다양한 방법이 만들어 졌어요. 대표적으로 웹 워커, 웹 어셈블리와 공유메모리 같은 기능이에요.
멀티 스레드 이야기를 시작하기 전에 웹에서 사용되는 언어인 Javascript의 싱글스레드가 어떻게 동작하는지 이로인해 어떤 상황이 생기고 어떻게 해결하는지 예시로 함께봐요. (자세한 설명은 스터디원들에게만 제공합니다.!!!!!)
// 무거운 연산을 메인 스레드에서 실행하는 상황 (렌더링 문제 + 상호작용 먹통 문제)button.addEventListener('click', () => { status.textContent = '처리 중...'; // 이 시점에 렌더링되지 않는다 const result = heavyComputation(); // 메인 스레드 점유 => 유저 상호작용 먹통 status.textContent = result; // 이제야 화면에 출력});// 방법 1. requestAnimationFrame을 사용 (렌더링 사이클 진입전 실행됨)button.addEventListener('click', () => { status.textContent = '처리 중...'; requestAnimationFrame(() => { // 다음 렌더링 사이클에 이 함수 실행.(운 안좋으면 "처리 중..." 문구 못봄) const result = heavyComputation(); status.textContent = result; });});// 방법 2. setTimeout을 사용button.addEventListener('click', () => { status.textContent = '처리 중...'; setTimeout(() => { // 다시 MacroTask Queue에 아래 함수 쌓음. (운 안좋으면 "처리 중..." 문구 못봄) const result = heavyComputation(); status.textContent = result; }, 0);});// 방법 3.requestAnimationFrame 2중 중첩button.addEventListener('click', () => { status.textContent = '처리 중...'; setTimeout(() => { // 첫 번째: Paint 기회 건너뛰기 setTimeout(() => { // 두 번째: 여기서 heavy 실행 const result = heavyComputation(); status.textContent = result; }, 0); }, 0);});위 코드에서는 웹 렌더링 라이프사이클에서 반드시 "처리 중..." 문구를 보여주기 위한 방법을 설명하면서 웹 엔진 자바스크립트 메인 스레드가 런타임에 어떻게 작동하는지 보여줬어요.
하지만 아직 무거운 함수를 실행하는 동안 유저 상호작용이 멈추는 문제는 해결하지 못했어요. 이 문제를 가장 간단하게 해결하려면 어떻게 해결하는게 좋을까요? 메인 스레드에서 실행하지 않으면 되요. 이것이 Web Worker가 필요한 이유에요.
Web Worker는 웹에서 JavaScript를 별도 스레드에서 실행하는 API라고 보면되요. 그래서 메인 스레드에 방해하지 않고 별도의 스레드에서 함수가 실행되요. 그리고 메인 스레드에서는 이 무거운 연산의 결과물만 받아 화면에 반영해줄 수 있어요.
이제 이전의 예시를 웹 워커로 실행하는 코드를 짜볼게요.(onMessage 콜백은 MacroTask로 쌓이기 때문에 "처리 중..."을 반드시 렌더링 하지는 않아요)
// main.jsconst worker = new Worker('worker.js');button.addEventListener('click', () => { status.textContent = '처리 중...'; worker.postMessage('start'); // 워커에게 위임하고 끝});worker.onmessage = (e) => { status.textContent = e.data; // 결과 받으면 업데이트};// worker.jsself.onmessage = () => { // 워커가 메시지 받은경우 함수 실행. const result = heavyComputation(); // 여기서 3초 걸려도 메인 무관 self.postMessage(result); // 메인 스레드로 보냄.};Worker는 메인 스레드와 완전히 격리된 실행 환경이에요. window도 없고 document도 없어요. 그래서 worker에서 document나 window 객체를 참조할 수 없기 때문에 localstorage, DOM api같은 것들은 사용할 수 없어요. 그래서 항상 DOM 조작은 메인 스레드에서만 수행되요.
이런 한계로 무거운 연산만 worker에서 실행되고 그 결과물만 메인 스레드에 받아서 사용자에게 전달되는 형태로 코드가 짜여요.
메인 스레드에서 워커로, 워커에서 메인 스레드로 데이터가 전달되는 방법은 postMessage에요. 이 전달하는 데이터는 기본적으로 복사되요.
그래서 100MB짜리 ArrayBuffer를 보내면 100MB가 Worker 메모리에 새로 만들어져요. 이는 사용자의 메모리 자원을 크게 소모시키기 때문에 복사를 피하기 위해서 소유권을 이전하는 방식을 사용해요.
const buffer = new ArrayBuffer(100 * 1024 * 1024); // 100MB// 방법 1. 복사 — 메인 스레드와 Worker 둘 다 메모리를 사용worker.postMessage({ buffer });// 방법 2. 메모리 소유권 이전 — 메인 스레드에서 buffer는 사라짐.worker.postMessage({ buffer }, [buffer]);console.log(buffer.byteLength); // 0두 번째 인자로 넘기는 배열은 Transferable 목록이에요. ArrayBuffer, MessagePort, ImageBitmap, OffscreenCanvas 타입의 자료들만 가능해요.
Web Worker의 postMessage는 데이터를 복사하거나 소유권을 이전해요. 하지만 만약 메인 스레드와 웹 워커가 같은 메모리를 바라보게 하고 싶으면 SharedArrayBuffer를 사용하면 돼요.
그리고 두 스레드가 동시에 같은 인덱스를 읽고 쓰면 레이스 컨디션이 발생해요. 이를 막으려고 Atomics API를 활용하기도 해요.
// main.jsconst sab = new SharedArrayBuffer(4 * 4); // 16바이트const shared = new Int32Array(sab);const worker = new Worker('worker.js');// 복사 아닌 참조 전달worker.postMessage({ sab });worker.onmessage = (e) => { if (e.data.type === 'DONE') { console.log('워커 완료, shared[0]:', shared[0]); }};// 메인에서 값 세팅 후 워커 깨우기shared[0] = 0;Atomics.store(shared, 1, 42); // 워커가 읽을 데이터Atomics.notify(shared, 0, 1); // shared[0] 기다리는 워커 깨우기// Worker.jsself.onmessage = (e) => { const shared = new Int32Array(e.data.sab); // shared[0]이 0인 동안 대기 — 메인이 notify할 때까지 블로킹 Atomics.wait(shared, 0, 0); // 깨어난 후 데이터 읽기 const value = Atomics.load(shared, 1); console.log('받은 값:', value); // 42 // 원자적 증가 — 레이스 컨디션 없이 안전 Atomics.add(shared, 0, 1); // shared[0]++ // 메인에 완료 알림 self.postMessage({ type: 'DONE' });};위 코드에서 주의할 점은 메인 스레드에서는 Atomics.wait을 사용할 수 없어요. Atomics.waitAsync()를 사용해야해요.(UI 전체가 멈출 수 있어서)
데이터까지 공유하여 사용하기 때문에 설계 복잡도는 훨씬 올라가기 때문에, 정말 필요하면 사용하는 것을 추천해요. 멀티 쓰레드 코드는 깔끔하게 짜기도 어렵고, 엣지케이스 없이 짜기가 난이도가 높으니 주의하세요.
그리고 SharedArrayBuffer는 보안 문제때문에 비활성화 되기도 했고, 실제로 취약점이 발견되어 패치된 적도 있어요.(2024년인가 25년도에...) 그래서 SharedArrayBuffer를 사용하기 위해서는 HTTP 헤더설정을 해야해요. 그렇지 않으면 브라우저에서 사용을 막아요.
Cross-Origin-Opener-Policy: same-originCross-Origin-Embedder-Policy: require-corp이 헤더를 키면 third party나 iframe이 이상해질 수도 있다고 하니 PG 결제 같은 것을 사용하면 한번 점검해보세요.
Worker를 쓰면 무조건 빨라질 것 같지만, 현실은 달라요.
postMessage 직렬화 비용이 있어서 작은 데이터를 수백 번 주고받는 구조라면 오히려 느려질 수 있어요.
그리고 Worker는 처음 생성할 때 스레드를 새로 만드는 비용이 있고 Worker 객체 각각이 싱글스레드이기 때문에, Worker Pool 같은 방법으로 미리 스레드풀을 만들고 병렬로 데이터 처리가 가능하게 만드는 방법을 사용하기도 해요.
const POOL_SIZE = navigator.hardwareConcurrency; // 사용자 컴퓨터의 논리 쓰레드 개수 값const pool = Array.from({ length: POOL_SIZE }, () => new Worker('./worker.js'));postMessage 기반 통신은 콜백 또는 ID 기반 응답 매핑을 직접 구현해야 해서 코드가 지저분해져요.
그래서 Comlink라는 라이브러리를 소개해드리려고 해요. 이는 worker를 Proxy로 감싸 함수 호출처럼 쓸 수 있게 해줘요. 그리고 worker.port.start() 방식으로 여러 탭이 열려있어도 워커를 공유하는 방식의 메모리 효율적인 구현이 필요한 경우 이를 더 쉽게 구현할 수 있게 해줘요.
// worker.jsexport const add = (a: number, b: number) => a + b;// main.ts// Create Worker (탭간 공유 X)const instance = new ComlinkWorker(new URL("./worker.js", import.meta.url), { /* normal Worker options*/});const result = await instance.add(2, 3);result === 5;// Create SharedWorker (탭간 공유) => 메모리 효율const instance = new ComlinkSharedWorker<typeof import("./worker")>( new URL("./worker", import.meta.url), { /* normal Worker options*/ });const result = await instance.add(2, 3);result === 5;Service Worker는 Web Worker와 이름이 비슷하지만 목적이 달라요.
Web Worker가 "CPU 연산 분리"가 주 목적이라면 Service Worker 웹을 네이티브 앱으로 만드는 것이 주 목적이에요. 그래서 서비스 워커는 브라우저와 서버 사이에 끼어서 요청을 가로채고, 캐시에서 응답하고, 푸시 알림 받고, 오프라인에서도 동작하게 만드는. 페이지 생명주기와 무관하게 백그라운드에서 독립적으로 실행되는 워커에요.
Worker / SharedWorker | Service Worker | |
|---|---|---|
수명 | 탭 열려있는 동안 | 탭 닫혀도 생존 |
DOM 접근 | 불가 | 불가 |
네트워크 가로채기 | 불가 | 가능 (fetch 이벤트) |
푸시 알림 | 불가 | 가능 |
설치 개념 | 없음 | 있음 (install → activate) |
그래서 PWA가 웹을 앱처럼 느껴지게 만들어 주는 핵심이 Service Worker에요. 이를 바탕으로 오프라인 웹앱을 만들어요.(캐싱도 제어하여 오프라인에서도 캐싱된 데이터 바탕으로 사용할 수 있어요.)
C나 C++ 코드를 WebAssembly로 컴파일하면 pthread가 포함될 수 있어요(POSIX thread). 하지만 웹에서는 OS 쓰레드를 사용하지 못하기 때문에 이를 Worker + SharedArrayBuffer + Atomics으로 에뮬레이션해요. 보통 Emscripten같은 c => wasm 변환 도구에서 처리해줘요.
pthread_create => new Worker()
pthread_mutex_lock => Atomics.wait
메모리 공유 => SharedArrayBuffer
위와 같이 WASM 변환 도구들 덕분에 멀티스레드 C++ 코드들을 브라우저에 거의 그대로 포팅할 수 있어요.
// 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();}웹에서 개발자가 멀티스레드를 명시적으로 원하는대로 쓰는 방식은 웹 워커를 사용하는 방식 1가지에요. 그리고 여전히 사용자 화면까지 전달하기 위해서 싱글스레드를 거쳐야해요. 그리고 구현도 많이 복잡해지고 관리도 힘들어요.
그래도 멀티 스레딩 기술이 존재하는 이유는 웹 개발에서 이 멀티스레딩 기능이 꼭 필요한 경우가 있기 때문이에요. 무거운 연산 작업으로 인해 메인 스레드에 병목이 발생하는 경우에요. 이 병목으로 인한 사용자 경험 저하를 막기 위해서는 멀티스레딩 기능이 유일한 탈출구에요.
저 또한 3D 에디터를 만들면서 성능 때문에 쓰레드 풀을 구현해서 사용하고 있어요. 멀티 쓰레드 기능들을 적절하게 활용해서 사용자들이 만족할 수 잇는 앱을 함께 만들어요.