[번역] 자바스크립트 이벤트 루프 완벽 해설: 태스크, 마이크로태스크, 그리고 렌더링의 위치
Soshy··조회수 —

거의 모든 프론트엔드 면접에는 setTimeout, Promise, 몇 개의 console.log 호출이 들어 있는 짧은 코드가 등장한다. 그리고 “출력 결과가 어떻게 될까요?”라는 질문이 뒤따른다. 대부분의 지원자는 “마이크로태스크(microtask)가 매크로태스크(macrotask)보다 먼저 실행된다”는 규칙을 외우는 데서 그친다. 이 규칙은 맞지만, 실제 동작 모델은 드러내지 못한다. 후속 질문에 답하려면 그 모델을 이해해야 한다. 렌더링(rendering)은 어디에서 일어나는가? Promise를 연이어 실행하면 페이지가 멈출 수 있는데, setTimeout을 연이어 실행할 때는 왜 그렇지 않은가? 실행에 200ms가 걸리는 클릭 핸들러(handler)는 왜 INP 점수를 망치는가?
이 글에서는 이벤트 루프(event loop)를 기초부터 살펴본다. 끝까지 읽으면 실행 순서를 묻는 어떤 문제든 손으로 따라가며 풀 수 있고, 실제 코드에서 어떤 실행 예약 수단을 선택해야 하는지도 알게 될 것이다.
기본 구조: 스레드 하나, 호출 스택 하나
페이지의 자바스크립트는 하나의 스레드(thread), 즉 메인 스레드에서 실행된다. 이 스레드에는 현재 어떤 함수가 실행 중인지 추적하는 호출 스택(call stack)이 있다. 한 번에 함수 하나만 실행되며, 그 함수가 끝까지 실행되어야 다른 작업이 실행될 수 있다. 선점은 없다. 함수 실행에 300ms가 걸리면, 그 300ms 동안 메인 스레드에서는 다른 일이 전혀 일어나지 않는다. 클릭 핸들러도, 레이아웃(layout) 계산도, 페인팅(painting)도 실행되지 않는다.
그럼에도 브라우저는 타이머(timer), 네트워크 응답, 사용자 입력, 렌더링을 처리해야 한다. 이벤트 루프는 하나의 호출 스택을 중심으로 이 모든 작업을 조율하는 장치다. “메인 스레드가 다음에 무엇을 해야 할까?”라는 질문을 반복해서 던지고, 그 답에 해당하는 작업을 호출 스택에 넘긴다.
태스크 큐: 작업이 기다리는 곳
현재 호출 스택에 올라가 있지 않은 작업은 태스크 큐(task queue)에서 기다린다. 작업의 각 단위를 태스크(task)라고 한다. 다음은 모두 태스크가 된다.
<script>의 최초 실행- DOM 이벤트 전달: 클릭, 키 입력, 메시지 이벤트 등
- 지정한 지연 시간이 지난
setTimeout또는setInterval의 콜백(callback) fetch완료와 같은 네트워크 콜백: Promise를 이행하는 부분
명세는 큐(queue)가 하나라고 설명하지 않는다. 사용자 상호작용, 타이머, 네트워크 등 태스크의 출처에 따라 구분된 여러 태스크 큐를 정의한다. 하나의 큐 안에서는 태스크가 순서대로 실행된다. 하지만 여러 큐 중 어느 큐를 다음에 처리할지는 브라우저가 자유롭게 선택할 수 있다. 브라우저가 타이머보다 입력을 우선 처리할 수 있는 이유가 여기에 있다. 각 큐 내부의 실행 순서 보장을 깨뜨리지 않고도 사용자 상호작용 큐를 먼저 선택할 수 있다.
마이크로태스크: 먼저 처리되는 큐
태스크 큐와는 별개로 마이크로태스크 큐라는 구조가 있다. 다음은 마이크로태스크를 큐에 추가한다.
- 상태가 확정된 Promise의
.then(),.catch(),.finally()콜백 await뒤에 이어지는 코드:async함수의 나머지 실행queueMicrotask(fn)MutationObserver콜백
핵심 규칙은 마이크로태스크 체크포인트(checkpoint)다. 태스크 실행이 끝나 호출 스택이 비면, 브라우저는 큐에 있는 모든 마이크로태스크를 실행한다. 이 과정에서 마이크로태스크가 새 마이크로태스크를 추가하더라도 계속 실행한다. 큐가 완전히 빌 때까지 멈추지 않는다. 그제야 이벤트 루프가 다음 단계로 넘어간다.
이벤트 루프의 동작 순서
애플리케이션 코드에 중요한 부분만 추리면, 이벤트 루프의 처리 모델은 다음과 같다.
다이어그램을 불러오는 중…
flowchart TD
A["태스크 큐에서<br/>실행 가능한 태스크 하나 선택"]
B["호출 스택이 빌 때까지<br/>선택한 태스크 실행"]
C["마이크로태스크 체크포인트<br/>큐가 빌 때까지<br/>모든 마이크로태스크 실행"]
D{"렌더링 시점인가?<br/>(대략 화면 갱신마다 한 번)"}
E["렌더링 갱신<br/>rAF 콜백<br/>스타일·레이아웃 계산<br/>ResizeObserver<br/>IntersectionObserver<br/>페인팅"]
F["남는 유휴 시간이 있다면<br/>requestIdleCallback"]
A --> B --> C --> D
D -->|아니요| A
D -->|예| E
E --> F
F --> A
이 도식에서 면접에 특히 중요한 내용은 세 가지다.
- 태스크 하나를 실행한 다음 마이크로태스크를 처리하고, 그 뒤에 렌더링을 할 수도 있다. 이벤트 루프는 타이머 콜백을 전부 실행한 뒤 렌더링하는 방식으로 동작하지 않는다. 태스크 하나를 실행하고, 마이크로태스크 큐를 완전히 비운 다음, 다음 프레임(frame)을 그릴 때가 되었는지 확인한다.
- 모든 태스크 뒤에 렌더링이 일어나는 것은 아니다. 브라우저는 렌더링 시점에만 화면을 갱신한다. 이 시점은 디스플레이의 주사율과 연결되어 있다. 60Hz에서는 프레임 하나에 쓸 수 있는 시간이 약 16.7ms다(
1000 / 60). 두 프레임 사이에 여러 태스크가 실행될 수 있다. - 렌더링은 코드와 같은 스레드를 사용한다. 태스크가 아직 호출 스택에서 실행 중이면 메인 스레드의 스타일(style) 재계산, 레이아웃 계산, 페인팅은 시작할 수 없다. 오래 실행되는 태스크는 다음 프레임을 직접 지연시킨다.
출력 순서 문제를 손으로 따라가기
다음은 전형적인 예제다. 아무 브라우저 콘솔(console)에서나 실행해 보자.
console.log('A: sync start');
setTimeout(() => console.log('E: timeout'), 0);
Promise.resolve().then(() => {
console.log('C: promise 1');
queueMicrotask(() => console.log('D: nested microtask'));
});
queueMicrotask(() => console.log('B2: queueMicrotask'));
console.log('B: sync end');
출력 결과는 다음과 같다.
A: sync start
B: sync end
C: promise 1
B2: queueMicrotask
D: nested microtask
E: timeout
레이블(label)은 의도적으로 알파벳 순서대로 붙이지 않았다. 실행 순서도 알파벳 순서가 아니기 때문이다.
실행 과정을 따라가 보자.
다이어그램을 불러오는 중…
sequenceDiagram
participant S as 호출 스택
participant M as 마이크로태스크 큐
participant T as 태스크 큐(타이머)
S->>S: "A", "B" 출력(현재 스크립트 태스크)
S->>T: setTimeout 콜백 실행 예약
S->>M: Promise 반응 작업 C 추가
S->>M: queueMicrotask로 B2 추가
Note over S: 스크립트 태스크 종료, 호출 스택이 빔
M->>S: C 실행, D를 큐 맨 뒤에 추가
M->>S: B2 실행
M->>S: D 실행(큐를 비우는 과정은 계속 진행 중)
Note over M: 큐가 비고 체크포인트 종료
T->>S: 다음 태스크: 타이머 콜백이 "E" 출력
마이크로태스크 큐를 비우는 도중에 추가된 D도 타이머 콜백보다 먼저 실행된다는 점에 주목하자. 큐가 정말로 비기 전에는 체크포인트가 끝나지 않는다. 이 사실 하나로 다음 절의 내용을 설명할 수 있다.
Promise 루프는 페이지를 멈추게 하는데 타이머 루프는 그렇지 않은 이유
다음 두 함수를 비교해 보자.
// 버전 1: 재귀적으로 예약하는 마이크로태스크
function spinMicro() {
queueMicrotask(spinMicro);
}
// 버전 2: 재귀적으로 예약하는 타이머
function spinTimer() {
setTimeout(spinTimer, 0);
}
spinMicro()는 마이크로태스크 큐가 비는 순간을 만들지 않는다. 따라서 체크포인트도 끝나지 않는다. 이벤트 루프는 렌더링 단계에 도달할 수도, 다음 태스크를 선택할 수도 없다. 스크롤(scroll)과 클릭을 포함해 탭(tab) 전체가 완전히 멈춘다. 이를 마이크로태스크 기아(microtask starvation)라고 한다.
spinTimer()는 반응성을 해치지 않는다. 각 반복이 별도의 태스크이므로, 이벤트 루프는 반복 사이에 입력을 처리하고 프레임을 렌더링할 수 있다. 실행 빈도에도 제한이 걸린다. HTML 표준에 따르면 타이머가 5단계 깊이로 중첩되면 브라우저는 약 4ms의 최소 지연 시간을 적용한다. 백그라운드(background) 탭의 타이머에는 훨씬 강한 제한이 걸린다. Chrome은 초당 한 번으로 제한하며, 강력한 실행 빈도 제한이 적용되면 분당 한 번으로 제한한다.
이것이 루프 안에서 await를 사용해도 브라우저에 페인팅할 기회를 주지 못하는 이유이기도 하다. 각 await는 나머지 실행을 마이크로태스크로 예약할 뿐이므로, 페이지는 여전히 다른 작업을 실행할 기회를 얻지 못할 수 있다.
async function processAll(items) {
for (const item of items) {
heavyWork(item); // 메인 스레드를 막는다
await Promise.resolve(); // 나머지 실행은 태스크가 아니라 마이크로태스크다. 여기서는 페인팅이 일어날 수 없다
}
}
requestAnimationFrame은 어디에 들어가는가
requestAnimationFrame(rAF) 콜백은 태스크도, 마이크로태스크도 아니다. “렌더링 갱신” 단계 안에서, 해당 프레임의 스타일 재계산과 레이아웃 계산보다 먼저 실행된다. 여기서 실무적으로 중요한 결과 두 가지가 나온다.
- rAF 콜백 안에서 DOM이나 스타일을 변경하면 그 변경은 같은 프레임에 반영된다. 콜백 실행 뒤에 스타일과 레이아웃을 계산하기 때문이다.
- 스타일을 변경한 직후
offsetHeight같은 레이아웃 값을 읽으면, 브라우저는 콜백 안에서 레이아웃 계산을 앞당겨 동기적으로 수행해야 한다. 읽기 작업을 모두 먼저 처리하고, 그다음 쓰기 작업을 한꺼번에 처리하자.
rAF는 백그라운드 탭에서 일시 중지되기도 한다. 따라서 애니메이션(animation)의 진행률은 프레임 수가 아니라 인수로 전달되는 타임스탬프(timestamp)를 기준으로 계산해야 한다.
let start;
function step(timestamp) {
start ??= timestamp;
const progress = Math.min((timestamp - start) / 1000, 1); // 1초 동안 실행되는 애니메이션
box.style.transform = `translateX(${progress * 200}px)`;
if (progress < 1) requestAnimationFrame(step);
}
requestAnimationFrame(step);
적절한 실행 예약 수단 선택하기
| 수단 | 사용하는 큐 | 다음 페인팅 전에 실행되는가? | 용도 |
|---|---|---|---|
| 동기 코드 | 현재 호출 스택 | 페인팅을 막는다 | 짧고 꼭 필요한 작업 |
Promise.then, await, queueMicrotask | 마이크로태스크 큐 | 그렇다. 항상 렌더링보다 먼저 실행된다 | 다른 작업이 상태를 관찰하기 전에 반드시 끝내야 하는 작업의 마무리 |
requestAnimationFrame | 렌더링 단계 | 그렇다. 페인팅 직전에 실행된다 | 프레임에 맞춘 시각적 갱신 |
setTimeout(fn, 0) | 타이머 태스크 큐 | 보장되지 않는다 | 브라우저가 먼저 렌더링하고 입력을 처리할 수 있도록 작업 미루기 |
scheduler.yield() | 태스크 큐. 나머지 실행에 우선순위가 부여된다 | 브라우저에 렌더링할 기회를 준다 | 대기 순서에서 뒤로 밀리지 않으면서 긴 태스크 나누기 |
requestIdleCallback | 유휴 시간 | 렌더링 후 시간이 남으면 실행된다 | 사용 분석 같은 우선순위가 낮은 작업 |
scheduler.yield()로 긴 태스크 개선하기
실행 시간이 50ms를 초과하는 태스크를 긴 태스크(long task)라고 한다. 긴 태스크가 실행 중일 때 클릭이 발생하면, 그 태스크가 끝날 때까지 클릭 핸들러는 시작조차 할 수 없다. 그다음에는 핸들러가 끝나야 다음 프레임을 그릴 수 있다. 이 때문에 Interaction to Next Paint(INP, 사용자 상호작용부터 다음 페인팅까지 걸리는 시간)가 나빠진다.
일반적인 해결 방법은 작업을 여러 덩어리로 나누고, 그 사이에 브라우저가 입력 핸들러를 실행하고 렌더링할 기회를 주는 것이다. 예전에는 setTimeout(resolve, 0)을 사용했다. 문제는 나머지 실행이 타이머 큐의 맨 뒤에 들어가, 이미 대기 중인 다른 작업보다 늦게 실행된다는 점이다. scheduler.yield()는 새 태스크에서 이행되는 Promise를 반환한다. 이때 나머지 실행은 같은 종류의 다른 대기 태스크보다 우선 처리되므로, 작업을 더 빨리 재개할 수 있다. 글을 쓰는 시점에는 Chromium 기반 브라우저와 최신 Firefox에서 사용할 수 있고, Safari에서는 지원하지 않는다. 따라서 최신 호환성 정보를 확인하고 대체 수단도 마련해 두자.
다음은 재사용 가능한 도우미 함수와 시간을 기준으로 실행을 나누는 루프를 사용해 중복을 줄인 DRY 버전이다.
const yieldToMain = () =>
globalThis.scheduler?.yield
? scheduler.yield()
: new Promise((resolve) => setTimeout(resolve, 0));
async function processInChunks(items, handle, budgetMs = 8) {
let sliceStart = performance.now();
for (const item of items) {
handle(item);
if (performance.now() - sliceStart > budgetMs) {
await yieldToMain(); // 이제 브라우저가 입력을 처리하고 렌더링할 수 있다
sliceStart = performance.now(); // 새 실행 구간을 시작한다
}
}
}
// 사용 예
processInChunks(rows, (row) => renderRow(row));
항목마다 처리 비용이 다르므로, 항목 N개마다 실행을 양보하기보다는 시간 한도에 따라 양보하는 편이 낫다. 여기서는 8ms를 사용했다. 각 실행 구간을 약 8ms 이내로 유지하면, 16.7ms짜리 프레임 안에서 스타일 계산, 레이아웃 계산, 페인팅을 할 시간을 남길 수 있다.
다이어그램을 불러오는 중…
flowchart TB
subgraph Chunked["실행을 양보하며 나눈 작업"]
direction TB
C1["8ms 작업 구간"]
Y1["실행 양보<br/>입력 처리와 페인팅 가능"]
C2["8ms 작업 구간"]
Y2["실행 양보<br/>입력 처리와 페인팅 가능"]
More["…"]
C1 --> Y1 --> C2 --> Y2 --> More
end
subgraph LongTask["하나의 300ms 태스크"]
direction TB
L1["모든 항목에 대해<br/>heavyWork 실행"]
L2["입력 처리와 페인팅이<br/>300ms 동안 기다림"]
L1 --> L2
end
More ~~~~ L1
실전에서 기억할 것
면접을 준비한다면 실행 순서 문제를 풀 때마다 다음 과정을 연습하자.
- 모든 동기 코드를 실행한다.
- 호출 스택이 비면, 새로 추가되는 마이크로태스크까지 포함해 마이크로태스크 큐를 완전히 비운다.
- 프레임을 그릴 때가 되었다면 rAF 콜백을 실행하고 렌더링한다.
- 다음 태스크를 선택한다.
실행 과정을 따라가면서 각 단계를 소리 내어 설명해 보자. 면접관은 최종 답보다 동작 모델을 이해하고 있는지에 더 관심이 있다. 마이크로태스크 기아가 무엇인지, await만으로는 왜 페이지에 페인팅할 기회를 줄 수 없는지 설명할 준비도 해 두자.
실무에서는 Chrome DevTools의 Performance 패널(panel)에서 긴 태스크를 찾자. 긴 태스크는 모서리에 빨간 표시가 붙는다. 그런 다음 시간을 기준으로 실행을 나누는 루프와 scheduler.yield()를 사용해 작업을 쪼개고, 대체 수단으로 setTimeout을 마련하자. rAF는 시각적 갱신에 사용하고, 일반적인 실행 예약에는 사용하지 말자. 무거운 동기 작업을 실행하는 Promise 체인(chain)은 짧게 유지하자.
결론
이벤트 루프는 단순한 순환 구조지만, 중요한 비대칭성이 하나 있다. 마이크로태스크는 모든 태스크가 끝날 때마다 큐가 완전히 빌 때까지 실행된다. 반면 렌더링은 프레임을 그릴 때가 되었고 코드가 실행 중이지 않을 때만 일어난다. 이 모델을 이해하면 실행 순서 문제는 정해진 절차대로 풀 수 있다. INP 문제도 각 태스크가 스레드를 얼마나 오래 붙잡고 있는지 살펴보는 문제로 바뀐다.