JavaScript는 기본적으로 한 번에 하나의 작업만 실행한다.
이때 "지금 어떤 코드를 실행할지", "비동기 작업이 끝나면 언제 콜백을 실행할지"를 조율하는 메커니즘이 바로 Event Loop다.
이 문서는 브라우저 런타임 기준으로 설명한다.
이벤트 루프를 이해할 때는 아래 6가지를 같이 봐야 한다.
| 구성 요소 | 역할 | 대표 예시 |
|---|---|---|
Call Stack |
지금 실행 중인 함수들의 실행 컨텍스트를 쌓아두는 곳 | 함수 호출, 재귀 호출 |
Heap |
객체, 배열, 함수, 클로저 등이 저장되는 메모리 영역 | {}, [], 함수 객체 |
Web APIs / Host APIs |
브라우저가 제공하는 비동기 기능 | setTimeout, DOM 이벤트, fetch |
Task Queue |
나중에 실행할 일반 작업 대기열 | setTimeout, 클릭 이벤트 |
Microtask Queue |
현재 작업 직후 우선 실행할 대기열 | Promise.then, queueMicrotask |
Event Loop |
스택이 비었는지 보고, 큐에서 다음 작업을 가져와 실행 흐름을 이어감 | 전체 스케줄링 |
흔히
macrotask라는 표현도 많이 쓰는데, 보통 여기서 말하는Task Queue를 가리킨다.
- 현재 실행 중인 코드는
Call Stack에서 실행된다. - 비동기 작업은 브라우저의
Web APIs같은 외부 영역에서 기다린다. - 작업이 끝나면 콜백이 바로 실행되는 것이 아니라, 큐에 들어가 대기한다.
Call Stack이 비면, 이벤트 루프가 큐를 확인해 다음 작업을 올린다.- 이때 Microtask가 Task보다 먼저 처리된다.
- 함수 호출들은
프레임(frame)스택을 형성한다. - 어떤 함수가 실행되면 해당 함수의 실행 컨텍스트가 스택에 쌓인다.
- 함수 실행이 끝나면 스택에서 제거된다.
- 가장 나중에 들어온 함수가 가장 먼저 빠져나온다. 즉, LIFO(Last In, First Out) 구조다.
function foo(b) {
const a = 10;
return a + b + 11;
}
function bar(x) {
const y = 3;
return foo(x * y);
}
const baz = bar(7); // 42- 전역 코드 실행이 시작된다.
bar(7)를 호출하면bar의 프레임이 스택에 쌓인다.bar내부에서foo(x * y)를 호출하면foo의 프레임이bar위에 쌓인다.foo실행이 끝나면foo프레임이 제거된다.- 이어서
bar실행이 끝나면bar프레임도 제거된다. - 최종적으로 전역 실행만 남는다.
Call Stack은 "실행 흐름"을 관리하는 영역이다.- 반면 객체나 함수 같은 값 자체는 주로
Heap에 저장된다. - 즉, 스택에는 "어떤 코드가 실행 중인지"가 쌓이고, 실제 참조 대상 객체는 힙에 존재하는 경우가 많다.
- 클로저로 캡처된 값은 바깥 함수가 끝난 뒤에도 계속 살아 있을 수 있다.
- 객체는
Heap에 할당된다. - 힙은 비교적 큰 메모리 영역이며, 동적으로 생성되는 데이터가 저장된다.
- 대표적으로 객체, 배열, 함수 객체, 클로저 관련 데이터가 여기에 놓인다.
const user = {
name: "Kim",
skills: ["JS", "TS"]
};위 코드에서 user 변수 자체는 현재 실행 컨텍스트에서 참조를 들고 있고, 실제 객체와 배열 데이터는 힙에 저장된다고 이해하면 된다.
- 비동기 코드를 이해할 때 "함수가 끝났는데도 데이터가 왜 살아 있지?" 같은 의문이 생긴다.
- 이는 값이 스택에서 바로 사라지는 것이 아니라, 힙에 저장되어 있고 여전히 참조되고 있기 때문이다.
- 더 이상 참조되지 않는 값은 가비지 컬렉터가 정리한다.
JavaScript 엔진 자체가 setTimeout이나 DOM 이벤트를 직접 처리하는 것은 아니다.
이런 기능은 브라우저 같은 호스트 환경이 제공한다.
예를 들어:
setTimeout은 브라우저 타이머 시스템에 등록된다.- 클릭 이벤트는 브라우저가 감지한다.
fetch요청도 브라우저나 런타임 환경이 처리한다.
이 작업들이 끝나면 관련 콜백이 큐로 이동할 준비를 한다.
이벤트 루프를 설명할 때 큐는 크게 두 종류로 나눠서 보는 것이 좋다.
- 일반적인 비동기 콜백이 대기하는 큐다.
- 흔히
macrotask queue라고도 부른다. - 예:
setTimeoutsetInterval- DOM 이벤트 콜백
- 일부 I/O 완료 콜백
JavaScript 런타임은 대기열에서 작업을 하나 꺼내 Call Stack으로 올려 실행한다.
- 현재 작업이 끝난 직후, 다음
Task로 넘어가기 전에 먼저 처리되는 큐다. - 예:
Promise.thenPromise.catchPromise.finallyqueueMicrotaskMutationObserver
microtask와 macrotask(task)는 Call Stack의 종류가 아니다.
둘 다 "나중에 실행할 작업이 대기하는 큐"이며, 우선순위가 다를 뿐이다.
앞에 있는 기능들을 구현할 때, 보통 다음과 같이 단순화해서 설명한다.
while (true) {
const task = taskQueue.getNextTask();
execute(task);
while (microtaskQueue.hasNext()) {
execute(microtaskQueue.getNextMicrotask());
}
renderIfNeeded();
}물론 실제 브라우저 구현은 더 복잡하지만, 이해를 위해서는 이 흐름이 핵심이다.
- 현재 실행 중인 스크립트(또는 작업)를 실행한다.
Call Stack이 비면Microtask Queue를 전부 비운다.- 필요한 경우 브라우저가 렌더링 기회를 가진다.
- 그 다음
Task Queue에서 하나를 꺼내 실행한다. - 다시
Microtask Queue를 전부 비운다. - 이 과정을 반복한다.
- 각 작업 자체는 동기적으로 끝까지 실행된다.
- 즉, 현재 실행 중인 함수가 중간에 다른 JavaScript 코드에 의해 끼어들어 중단되지 않는다.
- 앞선 작업이 끝나야 다음 작업이 실행된다.
- 하지만 작업이 큐에 등록되는 과정 자체는 비동기적으로 일어날 수 있다.
이 특징은 프로그램의 동작을 추론할 때 매우 중요하다.
실행 중인 함수는 중간에 선점되지 않고, 자기 차례가 오면 끝까지 실행된다.
- 하나의 작업이 너무 오래 걸리면 클릭, 스크롤, 입력 같은 사용자 상호작용이 늦어진다.
- 브라우저 화면 갱신도 밀릴 수 있다.
- 심하면 브라우저가 "스크립트 응답 없음" 경고를 띄울 수 있다.
그래서 긴 작업은 잘게 나누거나, 필요하면 다음 작업으로 넘겨 UI가 숨 쉴 시간을 주는 것이 좋다.
- 웹 브라우저에서는 클릭 이벤트가 발생하면 해당 콜백이 큐에 들어간다.
setTimeout도 시간이 지나면 관련 작업이 큐에 들어간다.Promise.then은 작업이 완료되면Microtask Queue에 등록된다.
- 두 개의 매개변수를 받는다.
- 첫 번째는 실행할 콜백, 두 번째는 지연 시간이다.
- 이 시간은 "정확한 실행 시점"이 아니라 최소 지연 시간이다.
setTimeout(() => {
console.log("timeout");
}, 1000);1초가 지났다고 해서 즉시 실행되는 것은 아니다.
그 시점에 스택이 바쁘거나 앞선 작업이 남아 있으면 더 늦게 실행된다.
- 지연 시간을
0으로 지정해도 바로 실행되는 것은 아니다. - 현재 실행 중인 작업이 끝나야 하고, 큐의 순서도 지켜야 한다.
- 그래서
0은 "즉시 실행"이 아니라 "가능한 한 빨리, 하지만 현재 작업 이후"에 가깝다.
console.log("script start");
setTimeout(() => {
console.log("setTimeout");
}, 0);
Promise.resolve().then(() => {
console.log("promise");
});
console.log("script end");script start
script end
promise
setTimeout- 전역 스크립트가 먼저 실행된다.
setTimeout콜백은Task Queue로 갈 준비를 한다.Promise.then콜백은Microtask Queue에 등록된다.- 현재 스크립트가 끝나고 스택이 비면, 이벤트 루프는 먼저
Microtask Queue를 비운다. - 그래서
promise가 먼저 출력된다. - 그 다음에
Task Queue의setTimeout콜백이 실행된다.
브라우저는 보통 하나의 Task가 끝나고, 필요한 Microtask 처리도 끝난 뒤에 렌더링 기회를 얻는다.
즉 다음과 같은 상황이 생길 수 있다.
- 무거운 동기 작업이 오래 걸리면 화면이 멈춘다.
Microtask를 계속 만들어내면 렌더링이 지연될 수 있다.
예를 들어 아래 코드는 좋지 않다.
function loop() {
queueMicrotask(loop);
}
loop();이런 코드는 Microtask Queue를 계속 채우기 때문에, 다음 Task나 렌더링으로 넘어가지 못할 수 있다.
Call Stack은 지금 실행 중인 함수들의 스택이다.Heap은 객체, 배열, 함수 등 실제 데이터가 저장되는 메모리 영역이다.- 비동기 작업은 브라우저의
Web APIs같은 호스트 환경에서 처리된다. - 작업이 끝나면 콜백은 큐에 들어간다.
Microtask Queue는Task Queue보다 먼저 비워진다.- JavaScript 코드는 기본적으로
run-to-completion방식으로 실행된다. - 긴 동기 작업이나 과도한
Microtask는 UI 멈춤의 원인이 된다.