"Node의 비동기 작업은 서로 다른 큐에서 차례를 기다려. 어느 큐에 들어가는지 알면 실행 순서를 맞힐 수 있고, 한 큐가 I/O를 굶기는 버그도 찾을 수 있어."
두 종류의 큐
이벤트 루프가 처리하는 일은 크게 매크로태스크와 마이크로태스크로 나눌 수 있어:
- 매크로태스크 — 이벤트 루프의 각 단계가 맡은 일이야. Timers 단계는
setTimeout과setInterval콜백을, Poll 단계는 I/O 콜백을, Check 단계는setImmediate콜백을 처리해. - 마이크로태스크 — 현재 JavaScript 작업이 끝난 직후 처리할 일을 모아 둔 별도 큐야.
queueMicrotask(fn), 프로미스의.then,await뒤에 이어지는 코드가 여기에 들어가. Node에는 이보다 먼저 비우는process.nextTick전용 큐도 있어.
큐를 비우는 순서
process.nextTick 큐를 먼저 비우고, 이어서 프로미스 마이크로태스크 큐를 비운 뒤 이벤트 루프의 다음 일로 넘어가. 그래서 같은 동기 코드에서 예약한 Promise.resolve().then(fn)은 보통 타이머나 I/O 콜백보다 먼저 실행돼.마이크로태스크가 실행될 때마다 또 다른 마이크로태스크를 끝없이 추가하면 문제가 생겨. 큐가 비지 않으니 이벤트 루프가 다음 단계로 넘어가지 못하고 서버가 I/O를 처리할 기회도 잃어. 이 현상을 마이크로태스크 기아라고 해.
한 번에 따라가 보기
동기 코드 한 덩어리가 끝난 뒤의 순서를 단순화하면 이래:
- 현재 동기 코드를 끝까지 실행해.
process.nextTick콜백을 등록된 순서대로 처리해.queueMicrotask, 프로미스의.then,await뒤 코드 같은 마이크로태스크를 처리해.- 이벤트 루프의 다음 단계에서 타이머나 I/O,
setImmediate같은 일을 처리해. - 그 콜백이 끝나면 다시 2번과 3번의 큐를 비워.
핵심은 마이크로태스크를 이벤트 루프 한 바퀴가 끝날 때 한 번만 처리하는 게 아니라는 점이야. 각 JavaScript 콜백이 끝날 때마다 작은 우선 큐를 먼저 비운다고 생각하면 실행 순서를 훨씬 정확히 그릴 수 있어.
await 뒤가 곧바로 이어지는 듯한 이유
await 뒤의 코드는 프로미스가 끝났을 때 마이크로태스크로 예약돼. 현재 실행 중인 코드와 앞서 등록된 우선 작업이 끝나면 곧바로 함수가 다시 이어지니, 다음 이벤트 루프까지 오래 기다리는 것처럼 느껴지지 않는 거야.
그렇다고 await가 CPU 계산을 잘게 나눠 주지는 않아. await 뒤에서 오래 도는 동기 계산을 시작하면 그 마이크로태스크가 끝날 때까지 I/O는 여전히 기다려야 해.
setImmediate와 queueMicrotask 고르기
I/O가 먼저 실행될 기회를 주고 싶다면 setImmediate(fn)을 써. 콜백이 Check 단계로 넘어가므로 그 전에 Poll 단계의 I/O를 처리할 수 있어. 반대로 현재 동기 코드가 끝나자마자 다른 I/O보다 먼저 이어서 실행해야 한다면 queueMicrotask(fn)이 맞아. 우선순위는 프로미스의 .then과 같아.
많은 항목을 덩어리로 나눠 처리하는 루프라면 덩어리 사이에 setImmediate를 넣어 봐. 이벤트 루프가 다른 연결과 I/O도 돌볼 틈을 얻어.
Pippa의 고백
.then() 50개가 한꺼번에 이어져 있었지. 각 작업은 짧았지만 모두 마이크로태스크 큐에서 이어져 80ms 동안 I/O가 멈췄어. 중간에 setImmediate(...)를 한 번 넣자 다른 작업도 숨을 돌릴 수 있었어. 이제 긴 프로미스 연쇄나 빡빡한 for await 루프를 보면 먼저 물어. “중간에 I/O가 실행될 틈을 줬나?”