이벤트 루프
JavaScript1 / 1
- 1.이벤트 루프
버튼을 눌러도 한동안 아무 반응이 없는 화면에는 이런 코드가 숨어 있습니다.
button.addEventListener("click", () => {
let total = 0;
for (let i = 0; i < 2_000_000_000; i++) {
total += i;
}
result.textContent = total;
});반복문이 실행되는 동안 다른 버튼이 클릭되지 않습니다. 자바스크립트가 한 번에 하나의 명령만 수행하는 건 알고 있지만, 이 설명만으로는 여러가지 비동기 작업이 처리되는 방식을 이해하기 어렵습니다.
브라우저의 비동기를 이해하려면 자바스크립트의 엔진과 브라우저의 역할을 먼저 이해해야 합니다.
자바스크립트 엔진과 콜 스택
콜 스택(Call Stack)은 현재 어떤 함수를 실행하고 있는지 추적하는 자료구조입니다. 함수를 호출하면 프레임이 위에 쌓이고, 함수가 끝나면 가장 위 프레임부터 실행되는 LIFO 구조입니다.
function add(a, b) {
return a + b;
}
function calculate() {
const result = add(3, 5);
console.log(result);
}
calculate();위 코드의 실행 순서는 다음과 같습니다.
스택 맨 위 함수는 끝날 때까지 다른 코드를 실행 할 수 없습니다. 이것이 앞서 설명한 엄청 오래 걸리는 반복문이 다른 버튼과 렌더링을 막은 일의 전말입니다. 자바스크립트의 이 특징을 Run-to-Completion이라 부릅니다.
여기서는 역할을 정확히 구분할 필요가 있습니다.
구성 요소 | 소속 | 역할 |
|---|---|---|
콜 스택 | 자바스크립트 엔진(V8) | 현재 실행 중인 함수와 실행 상태를 관리 |
Web API | 브라우저 | 타이머, 네트워크, DOM 이벤트 등 기능 제공 |
태스크 큐, 마이크로태스크 큐 | 호스트 환경 | 실행 가능한 콜백 대기 |
이벤트 루프 | 호스트 환경 | 태스크, 마이크로태스크, 렌더링 진행 순서 조정 |
호스트 환경은 자바스크립트 엔진을 가지고 실행하는 프로그램입니다. 이 글에서는 브라우저를 위주로 다루지만 Node.js도 호스트 환경입니다. 큐와 이벤트 루프는 모든 호스트에 존재하지만, DOM 이벤트같은 Web API는 브라우저에만 존재합니다.
이벤트 루프가 콜 스택과 두 가지 테스트 큐 모두 가지고 있는 것은 아닙니다. 콜 스택은 엔진에 있고, 큐와 이벤트 루프는 브라우저에 있습니다. 예시로 setTimeout, fetch, addEventListner가 V8 엔진이 아니라 브라우저가 제공하는 Web API입니다.
태스크와 마이크로태스크
먼저 예제를 하나 보겠습니다.
console.log("A");
setTimeout(() => {
console.log("C");
}, 0);
Promise.resolve().then(() => {
console.log("B");
});
console.log("D");출력은 A -> D -> B -> C입니다.
Promise의 .then, queueMicrotask, MutationObserver 콜백은 마이크로태스크 큐(Microtask Queue)로 전달됩니다.
setTimeout, setInterval, 사용자 입력, 네트워크 처리 작업은 태스크 큐(Task Queue)로 전달됩니다.
구분 | 대표적인 출처 | 처리 방식 |
|---|---|---|
마이크로태스크 |
| 체크포인트가 시작되면 큐가 빌 때까지 처리 |
태스크 | 타이머, 사용자 입력, 네트워크 이벤트 | 이벤트 루프가 한 번에 하나 실행 |
보통 구조를 단순화해서 하나의 태스크 큐만 그리는데 실제로 하나만 있는 것은 아닙니다. HTML 표준에서는 타이머, 사용자 입력, 네트워크처럼 태스크 소스별로 큐를 둘 수 있습니다. 브라우저는 어느 에서 다음 태스크를 가져올지 고르며, 입력 반응성을 위해 사용자 입력을 우선할 수도 있습니다.
정리하면 이벤트 루프는 다음 과정을 반복합니다.
여러 태스크 큐 중 하나에서 실행할 태스크를 선택합니다.
선택한 태스크를 실행 완료까지 처리합니다.
마이크로태스크 체크포인트에서 큐가 빌 때까지 마이크로태스크를 실행합니다.
렌더링 타이밍이 있으면 렌더링 단계를 진행합니다.
1번으로 돌아간다.
이벤트 루프는 태스크와 렌더링을 관리하고 진행하는 루프입니다.
마이크로태스크 실행 시점
"태스크 하나가 종료되면 마이크로태스크를 실행한다"는 대부분의 상황에 맞는 말이지만 100% 맞는 말은 아닙니다. 브라우저는 자바스크립트를 실행 컨텍스트 스택이 비어 안전하게 정리할 수 있는 시점에도 마이크로태스크 체크포인트를 수행합니다.
하나의 버튼에 이벤트리스터를 두 개를 붙이면 이 차리를 알 수 있습니다.
const button = document.querySelector("button");
button.addEventListener("click", () => {
console.log("listener 1");
Promise.resolve().then(() => console.log("microtask 1"));
});
button.addEventListener("click", () => {
console.log("listener 2");
Promise.resolve().then(() => console.log("microtask 2"));
});사용자가 버튼을 클릭하면 브라우저가 각 리스너를 콜백으로 호출하고, 첫 번째 리스너가 반환되어 스택이 빈 시점에 마이크로태스크를 처리합니다.
listener 1
microtask 1
listener 2
microtask 2반면 자바스크립트에서 button.click()을 호출하면 결과가 달라집니다.
listener 1
listener 2
after click
microtask 1
microtask 2마이크로태스크의 경계를 추론할 때는 "태스크가 끝났나"뿐 아니라 "자바스크립트 콜 스택이 비었나"를 같이 확인해 봐야 합니다.
setTimeout()의 실행
setTimeout(fn, 0)은 0ms 뒤에 콜백을 실행하라는 뜻이 아닙니다. 지정한 시간은 타이머가 완료되기 전까지 기다릴 최소 시간입니다. 시간이 지나도 현재 스택, 먼저 선택된 태스크가 끝날 때까지 기다립니다.
const startedAt = performance.now();
setTimeout(() => {
console.log(performance.now() - startedAt);
}, 0);
for (let i = 0; i < 1_000_000_000; i++) {
// 이 반복문이 끝날 때까지 타이머 콜백은 실행되지 않습니다.
}타이머를 연속으로 중첩하는 경우에는 규칙이 하나 더 있습니다. 중첩 단계가 5를 넘으면 브라우저가 다음 타이머의 최소 지연 시간을 4ms로 증가시킵니다. 백그라운드 텝이나 절전 상태에서는 브라우저 정책에 따라 더 길어질 수도 있습니다.
렌더링 기회
마이크로태스크가 끝났다고 브라우저가 매번 화면을 그리지 않습니다. 브라우저가 지금을 렌더링 기회(Rendering Opportunity)라고 판단할 때만 렌더링 단계가 실행됩니다. 디스플레이가 60Hz라면 한 프레임 예산은 약 16.7ms지만 120Hz에서는 약 8.3ms입니다. 탭이 가려졌거나 바뀐 내용이 없으면 렌더링을 건너뛸 수도 있습니다.
한번의 렌더링 기회는 대체로 다음 순서로 진행됩니다.
requestAnimationFrame 콜백은 다음 화면을 그리기 직전에 실행됩니다. 애니메이션에서 DOM을 바꿀 코드가 타이머보다. requestAnimationFrame이 적합합니다.
마이크로태스크는 큐가 빌 때까지 처리하며, 처리 중 새로 추가된 마이크로태스크도 같은체크포인트에서 실행합니다. 그래서 다음 코드는 렌더링까지 갈 기회를 제거합니다.
// 렌더링을 굶깁니다. 실제 페이지에서 실행하지 않는 편이 좋습니다.
function starveRendering() {
queueMicrotask(starveRendering);
}
starveRendering();같은 재귀를 타이머로 넘기면 매 호출이 다음 태스크가 됩니다.
function keepWorking() {
// 짧은 작업
setTimeout(keepWorking, 0);
}
keepWorking();이 코드도 CPU를 계속 쓰지만 태스크 사이에 렌더링과 입력을 처리할 기회가 생깁니다. 반면 마이크로태스크 무한 재귀는 체크포인트 자체가 끝나지 않아 탭을 정지시킵니다.