3강. 이벤트 루프(Event Loop)와 비동기 실행 모델
by
gg582 · 2026-07-09 13:35:04 · 55 views
3강. 이벤트 루프(Event Loop)와 비동기 실행 모델
들어가기 전에
JavaScript를 처음 배우는 개발자들이 가장 많이 하는 질문이 있습니다.
JavaScript는 싱글 스레드라면서 어떻게 여러 작업을 동시에 처리할 수 있을까?
예를 들어 다음 코드를 보겠습니다.
console.log("A");
setTimeout(() => {
console.log("B");
}, 0);
console.log("C");
많은 입문자는 다음과 같이 예상합니다.
A
B
C
그러나 실제 출력은
A
C
B
입니다.
이번에는 조금 더 복잡한 예제를 보겠습니다.
console.log("A");
setTimeout(() => {
console.log("B");
}, 0);
Promise.resolve().then(() => {
console.log("C");
});
console.log("D");
출력은
A
D
C
B
입니다.
왜 Promise가 먼저 실행될까요?
왜 setTimeout(..., 0)은 즉시 실행되지 않을까요?
이번 강의에서는 JavaScript 런타임이 비동기 작업을 처리하는 방식을 살펴봅니다.
JavaScript는 정말 싱글 스레드인가?
먼저 오해를 하나 바로잡아야 합니다.
JavaScript 언어 자체는 하나의 Call Stack만 사용하는 싱글 스레드 언어입니다.
동시에 두 개의 JavaScript 코드가 실행되는 일은 없습니다.
예를 들어
foo();
bar();
에서는
foo()
↓
foo 종료
↓
bar()
순서로만 실행됩니다.
Call Stack에는 항상 하나의 실행 흐름만 존재합니다.
그런데 네트워크는 누가 처리하는가?
다음 코드를 보겠습니다.
fetch("/api/data");
만약 JavaScript가 직접 네트워크를 처리한다면
HTTP 요청이 끝날 때까지 브라우저가 멈춰야 합니다.
하지만 실제로는 그렇지 않습니다.
웹 페이지는 계속 움직이고,
스크롤도 되고,
버튼도 눌립니다.
왜 그럴까요?
그 이유는 JavaScript 엔진이 네트워크를 처리하지 않기 때문입니다.
JavaScript Runtime
브라우저는 단순히 JS 엔진만 있는 것이 아닙니다.
전체 구조는 다음과 같습니다.
┌───────────────────────────────────────────────────────────┐
│ Browser Runtime │
├───────────────────────────────────────────────────────────┤
│ JavaScript Engine │
│ │
│ ┌───────────────────────────────┐ │
│ │ Call Stack │ │
│ └───────────────────────────────┘ │
├───────────────────────────────────────────────────────────┤
│ Web APIs │
│ │
│ • Timer │
│ • DOM │
│ • Fetch │
│ • XMLHttpRequest │
│ • File API │
├───────────────────────────────────────────────────────────┤
│ Task Queues │
│ │
│ • Task Queue │
│ • Microtask Queue │
├───────────────────────────────────────────────────────────┤
│ Event Loop │
└───────────────────────────────────────────────────────────┘
JavaScript 엔진은
오직 JavaScript 코드만 실행합니다.
나머지는 브라우저가 담당합니다.
Web API
예를 들어
setTimeout(callback, 1000);
을 실행하면
JavaScript는
타이머를 직접 세지 않습니다.
실행 순서는 다음과 같습니다.
Call Stack
↓
setTimeout()
↓
Browser Timer 등록
↓
JavaScript는 다음 코드 실행
타이머는 브라우저가 관리합니다.
JavaScript는 기다리지 않습니다.
setTimeout의 실제 동작
다음 코드를 보겠습니다.
console.log("Start");
setTimeout(() => {
console.log("Timer");
}, 1000);
console.log("End");
실행 순서는
Call Stack
↓
console.log("Start")
↓
setTimeout()
↓
Browser Timer 시작
↓
console.log("End")
입니다.
1초 후
브라우저는
callback
을
Task Queue에 넣습니다.
아직 실행되는 것은 아닙니다.
Task Queue
Task Queue는
실행 대기 중인 작업들이 저장되는 큐입니다.
대표적인 작업은
- setTimeout
- setInterval
- DOM Event
- MessageChannel
등입니다.
큐이므로
먼저 들어온 작업부터 실행됩니다.
Task Queue
──────────────
click
↓
timer
↓
message
하지만
큐에 들어갔다고 바로 실행되는 것은 아닙니다.
Event Loop
이벤트 루프는 매우 단순한 역할만 수행합니다.
계속 반복하면서
Call Stack이 비었는가?
↓
YES
↓
Queue에서 하나 꺼냄
↓
Call Stack으로 이동
↓
실행
↓
다시 반복
을 수행합니다.
즉,
이벤트 루프는
JavaScript를 실행하는 것이 아니라
언제 실행할지를 결정하는 스케줄러입니다.
왜 setTimeout(..., 0)은 즉시 실행되지 않는가?
다음 코드를 보겠습니다.
console.log("A");
setTimeout(() => {
console.log("B");
}, 0);
console.log("C");
실행 과정은 다음과 같습니다.
Call Stack
console.log("A")
↓
setTimeout()
↓
Timer 등록
↓
console.log("C")
↓
Call Stack 비움
↓
Task Queue
↓
callback
↓
console.log("B")
출력은
A
C
B
입니다.
0ms는
"즉시 실행"
이 아니라
"가능한 가장 빠른 시점에 Queue에 넣어라."
라는 의미입니다.
Promise는 왜 더 빠른가?
다음 코드를 보겠습니다.
console.log("A");
Promise.resolve().then(() => {
console.log("B");
});
console.log("C");
출력은
A
C
B
입니다.
이번에는
Task Queue가 아니라
Microtask Queue가 사용됩니다.
Microtask Queue
JavaScript에는 두 개의 주요 Queue가 존재합니다.
Task Queue
↓
Microtask Queue
Microtask에는
- Promise.then()
- catch()
- finally()
- queueMicrotask()
등이 들어갑니다.
그리고 Event Loop는
항상
Microtask를 먼저 처리합니다.
Event Loop의 우선순위
이벤트 루프는 다음 순서를 따릅니다.
현재 Task 실행
↓
Call Stack 비움
↓
Microtask Queue 모두 실행
↓
Render
↓
Task Queue 하나 실행
↓
다시 반복
여기서 중요한 점은
Microtask는
하나만 실행하는 것이 아니라
Queue가 빌 때까지 모두 실행한다는 것입니다.
Promise와 setTimeout 비교
console.log("A");
setTimeout(() => {
console.log("Timer");
}, 0);
Promise.resolve().then(() => {
console.log("Promise");
});
console.log("B");
실행 과정은
Call Stack
A
↓
setTimeout 등록
↓
Promise 등록
↓
B
↓
Call Stack 비움
↓
Microtask Queue
Promise
↓
Task Queue
Timer
따라서 출력은
A
B
Promise
Timer
입니다.
async / await의 내부 동작
다음 코드를 보겠습니다.
async function test() {
console.log("A");
await Promise.resolve();
console.log("B");
}
test();
console.log("C");
출력은
A
C
B
입니다.
왜일까요?
await는 현재 함수를 두 부분으로 나눕니다.
개념적으로는
Promise.resolve().then(() => {
console.log("B");
});
와 거의 같은 방식으로 동작합니다.
즉,
await 이후의 코드는
Microtask Queue에서 이어서 실행됩니다.
이벤트 루프가 필요한 이유
만약 JavaScript가
모든 네트워크 요청을 직접 처리한다면
fetch("/large-file");
를 실행하는 순간
브라우저 전체가 멈추게 됩니다.
현재 구조에서는
fetch()
↓
Browser 처리
↓
JavaScript는 계속 실행
↓
응답 완료
↓
Queue 등록
↓
Event Loop
↓
Callback 실행
이 이루어집니다.
덕분에
웹 페이지는 계속 반응할 수 있습니다.
실행 흐름 전체 정리
브라우저에서
setTimeout(callback, 1000);
이 실행되는 전체 과정은 다음과 같습니다.
JavaScript
↓
Call Stack
↓
setTimeout()
↓
Browser Timer
↓
1초 경과
↓
Task Queue
↓
Event Loop
↓
Call Stack
↓
callback 실행
Promise는
Promise
↓
Microtask Queue
↓
Event Loop
↓
Call Stack
를 거칩니다.
Microtask가 항상 먼저 처리됩니다.
이번 강의 요약
JavaScript는 하나의 Call Stack만 사용하는 싱글 스레드 언어입니다.
타이머, 네트워크, DOM 이벤트 등은 JavaScript 엔진이 아니라 브라우저의 Web API가 처리합니다.
비동기 작업이 완료되면 콜백은 Queue에 등록되고, Event Loop가 Call Stack이 비는 시점에 이를 실행합니다.
Promise와 await는 Task Queue가 아닌 Microtask Queue를 사용하므로 일반적인 타이머보다 먼저 실행됩니다.
JavaScript의 비동기 처리는 멀티스레드 실행이 아니라 이벤트 루프와 큐를 이용한 작업 스케줄링이라고 이해하는 것이 가장 정확합니다.
다음 강의
다음 강의에서는 JavaScript의 객체 시스템을 살펴봅니다.
- 객체는 어떻게 생성되는가
- 프로토타입(Prototype)
- 프로토타입 체인
class문법의 정체- 상속(Inheritance)의 내부 동작
this가 결정되는 방식
이 강의를 통해 JavaScript 객체 모델과 ECMAScript의 프로토타입 기반 상속 구조를 이해하게 됩니다.