웹 애플리케이션에서 수만 건의 JSON 데이터를 가공하거나 복잡한 연산을 수행할 때 브라우저 화면이 완전히 얼어붙어 클릭조차 되지 않는 현상을 경험해봤을까? 웹 서비스를 개발하다 보면 한 번쯤 마주치는 대표적인 UI 멈춤(Freezing) 현상이다.
다만 대부분의 개발자들은 자바스크립트가 싱글 스레드로 동작한다는 점을 잊고, 단지 setTimeout이나 Promise로 감싸면 비동기로 처리될 것이라 착각하다 문제를 해결하지 못하는 경우가 많다.
이번에는 Web Worker가 정확히 뭔지, 왜 필요한지, 그리고 백그라운드 스레드로 대용량 연산을 분리하는 방법을 완벽하게 정리해서 소개하겠다.
자바스크립트는 기본적으로 단 하나의 메인 스레드(Main Thread)에서 실행된다. 이 메인 스레드는 자바스크립트 코드 실행뿐만 아니라 HTML/CSS를 화면에 그리는 렌더링(Reflow/Repaint), 사용자의 클릭이나 스크롤 같은 이벤트를 모두 혼자서 처리한다.
만약 메인 스레드에서 5초가 걸리는 대용량 데이터 필터링 연산을 수행하면, 그 5초 동안 브라우저는 화면을 새로 그리지도 못하고 사용자의 입력을 받지도 못하는 먹통 상태가 된다.
Web Worker는 스크립트 실행을 웹 애플리케이션의 메인 스레드와 분리된 별도의 백그라운드 스레드에서 구동할 수 있게 해주는 HTML5 API다. 무거운 연산 작업을 워커 스레드로 넘겨 처리하면 메인 스레드는 멈추지 않고 부드러운 UI 렌더링을 유지할 수 있다.
| 구분 | 메인 스레드 (Main Thread) | 웹 워커 (Web Worker) |
|---|---|---|
| 실행 위치 | 브라우저 메인 스레드 | 별도의 백그라운드 스레드 |
| DOM 접근 여부 | 가능 (document, window 등) | 불가 (self 객체만 가능) |
| UI 렌더링 영향 | 무거운 연산 시 UI 블로킹(멈춤) 발생 | UI 렌더링에 영향 전혀 없음 |
| 주요 용도 | DOM 조작, 이벤트 처리, UI 업데이트 | 대용량 데이터 가공, 암호화, 이미지 처리 |
메인 스레드와 Web Worker는 메모리를 직접 공유하지 않고 메시지 기반 이벤트 체계로 데이터를 주고받는다. 메인 스레드에서 postMessage()를 통해 전달할 데이터를 전송하면, Web Worker에서는 onmessage 이벤트 리스너로 이를 수신하여 연산을 수행한 뒤 다시 결과를 메인 스레드로 되돌려주는 구조다.
수십억 번의 복잡한 연산을 수행할 때 기존 방식과 Web Worker 적용 방식의 차이를 코드 예제로 비교해보자.
✗ 잘못된 코드: 메인 스레드에서 무거운 연산 직접 실행 (UI가 완전히 멈춤)
// 메인 스레드에서 수십억 건의 복잡한 계산 수행
function heavyCalculation() {
console.log('연산 시작...');
let sum = 0;
// 20억 번의 루프 - 메인 스레드를 완전히 점유함
for (let i = 0; i < 2000000000; i++) {
sum += i;
}
console.log('연산 완료:', sum);
return sum;
}
document.getElementById('calc-btn').addEventListener('click', () => {
// 이 함수가 실행되는 동안 브라우저 버튼 클릭, 스크롤, 애니메이션이 모두 멈춤!
const result = heavyCalculation();
document.getElementById('result').innerText = result;
});위 코드는 heavyCalculation()이 실행되는 동안 메인 스레드가 블로킹되어 사용자가 화면의 다른 요소와 전혀 상호작용할 수 없는 치명적인 문제가 발생한다.
✓ 올바른 코드: Web Worker를 생성하여 백그라운드에서 비동기 연산 처리
먼저 백그라운드 연산을 담당할 독립된 스크립트 파일(worker.js)을 작성한다.
// worker.js (웹 워커 전용 파일)
self.onmessage = function(event) {
const { count } = event.data;
console.log('Worker: 무거운 연산 시작 (입력값:', count, ')');
let sum = 0;
for (let i = 0; i < count; i++) {
sum += i;
}
// 연산 결과를 메인 스레드로 전송
self.postMessage({ status: 'success', result: sum });
};이후 메인 자바스크립트 파일에서 Worker 객체를 생성하고 메시지를 송수신한다.
// main.js (메인 스레드)
// 1. Web Worker 인스턴스 생성
const myWorker = new Worker('worker.js');
document.getElementById('calc-btn').addEventListener('click', () => {
document.getElementById('status').innerText = '백그라운드에서 계산 중...';
// 2. 워커 스레드로 데이터 전송 (20억 건 연산 요청)
myWorker.postMessage({ count: 2000000000 });
});
// 3. 워커 스레드로부터 결과 수신
myWorker.onmessage = function(event) {
const { status, result } = event.data;
if (status === 'success') {
document.getElementById('result').innerText = result;
document.getElementById('status').innerText = '연산 완료!';
}
};
// 4. 워커 에러 예외 처리
myWorker.onerror = function(error) {
console.error('Worker 에러 발생:', error.message);
document.getElementById('status').innerText = '연산 중 에러 발생!';
};결과: 연산 버튼을 클릭해도 버튼 누름 효과 및 스크롤, 다른 UI 애니메이션이 매우 부드럽게 유지되며, 워커 스레드가 연산을 마쳤을 때 화면에 결괏값이 출력된다.
Web Worker는 강력하지만 스레드 분리 구조로 인한 명확한 제약 사항들이 존재한다. 이 제약들을 모르고 사용하면 예기치 못한 에러가 발생하게 된다.
Web Worker 내부에서는 window, document 객체에 전혀 접근할 수 없다. 즉, 워커 내부에서 직접 document.getElementById()를 호출해 DOM을 변경하려 하면 ReferenceError가 발생한다. 모든 UI 업데이트는 연산 결과를 메인 스레드로 전달한 뒤 메인 스레드에서 수행해야 한다.
Web Worker 스크립트 파일은 동일 출처 정책(Same-Origin Policy)을 따른다. 로컬 파일 시스템(file:// 프로토콜)에서 실행 시 브라우저 보안 정책에 의해 워커 생성이 차단되므로 반드시 로컬 웹 서버 환경(http://localhost 등)에서 테스트해야 한다.
postMessage()로 대용량 데이터를 주고받을 때 자바스크립트는 기본적으로 데이터 구조를 깊은 복사(Structured Clone)한다. 데이터가 수백 메가바이트(MB) 수준으로 매우 크면 복사 과정 자체에서 메인 스레드 지연이 발생할 수 있으므로, 이 경우 메모리 소유권을 이전하는 Transferable Objects(예: ArrayBuffer)를 활용하는 것이 좋다.
JavaScript Web Worker는 브라우저의 단일 스레드 한계를 극복하고 대용량 데이터 연산이나 복잡한 필터링 작업을 부드럽게 처리할 수 있는 최고의 솔루션이다. 단순 연산이나 API 요청에는 Promise를 사용하고, CPU 점유율이 높은 비거운 작업에는 Web Worker를 선택하는 최적화 습관이 필요하다.
JavaScript Web Worker는 웹 애플리케이션의 반응성과 사용자 경험(UX)을 결정짓는 중요한 기술이다. 무거운 연산을 백그라운드로 격리하는 작은 구조적 변화가 모여 서비스의 품질을 높인다는 점을 잊지 말자. 이 글의 실전 예제 코드를 참고하여 프로젝트에 백그라운드 스레드를 적용하면 렉 없이 쾌적하게 동작하는 최적화된 웹 애플리케이션을 완성할 수 있을 것이다.