웹 애플리케이션에서 수만 개가 넘는 데이터를 정렬하거나 복잡한 암호화 연산을 실행할 때 브라우저 화면이 완전히 멈춰버리는 현상을 경험해봤을 것이다. 클릭도 안 되고 로딩 스피너마저 멈추는 이유는 자바스크립트가 단일 스레드(Single Thread)로 동작하기 때문이다. 다만 스크립트가 메인 스레드를 독점할 때 백그라운드에서 연산을 분리하는 정확한 방법을 모르는 경우가 많다.
이번에는 자바스크립트의 비동기 처리 한계를 극복하게 해주는 Web Worker가 정확히 무엇인지, 왜 필요한지, 그리고 실무 코드에 어떻게 적용하는지 완벽하게 정리해서 소개하겠다.
자바스크립트의 싱글 스레드 환경은 이벤트 루프를 통해 비동기 I/O를 처리한다. setTimeout이나 Promise, fetch 같은 비동기 작업은 네트워크 응답을 기다리는 동안 메인 스레드를 멈추지 않게 도와준다.
문제는 네트워크 대기가 아니라 CPU 자원을 극도로 소모하는 '순수 연산'이다. 메인 스레드가 무거운 복잡 연산을 수행하는 동안에는 브라우저의 렌더링 엔진도 멈춰 버린다. 화면을 초당 60프레임(60fps)으로 새로고침해야 하는데 메인 스레드가 갇혀버리니 화면이 그대로 굳는 것이다.
Web Worker는 브라우저의 메인 스레드와 별개로 작동하는 백그라운드 스레드를 생성하는 웹 API다. 메인 스레드는 UI 이벤트와 렌더링을 계속 담당하고, 무거운 연산은 별도의 스레드로 던져서 처리한 뒤 결과만 받아온다. 이렇게 하면 대용량 연산 중에도 사용자는 지연 없는 인터렉션을 경험할 수 있다.
메인 스레드와 Web Worker 스레드는 구동되는 환경과 접근 가능한 기능에 차이가 있다. 두 환경의 특징을 정리하면 다음과 같다.
| 구분 | 메인 스레드 (Main Thread) | 웹 워커 스레드 (Web Worker) |
|---|---|---|
| 역할 | DOM 조작, UI 렌더링, 사용자 이벤트 처리 | 백그라운드 데이터 가공 및 복잡한 CPU 연산 |
| DOM 접근 | 가능 (window, document) | 불가 (DOM 관련 객체 접근 시 TypeError 발생) |
| 데이터 통신 | 직접 변수 참조 및 메서드 호출 | postMessage() 기반 메시지 패싱(Message Passing) |
| 주요 사용처 | 화면 업데이트, CSS 애니메이션, 사용자 입력 대응 | 대용량 JSON 파싱, 정렬, 이미지 처리, 암호화 |
1억 번 이상의 반복문 연산을 수행할 때 메인 스레드에서 직접 실행하는 방식과 Web Worker를 활용한 방식을 코드로 비교해보자.
✗ 메인 스레드에서 대량 연산을 수행하면 연산이 끝날 때까지 UI 스레드가 블록되어 버튼 클릭이나 애니메이션이 모두 멈춘다.
// main.js - 잘못된 접근
const calculateBtn = document.querySelector('#calc-btn');
const statusDiv = document.querySelector('#status');
calculateBtn.addEventListener('click', () => {
statusDiv.textContent = '연산 중...'; // 메인 스레드가 곧바로 블록되어 화면에 안 보일 수 있음
let sum = 0;
// 1억 번의 루프로 메인 스레드 점유
for (let i = 0; i < 100000000; i++) {
sum += i;
}
statusDiv.textContent = `완료! 결과: ${sum}`;
});이 코드가 실행되는 동안 브라우저는 응답 없음 상태에 빠지며 사용자는 웹 페이지가 고장났다고 판단하게 된다.
✓ 무거운 연산 로직을 별도의 워커 파일(worker.js)로 분리하고 메인 스레드에서는 postMessage로 연산을 요청한다.
// worker.js - 백그라운드 스레드 파일
self.onmessage = function (e) {
const countTo = e.data;
let sum = 0;
for (let i = 0; i < countTo; i++) {
sum += i;
}
// 연산 결과를 메인 스레드로 전송
self.postMessage(sum);
};// main.js - 메인 스레드 파일
const calculateBtn = document.querySelector('#calc-btn');
const statusDiv = document.querySelector('#status');
// Web Worker 생성
const worker = new Worker('worker.js');
calculateBtn.addEventListener('click', () => {
statusDiv.textContent = '백그라운드에서 연산 중... (UI 입력 가능)';
// 워커로 작업 데이터 전송
worker.postMessage(100000000);
});
// 워커로부터 결과를 전달받았을 때 호출
worker.onmessage = function (e) {
const result = e.data;
statusDiv.textContent = `연산 완료! 결과: ${result}`;
};
// 워커 오류 처리
worker.onerror = function (error) {
console.error('Worker 에러 발생:', error.message);
statusDiv.textContent = '연산 중 에러가 발생했습니다.';
};
버튼을 누르면 화면에 '백그라운드에서 연산 중...'이라는 텍스트가 즉시 표시된다. 백그라운드에서 1억 번의 연산이 진행되는 동안에도 다른 UI 요소를 자유롭게 클릭하거나 입력할 수 있으며, 연산이 끝나면 결과가 정상적으로 업데이트된다.
Web Worker는 매우 강력하지만 멀티스레드 특성상 엄격한 제약사항이 존재한다. 실무 개발 시 자주 저지르는 두 가지 실수를 파악해야 한다.
Web Worker 내부에서는 window, document, parent 같은 브라우저 DOM 요소에 접근할 수 없다. 백그라운드 스레드에서 직접 화면을 수정하려고 하면 Uncaught ReferenceError: document is not defined 오류가 발생한다. UI 조작은 반드시 결과를 postMessage()로 메인 스레드에 전달한 후 메인 스레드에서 처리해야 한다.
메인 스레드와 워커 간에 데이터를 주고받을 때 기본적으로 '구조화된 복사 알고리즘(Structured Clone Algorithm)'이 동작한다. 데이터의 복사본이 생성되어 전달되는 방식이다. 만약 수백 MB 크기의 대용량 ArrayBuffer 데이터를 워커로 보낸다면, 복사하는 과정 자체에서 메인 스레드가 멈출 수 있다.
이런 경우에는 데이터 소유권을 통째로 넘겨버리는 Transferable Objects 방식을 사용해야 메모리 복사 비용 없이 즉시 전달할 수 있다.
// 대용량 ArrayBuffer 전송 시 Transferable 객체 활용
const bigArray = new Uint8Array(1024 * 1024 * 100); // 100MB 데이터
// 세 번째 인자로 Transferable 객체 배열을 전달해 데이터 소유권 이전
worker.postMessage(bigArray.buffer, [bigArray.buffer]);
// 전송 직후 메인 스레드의 bigArray.byteLength는 0이 되어 사용할 수 없게 됨 (메모리 이동)
자바스크립트 대용량 연산 시 발생하는 화면 멈춤은 Web Worker를 활용해 백그라운드 스레드로 처리하는 것이 정석이다. 메인 스레드는 UI 조작과 반응성 유지에 집중하고, CPU 사용량이 많은 가공 로직은 별도의 스레드로 분리해야 쾌적한 UX를 제공할 수 있다. 이 글의 실전 예제 코드를 참고해서 프로젝트 내 무거운 알고리즘에 Web Worker를 도입해보면, UI 프레임 드랍 없는 안정적인 서비스를 구축할 수 있을 것이다.