이벤트 루프의 한 사이클 — 마이크로태스크 체크포인트는 건너뛸 수 없다
JavaScript 핵심 개념에서 이벤트 루프를 한 번 정리했다. 어떤 API가 어느 큐로 가는지, 그리고 **"마이크로태스크 큐를 전부 비운 뒤 매크로태스크를 하나만 처리한다"**는 규칙까지. 그 글의 결론은 지금도 그대로 유효하다. 이 글은 그 규칙을 다시 설명하지 않는다.
대신 그 규칙만으로는 설명되지 않는 것들을 다룬다.
async function이 프로미스를return하는 것과return await하는 것의 출력 순서가 왜 다른가- 큐를 "전부 비운다"는 말이 정확히 무슨 뜻이고, 그래서 왜
queueMicrotask로 무거운 작업을 쪼개면 더 심하게 얼어붙는가 await가 붙어 있는데도 UI가 멈추는 코드는 무엇을 착각한 것인가
이 세 질문의 답은 전부 한 곳에서 나온다. 이벤트 루프가 한 바퀴 도는 동안 정확히 무슨 순서로 무엇을 하는지다.
정의 — 이벤트 루프는 무엇을 하는 루프인가
이벤트 루프는 비동기를 처리해 주는 마법 장치가 아니다.
이벤트 루프는 콜 스택이 빌 때마다 정해진 순서로 큐를 소비하는, 단 하나의 while 루프다.
의사코드로 쓰면 이 정도다.
while (true) {
const task = pickTask(); // 태스크 소스들 중 하나에서 하나만 꺼낸다
run(task); // 콜 스택이 빌 때까지 끝까지 실행한다
drainMicrotasks(); // 마이크로태스크 체크포인트 — 큐가 빌 때까지
if (shouldRenderNow()) { // "지금 그릴 시점인가"를 여기서 판단한다
render();
}
}
이 루프에는 조건 분기도, 우선순위 힙도, 스레드 풀도 없다. 그래서 이벤트 루프를 이해한다는 건 사실상 pickTask와 drainMicrotasks의 정확한 경계를 아는 것과 같다.
세 구성 요소는 각각 누구의 것인가
혼란은 대개 "이건 자바스크립트 기능이다"라고 뭉뚱그리는 데서 온다. 세 구성 요소는 명세도 구현 주체도 다르다.
| 구성 요소 | 누구 소유인가 | 어디에 규정되어 있나 |
|---|---|---|
| 콜 스택 | 자바스크립트 엔진 (V8 등) | ECMAScript — 실행 컨텍스트 스택 |
| Web API (타이머, 네트워크, DOM 이벤트) | 호스트 환경 (브라우저) | HTML 명세, DOM 명세 등 |
| 마이크로태스크 큐 | 엔진과 호스트가 공유 | ECMAScript(Job Queue) + HTML(microtask queue) |
| 매크로태스크 큐 | 호스트 환경 | HTML 명세 — task queue |
setTimeout은 자바스크립트 언어 기능이 아니다. ECMAScript 명세에는 setTimeout이라는 이름이 없다. fetch도, addEventListener도 없다. 이것들은 전부 브라우저가 제공하는 기능이고, 엔진은 그저 "이 함수를 몇 ms 뒤에 나한테 다시 넘겨줘"라고 위임할 뿐이다.
이 구분이 왜 중요한가. 위임했다는 건 기다리는 주체가 자바스크립트가 아니라는 뜻이기 때문이다. setTimeout(fn, 1000)을 호출한 순간 콜 스택에서는 그 호출이 즉시 끝나고, 1초를 세는 일은 브라우저의 다른 스레드가 한다. 자바스크립트는 한 번도 기다린 적이 없다.
그림의 순환을 문장으로 옮기면 이렇다.
- 콜 스택이 동기 코드를 실행하다
setTimeout·fetch같은 것을 만나면 Web API에 위임하고 즉시 다음 줄로 넘어간다 - 위임받은 일이 끝나면 Web API가 콜백을 매크로태스크 큐에 넣는다
- 프로미스가 이행되면 그 반응(reaction) 작업이 마이크로태스크 큐에 들어간다
- 이벤트 루프는 콜 스택이 비어 있을 때만 큐에서 꺼내 콜 스택 위에 올린다
핵심은 4번이다. 이벤트 루프는 콜 스택이 비어 있지 않으면 아무 일도 하지 않는다. 큐에 아무리 많은 콜백이 쌓여 있어도, 지금 실행 중인 함수가 끝나기 전에는 단 하나도 꺼내지 못한다.
run-to-completion — 이 루프가 지키는 단 하나의 약속
위 문장을 뒤집으면 이렇게 된다.
한 번 실행이 시작된 코드는 끝까지 실행된다. 중간에 다른 코드가 끼어들 수 없다.
이것이 run-to-completion이다. 자바스크립트에서 다음과 같은 코드를 락 없이 쓸 수 있는 이유가 여기 있다.
let count = 0;
function increment() {
const cur = count; // 읽고
count = cur + 1; // 쓴다 — 이 사이에 아무도 끼어들 수 없다
}
멀티스레드 언어라면 읽기와 쓰기 사이에 다른 스레드가 끼어들어 갱신 손실이 생긴다. 자바스크립트에서는 그 일이 구조적으로 불가능하다. increment가 콜 스택에 올라가 있는 동안 이벤트 루프는 멈춰 있기 때문이다.
이 보장이 JS는 왜 동시성 이슈가 없는가 의 근거 전체다. 다만 여기에는 조건이 하나 숨어 있다 — 이 보장은 메인 스레드 하나 안에서만 성립한다. Web Worker와 SharedArrayBuffer를 쓰는 순간 진짜 병렬 실행이 생기고, 그때부터는 다른 언어와 똑같이 원자성 문제가 돌아온다. 그 경계는 위 글에서 다룬다.
그리고 run-to-completion은 동시에 이 글 후반부에서 다룰 모든 문제의 원인이기도 하다. 끼어들 수 없다는 건 안전하다는 뜻인 동시에, 내가 오래 붙잡으면 아무것도 못 한다는 뜻이니까.
큐 분류는 이미 정리했다
어떤 API가 어느 큐로 가는지, 그리고 "마이크로태스크 전부 → 매크로태스크 하나"라는 우선순위 규칙은 JavaScript 핵심 개념에 정리해 뒀으니 여기서는 한 문단으로 압축한다. setTimeout·setInterval·DOM 이벤트·네트워크 응답·MessageChannel은 매크로태스크로, Promise.then/catch/finally·await 이후 코드·queueMicrotask·MutationObserver는 마이크로태스크로 들어간다. 이벤트 루프는 매크로태스크를 하나 처리할 때마다 마이크로태스크 큐를 끝까지 비운다. 그래서 setTimeout(fn, 0)이 먼저 등록됐어도 Promise.resolve().then(fn)보다 늦게 실행된다.
이 글에서 필요한 건 이 규칙의 결론이 아니라 "끝까지 비운다"의 정확한 의미다.
매크로태스크 큐는 사실 하나가 아니다
앞의 글에서 "매크로태스크 큐"라고 단수로 부른 것은 설명을 위한 단순화다. HTML 명세는 태스크 소스(task source) 를 여러 개 정의한다. 타이머 소스, 사용자 상호작용 소스, 네트워킹 소스, DOM 조작 소스 등이 각각 별도의 큐를 가진다. 그리고 이벤트 루프가 다음 태스크를 고를 때, 어느 소스에서 꺼낼지는 브라우저 재량이다.
명세는 한 가지만 요구한다. 같은 태스크 소스 안에서는 등록 순서를 지킬 것. 소스가 다르면 순서를 뒤집어도 규격 위반이 아니다.
그래서 브라우저는 사용자가 클릭한 이벤트를 대기 중인 타이머보다 먼저 처리할 수 있다. 실제로 그렇게 한다 — 그래야 반응이 빠르니까. 여기서 실무적으로 중요한 결론이 나온다.
setTimeout(() => console.log('A'), 0);
setTimeout(() => console.log('B'), 0);
// A → B 는 보장된다 (같은 타이머 소스, 같은 지연값)
setTimeout(() => console.log('A'), 0);
element.click(); // 사용자 상호작용 소스
// 이 둘의 순서는 보장되지 않는다
서로 다른 종류의 비동기 작업 사이에 실행 순서를 기대하는 코드를 쓰면 안 된다. 순서가 필요하면 큐가 정해 주기를 기다릴 게 아니라 프로미스 체인으로 의존 관계를 명시해야 한다.
반면 마이크로태스크 큐는 정말로 하나다. 소스 구분도, 우선순위도 없는 순수 FIFO다. 이 비대칭이 뒤에서 볼 틱 계산을 예측 가능하게 만들어 준다.
브라우저는 "기다림"을 어디에 위임하는가
Web API에 위임한다고 했지만, 브라우저도 결국 프로그램이다. 브라우저는 수백 개의 소켓과 타이머를 어떻게 동시에 지켜보는가?
여기서 한 계층 아래로 내려가면 운영체제의 I/O 멀티플렉싱이 나온다. 브라우저의 네트워크 스레드는 epoll(리눅스) / kqueue(macOS) / IOCP(윈도우) 같은 시스템 콜로 "이 수백 개의 파일 디스크립터 중 하나라도 준비되면 깨워 달라"고 커널에 등록해 두고 블로킹된다. 준비된 것이 생기면 커널이 깨우고, 브라우저는 해당 콜백을 매크로태스크 큐에 넣는다.
즉 "기다림"은 사라진 게 아니라 계층을 내려가며 위임된 것이다. 자바스크립트 → 브라우저 → 커널 순으로. 자세한 구조는 I/O 멀티플렉싱 — select/poll/epoll에서 다룬다.
Node.js는 이 글의 범위가 아니다
Node.js에도 이벤트 루프가 있지만 구조가 다르다. 브라우저는 "태스크 하나 → 마이크로태스크 체크포인트 → 렌더링 판단"의 단순한 순환인 반면, Node는 libuv 위에서 timers → pending callbacks → poll → check → close callbacks 라는 여러 단계(phase)를 순회하고, 각 단계 사이마다 마이크로태스크를 비운다. 게다가 마이크로태스크가 두 종류다 — process.nextTick 큐가 프로미스 큐보다 먼저 비워진다. 브라우저에는 없는 개념이다.
그래서 브라우저 기준으로 외운 순서를 Node에 그대로 적용하면 틀린다. Node 쪽 구조는 Node.js — libuv와 논블로킹 I/O 모델에서 따로 다루고, 이 글은 브라우저로 한정한다.
이유 — 왜 그 순서로 실행되는가
이제 규칙을 알고 있다고 가정하고, 규칙만으로는 못 푸는 예제를 본다.
핵심 예제
async function a() {
return Promise.resolve('a');
}
a().then((v) => console.log(v));
Promise.resolve()
.then(() => console.log(1))
.then(() => console.log(2))
.then(() => console.log(3));
// 출력: 1 → 2 → a → 3
a()가 먼저 호출됐고, Promise.resolve('a')는 이미 이행된 프로미스다. 그런데 a는 1, 2 다음에 나온다. 그리고 여기서 자연스럽게 떠오르는 질문 하나 —
왜 하필
1과2사이가 아니라,2다음인가?
a가 늦는다는 것까지는 "뭔가 추가 단계가 있나 보다"로 넘길 수 있다. 하지만 하필 세 번째 자리라는 건 우연이 아니다. 정확히 몇 단계가 추가되는지가 정해져 있다는 뜻이다.
풀어 보려면 먼저 동기 구간이 끝난 시점의 마이크로태스크 큐 상태를 봐야 한다.
| 순서 | 실행되는 코드 | 마이크로태스크 큐에 들어가는 것 |
|---|---|---|
| 1 | a() 호출 → 즉시 Promise.resolve('a') 반환 | J1 — a의 반환값을 푸는 작업 |
| 2 | .then(v => console.log(v)) 등록 | 없음 — a()의 프로미스는 아직 대기 중 |
| 3 | Promise.resolve() → 이미 이행됨 | — |
| 4 | .then(cb1) 등록 | cb1 — 대상이 이미 이행됐으므로 즉시 예약 |
| 5 | .then(cb2), .then(cb3) 등록 | 없음 — 앞 단계가 끝나야 예약된다 |
동기 코드가 끝난 시점의 큐는 [J1, cb1]이다. 이미 여기서 승부가 갈렸다. a의 체인은 앞자리를 잡았지만, 그 자리에 들어 있는 건 출력이 아니라 아직 값을 꺼내지도 못한 준비 작업이다.
이제 이벤트 루프가 큐를 비운다.
| 틱 | 꺼낸 작업 | 하는 일 | 실행 후 큐 상태 | 출력 |
|---|---|---|---|---|
| 1 | J1 | 반환값이 thenable임을 확인하고 .then(resolve) 호출 → 이미 이행돼 있으므로 반응 작업 예약 | [cb1, J2] | — |
| 2 | cb1 | console.log(1) → 다음 체인 예약 | [J2, cb2] | 1 |
| 3 | J2 | 내부 프로미스의 값 'a'를 a()의 프로미스에 전달 → 이행 → 대기하던 .then 예약 | [cb2, J3] | — |
| 4 | cb2 | console.log(2) → 다음 체인 예약 | [J3, cb3] | 2 |
| 5 | J3 | console.log('a') | [cb3] | a |
| 6 | cb3 | console.log(3) | [] | 3 |
두 체인이 완벽하게 교대로 실행된다. a 체인이 홀수 틱, 숫자 체인이 짝수 틱을 가져간다. 그리고 a 체인의 앞 두 틱(1틱·3틱)은 아무것도 출력하지 않는 내부 작업이다. 이 두 틱이 전부다.
여기서 두 가지 결론이 나온다.
첫째, 틱은 시간 단위가 아니다. 위 표의 "틱"은 밀리초도, 프레임도, 이벤트 루프의 한 바퀴도 아니다. 마이크로태스크 큐에서 작업 하나를 꺼내 실행하는 횟수를 세는 단위다. 6틱은 6ms가 아니라 마이크로초 단위로 끝난다. setTimeout(fn, 0)이 아직 한 번도 실행되지 않은 사이에 이 6틱이 전부 지나간다. (참고로 HTML 명세에서 "tick"이라는 용어는 다른 뜻으로 쓰인다. 이 글의 "틱"은 마이크로태스크 실행 횟수를 세기 위한 이 글 안의 표기다.)
둘째, 마이크로태스크 큐는 우선순위 없는 순수 FIFO다. a 체인이 "더 중요해서" 먼저 처리되는 일은 없다. 큐에 늦게 들어간 쪽이 무조건 뒤로 간다. 그래서 몇 틱을 쓰는지만 세면 출력 순서를 정확히 예측할 수 있다. 순서를 결정하는 건 코드의 위치가 아니라 각 단계가 큐에 들어가는 시점이다.
세 가지 반환 방식 대비
같은 함수의 몸통만 바꿔서, 출력 순서가 어떻게 당겨지는지 본다.
// ① return 프로미스 — 2틱 소모
async function a() {
return Promise.resolve('a');
}
// 출력: 1 → 2 → a → 3
// ② return await 프로미스 — 1틱 소모
async function a() {
return await Promise.resolve('a');
}
// 출력: 1 → a → 2 → 3
// ③ 프로미스 없이 값만 반환 — 0틱 소모
async function a() {
return 'a';
}
// 출력: a → 1 → 2 → 3
a가 한 칸씩 앞으로 당겨진다. ③에서는 a()가 동기 구간에 이미 이행되므로 .then이 큐의 맨 앞자리를 차지하고, 다른 체인보다 먼저 나온다.
await을 붙였는데 오히려 빨라졌다는 점이 직관에 어긋난다. 보통 await은 "기다리는 것"이라 느려질 거라고 생각하니까. 왜 반대인가?
왜 ①만 1틱을 더 쓰는가
return Promise.resolve('a')를 실행하면, 엔진은 a()가 반환할 바깥 프로미스를 다른 프로미스로 이행(resolve) 해야 한다. 이때 엔진 입장에서 문제가 하나 있다.
그 반환값이 네이티브 프로미스인지, 아니면 누가 손으로 만든
{ then(cb) { ... } }짜리 thenable인지 resolve 함수는 알 수 없다.
thenable이라면 then 프로퍼티가 게터일 수도 있고, then을 호출하는 순간 임의의 사용자 코드가 실행될 수도 있다. 그 사용자 코드를 지금 이 자리에서 동기적으로 실행해 버리면, resolve 도중에 남의 코드가 끼어드는 셈이 된다. 실행 순서가 예측 불가능해지고 재진입 문제가 생긴다.
그래서 ECMAScript 명세는 이렇게 정했다. .then을 호출하는 일 자체를 마이크로태스크로 미룬다. (명세상 이름은 NewPromiseResolveThenableJob이다.) 위 표의 1틱이 정확히 이 작업이고, 3틱은 그렇게 호출된 .then의 반응이 실행되는 틱이다. 합쳐서 2틱.
즉 이 1틱은 성능을 위한 게 아니라 안전장치의 값이다. "무엇이 올지 모르니 일단 큐에 넣고 다음 틱에 안전하게 처리하자"는 비용.
그럼 await은 왜 이 비용을 안 내는가? await은 PromiseResolve 추상 연산을 거치는데, 이 연산은 대상이 이미 네이티브 프로미스이고 그 constructor가 손대지 않은 %Promise%인지 확인할 수 있다. 확인이 되면 새로 감싸지 않고 그 프로미스를 그대로 쓴다. 위장 thenable일 걱정이 없으니 미룰 이유도 없고, 곧바로 반응을 예약한다. 그래서 1틱.
정리하면 ①은 "안전을 위해 한 단계 미루기" 를 하고, ②는 "안전이 이미 확인됐으니 그 단계를 건너뛰기" 를 한다.
역사적 맥락 — 예전에는 await이 3틱이었다
지금은 await이 1틱이지만, Chrome 73 / Node 12 이전에는 3틱이었다. 당시 명세의 await v는 v가 무엇이든 상관없이 무조건 새 프로미스로 감쌌다. 그래서 이런 일이 벌어졌다.
| 틱 | 하는 일 |
|---|---|
| 1 | 새로 만든 래퍼 프로미스를 v로 이행하려는데 v가 thenable → v.then(...) 호출을 다음 틱으로 미루는 작업 예약 |
| 2 | 그 작업이 실행되어 v.then(resolveWrapper) 호출 → v가 이미 이행돼 있으므로 값 전달 반응 예약 |
| 3 | 래퍼 프로미스가 이행되고, 그 반응으로 async 함수가 재개 |
즉 3틱 중 앞의 2틱은 순전히 "쓸데없이 씌운 포장을 다시 벗기는" 데 쓰였다. v가 이미 네이티브 프로미스인데 굳이 한 겹 더 감쌌기 때문이다.
TC39는 이 낭비를 없애는 명세 개정을 통과시켰다. await이 PromiseResolve를 쓰도록 바꿔서, 대상이 이미 네이티브 프로미스면 감싸지 않게 한 것이다. 앞의 두 틱이 통째로 사라지고 3틱이 1틱이 됐다. 그래서 버전에 따라 같은 코드의 출력 순서가 달라지는 예제들이 인터넷에 아직 돌아다닌다. 오래된 자료의 출력 순서가 지금과 다르다면 대개 이 개정 이전 기준이다.
여기서 일반화가 하나 나온다.
틱이 늘어나는 원인은 거의 항상 "프로미스가 프로미스를 감싸서, 그걸 다시 푸는 절차"다.
return Promise.resolve(x)의 2틱도, 옛 await의 3틱도 같은 원인이다. 반대로 감싸는 일이 없으면(③) 0틱이다. 틱 수를 세고 싶으면 규칙을 외울 게 아니라 **"여기서 프로미스가 몇 겹 씌워지는가"**를 세면 된다.
다만, 이 틱 차이는 실무 성능에 거의 영향이 없다
정직하게 덧붙인다. 지금까지 센 1틱, 2틱의 실제 비용은 마이크로초 단위다. return await으로 바꿔서 체감할 성능 개선이 생기는 코드는 사실상 없다. 이 계산이 쓸모 있는 곳은 성능 튜닝이 아니라, 출력 순서가 예상과 다를 때 원인을 짚는 순간이다.
그리고 return await에는 성능과 무관한 실용적 이점이 따로 있다.
// ✗ 여기서는 catch가 잡지 못한다
async function bad() {
try {
return fetchUser(); // 프로미스를 그대로 반환 — 함수는 이미 빠져나갔다
} catch (e) {
console.log('안 잡힘');
}
}
// ✓ 여기서는 잡힌다
async function good() {
try {
return await fetchUser(); // 이 자리에서 거부가 예외로 던져진다
} catch (e) {
console.log('잡힘');
}
}
return promise는 프로미스를 호출자에게 넘기고 함수를 빠져나가므로, 나중에 그 프로미스가 거부돼도 이미 사라진 try 블록이 잡을 수 없다. return await은 거부를 이 함수 안에서 예외로 되살리기 때문에 catch에 걸린다. 스택 트레이스에 이 함수가 남는다는 이점도 있다.
return await을 쓰는 이유는 1틱을 줄이려는 게 아니라 에러를 이 함수 안에서 잡으려는 것이다. ESLint의no-return-await규칙이 한때 이걸 무조건 금지했다가, 지금은try블록 안에서는 예외로 두도록 바뀐 것도 같은 이유다.
판단 — 무거운 작업을 어떻게 쪼갤 것인가
이제 실전 문제로 간다. 10만 건짜리 데이터를 가공해서 화면에 뿌려야 한다. 그냥 돌리면 몇 초 동안 탭이 얼어붙는다. 클릭도 안 먹고, 스크롤도 안 되고, 로딩 스피너조차 멈춘다.
원인은 명확하다. run-to-completion 때문에, 이 함수가 콜 스택에 올라가 있는 동안 이벤트 루프는 아무것도 못 한다.
해법도 명확해 보인다. 쪼개서 중간에 양보하면 된다.
첫 시도 — queueMicrotask로 쪼개기
function processAll(items, onDone) {
let i = 0;
function chunk() {
const end = Math.min(i + 1000, items.length);
for (; i < end; i++) heavyWork(items[i]);
if (i < items.length) {
queueMicrotask(chunk); // 양보한 셈 치고 다음 청크
} else {
onDone();
}
}
chunk();
}
// 결과: 여전히 완전히 얼어붙는다. 쪼개기 전과 아무 차이가 없다.
1000건씩 100번으로 쪼갰고, 사이사이에 이벤트 루프에 제어권을 넘겼다. 그런데 화면은 그대로 멈춰 있다.
왜인가. 이벤트 루프의 한 사이클을 다시 보면 답이 나온다.
run(task);
drainMicrotasks(); // ← 큐가 빌 때까지 비운다
if (shouldRenderNow()) render();
drainMicrotasks는 큐가 빌 때까지 돈다. 그런데 각 청크가 실행되면서 다음 청크를 다시 마이크로태스크 큐에 넣는다. 하나 꺼내면 하나가 새로 들어온다. 큐가 영원히 비지 않는다. 체크포인트가 끝나지 않으므로 그 다음 줄 — 렌더링 판단, 다음 태스크 선택 — 에는 순서가 영영 가지 않는다.
queueMicrotask로 재귀하는 건 양보가 아니라 while 루프를 마이크로태스크 큐로 옮겨 적은 것이다. 오히려 각 청크마다 큐 조작 비용이 붙어 원본보다 느리다.
위 그림은 단순화한 도식이다. 실제로는 매크로태스크가 끝날 때마다 매번 렌더링이 일어나지 않는다. 이 점은 바로 아래에서 보정한다.
보정 — 렌더링은 매크로태스크마다 일어나지 않는다
"양보하면 화면이 그려진다"는 표현은 편의상 쓸 뿐 정확하지 않다. 실제 순서는 이렇다.
- 태스크 하나 실행
- 마이크로태스크 체크포인트 (큐가 빌 때까지)
- "지금이 렌더링할 시점인가?" 판단
- 그렇다면 →
requestAnimationFrame콜백 실행 → 스타일 계산 → 레이아웃 → 페인트
3번이 핵심이다. 렌더링 기회는 매크로태스크마다가 아니라 디스플레이 주사율에 맞춰서 열린다. 60Hz 모니터라면 약 16.7ms에 한 번이다. 그 사이에 매크로태스크를 20개 처리했다면 20번 그리는 게 아니라 한 번 그린다. 반대로 탭이 백그라운드에 있거나 화면 갱신이 필요 없다고 판단되면 아예 건너뛴다.
그래서 정확한 표현은 이쪽이다.
"양보하면 매번 그려진다"가 아니라, "양보해야 그릴 기회라도 생긴다" 이다.
양보는 렌더링을 일으키는 행위가 아니라, 렌더링이 일어날 수 있는 틈을 여는 행위다. 마이크로태스크로 재귀하면 그 틈 자체가 열리지 않으므로 그릴 기회가 0이 된다.
4번의 뒷부분 — 스타일 계산과 레이아웃이 왜 저 위치에 있고 무엇이 비싼지, requestAnimationFrame이 왜 하필 저 자리에 놓이는지 — 는 Reflow와 Repaint에서 다뤘다. requestAnimationFrame이 "다음 프레임 직전"에 실행되도록 정의된 건, 여기서 DOM을 건드리면 그 변경이 같은 프레임의 레이아웃에 반영되어 중간 상태가 화면에 새지 않기 때문이다.
같은 함정이 await에도 있다
queueMicrotask가 양보가 아니라면, await은 어떤가?
async function processAll(items) {
for (let i = 0; i < items.length; i++) {
heavyWork(items[i]);
if (i % 1000 === 0) await Promise.resolve(); // 양보?
}
}
// 결과: 여전히 얼어붙는다.
await Promise.resolve()는 마이크로태스크 한 틱을 쓰고 곧바로 돌아온다. 위의 queueMicrotask 재귀와 정확히 같은 일이다. 체크포인트 안에서 맴돌 뿐 밖으로 나가지 못한다.
"await이 있으니 비동기고, 비동기니까 안 막히겠지" 가 여기서 깨진다. await은 함수를 잠시 멈추는 문법일 뿐, 이벤트 루프에게 제어권을 돌려준다는 보장이 아니다.
판별 기준 — await이 붙었느냐가 아니라, 실제로 나중에 이행되느냐
그럼 어떤 await은 양보가 되고 어떤 건 안 되는가. 기준은 하나다.
그 프로미스가 지금 이 순간 이미 이행되어 있는가, 아니면 나중에 이행되는가.
- 나중에 이행되는 프로미스 — 실제 네트워크 요청, 타이머, 파일 읽기. 완료 콜백이 매크로태스크로 들어오므로, 그것을 기다리는 동안 현재 태스크가 끝난다. → 진짜 양보
- 이미 이행된 프로미스 —
Promise.resolve(), 캐시 히트, 동기 계산 결과를 감싼 것. 반응이 마이크로태스크로 들어오므로 체크포인트 안에서 즉시 돌아온다. → 양보 아님
같은 await 한 줄이 어느 쪽인지는 런타임에 결정된다. 코드만 봐서는 알 수 없다. 이게 다음 버그를 만든다.
실제 버그 사례 — 조건에 따라 죽는 폴링
async function poll() {
const data = await fetchStatus();
update(data);
await Promise.resolve(); // "한 박자 쉬어 가기"
poll(); // 재귀
}
평소에는 멀쩡하다. fetchStatus()가 진짜 네트워크를 타면 응답이 매크로태스크로 들어오고, 그 사이에 렌더링도 입력 처리도 정상적으로 일어난다.
문제는 fetchStatus()가 즉시 이행되는 순간이다.
- 응답이 캐시에 있어서 곧바로 반환될 때
- 테스트나 로컬 개발에서 목 데이터를
Promise.resolve(mock)로 돌려줄 때 - 서비스 워커가 캐시로 가로챌 때
- 요청이 즉시 실패해서 미리 만들어 둔 거부 프로미스가 반환될 때
이 순간 poll 안의 모든 await이 마이크로태스크 한 틱짜리가 된다. 그리고 재귀는 끝이 없다. 재귀 전체가 하나의 거대한 마이크로태스크 루프로 변하고, 탭이 완전히 얼어붙는다.
가장 고약한 건 재현 조건이다. 개발 중엔 멀쩡하고, 스테이징에서도 멀쩡하고, 캐시가 채워진 특정 사용자에게서만 죽는다. 그리고 얼어붙은 뒤에는 개발자 도구조차 잘 안 열린다 — 메인 스레드가 통째로 잡혀 있으니까.
고치는 방법은 await Promise.resolve()를 진짜 양보로 바꾸는 것이다.
async function poll() {
const data = await fetchStatus();
update(data);
await yieldToEventLoop(); // 아래에서 만든다 — 매크로태스크 경계를 만든다
poll();
}
덧붙이면, 이 폴링 코드에는 태스크 경계와 관련된 다른 문제도 숨어 있다. 응답이 뒤바뀌어 도착하거나 중복 요청이 겹치는 경우다. 태스크 경계가 어디에 생기는지가 그 문제들의 전제이고, 그건 async에서의 Race Condition에서 다룬다.
양보 수단 비교
진짜 양보 = 매크로태스크 경계 만들기다. 선택지가 셋 있다.
| 수단 | 지연 | 문제점 |
|---|---|---|
setTimeout(fn, 0) | 중첩 5단계 이상부터 4ms 클램핑 | 청크 1000개면 그것만으로 4초 오버헤드. 백그라운드 탭에서는 1000ms까지 늘어남 |
MessageChannel | 클램핑 없음 (거의 즉시) | 표준이지만 용도 밖 활용. 다른 태스크에 양보는 되지만 우선순위 제어는 안 됨 |
scheduler.yield() | 클램핑 없음 | 양보 후 재개가 새로 도착한 태스크보다 우선. 다만 크로미움 계열 한정이라 폴백 필요 |
setTimeout의 4ms 클램핑은 오해가 잦은데, 성능을 위한 설계가 아니라 역사적 호환성 때문이다. 초기 웹 스크립트들이 setTimeout(fn, 0) 재귀로 애니메이션을 돌리며 CPU를 100% 태우는 일이 흔했고, 브라우저들이 이를 막으려 하한선을 두면서 굳어졌다. 지금은 HTML 명세에 "중첩 깊이 5 초과 시 최소 4ms"로 못 박혀 있다. 없앨 수 없다 — 없애면 그 시절 페이지들이 깨지니까.
scheduler.yield()의 "재개 우선" 성질은 실제로 중요하다. setTimeout이나 MessageChannel로 양보하면 내 다음 청크가 큐의 맨 뒤로 간다. 그 사이 다른 태스크가 계속 들어오면 내 작업이 굶을 수 있다. scheduler.yield()는 재개 작업을 우선 처리하므로, 양보하되 진도는 나간다.
폴백을 갖춘 yielder
// 매크로태스크 경계를 만드는 양보 함수.
// scheduler.yield()가 있으면 그걸 쓰고, 없으면 MessageChannel로 폴백한다.
const yieldToEventLoop = (() => {
if (typeof scheduler !== 'undefined' && scheduler.yield) {
return () => scheduler.yield();
}
// MessageChannel 폴백 — port1로 온 메시지는 매크로태스크로 들어온다.
const channel = new MessageChannel();
const waiting = [];
channel.port1.onmessage = () => waiting.shift()?.();
channel.port1.start();
return () =>
new Promise((resolve) => {
waiting.push(resolve);
channel.port2.postMessage(null);
});
})();
// 확인
console.log('start');
setTimeout(() => console.log('timer'), 0);
await yieldToEventLoop();
console.log('resumed');
// 출력: start → resumed → timer
// (MessageChannel 태스크가 4ms 클램핑된 타이머보다 먼저 도착한다)
MessageChannel은 port2.postMessage()로 보낸 메시지를 port1이 매크로태스크로 받는다. 클램핑이 없어서 즉시 다음 태스크로 잡히고, 그 사이에 렌더링 기회와 입력 처리가 끼어들 수 있다. React가 오랫동안 스케줄러 내부에서 이 기법을 쓴 것도 같은 이유다.
청크는 개수가 아니라 시간으로 끊는다
이제 양보 수단은 생겼다. 남은 질문은 얼마나 자주 양보할 것인가다.
앞의 예제처럼 "1000건마다"로 끊는 건 나쁜 기준이다. 기기 성능 편차가 너무 크기 때문이다. 최신 데스크톱에서 1000건이 2ms면, 저가형 안드로이드에서는 같은 1000건이 40ms일 수 있다. 개수로 끊으면 한쪽에서는 불필요하게 자주 양보해 느려지고, 다른 쪽에서는 프레임을 놓친다.
기준은 개수가 아니라 시간이어야 한다.
const FRAME_BUDGET_MS = 5;
async function processAll(items, onProgress) {
let deadline = performance.now() + FRAME_BUDGET_MS;
for (let i = 0; i < items.length; i++) {
heavyWork(items[i]);
// 예산을 다 썼으면 양보하고 예산을 새로 잡는다.
if (performance.now() >= deadline) {
onProgress?.(i + 1, items.length);
await yieldToEventLoop();
deadline = performance.now() + FRAME_BUDGET_MS;
}
}
}
// 100,000건 처리 중에도 스크롤·클릭·스피너가 정상 동작한다.
왜 하필 5ms인가? 60fps를 유지하려면 한 프레임이 16.7ms 안에 끝나야 한다. 그런데 그 16.7ms는 전부 내 것이 아니다. 스타일 계산, 레이아웃, 페인트, 합성, 그리고 그 프레임에 들어온 입력 이벤트 처리까지 브라우저가 써야 한다. 내 자바스크립트가 가져갈 몫은 그중 일부여야 하고, 5ms 정도가 안전한 선이다.
performance.now()를 쓰는 이유도 짚어 둔다. Date.now()는 시스템 시계 기준이라 밀리초 단위 정수이고 시계 조정에 영향을 받는다. performance.now()는 페이지 로드 시점 기준의 단조 증가 시계라 이런 측정에 적합하다.
측정 도구는 개발자도구 Performance에서 다뤘다. 어떤 태스크가 프레임 예산을 넘겨 잡아먹고 있는지는 결국 실측으로 확인해야 한다.
queueMicrotask는 나쁜 API가 아니라 용도가 반대다
여기까지 오면 queueMicrotask가 피해야 할 API처럼 보이지만, 그렇지 않다. 용도가 정반대일 뿐이다.
| 마이크로태스크 | 매크로태스크 | |
|---|---|---|
| 실행 시점 | 화면이 그려지기 전에 반드시 | 화면이 그려질 기회를 지나서 |
| 쓰임 | 현재 상태를 일관되게 마무리하기 | 제어권을 브라우저에 넘기기 |
| 한 줄 요약 | "그려지기 전에 반드시 끝나야 하는 일" | "그려지도록 비켜 주는 일" |
마이크로태스크는 "화면이 그려지기 전에 반드시 끝나야 하는 일"을 위한 도구다. 사용자가 중간 상태를 보면 안 되는 경우가 여기 해당한다.
// DOM을 여러 번 건드리지만 사용자에게는 최종 상태만 보이게 한다.
let pending = false;
function scheduleFlush() {
if (pending) return;
pending = true;
queueMicrotask(() => {
pending = false;
applyAllPendingUpdates(); // 이 시점은 렌더링 판단보다 앞이다
});
}
프레임워크들이 상태 변경을 모아 한 번에 반영할 때 마이크로태스크를 쓰는 이유가 이것이다. MutationObserver가 마이크로태스크인 것도 같은 맥락이다 — DOM 변경을 감지해서 반응한 결과가 같은 프레임 안에 반영돼야 하니까.
반면 양보는 "화면이 그려지도록 비켜 주는 일"이다. 목적이 정반대이므로 도구도 반대여야 한다. queueMicrotask로 양보하려는 건 "비켜 주려고 문 앞을 막아서는" 것과 같다.
선택 기준
| 상황 | 도구 | 이유 |
|---|---|---|
| DOM을 만져야 하고 응답성도 지켜야 함 | scheduler.yield() / MessageChannel 청킹 | 메인 스레드에서만 DOM 접근 가능. 시간 예산으로 끊는다 |
| DOM과 무관한 순수 계산 (파싱, 압축, 이미지 처리) | Web Worker | 별도 스레드라 메인 스레드를 아예 안 건드린다. 쪼갤 필요조차 없다 |
| 급하지 않은 후처리 (로그 전송, 프리페치) | requestIdleCallback | 브라우저가 한가할 때만 실행. 언제 실행될지 보장 없음 |
| 애니메이션·시각적 갱신 | requestAnimationFrame | 프레임 직전에 실행되어 같은 프레임의 레이아웃에 반영된다 |
| 현재 태스크 뒷정리, 상태 일괄 반영 | queueMicrotask | 렌더링 전에 반드시 끝난다는 보장이 필요할 때 |
첫 줄과 둘째 줄의 갈림길이 실무에서 제일 자주 온다. 판단은 간단하다 — DOM을 만질 필요가 없으면 Web Worker가 정답이다. 쪼개기·양보·예산 관리가 전부 필요 없어진다. 청킹은 어쩔 수 없이 메인 스레드에 있어야 할 때의 차선책이다.
마무리 — 무한 중첩 두 가지의 대비
이 글의 내용이 한 예제로 압축된다. 같은 횟수를 반복하는 두 코드다.
// ① setTimeout 무한 중첩 — 태스크를 N개 만든다
let n = 0;
function loopA() {
if (++n >= 1000) return;
setTimeout(loopA, 0);
}
// ② Promise 무한 중첩 — 태스크 하나를 N배 길게 늘인다
let m = 0;
function loopB() {
if (++m >= 1000) return;
Promise.resolve().then(loopB);
}
1000회 기준 실측이 직관을 정면으로 뒤집는다.
① setTimeout | ② Promise | |
|---|---|---|
| 총 소요 시간 | 약 4.3초 | 약 1ms |
| 만들어지는 태스크 | 1000개 | 1개 |
| 그 사이 렌더링 | 정상 (매 프레임) | 0회 |
| 그 사이 입력 처리 | 정상 | 없음 |
| CSS 애니메이션 | 정상 동작 | 정지 |
| 체감 | 느리지만 멀쩡함 | 빠르지만 탭이 죽음 |
②가 4000배 빠른데도 사용자 입장에서는 ②만 "멈췄다"고 느낀다.
setTimeout 쪽은 태스크를 1000개로 나눠 만든다. 4ms 클램핑 때문에 총 4.3초가 걸리지만, 그 4.3초 동안 이벤트 루프는 1000번 자유로워진다. 매 태스크 사이마다 렌더링 기회가 열리고 입력이 처리된다. 스피너가 돌고, 스크롤이 되고, 클릭이 먹는다.
Promise 쪽은 태스크를 하나도 새로 만들지 않는다. 처음 시작된 태스크 하나를 1000배 길게 늘일 뿐이다. 마이크로태스크 체크포인트가 끝나지 않으므로 그 다음 줄인 렌더링 판단에 순서가 가지 않는다. 자바스크립트와 무관하게 GPU에서 돌던 CSS 애니메이션까지 멈춘다 — 합성 결과를 화면에 반영하는 단계 역시 메인 스레드의 렌더링 절차를 거쳐야 하기 때문이다.
비동기냐 동기냐가 아니라, 태스크 경계를 만드느냐 아니냐가 응답성을 가른다.
정리하면 이 글의 결론은 세 문장이다.
- 이벤트 루프는 콜 스택이 빌 때만 움직인다. 그래서 run-to-completion이 보장되고, 그 대가로 내가 오래 붙잡으면 브라우저는 아무것도 못 한다.
- 마이크로태스크 체크포인트는 큐가 빌 때까지 끝나지 않는다. 그래서 마이크로태스크로 재귀하면 양보가 아니라 봉쇄가 된다.
- 틱이 늘어나는 원인은 거의 항상 프로미스가 프로미스를 감쌌다가 다시 푸는 절차다. 규칙을 외울 게 아니라 몇 겹 씌워지는지를 세면 된다.