JavaScript는 이벤트 루프로 비동기를 처리한다. 하지만 서버의 멀티스레드 이벤트 루프와는 다르다.
이 문서는 다음 질문에 답한다:
이 문서를 읽고 나면:
"JavaScript는 싱글 스레드다"는 무슨 의미인가?
핵심: JavaScript 엔진(V8, SpiderMonkey 등)이 코드를 실행할 때 하나의 Call Stack만 사용한다.
function first() {
console.log('1');
second();
console.log('3');
}
function second() {
console.log('2');
}
first();
// 출력: 1 → 2 → 3
Call Stack 동작:
1. first() 호출 → Call Stack에 push
2. console.log('1') 실행
3. second() 호출 → Call Stack에 push
4. console.log('2') 실행
5. second() 종료 → Call Stack에서 pop
6. console.log('3') 실행
7. first() 종료 → Call Stack에서 pop
Call Stack이 하나면 한 번에 하나의 작업만 실행할 수 있다.
JavaScript 엔진과 브라우저는 다르다:
브라우저는 JavaScript 엔진 외에도 다양한 스레드를 운영한다:
이 구분이 "싱글 스레드인데 비동기가 가능한 이유"의 핵심이다.
다음 코드의 실행 순서를 예측해보자:
console.log('1');
setTimeout(() => console.log('2'), 0);
console.log('3');
출력 결과:
1
3
2
왜 이런 순서로 출력될까? JavaScript가 1을 실행하다가 setTimeout으로 전환했다가 다시 3으로 돌아오는 "컨텍스트 스위칭"을 하는가?
답: 아니다.
컨텍스트 스위칭이라면 실행 중인 코드를 중단하고 다른 코드로 전환할 수 있어야 한다. 하지만 JavaScript는 현재 실행 중인 작업을 완전히 끝낸 후 다음 작업으로 넘어간다.
이것은 컨텍스트 스위칭이 아니라 작업 순서 관리다.
┌─────────────────────────────────────────────────┐
│ JavaScript 엔진 (싱글 스레드) │
│ │
│ ┌─────────────────────┐ │
│ │ Call Stack │ │
│ │ (한 번에 하나만) │ │
│ └─────────────────────┘ │
│ ↕ │
│ │
└─────────────────────────────────────────────────┘
↕
┌───────────────────────┐
│ 이벤트 루프 │
│ (감시자 + 중개자) │
└───────────────────────┘
↕
┌─────────────────────────────────────────────────┐
│ Queue System │
│ ┌──────────────────┐ ┌──────────────────┐ │
│ │ Microtask Queue │ │ Task Queue │ │
│ │ (높은 우선순위) │ │ (낮은 우선순위) │ │
│ └──────────────────┘ └──────────────────┘ │
└─────────────────────────────────────────────────┘
↑
┌─────────────────────────────────────────────────┐
│ 브라우저 Web APIs (멀티 스레드) │
│ │
│ ┌─────────────┐ ┌──────────────┐ │
│ │ Timer Thread│ │Network Thread│ │
│ └─────────────┘ └──────────────┘ │
│ ┌─────────────┐ ┌──────────────┐ │
│ │ DOM Thread │ │Render Thread │ │
│ └─────────────┘ └──────────────┘ │
└─────────────────────────────────────────────────┘
이벤트 루프는 "스케줄러"가 아니라 "감시자(Watcher) + 중개자(Mediator)"다.
이벤트 루프가 하는 일:
1. 무한 반복(Loop)하면서 Call Stack을 계속 감시
2. Call Stack이 비었는지 확인
3. 비었으면 → Queue를 확인 (Microtask Queue 먼저, 그 다음 Task Queue)
4. Queue에서 작업을 꺼내 Call Stack에 넣음
5. 1번으로 돌아가기
의사 코드로 표현하면:
while (true) {
if (callStack.isEmpty()) {
// Microtask Queue를 전부 비울 때까지 실행
while (!microtaskQueue.isEmpty()) {
const task = microtaskQueue.dequeue();
callStack.push(task);
task.execute();
}
// Task Queue에서 하나만 실행
if (!taskQueue.isEmpty()) {
const task = taskQueue.dequeue();
callStack.push(task);
task.execute();
}
}
}
JavaScript에는 두 종류의 Queue가 있다:
Microtask Queue (높은 우선순위)
Task Queue (낮은 우선순위)
console.log('1');
setTimeout(() => console.log('Task 1'), 0);
setTimeout(() => console.log('Task 2'), 0);
Promise.resolve().then(() => console.log('Micro 1'));
Promise.resolve().then(() => console.log('Micro 2'));
console.log('2');
출력 결과:
1
2
Micro 1
Micro 2
Task 1
Task 2
실행 흐름:
1. console.log('1') → Call Stack에서 즉시 실행
2. setTimeout 실행 → 브라우저가 콜백을 Task Queue에 등록
3. setTimeout 실행 → 브라우저가 콜백을 Task Queue에 등록
4. Promise.then 실행 → 콜백을 Microtask Queue에 등록
5. Promise.then 실행 → 콜백을 Microtask Queue에 등록
6. console.log('2') → Call Stack에서 즉시 실행
7. Call Stack 비워짐! ← 이벤트 루프 작동 시작
8. Microtask Queue 확인 → Micro 1, Micro 2 전부 실행
9. Task Queue 확인 → Task 1만 실행
10. Call Stack 비워짐
11. Microtask Queue 확인 → 비어있음
12. Task Queue 확인 → Task 2 실행
Microtask Queue: 한 번 확인하면 큐가 빌 때까지 전부 실행
Task Queue: 한 번에 하나만 실행하고 다시 Microtask 확인
다이어그램으로 표현하면:
┌─────────────────────────────────────────────┐
│ 이벤트 루프 실행 흐름 │
└─────────────────────────────────────────────┘
↓
┌───────────────────────┐
│ Call Stack 비었는가? │
└───────────────────────┘
↓ YES
┌───────────────────────┐
│ Microtask Queue 확인 │
│ (전부 실행) │
└───────────────────────┘
↓
┌───────────────────────┐
│ Task Queue 확인 │
│ (하나만 실행) │
└───────────────────────┘
↓
┌───────────────────────┐
│ 렌더링 기회 │
└───────────────────────┘
↓
(다시 처음으로)
function recursiveMicrotask() {
Promise.resolve().then(() => {
console.log('Microtask');
recursiveMicrotask(); // 계속 Microtask 추가
});
}
recursiveMicrotask();
setTimeout(() => console.log('나는 영원히 실행 안됨'), 0);
문제:
실제 프로젝트 예시:
// React에서 발생할 수 있는 무한 루프
useEffect(() => {
Promise.resolve().then(() => {
setState(prev => prev + 1);
// state 변경 → useEffect 재실행 → 무한 루프
});
}, [state]);
console.log('1');
setTimeout(() => console.log('2'), 1000);
console.log('3');
1초 타이머는 누가 재는가? JavaScript 엔진이 Call Stack에서 기다리는가?
답: 아니다. 브라우저의 Timer Thread가 타이머를 잰다.
setTimeout(() => console.log('완료'), 1000);
console.log('다음 코드');
동작 과정:
setTimeout 실행
console.log('다음 코드') 즉시 실행
1초 후 (Timer Thread에서)
Call Stack이 비면
fetch도 동일한 방식:
fetch('https://api.example.com/data')
.then(response => console.log(response));
console.log('요청 보냄');
핵심: JavaScript 엔진은 코드 실행만 담당한다. 시간이 걸리는 작업은 브라우저의 다른 스레드가 처리한다.
브라우저 렌더링(화면 그리기)은 Task 사이에 일어난다:
Task 1 실행
↓
Microtask Queue 전부 실행
↓
[렌더링 기회] ← 브라우저가 필요시 화면 업데이트
↓
Task 2 실행
↓
Microtask Queue 전부 실행
↓
[렌더링 기회]
렌더링 타이밍 다이어그램:
┌──────────────────────────────────────────────┐
│ 시간 흐름 → │
└──────────────────────────────────────────────┘
[Task 1]─[Micro]─[🎨 Render]─[Task 2]─[Micro]─[🎨 Render]
Task: Call Stack이 점유됨 (렌더링 불가)
Micro: 여전히 Call Stack 점유됨 (렌더링 불가)
Render: Call Stack 비어있음 (렌더링 가능)
button.addEventListener('click', () => {
box.style.transform = 'translateX(100px)';
// 무거운 작업 (3초)
let sum = 0;
for (let i = 0; i < 3000000000; i++) {
sum += i;
}
console.log('작업 끝');
});
문제 상황:
box.style.transform 실행 → DOM 수정 (아직 화면에 안 그려짐)왜 이런 문제가 발생하는가?
핵심 아이디어: "하나의 큰 Task" → "여러 개의 작은 Task로 나누기"
Task 사이에 렌더링이 발생하므로, 작업을 여러 Task로 분할한다.
button.addEventListener('click', () => {
box.style.transform = 'translateX(100px)';
let i = 0;
const total = 3000000000;
const chunkSize = 10000000; // 1000만 번씩
function doChunk() {
const end = Math.min(i + chunkSize, total);
// 청크 단위로 실행
for (; i < end; i++) {}
if (i < total) {
setTimeout(doChunk, 0); // 다음 Task로 미루기
} else {
console.log('작업 끝');
}
}
doChunk();
});
개선 효과:
Before (하나의 큰 Task):
[────── 3초 작업 ──────][렌더링] ← 3초 후에야 화면 반응
After (여러 작은 Task):
[작업][렌더링][작업][렌더링][작업][렌더링]... ← 즉시 반응
장점:
단점:
setTimeout(fn, 0)은 최소 4ms 지연애니메이션 작업에 사용:
function doWork(deadline) {
while ((deadline.timeRemaining() > 0 || deadline.didTimeout) && hasMoreWork()) {
// 작업 수행
performWork();
}
if (hasMoreWork()) {
requestAnimationFrame(doWork);
}
}
requestAnimationFrame(doWork);
특징:
긴급하지 않은 작업에 사용:
requestIdleCallback((deadline) => {
while (deadline.timeRemaining() > 0 && hasMoreWork()) {
performWork();
}
if (hasMoreWork()) {
requestIdleCallback(doWork);
}
}, { timeout: 2000 }); // 최대 2초 안에는 실행
특징:
| 용도 | API | 이유 |
|---|---|---|
| 애니메이션 | requestAnimationFrame | 렌더링과 동기화, 60fps 보장 |
| 긴급하지 않은 작업 | requestIdleCallback | 유휴 시간 활용, UX 영향 최소 |
| 무거운 계산 분할 | setTimeout | 간단하고 범용적 |
| 우선순위 지정 필요 | scheduler.postTask() | 최신 API, 세밀한 제어 |
1. JavaScript 엔진 vs 브라우저
2. 이벤트 루프
3. Queue 시스템
4. 렌더링
5. 해결 방법