
Web Worker: 무거운 연산을 메인 스레드 밖으로
CSV 파일을 파싱하는 동안 UI가 완전히 멈췄다. Web Worker로 무거운 연산을 분리하니 UI가 부드럽게 유지됐다.

CSV 파일을 파싱하는 동안 UI가 완전히 멈췄다. Web Worker로 무거운 연산을 분리하니 UI가 부드럽게 유지됐다.
프론트엔드 개발자가 알아야 할 4가지 저장소의 차이점과 보안 이슈(XSS, CSRF), 그리고 언제 무엇을 써야 하는지에 대한 명확한 기준.

버튼을 눌렀는데 부모 DIV까지 클릭되는 현상. 이벤트는 물방울처럼 위로 올라갑니다(Bubbling). 반대로 내려오는 캡처링(Capturing)도 있죠.

느리다고 느껴서 감으로 최적화했는데 오히려 더 느려졌다. 프로파일러로 병목을 정확히 찾는 법을 배운 이야기.

텍스트에서 바이너리로(HTTP/2), TCP에서 UDP로(HTTP/3). 한 줄로서기 대신 병렬처리 가능해진 웹의 진화. 구글이 주도한 QUIC 프로토콜 이야기.

100MB짜리 CSV 파일을 업로드하는 순간, 웹사이트가 완전히 얼어붙었다. 로딩 스피너조차 돌지 않았다. 사용자는 버튼을 연타하고, 결국 페이지를 닫아버렸다. 이 문제를 해결하려고 코드를 뜯어보니, JavaScript가 단일 스레드로 동작한다는 근본적인 한계와 마주쳤다.
메인 스레드가 CSV 파싱에 모든 자원을 쏟아붓는 동안, UI 렌더링도, 사용자 인터랙션도 전부 대기 상태로 빠졌다. 마치 주방에 요리사가 한 명뿐인데, 그 요리사가 거대한 재료를 썰느라 다른 주문을 전혀 받지 못하는 상황이었다.
Web Worker를 도입한 후, 무거운 연산은 별도 스레드로 분리되고 UI는 부드럽게 유지됐다. 파일 파싱 시간은 동일했지만, 사용자는 더 이상 화면이 멈춘다고 느끼지 않았다. 이 경험을 통해 멀티스레딩이 사용자 경험에 얼마나 결정적인지 이해했다.
The moment a user uploaded a 100MB CSV file, the entire website froze. Even the loading spinner stopped spinning. Users frantically clicked buttons, then gave up and closed the tab. Digging into the code to fix this, I ran into JavaScript's fundamental limitation: single-threaded execution.
While the main thread devoted all its resources to parsing CSV, UI rendering and user interactions were completely blocked. It was like having only one chef in a kitchen—while that chef is chopping a massive ingredient, no other orders can be taken.
After introducing Web Workers, heavy computation moved to a separate thread and the UI stayed smooth. The parsing time remained the same, but users no longer felt the screen had frozen. This experience taught me how critical multithreading is for user experience.
JavaScript는 태생적으로 단일 스레드다. 브라우저 환경에서 메인 스레드는 UI 렌더링, 이벤트 처리, JavaScript 실행을 전부 혼자 담당한다. 한 명의 요리사가 주문 받고, 요리하고, 서빙까지 다 하는 셈이다. 이 구조는 간단하고 예측 가능하지만, 무거운 작업이 들어오면 전체 시스템이 멈춘다.
Web Worker는 이 주방에 요리사를 한 명 더 추가하는 기술이다. 메인 스레드와 완전히 독립된 별도 스레드에서 JavaScript를 실행한다. 중요한 점은, 이 추가 요리사는 주방 안쪽(메인 스레드)에 직접 접근할 수 없다는 것이다. 대신 메시지로 소통한다. "이 재료를 썰어줘" 하고 요청하면, 완성된 재료를 다시 메시지로 전달받는다.
Web Worker의 핵심은 postMessage와 onmessage다. 메인 스레드에서 Worker를 생성하고 메시지를 주고받는 구조다.
// main.js (메인 스레드)
const worker = new Worker('worker.js');
// Worker에게 작업 요청
worker.postMessage({ data: largeDataset });
// Worker로부터 결과 수신
worker.onmessage = (event) => {
const result = event.data;
console.log('처리 완료:', result);
updateUI(result);
};
// 에러 처리
worker.onerror = (error) => {
console.error('Worker 에러:', error);
};
// worker.js (Worker 스레드)
self.onmessage = (event) => {
const { data } = event.data;
// 무거운 연산 수행
const result = processLargeDataset(data);
// 메인 스레드로 결과 전송
self.postMessage(result);
};
function processLargeDataset(data) {
// CSV 파싱, 데이터 정렬, 복잡한 계산 등
return data.map(item => heavyComputation(item));
}
이 패턴이 와닿은 이유는, 비동기 프로그래밍과 구조가 비슷하면서도 근본적으로 다르기 때문이다. async/await는 여전히 메인 스레드에서 실행되지만, Web Worker는 진짜 별도 스레드다. CPU 바운드 작업에서 체감 차이가 극명했다.
JavaScript is inherently single-threaded. In the browser, the main thread handles UI rendering, event processing, and JavaScript execution—all by itself. It's like one chef taking orders, cooking, and serving everything. This design is simple and predictable, but when heavy work arrives, the entire system freezes.
Web Workers add another chef to this kitchen. They run JavaScript in a completely separate thread, independent of the main thread. The key point: this additional chef can't directly access the kitchen interior (main thread). Instead, they communicate via messages. You request "chop these ingredients," and receive the finished ingredients back via message.
The core of Web Workers is postMessage and onmessage. You create a Worker from the main thread and exchange messages.
// main.js (main thread)
const worker = new Worker('worker.js');
// Request work from Worker
worker.postMessage({ data: largeDataset });
// Receive result from Worker
worker.onmessage = (event) => {
const result = event.data;
console.log('Processing complete:', result);
updateUI(result);
};
// Error handling
worker.onerror = (error) => {
console.error('Worker error:', error);
};
// worker.js (Worker thread)
self.onmessage = (event) => {
const { data } = event.data;
// Perform heavy computation
const result = processLargeDataset(data);
// Send result to main thread
self.postMessage(result);
};
function processLargeDataset(data) {
// CSV parsing, data sorting, complex calculations, etc.
return data.map(item => heavyComputation(item));
}
This pattern resonated because it's structurally similar to async programming, yet fundamentally different. async/await still runs on the main thread, but Web Workers are genuinely separate threads. The performance difference for CPU-bound tasks was dramatic.
Web Worker로 데이터를 전송할 때, 기본적으로 데이터는 복사된다. 100MB 배열을 전송하면, 메모리에 100MB가 추가로 생긴다. 이 복사 과정 자체가 병목이 될 수 있다.
Transferable Objects는 데이터를 복사하지 않고 소유권을 이전한다. 마치 물건을 복사하지 않고 그냥 건네주는 것과 같다. 메모리 효율이 극적으로 개선된다.
// ArrayBuffer, ImageBitmap 등이 Transferable
const buffer = new ArrayBuffer(1024 * 1024 * 100); // 100MB
const typedArray = new Uint8Array(buffer);
// 두 번째 인자로 transfer할 객체 지정
worker.postMessage({ data: typedArray }, [buffer]);
// 이 시점부터 buffer는 메인 스레드에서 사용 불가
// console.log(buffer.byteLength); // 0
실제 프로젝트에서 이미지 처리 Worker를 만들 때, ImageBitmap을 transfer하니 전송 시간이 거의 제로에 가까워졌다. 10MB 이미지 파일 전송이 1초에서 10ms로 줄어든 경험이 있다.
100,000개 행의 CSV를 파싱하는 Worker를 구현한 사례다. 메인 스레드는 전혀 블로킹되지 않는다.
// csvWorker.js
self.onmessage = async (event) => {
const { csvText } = event.data;
const lines = csvText.split('\n');
const headers = lines[0].split(',');
const result = [];
for (let i = 1; i < lines.length; i++) {
const values = lines[i].split(',');
const row = {};
headers.forEach((header, index) => {
row[header.trim()] = values[index]?.trim();
});
result.push(row);
// 진행상황 보고 (매 1000개마다)
if (i % 1000 === 0) {
self.postMessage({
type: 'progress',
current: i,
total: lines.length
});
}
}
self.postMessage({ type: 'complete', data: result });
};
// main.js
const worker = new Worker('csvWorker.js');
worker.onmessage = (event) => {
const { type, data, current, total } = event.data;
if (type === 'progress') {
updateProgressBar(current / total);
} else if (type === 'complete') {
renderTable(data);
worker.terminate(); // 작업 완료 후 Worker 종료
}
};
// CSV 파일 읽기
file.text().then(csvText => {
worker.postMessage({ csvText });
});
이 패턴의 핵심은 중간 진행상황까지 보고한다는 점이다. 사용자는 로딩 바가 움직이는 걸 보며 "일이 진행되고 있구나" 안심한다. UI가 멈추지 않으니 페이지를 닫지 않는다.
Google의 Comlink 라이브러리는 Worker 통신을 RPC(Remote Procedure Call)처럼 만들어준다. postMessage 보일러플레이트를 제거하고 일반 함수 호출처럼 사용할 수 있다.
// worker.js
import { expose } from 'comlink';
const api = {
async parseCSV(csvText) {
// 파싱 로직
return parsedData;
},
async processImage(imageData) {
// 이미지 처리 로직
return processedImage;
}
};
expose(api);
// main.js
import { wrap } from 'comlink';
const worker = new Worker('worker.js');
const api = wrap(worker);
// 일반 async 함수처럼 호출
const result = await api.parseCSV(csvText);
console.log(result);
Comlink를 도입한 후, Worker 코드가 절반으로 줄었다. postMessage/onmessage 매칭을 신경 쓰지 않아도 되니 버그도 줄었다.
Next.js 프로젝트에서 Worker를 사용하려면 파일 경로 처리가 까다롭다. public 폴더를 활용하거나 webpack 설정을 수정해야 한다.
// hooks/useCSVWorker.js
import { useEffect, useRef, useState } from 'react';
export function useCSVWorker() {
const workerRef = useRef(null);
const [progress, setProgress] = useState(0);
const [result, setResult] = useState(null);
useEffect(() => {
// Worker 초기화
workerRef.current = new Worker('/workers/csv.worker.js');
workerRef.current.onmessage = (event) => {
const { type, data, current, total } = event.data;
if (type === 'progress') {
setProgress(current / total);
} else if (type === 'complete') {
setResult(data);
setProgress(1);
}
};
return () => {
workerRef.current?.terminate();
};
}, []);
const parseCSV = (csvText) => {
workerRef.current?.postMessage({ csvText });
};
return { parseCSV, progress, result };
}
// components/CSVUploader.jsx
import { useCSVWorker } from '../hooks/useCSVWorker';
export default function CSVUploader() {
const { parseCSV, progress, result } = useCSVWorker();
const handleFileUpload = async (event) => {
const file = event.target.files[0];
const text = await file.text();
parseCSV(text);
};
return (
<div>
<input type="file" onChange={handleFileUpload} />
{progress > 0 && progress < 1 && (
<progress value={progress} max={1} />
)}
{result && <DataTable data={result} />}
</div>
);
}
Custom Hook 패턴으로 Worker 로직을 캡슐화하니, 컴포넌트는 Worker의 존재를 몰라도 됐다. 테스트 작성도 훨씬 쉬워졌다.
When sending data to a Web Worker, data is copied by default. Sending a 100MB array creates an additional 100MB in memory. This copying process itself can become a bottleneck.
Transferable Objects transfer ownership without copying data. It's like handing over an item instead of duplicating it. Memory efficiency improves dramatically.
// ArrayBuffer, ImageBitmap, etc. are Transferable
const buffer = new ArrayBuffer(1024 * 1024 * 100); // 100MB
const typedArray = new Uint8Array(buffer);
// Specify objects to transfer as second argument
worker.postMessage({ data: typedArray }, [buffer]);
// From this point, buffer is unusable on main thread
// console.log(buffer.byteLength); // 0
When building an image processing Worker in a real project, transferring ImageBitmap reduced transmission time to nearly zero. A 10MB image file transfer dropped from 1 second to 10ms.
Here's a Worker implementation that parses 100,000 rows of CSV. The main thread experiences zero blocking.
// csvWorker.js
self.onmessage = async (event) => {
const { csvText } = event.data;
const lines = csvText.split('\n');
const headers = lines[0].split(',');
const result = [];
for (let i = 1; i < lines.length; i++) {
const values = lines[i].split(',');
const row = {};
headers.forEach((header, index) => {
row[header.trim()] = values[index]?.trim();
});
result.push(row);
// Report progress (every 1000 rows)
if (i % 1000 === 0) {
self.postMessage({
type: 'progress',
current: i,
total: lines.length
});
}
}
self.postMessage({ type: 'complete', data: result });
};
// main.js
const worker = new Worker('csvWorker.js');
worker.onmessage = (event) => {
const { type, data, current, total } = event.data;
if (type === 'progress') {
updateProgressBar(current / total);
} else if (type === 'complete') {
renderTable(data);
worker.terminate(); // Terminate Worker after completion
}
};
// Read CSV file
file.text().then(csvText => {
worker.postMessage({ csvText });
});
The key to this pattern is reporting intermediate progress. Users see the loading bar move and feel reassured that "work is happening." Since the UI doesn't freeze, they don't close the page.
Google's Comlink library makes Worker communication feel like RPC (Remote Procedure Call). It removes postMessage boilerplate and lets you use Workers like regular function calls.
// worker.js
import { expose } from 'comlink';
const api = {
async parseCSV(csvText) {
// Parsing logic
return parsedData;
},
async processImage(imageData) {
// Image processing logic
return processedImage;
}
};
expose(api);
// main.js
import { wrap } from 'comlink';
const worker = new Worker('worker.js');
const api = wrap(worker);
// Call like a regular async function
const result = await api.parseCSV(csvText);
console.log(result);
After adopting Comlink, Worker code size dropped by half. Not worrying about postMessage/onmessage matching also reduced bugs.
Using Workers in Next.js requires careful file path handling. You'll need to use the public folder or modify webpack configuration.
// hooks/useCSVWorker.js
import { useEffect, useRef, useState } from 'react';
export function useCSVWorker() {
const workerRef = useRef(null);
const [progress, setProgress] = useState(0);
const [result, setResult] = useState(null);
useEffect(() => {
// Initialize Worker
workerRef.current = new Worker('/workers/csv.worker.js');
workerRef.current.onmessage = (event) => {
const { type, data, current, total } = event.data;
if (type === 'progress') {
setProgress(current / total);
} else if (type === 'complete') {
setResult(data);
setProgress(1);
}
};
return () => {
workerRef.current?.terminate();
};
}, []);
const parseCSV = (csvText) => {
workerRef.current?.postMessage({ csvText });
};
return { parseCSV, progress, result };
}
// components/CSVUploader.jsx
import { useCSVWorker } from '../hooks/useCSVWorker';
export default function CSVUploader() {
const { parseCSV, progress, result } = useCSVWorker();
const handleFileUpload = async (event) => {
const file = event.target.files[0];
const text = await file.text();
parseCSV(text);
};
return (
<div>
<input type="file" onChange={handleFileUpload} />
{progress > 0 && progress < 1 && (
<progress value={progress} max={1} />
)}
{result && <DataTable data={result} />}
</div>
);
}
Encapsulating Worker logic in a Custom Hook pattern meant components didn't need to know Workers existed. Writing tests also became much easier.
내 경험상, 메인 스레드에서 100ms 이상 걸리는 작업은 Worker로 분리를 고려할 시점이다. 사용자가 렉을 체감하기 시작하는 임계점이 대략 100ms다.
10,000개 배열을 정렬하는데 Worker를 사용했다가, Worker 생성 및 데이터 전송 시간이 직접 정렬하는 것보다 오래 걸린 경험이 있다. 벤치마크 없이 섣불리 도입하면 역효과다.
동일한 100,000개 객체 배열을 정렬하는 작업을 측정했다.
메인 스레드 직접 실행:
Web Worker 사용:
절대적인 실행 시간은 30ms 늘었지만, 사용자 경험은 극적으로 개선됐다. 메인 스레드가 자유로우니 로딩 애니메이션도 부드럽게 돌아가고, 취소 버튼도 즉시 반응했다.
Worker는 샌드박스 환경에서 실행된다. 주방 비유로 돌아가면, 보조 요리사는 홀(DOM)에 나갈 수 없고, 주방 안 도구(window 객체)도 사용 못한다.
Worker에서 사용 불가:
document, window, parent 같은 DOM 관련 객체localStorage, sessionStoragealert(), confirm() 같은 UI 메서드Worker에서 사용 가능:
fetch, XMLHttpRequest (네트워크 요청)setTimeout, setIntervalIndexedDB, Cache APIWebSocket실제로 Worker에서 API를 호출해 데이터를 받아오고, 가공한 뒤 메인 스레드로 전달하는 패턴을 많이 썼다. 네트워크 대기 시간 동안에도 메인 스레드는 자유롭다.
여러 Worker 간 메모리를 공유하려면 SharedArrayBuffer를 사용한다. 하지만 Spectre/Meltdown 보안 취약점 때문에 Cross-Origin Isolation 설정이 필요하다.
// 서버 헤더 필요:
// Cross-Origin-Opener-Policy: same-origin
// Cross-Origin-Embedder-Policy: require-corp
const sharedBuffer = new SharedArrayBuffer(1024);
const sharedArray = new Int32Array(sharedBuffer);
// Worker들이 동일한 메모리 접근
worker1.postMessage({ sharedArray }, []);
worker2.postMessage({ sharedArray }, []);
// Atomics로 동기화
Atomics.wait(sharedArray, 0, 0); // 값이 변할 때까지 대기
Atomics.notify(sharedArray, 0, 1); // 대기 중인 Worker 깨우기
실제로는 거의 사용하지 않았다. 복잡도가 급격히 올라가고, 대부분의 경우 일반 Worker로 충분했다. 게임 엔진이나 실시간 시뮬레이션 같은 극한의 성능이 필요한 경우에만 고려할 법하다.
In my experience, if a task takes over 100ms on the main thread, it's time to consider moving it to a Worker. Around 100ms is the threshold where users start feeling lag.
I once used a Worker to sort 10,000 items, only to find that Worker creation and data transfer took longer than direct sorting. Premature adoption without benchmarking backfires.
I measured sorting the same 100,000-object array.
Direct Main Thread Execution:
Using Web Worker:
Absolute execution time increased by 30ms, but user experience improved dramatically. With the main thread free, loading animations ran smoothly and the cancel button responded instantly.
Workers run in a sandboxed environment. Returning to the kitchen metaphor, the assistant chef can't go to the dining room (DOM) and can't use kitchen tools (window object).
Unavailable in Workers:
document, window, parentlocalStorage, sessionStoragealert(), confirm()Available in Workers:
fetch, XMLHttpRequest (network requests)setTimeout, setIntervalIndexedDB, Cache APIWebSocketI frequently used a pattern where Workers fetch data from APIs, process it, then send it to the main thread. The main thread stays free even during network wait time.
To share memory between multiple Workers, use SharedArrayBuffer. However, due to Spectre/Meltdown security vulnerabilities, Cross-Origin Isolation configuration is required.
// Server headers required:
// Cross-Origin-Opener-Policy: same-origin
// Cross-Origin-Embedder-Policy: require-corp
const sharedBuffer = new SharedArrayBuffer(1024);
const sharedArray = new Int32Array(sharedBuffer);
// Workers access same memory
worker1.postMessage({ sharedArray }, []);
worker2.postMessage({ sharedArray }, []);
// Synchronize with Atomics
Atomics.wait(sharedArray, 0, 0); // Wait until value changes
Atomics.notify(sharedArray, 0, 1); // Wake waiting Worker
In practice, I rarely used this. Complexity skyrockets, and regular Workers suffice for most cases. Only consider this for extreme performance needs like game engines or real-time simulations.
Web Worker는 JavaScript의 단일 스레드 한계를 우회하는 강력한 도구다. 메인 스레드가 UI에 집중하고, 무거운 연산은 별도 스레드로 분리하는 구조가 핵심이다.
기억할 포인트:
CSV 파싱으로 시작한 문제는, 결국 "사용자가 기다리는 동안 무엇을 보여줄 것인가"라는 UX 질문이었다. Web Worker는 그 답 중 하나였고, 실제 프로젝트에서 사용자 이탈률을 눈에 띄게 낮췄다. 기술은 도구일 뿐이지만, 제대로 쓰면 경험의 질을 바꿀 수 있다는 걸 다시 한번 확인했다.