자바스크립트 이벤트 루프(Event Loop) 완벽 이해하기
![]()
“싱글 스레드인데 어떻게 비동기가 가능할까?”
자바스크립트를 공부하다 보면 누구나 한 번쯤 이런 의문을 갖게 됩니다.
“자바스크립트는 싱글 스레드라면서 어떻게
setTimeout,fetch,Promise같은 비동기 작업을 동시에 처리하는 걸까?”
저도 처음에는 async/await와 Promise를 문법으로만 사용했습니다.
하지만 이벤트 루프(Event Loop)의 동작 원리를 이해하고 나니 비동기 코드를 바라보는 관점이 완전히 달라졌습니다.
특히 브라우저가 왜 멈추는지, setTimeout(0)은 왜 즉시 실행되지 않는지, Promise가 왜 더 먼저 실행되는지까지 모두 하나의 원리로 설명할 수 있게 되었습니다.
이번 글에서는 이벤트 루프를 단순히 정의하는 것이 아니라, 브라우저 내부에서 코드가 실제 어떤 순서로 실행되는지를 따라가며 이해해 보겠습니다.
먼저 이 코드의 실행 결과를 맞혀보세요.
“`javascript
console.log(“1”);
setTimeout(() => {
console.log(“2”);
}, 0);
console.log(“3”);
“`
결과는 무엇일까요?
많은 사람들이
1
2
3
이라고 예상합니다.
하지만 실제 결과는
1
3
2
입니다.
왜 0초를 기다리라고 했는데 가장 마지막에 실행될까요?
이 질문 하나를 이해하면 이벤트 루프의 절반은 이해한 것입니다.
자바스크립트는 싱글 스레드이다
자바스크립트 엔진(V8)은 기본적으로 하나의 Call Stack만 가지고 있습니다.
Call Stack은 현재 실행 중인 함수들이 차곡차곡 쌓이는 공간입니다.
예를 들어
“`javascript
function a(){
b();
}
function b(){
console.log(“Hello”);
}
a();
“`
실행 과정은 다음과 같습니다.

스택은 항상 하나의 작업만 처리합니다.
그래서 자바스크립트를 싱글 스레드 언어라고 부릅니다.
그렇다면 setTimeout은 누가 실행할까?
여기서 많은 사람들이 오해하는 부분이 있습니다.
사실 setTimeout()은 자바스크립트 엔진의 기능이 아닙니다.
브라우저가 제공하는 Web API입니다.
즉,
javascript
setTimeout(...)
을 호출하면
자바스크립트는
“이건 내가 할 일이 아니네.”
하고 브라우저에게 일을 넘깁니다.
브라우저는 별도의 스레드에서 시간을 측정하고,
자바스크립트 엔진은 그동안 다음 코드를 계속 실행합니다.
즉,

그래서 메인 스레드가 멈추지 않는 것입니다.
그런데 왜 바로 실행되지 않을까?
타이머가 끝났다고 해서 콜백 함수가 바로 실행되는 것은 아닙니다.
브라우저는 콜백 함수를 Task Queue에 넣어 둡니다.
하지만 아직 실행되지 않습니다.
왜냐하면 Call Stack이 비어 있어야 하기 때문입니다.
여기서 등장하는 것이 오늘의 주인공입니다.
Event Loop는 교통경찰이다

이벤트 루프(Event Loop)는 매우 단순한 역할만 수행합니다.
이벤트 루프는 Task Queue를 먼저 감시하는 것이 아니라 Call Stack이 비어 있는지 확인하는 감시자입니다.
Stack이 비어 있을 때만 Task Queue에서 하나의 작업을 꺼내 Call Stack으로 옮깁니다.
이 과정을 끊임없이 반복하는 것이 Event Loop입니다.
그래서 앞의 예제는 다음과 같은 5단계 흐름으로 실행됩니다.
Step 1: 메인 스레드(Call Stack)에서 console.log(1)이 가장 먼저 실행됩니다.
Step 2: 비동기 함수 setTimeout()이 Call Stack에 들어오면, 브라우저의 Web API 환경으로 타이머 작업을 위임합니다.
Step 3: Web API에서 타이머가 끝나면 콜백 console.log(2)는 Task Queue로 대기하러 갑니다. 그동안 메인 스레드는 멈추지 않고 계속 console.log(3)을 마저 실행합니다.
Step 4: 모든 동기 코드가 실행되어 Call Stack이 완전히 비워지는 순간! Event Loop가 이를 감지하고 Task Queue에 대기 중이던 작업을 Call Stack으로 밀어넣습니다.
Step 5: 드디어 Call Stack에서 대기하던 콜백 console.log(2)가 실행되며 모든 작업이 종료됩니다.
이 순서가 됩니다.
Promise는 왜 더 빨리 실행될까?
이제 조금 더 어려운 예제를 보겠습니다.
“`javascript
console.log(“1”);
setTimeout(() => {
console.log(“setTimeout”);
},0);
Promise.resolve()
.then(()=>{
console.log(“Promise”);
});
console.log(“2”);
“`
결과는
1
2
Promise
setTimeout
입니다.
여기서 많은 사람들이 헷갈립니다.
분명 둘 다 비동기인데 왜 Promise가 먼저 실행될까요?
Microtask Queue라는 공간이 하나 더 있다
사실 브라우저에는 Queue가 하나만 있는 것이 아닙니다.

