05. Main Thread와 Web Worker
JavaScript가 실행되면 왜 화면이 멈출까?
앞의 글에서 Event Loop를 살펴봤다.
JavaScript의 실행 흐름을 간단하게 정리하면 다음과 같다.
JavaScript
↓
Call Stack
↓
Web API
↓
Microtask / Task Queue
↓
Event Loop
↓
Call Stack그렇다면 이런 상황을 생각해보자.
웹 애플리케이션에서 10,000개의 파일을 읽고 파싱해야 한다.
for (const file of files) {
parse(file);
}또는 매우 큰 데이터를 계산해야 한다.
const result = heavyCalculation(data);이 작업이 몇 초 동안 실행된다면 어떻게 될까?
사용자는 버튼을 눌러도 반응이 없고,
스크롤도 끊기고,
화면의 애니메이션도 멈춘 것처럼 보일 수 있다.
왜 그럴까?
핵심은 Main Thread다.
1. Main Thread란 무엇일까?
브라우저는 하나의 스레드만 사용하는 것이 아니다.
네트워크, 렌더링, 이미지 처리 등 다양한 작업이 여러 실행 주체와 스레드를 통해 처리될 수 있다.
그중 웹 페이지의 JavaScript 실행과 UI 상호작용을 이해할 때 중요한 것이 Main Thread다.
쉽게 말하면 Main Thread는 우리가 작성한 일반적인 JavaScript 코드가 실행되고, 사용자 입력과 화면 업데이트가 밀접하게 연결되는 실행 흐름이라고 생각하면 된다.
개념적으로 보면
Browser
│
├── Main Thread
│ ├── JavaScript
│ ├── Event 처리
│ └── UI 관련 작업
│
├── Network
├── Rendering 관련 작업
└── 기타 브라우저 작업처럼 생각할 수 있다.
정확한 브라우저 내부 구조는 브라우저와 상황에 따라 더 복잡하지만, 프론트엔드 개발에서는 Main Thread를 오래 점유하면 UI가 막힐 수 있다는 점이 중요하다.
2. JavaScript가 화면을 막는 이유
앞에서 Call Stack을 배웠다.
JavaScript 코드가 실행되면 Call Stack에 실행 컨텍스트가 들어가고, 코드가 끝나야 다음 작업을 처리할 수 있다.
예를 들어
console.log("start");
for (let i = 0; i < 10_000_000_000; i++) {
// 매우 무거운 작업
}
console.log("end");이 코드는 오래 걸릴 수 있다.
실행 중에는
Main Thread
┌─────────────────────────────┐
│ heavy calculation │
│█████████████████████████████│
└─────────────────────────────┘처럼 Main Thread가 오랫동안 JavaScript 작업을 수행하게 된다.
그동안 사용자가 버튼을 클릭하면?
사용자 클릭
↓
이벤트 처리 필요
↓
하지만 Main Thread는 바쁨
↓
이벤트 처리 지연화면 업데이트가 필요한 상황에서도 마찬가지다.
JavaScript 실행
██████████████████████████
화면 업데이트
↓
기다려야 함
사용자 입력
↓
기다려야 함결과적으로 사용자는
"화면이 멈췄다."
라고 느끼게 된다.
3. 이것이 바로 UI Blocking이다
이런 상황을 흔히 UI Blocking이라고 표현한다.
예를 들어 다음과 같은 현상이 발생할 수 있다.
버튼 클릭
→ 반응이 늦음
스크롤
→ 버벅거림
애니메이션
→ 끊김
입력
→ 글자가 늦게 나타남
화면 업데이트
→ 한참 뒤에 나타남특히 데이터가 많은 서비스에서 문제가 심해질 수 있다.
예를 들어
1,000개 데이터
10,000개 데이터
100,000개 데이터를 한 번에 파싱하거나 가공하는 작업이 JavaScript에서 실행된다면 Main Thread가 오래 점유될 수 있다.
4. 비동기라고 해결되는 것은 아니다
여기서 중요한 오해가 하나 있다.
앞에서 Event Loop와 비동기를 배웠기 때문에
"그럼 무거운 작업을 Promise로 만들면 되는 것 아닌가?"
라고 생각할 수 있다.
예를 들어
await heavyCalculation();처럼 만들면 Main Thread가 막히지 않을 것 같아 보인다.
하지만 async/await 자체가 계산을 다른 스레드로 옮겨주는 것은 아니다.
예를 들어
async function process() {
const result = heavyCalculation(data);
return result;
}여기서 heavyCalculation()이 CPU를 오래 사용하는 동기 함수라면 여전히 Main Thread에서 실행된다.
Promise도 마찬가지다.
await Promise.resolve(heavyCalculation(data));라고 작성해도 heavyCalculation(data) 자체가 먼저 실행된다.
즉,
Promise
≠
다른 스레드에서 실행이다.
이 차이는 매우 중요하다.
5. setTimeout으로 나누면 해결될까?
그렇다면 작업을 setTimeout으로 넘기면 어떨까?
예를 들어
setTimeout(() => {
heavyCalculation(data);
}, 0);이렇게 작성하면 현재 실행 중인 코드와 계산을 분리할 수는 있다.
하지만 계산 자체는 여전히 JavaScript 실행 흐름에서 수행된다.
setTimeout
↓
Task Queue
↓
Event Loop
↓
Call Stack
↓
heavyCalculation()즉, 계산이 시작된 이후에는 여전히 Main Thread를 점유할 수 있다.
그래서
Task Queue로 보낸 것과 다른 스레드에서 실행하는 것은 완전히 다른 문제다.
6. 그렇다면 어떻게 해야 할까?
여기서 Web Worker가 등장한다.
Web Worker는 JavaScript 코드를 Main Thread와 분리된 Worker 실행 환경에서 실행할 수 있도록 해준다.
개념적으로 보면
기존
Main Thread
│
├── JavaScript
├── UI
├── Event
└── 계산에서
Worker 사용
Main Thread Worker
│ │
├── UI ├── 계산
├── Event ├── 데이터 처리
└── JavaScript └── 파싱
│
│ message
↓
└───────────────────┘처럼 작업을 분리할 수 있다.
7. 가장 간단한 Web Worker 예제
먼저 Worker 파일을 만든다.
// worker.js
self.onmessage = (event) => {
const result = heavyCalculation(event.data);
self.postMessage(result);
};그리고 Main Thread에서는
const worker = new Worker("./worker.js");
worker.postMessage(data);
worker.onmessage = (event) => {
console.log(event.data);
};이런 식으로 통신할 수 있다.
흐름은 다음과 같다.
Main Thread
│
│ postMessage(data)
↓
Worker
│
│ 계산
↓
Worker
│
│ postMessage(result)
↓
Main ThreadWorker가 계산하는 동안 Main Thread는 다른 작업을 처리할 수 있다.
8. Worker는 새로운 JavaScript 실행 환경이다
Worker를 단순히
"함수를 다른 곳에서 실행하는 기능"
이라고 생각하면 조금 부족하다.
Worker는 별도의 JavaScript 실행 환경이다.
그래서 Worker 내부에는 자신의 실행 컨텍스트와 Call Stack 등이 존재한다.
개념적으로 보면
Main Thread Worker
Execution Context Execution Context
↓ ↓
Call Stack Call Stack
↓ ↓
JavaScript JavaScript각각 독립적으로 JavaScript를 실행할 수 있다.
그래서 Main Thread에서
heavyCalculation(data);를 직접 실행하는 대신 Worker에서 처리하도록 만들 수 있다.
9. Worker와 Main Thread는 어떻게 데이터를 주고받을까?
Worker와 Main Thread는 같은 메모리를 자유롭게 공유하면서 변수를 읽고 쓰는 방식으로 동작하지 않는다.
대신 메시지를 주고받는다.
Main Thread:
worker.postMessage(data);Worker:
self.onmessage = (event) => {
console.log(event.data);
};그리고 Worker에서 다시 Main Thread로 보내려면
self.postMessage(result);Main Thread:
worker.onmessage = (event) => {
console.log(event.data);
};이런 구조다.
Main Thread
│
│ postMessage
↓
Worker
│
│ postMessage
↓
Main Thread이것을 Message Passing이라고 생각하면 된다.
10. Worker에서는 DOM을 직접 사용할 수 없다
여기서 중요한 제한이 하나 있다.
Worker는 Main Thread와 별도의 실행 환경이기 때문에 일반적인 DOM에 직접 접근할 수 없다.
예를 들어 Worker에서
document.querySelector("#app");같은 코드를 사용하는 것은 일반적인 Dedicated Worker 환경에서는 불가능하다.
즉,
Main Thread
DOM
├── document
├── window
└── UI
Worker
DOM 직접 접근 불가라고 이해하면 된다.
그래서 Worker는 UI를 직접 변경하는 용도보다는
데이터 계산
데이터 변환
파일 파싱
복잡한 연산같은 작업에 적합하다.
11. Worker가 필요한 작업
대표적으로 다음과 같은 작업을 생각할 수 있다.
대용량 데이터 파싱
대규모 JSON 처리
이미지 처리
암호화/복호화
복잡한 수학 계산
파일 분석
대량의 데이터 변환예를 들어
for (const file of files) {
parseDICOM(file);
}처럼 파일이 수천~수만 개이고 각 파일을 파싱하는 데 CPU 비용이 많이 든다면 Main Thread에서 한 번에 처리하는 것은 부담이 될 수 있다.
이런 상황에서는
Main Thread
→ 사용자 입력
→ UI 업데이트
→ Worker 관리
Worker
→ 파일 파싱
→ 데이터 계산
→ 결과 전달처럼 역할을 나눌 수 있다.
12. 그런데 Worker를 사용한다고 무조건 빨라지는 것은 아니다
여기서 중요한 점이 있다.
Worker는 성능을 무조건 향상시키는 마법 같은 기능이 아니다.
오히려 작은 작업까지 전부 Worker로 보내면 손해가 발생할 수도 있다.
왜냐하면 Main Thread와 Worker 사이에서 데이터를 주고받아야 하기 때문이다.
Main Thread
│
│ 데이터 전달
↓
Worker
│
│ 계산
↓
Main Thread데이터가 매우 크다면 이 전달 과정 자체도 비용이 될 수 있다.
따라서 Worker를 사용할 때는
작업 비용
+
데이터 전달 비용
+
Worker 관리 비용을 함께 고려해야 한다.
13. Structured Clone
postMessage()로 객체를 전달할 때는 데이터를 전달하는 과정이 필요하다.
일반적으로 브라우저는 Structured Clone Algorithm을 사용해 메시지 데이터를 복제할 수 있는 형태로 전달한다.
예를 들어
worker.postMessage({
name: "Yonghee",
values: [1, 2, 3]
});처럼 객체를 전달할 수 있다.
하지만 이것은
Main Thread의 객체
↓
데이터를 전달할 수 있는 형태로 복제
↓
Worker에서 사용하는 비용을 고려해야 한다.
특히 대용량 데이터를 자주 복제하면 부담이 될 수 있다.
14. Transferable Objects
그렇다면 데이터를 복제하지 않고 소유권을 넘길 수는 없을까?
여기서 Transferable Objects가 등장한다.
대표적으로 ArrayBuffer 같은 객체를 transfer할 수 있다.
예를 들어
const buffer = new ArrayBuffer(1024);
worker.postMessage(buffer, [buffer]);처럼 전달할 수 있다.
이 경우 개념적으로는
Main Thread
│
│ ArrayBuffer 소유권 이동
↓
Worker과 같은 방식으로 처리된다.
대신 Main Thread에서는 해당 버퍼를 계속 사용할 수 없는 상태가 될 수 있다.
즉,
Structured Clone
→ 데이터 복제
Transferable
→ 소유권 이전이라는 차이를 기억하면 된다.
15. Worker와 Event Loop는 어떻게 연결될까?
앞의 글에서 Event Loop를 배웠다.
Worker에서도 JavaScript가 실행되기 때문에 Worker 역시 자신의 실행 흐름을 가진다.
개념적으로
Main Thread
──────────────────
Call Stack
Event Loop
Task / Microtask
──────────────────
Worker
──────────────────
Call Stack
Event Loop
Task / Microtask
──────────────────처럼 각각 독립된 실행 환경에서 동작한다고 생각할 수 있다.
그리고 두 환경은
postMessage()를 통해 메시지를 주고받는다.
16. 실제 상황을 생각해보자
의료 영상 서비스를 예로 들어보자.
하나의 검사에 수천 개의 이미지 파일이 존재한다고 가정하자.
Study
├── image 1
├── image 2
├── image 3
├── ...
└── image 10,000각 파일을 파싱해야 한다.
Main Thread에서 전부 처리하면
Main Thread
파일 1 파싱
파일 2 파싱
파일 3 파싱
...
파일 10,000 파싱이 작업이 계속 실행되는 동안 UI가 영향을 받을 수 있다.
예를 들어
사용자
↓
스크롤
↓
Main Thread가 파싱 중
↓
스크롤 이벤트 처리 지연
↓
화면 버벅임이런 문제가 발생할 수 있다.
17. Worker로 분리하면
파싱 작업을 Worker로 보내면 구조를 다음처럼 나눌 수 있다.
Main Thread
────────────────────────
UI
사용자 입력
Viewer
화면 업데이트
Worker 관리
────────────────────────
│
│ files
↓
Worker
────────────────────────
파일 파싱
데이터 변환
결과 생성
────────────────────────
│
│ result
↓
Main Thread그러면 Main Thread가
10,000개 파일 파싱자체를 직접 수행하는 대신 Worker가 담당하게 된다.
물론 Worker에서 계산한 결과를 Main Thread가 받아서 화면에 반영하는 과정은 여전히 필요하다.
18. 그렇다면 파일 10,000개를 한 번에 Worker에 보내면 될까?
여기서 실제 개발에서는 또 다른 문제가 발생한다.
worker.postMessage(files);파일이 10,000개라고 해서 무조건 한 번에 Worker로 보내는 것이 좋은 것은 아니다.
데이터 전달량도 커지고,
Worker가 한 번에 너무 많은 작업을 처리하면 결과를 기다리는 시간이 길어질 수 있다.
그래서 실무에서는 작업을 나누는 전략이 중요하다.
예를 들어
10,000개
↓
500개
↓
500개
↓
500개
...처럼 Batch 단위로 나눌 수 있다.
그리고 Worker가 처리한 결과를 다시 전달하면서 다음 Batch를 처리하도록 만들 수 있다.
Main Thread
↓
Batch 1
↓
Worker
↓
결과
↓
Batch 2
↓
Worker
↓
결과
↓
...이런 구조를 사용하면 한 번에 너무 많은 작업을 몰아넣는 것을 피할 수 있다.
19. Worker에서 중요한 것은 "분리"다
Worker를 이해할 때 가장 중요한 개념은
무거운 계산을 Main Thread와 분리한다.
는 것이다.
기존
Main Thread
│
├── UI
├── Event
├── JavaScript
└── Heavy Work에서
개선
Main Thread Worker
│ │
├── UI ├── Heavy Work
├── Event ├── Parsing
└── 화면 업데이트 └── Calculation으로 역할을 나눈다.
이것이 Worker의 핵심이다.
20. Worker가 해결하는 것과 해결하지 않는 것
Worker가 해결하는 문제는 명확하다.
해결할 수 있는 것
CPU를 많이 사용하는 JavaScript 작업
Main Thread 장시간 점유
UI 반응성 저하
대량 데이터 처리
복잡한 계산하지만 다음 문제를 자동으로 해결해주는 것은 아니다.
네트워크 속도
서버 응답 시간
DOM 렌더링 자체
불필요한 React 렌더링
잘못된 데이터 구조
과도한 메모리 사용즉,
Worker는 "JavaScript가 무거워서 Main Thread가 막히는 문제"를 해결하기 위한 도구다.
21. React에서는 어떻게 연결될까?
이제 다음 단계로 React와 연결해보자.
React 애플리케이션에서도 결국 JavaScript는 브라우저에서 실행된다.
React
↓
JavaScript
↓
Main Thread
↓
React Render
↓
DOM 변경
↓
Browser Rendering따라서 React 코드 안에서 무거운 계산을 실행하면 Main Thread를 점유할 수 있다.
예를 들어
function Component({ data }) {
const result = heavyCalculation(data);
return <div>{result}</div>;
}여기서 heavyCalculation()이 매우 무겁다면 React Render 과정에서도 비용이 발생할 수 있다.
결국
React
↓
Render
↓
heavyCalculation
↓
Main Thread 점유
↓
UI 반응성 저하로 이어질 수 있다.
따라서 React의 성능 문제를 이해하려면 단순히
useMemo를 사용하면 된다
memo를 사용하면 된다에서 끝나면 안 된다.
먼저
이 작업이 어느 스레드에서 실행되고 있는가?
를 생각해야 한다.
22. React의 Render와 Worker는 다른 문제다
예를 들어
React Render자체를 Worker로 옮긴다고 생각하면 안 된다.
일반적인 React DOM 애플리케이션에서는 UI와 DOM을 다루는 작업이 Main Thread와 연결되어 있다.
Worker는 보통
계산
데이터 처리
파싱같은 부분을 담당한다.
그래서 구조를
React
│
├── UI
├── State
├── Event
└── Worker 결과 반영
↑
│
Worker
└── 계산처럼 설계할 수 있다.
23. Main Thread와 Worker를 언제 나눠야 할까?
실무에서는 단순히 "무거우면 Worker"라고 판단하기보다 다음을 생각하는 것이 좋다.
① CPU 사용량이 높은가?
복잡한 계산
대량 파싱
데이터 변환처럼 CPU를 오래 사용하는 작업이라면 Worker를 고려할 수 있다.
② Main Thread를 얼마나 오래 점유하는가?
작업이 짧다면 굳이 Worker를 사용할 필요가 없을 수 있다.
반대로 사용자 입력이나 화면 업데이트가 눈에 띄게 지연될 정도라면 분리를 고려할 가치가 있다.
③ 데이터를 얼마나 자주 주고받는가?
Main Thread
↕
Worker
↕
Main Thread
↕
Worker처럼 메시지를 지나치게 자주 주고받으면 Worker의 장점이 줄어들 수 있다.
④ 데이터가 얼마나 큰가?
대용량 데이터를 복제해서 전달한다면 전달 비용이 커질 수 있다.
이 경우 Transferable 등을 고려할 수 있다.
24. 전체 흐름을 다시 연결해보자
지금까지 배운 내용을 모두 연결하면 꽤 큰 그림이 만들어진다.
사용자
↓
브라우저
↓
JavaScript
↓
Main Thread
↓
Call Stack
↓
Event Loop여기서 무거운 작업이 발생하면
Main Thread
↓
Heavy JavaScript
↓
UI Blocking이 될 수 있다.
Worker를 사용하면
Main Thread
│
│ postMessage
↓
Worker
│
│ Heavy Work
↓
Worker
│
│ postMessage
↓
Main Thread
↓
UI 업데이트로 분리할 수 있다.
25. 지금까지의 시리즈를 다시 연결하면
우리가 처음 만들었던 전체 흐름도 이제 상당히 구체적으로 연결된다.
URL 입력
↓
DNS
↓
IP
↓
TCP / QUIC
↓
TLS
↓
HTTP
↓
HTML / CSS / JS
↓
브라우저 렌더링
↓
DOM / CSSOM
↓
Layout / Paint / Composite
↓
JavaScript 실행
↓
Execution Context
↓
Call Stack
↓
Event Loop
↓
Web API
↓
Task / Microtask
↓
Main Thread
↓
필요하다면 Worker
↓
계산 결과 전달
↓
React
↓
Render
↓
DOM 변경
↓
Browser Rendering이제 각각의 개념이 따로 존재하는 것이 아니라 하나의 브라우저 동작 과정으로 연결된다.
마무리
이번 글에서 가장 중요한 것은 Web Worker의 API를 외우는 것이 아니다.
핵심은 Main Thread의 역할과 한계를 이해하는 것이다.
JavaScript 코드가 Main Thread를 오래 점유하면
JavaScript
████████████████████████
↓
Main Thread 점유
↓
사용자 입력 지연
↓
렌더링 지연
↓
UI가 멈춘 것처럼 보임이런 문제가 발생할 수 있다.
이때 CPU를 많이 사용하는 작업을 Worker로 분리하면
Main Thread Worker
───────────────── ─────────────────
UI 계산
사용자 입력 파싱
React 데이터 처리
화면 업데이트 복잡한 연산
───────────────── ─────────────────
│ │
└────── message ──────────┘처럼 역할을 나눌 수 있다.
그리고 여기서 중요한 실무 판단이 하나 남는다.
"작업을 Worker로 옮길 것인가?"가 아니라 "Main Thread가 반드시 해야 하는 작업과 분리할 수 있는 작업은 무엇인가?"
를 생각해야 한다.
다음 글부터는 이제 이 구조 위에 React를 올려보자.
지금까지 살펴본
브라우저 렌더링
↓
JavaScript
↓
Event Loop
↓
Main Thread
↓
Web Worker위에서 React는 정확히 어떤 역할을 하는지,
React
↓
Render
↓
Re-render
↓
Commit
↓
DOM
↓
Browser Rendering의 흐름으로 연결해서 살펴볼 것이다.