이렇게 두 종류가 존재합니다.
이벤트 루프는
“`
1. Call Stack이 비면
↓
- Microtask Queue를 모두 비운다.
↓
- 그 다음 Task Queue를 하나 실행한다.
“`
이 규칙을 가지고 있습니다.
그래서 Promise가 항상 먼저 실행되는 것입니다.
이 부분을 이해하면 async/await의 동작도 훨씬 쉽게 이해할 수 있습니다.
브라우저는 언제 화면을 그릴까?
많은 개발자가 놓치는 부분이 하나 있습니다.
브라우저는 아무 때나 화면을 다시 그리지 않습니다.
대략적인 순서는 다음과 같습니다.

즉,
렌더링도 하나의 작업입니다.
그런데 Call Stack이 계속 꽉 차 있다면
브라우저는 렌더링할 기회를 얻지 못합니다.
브라우저가 멈추는 진짜 이유
예를 들어
javascript
while(true){}
를 실행하면
브라우저는
-
클릭도 안 되고
-
스크롤도 안 되고
-
화면도 갱신되지 않습니다.
이유는 간단합니다.
Call Stack이 영원히 비워지지 않기 때문입니다.
이벤트 루프는 Queue를 확인할 수도 없고,
브라우저는 Render를 수행할 수도 없습니다.
우리가 흔히 말하는 프리징(Freezing) 현상이 바로 이것입니다.
setTimeout(0)의 진짜 의미
많은 사람들이
javascript
setTimeout(fn,0);
을
“즉시 실행”
이라고 생각합니다.
하지만 정확한 의미는
“현재 실행 중인 작업이 모두 끝난 뒤, Task Queue에 넣어 주세요.”
입니다.
즉,
실행을 빠르게 하는 것이 아니라
현재 작업을 뒤로 미루는 것입니다.
그래서 무거운 작업을 잠시 양보하여
브라우저가 화면을 갱신할 시간을 만들어 줄 수 있습니다.
실무에서는 언제 도움이 될까?
예전에 대량의 데이터를 화면에 출력하는 기능을 개발하면서 브라우저가 몇 초 동안 멈춘 적이 있었습니다.
처음에는 프레임워크의 렌더링 문제라고 생각했지만 원인은 이벤트 루프였습니다.
수만 건의 데이터를 한 번에 처리하면서 Call Stack을 계속 점유했고, 브라우저는 렌더링할 기회를 얻지 못했던 것입니다.
이후 작업을 여러 개의 작은 단위로 나누고 비동기적으로 분산하자 화면은 훨씬 부드러워졌습니다.
그때 이벤트 루프는 단순한 CS 면접 문제가 아니라 사용자 경험을 결정하는 핵심 원리라는 것을 체감했습니다.
마무리
이벤트 루프는 생각보다 복잡한 알고리즘이 아닙니다.
오히려 원리는 아주 단순합니다.
- 자바스크립트는 하나의 Call Stack만 사용한다.
- 시간이 오래 걸리는 작업은 Web API가 대신 처리한다.
- 작업이 끝나면 Queue에서 대기한다.
- Event Loop는 Stack이 비었을 때 Queue의 작업을 실행한다.
- Promise는 Task Queue보다 우선순위가 높은 Microtask Queue에서 처리된다.
이 다섯 가지만 이해해도 대부분의 비동기 동작을 설명할 수 있습니다.
앞으로 Promise, async/await, fetch, setTimeout을 사용할 때는 문법만 기억하지 말고, 브라우저 내부에서 이벤트 루프가 어떤 순서로 움직이고 있을지 함께 떠올려 보세요.
비동기 코드를 읽고 작성하는 시야가 훨씬 넓어질 것입니다.
답글 남기기