await은 정말 Event Loop에 실행권을 양보할까?
Promise Microtask의 원자성과 Event Loop Starvation
들어가며
비동기 실행을 공부하던 중 Node.js의 promise-use-cases 저장소에서 Promise의 Microtask 스케줄링이 Starvation을 만들 수 있다는 이슈 (새 탭에서 열림)를 보게 됐다. await이 반복되는 동안 Microtask Queue가 계속 채워지면 타이머나 I/O 같은 다른 작업이 실행 기회를 얻지 못할 수 있다는 내용이었다.
평소에는 await을 만나면 현재 실행이 중단되고 브라우저가 다른 작업을 처리한다고 단순하게 생각했다. 하지만 현재 async 함수가 멈추는 것과 Event Loop가 다음 Task로 넘어가는 것은 별개의 문제였다.
아래 코드에서 그 차이가 드러난다.
async function main() {
while (true) {
await Promise.resolve();
}
}
main();
setTimeout(() => {
console.log("실행될까?");
}, 0);위 코드에는 분명 await이 있지만 setTimeout 콜백은 실행되지 않는다. 이 현상을 두고 Promise의 원자성과 실행 순서를 지켜야 한다는 입장과 다른 작업이 굶지 않도록 공정한 스케줄링이 필요하다는 입장이 맞섰다.
이 글에서는 이슈의 논의를 따라 Promise와 await의 실행 방식을 다시 살펴본다. Microtask가 타이머와 I/O를 굶기는 이유와 Promise가 이런 방식을 택한 배경, 원자성과 공정성 사이의 트레이드오프, 실제 애플리케이션에서 Event Loop에 양보하는 방법까지 차례로 정리했다.
Promise 자체가 Microtask인 것은 아니다
먼저 자주 사용하는 표현부터 정확히 정리할 필요가 있다.
Promise 자체가 Microtask Queue에 들어가는 것은 아니다.
Promise 생성자에 전달한 함수는 즉시 실행된다.
console.log("A");
new Promise((resolve) => {
console.log("B");
resolve();
});
console.log("C");출력 결과는 다음과 같다.
A
B
CMicrotask로 예약되는 것은 Promise가 완료됐을 때 실행할 .then() 콜백, 즉 Promise Reaction Job이다.
console.log("A");
Promise.resolve().then(() => {
console.log("B");
});
console.log("C");A
C
BB를 출력하는 콜백은 현재 JavaScript 실행이 끝난 뒤 처리할 Microtask로 등록된다.
Event Loop는 Microtask Queue를 끝까지 비운다
브라우저의 Event Loop를 단순화하면 아래 흐름으로 움직인다.
Task 하나 실행
→ Microtask Queue가 빌 때까지 실행
→ 렌더링 기회
→ 다음 Task 실행
setTimeout 콜백은 Timer Task로 예약된다. 반면 Promise의 .then()과 await 이후의 실행은 Microtask로 예약된다.
setTimeout(() => console.log("timer"), 0);
Promise.resolve().then(() => {
console.log("promise");
});현재 Task가 끝나면 Event Loop는 다음 Timer Task를 선택하기 전에 Microtask Queue를 먼저 비운다. 그래서 promise가 timer보다 먼저 출력된다.
promise
timer새 Microtask가 처리 중 등록될 때도 같은 원칙이 적용된다.
Microtask를 실행하면서 새로운 Microtask가 등록되면, 새 작업도 같은 Microtask checkpoint에서 이어서 실행한다.
queueMicrotask(function repeat() {
queueMicrotask(repeat);
});위 코드는 Microtask를 실행할 때마다 새로운 Microtask를 만든다. Queue가 영원히 비지 않으므로 Event Loop는 다음 Task나 렌더링 단계로 넘어갈 수 없다.
await은 무엇에 실행권을 양보할까?
다음 코드를 다시 살펴보자.
async function main() {
while (true) {
await Promise.resolve();
}
}await을 만나면 main() 실행은 중단된다. 그리고 Promise가 완료되면 await 다음 부분을 실행할 작업이 Microtask Queue에 등록된다.
main() 실행
→ await에서 중단
→ main()을 재개할 Microtask 등록
→ Microtask에서 main() 재개
→ 다시 await에서 중단
→ main()을 재개할 Microtask 등록
→ 반복Promise.resolve()는 이미 완료된 Promise이므로 기다려야 할 외부 작업이 없다. main()을 재개하는 Microtask가 즉시 계속 만들어진다.
그 결과 Queue는 아래처럼 비워지지 않는다.
Microtask Queue
[main 재개]
↓ 실행
[main 재개]
↓ 실행
[main 재개]
↓
...
Task Queue
[setTimeout callback] ← 실행 기회를 얻지 못함
여기서 얻을 수 있는 중요한 결론은 다음과 같다.
await은 현재 async 함수를 중단하지만, 반드시 Event Loop의 다음 순서로 양보하는 것은 아니다. await하는 Promise가 무엇을 기다리는지에 따라 결과가 달라진다.
await Promise.resolve(); // Microtask로만 이동
await fetch(url); // 네트워크 완료를 기다림
await timerPromise(); // 미래의 Timer Task를 기다림Starvation은 무엇인가?
Starvation(기아 현상)은 실행할 수 있는데도 순서가 돌아오지 않는 상태다. 작업은 준비돼 있는데 다른 작업이 실행 기회를 계속 차지해 오랫동안 밀려 있는 것이다.
앞의 예제에서는 Microtask Queue가 굶는 것이 아니다. 굶고 있는 대상은 다음과 같다.
setTimeout과setInterval콜백- 사용자 클릭과 입력 이벤트
- 네트워크 및 파일 I/O 후속 작업
- 브라우저의 렌더링
따라서 표현을 더 정확하게 고치면 이렇게 된다.
무한히 이어지는 Promise Microtask가 Event Loop의 Task와 렌더링을 Starvation 상태로 만든다.
실제 논의가 시작된 배경 CSP
GitHub 이슈를 작성한 Kyle Simpson은 CSP(Communicating Sequential Processes)를 async/await으로 구현하면서 이 문제를 경험했다고 한다.
“Microtask가 대기 중인 Event Loop의 작업보다 계속 먼저 줄을 서는 것이 starvation의 한 형태다.”
CSP에서는 여러 개의 독립적인 프로세스가 채널을 통해 메시지를 주고받는다. 메시지를 보내거나 받을 준비가 되지 않았다면 해당 프로세스만 기다리고, 다른 프로세스는 계속 실행된다.
async function producer() {
while (true) {
await channel.put(createMessage());
}
}
async function consumer() {
while (true) {
const message = await channel.take();
process(message);
}
}이 구조에서는 각 프로세스가 공평하게 실행될 것이라는 기대가 생긴다. 하지만 특정 프로세스의 Promise가 계속 즉시 완료된다면 그 프로세스가 Microtask Queue를 독점할 수 있다.
Kyle은 Promise 스케줄러에 다음과 같은 공정성 장치가 필요하다고 주장했다.
- 프로세스 사이의 Round-robin 스케줄링
- 일정 횟수의 Microtask를 실행한 뒤 Event Loop에 양보
- Microtask 스케줄링에 개입할 수 있는 Hook
- 과도한 Microtask 연쇄를 감지하는 제한 또는 경고
그가 요구한 것은 Promise 동작 전체를 사용자 코드로 대체하는 기능이 아니라 특정 Microtask를 즉시 실행할지, 다음 Event Loop tick으로 미룰지를 결정할 수 있는 제한적인 Hook이었다.
“다른 Promise가 굶지 않도록 Microtask의 스케줄링을 다음 tick까지 미룰 방법을 요구하는 것이다.”
Generator 기반 실행기를 사용하면 라이브러리가 자체적으로 Round-robin 스케줄러를 만들 수 있다. 하지만 네이티브 async/await은 Promise의 스케줄링 방식을 직접 제어하기 어렵다는 것을 문제로 제기했다.
반대편의 주장: 원자성은 의도된 설계다
논의에서는 Promise의 현재 동작이 실수가 아니라 웹에서 필요한 원자성에 가까운 실행 특성이라는 의견도 나왔다.
TC39에 참여한 Daniel Ehrenberg는 Promise가 외부 Event Loop로 바로 양보하지 않는 성질을 일종의 원자성 보장이라고 설명했다. 웹에서는 여러 중간 상태가 각각 렌더링되는 것을 피하는 것이 명시적인 설계 목표였다는 것이다.
“Promise는 모든 후속 작업이 끝날 때까지 외부 Event Loop에 양보하지 않는 일종의 원자성을 제공한다.”
다음과 같이 하나의 상태 변경이 여러 Promise 작업으로 연결돼 있다고 가정해 보자.
updateFirstState();
await Promise.resolve();
updateSecondState();
await Promise.resolve();
updateLastState();Microtask Queue를 모두 처리한 뒤 렌더링 기회를 갖는 현재 방식에서는 중간 작업 사이에 다른 Task가 끼어들지 않는다.
첫 번째 상태 변경
→ 두 번째 상태 변경
→ 마지막 상태 변경
→ 렌더링 기회만약 일정 횟수마다 강제로 다음 Task로 양보한다면 다음 상황이 발생할 수 있다.
첫 번째 상태 변경
→ 렌더링
→ 사용자 이벤트
→ 두 번째 상태 변경
→ 렌더링
→ 마지막 상태 변경Microtask를 우선 처리하는 설계는 다음 특성을 제공한다.
- Promise 후속 작업의 예측 가능한 FIFO 순서
- 현재 작업에서 파생된 후속 처리의 빠른 완료
- 외부 이벤트가 중간에 끼어들지 않는 실행 특성
- 불필요한 중간 상태 렌더링 감소
하지만 그 대가로 다른 작업에 대한 공정성을 보장하지 않는다.
원자성과 예측 가능성 ↑
공정성과 응답성 ↓
이 논의는 어느 한쪽이 무조건 옳다는 결론보다, Promise가 어떤 가치를 우선하도록 설계됐는지를 보여준다.
await이 있으면 non-blocking이라는 오해
이슈에서 또 하나 중요하게 다뤄진 내용은 await에 대한 개발자의 오해였다.
Ian Storm Taylor는 Promise를 await하면 Event Loop를 차단하지 않는다고 생각하기 쉽지만, 실제 동작을 설명하는 문서가 부족하고 잘못된 설명도 널리 퍼져 있다고 지적했다.
“Promise를 기다리기만 하면 Event Loop를 막지 않는다는 생각은 널리 퍼진 오해로 보인다.”
다량의 JSON 문자열을 파싱하는 코드를 다음처럼 작성했다고 가정해 보자.
async function parseAll(items) {
const result = [];
for (const item of items) {
await Promise.resolve();
result.push(JSON.parse(item));
}
return result;
}await이 있으므로 Event Loop를 차단하지 않을 것처럼 보인다. 하지만 모든 반복이 Microtask 안에서 이어지기 때문에 브라우저는 렌더링이나 사용자 입력을 처리할 기회를 얻지 못한다.
무한 반복이 아니더라도 데이터가 충분히 많다면 화면이 멈춘 것처럼 보일 수 있다.
비동기 함수다 ≠ Event Loop를 차단하지 않는다비동기라는 말은 작업이 나중에 이어질 수 있다는 뜻이지, 모든 작업에 공평하게 실행 기회를 배분한다는 뜻이 아니다.
논의에 참여한 devsnek은 이 문제를 “Promise가 Microtask Queue를 굶기는 현상”이라고 부르는 것도 정확하지 않다고 지적했다. await으로 중단된 함수는 Queue의 뒤로 이동하므로 이미 등록된 다른 Microtask는 실행될 수 있다. 문제는 호스트가 Microtask Queue를 전부 비우기 전까지 타이머와 I/O를 처리하지 않는다는 점이다.
“Microtask Queue가 비지 않는 동안 호스트는 타이머와 다른 플랫폼 작업을 실행할 수 없다.”
실제 Event Loop에 실행권을 양보하려면
다른 Task와 렌더링에 실행 기회를 주려면 Microtask가 아니라 새로운 Task로 경계를 넘겨야 한다.
브라우저에서는 다음과 같이 구현할 수 있다.
function yieldToEventLoop() {
return new Promise((resolve) => {
setTimeout(resolve, 0);
});
}긴 작업은 일정한 크기로 나누고, 각 묶음이 끝날 때 Event Loop에 양보한다.
async function parseAll(items) {
const result = [];
const chunkSize = 100;
for (let index = 0; index < items.length; index++) {
result.push(JSON.parse(items[index]));
if ((index + 1) % chunkSize === 0) {
await yieldToEventLoop();
}
}
return result;
}100개 처리
→ Event Loop에 양보
→ 입력·타이머·렌더링 처리 가능
→ 다음 100개 처리Node.js에서는 목적에 따라 setImmediate() 등을 이용해 다음 Event Loop 단계로 넘기는 방법을 고려할 수 있다.
function yieldToEventLoop() {
return new Promise((resolve) => {
setImmediate(resolve);
});
}다만 무조건 매 반복마다 양보하면 스케줄링 비용 때문에 전체 처리 시간이 늘어난다. 작업량, 응답성 요구사항, 실행 환경을 기준으로 적절한 단위를 정해야 한다.
CPU 연산 자체가 매우 무겁다면 단순히 Task를 나누는 것만으로 해결하지 말고 Web Worker나 Worker Thread로 메인 실행 흐름에서 분리하는 방법도 고려해야 한다.
실제 코드를 작성할 때 고려하면 좋은 점
await Promise.resolve()를 양보 수단으로 사용하지 않기
await Promise.resolve();이 코드는 현재 함수의 실행을 Microtask로 나누지만, 타이머·I/O·렌더링에 실행권을 주는 수단은 아니다.
긴 반복 작업은 측정한 뒤 분할하기
대량 데이터 변환이나 직렬화처럼 메인 스레드를 오래 사용하는 작업은 Performance 도구와 Long Task를 통해 먼저 측정한다. 문제가 확인되면 작업을 적절한 크기로 나누거나 Worker로 이동한다.
Microtask 안에서 Microtask를 무제한 생성하지 않기
다음 패턴은 재귀 호출의 Stack Overflow를 피할 수는 있지만 Event Loop를 계속 점유할 수 있다.
function repeat() {
queueMicrotask(repeat);
}
repeat();Promise의 빠른 후속 처리도 중요한 보장
Microtask를 항상 위험한 것으로 볼 필요는 없다. Promise 후속 작업을 다음 외부 이벤트보다 먼저 처리하는 것은 예측 가능한 상태 전이와 빠른 후속 처리를 위한 중요한 동작이다.
문제는 Microtask의 존재가 아니라, 한 번의 checkpoint에서 끝나지 않는 작업량이다.
논의는 어떻게 끝났을까?
해당 이슈에서는 Microtask 실행 횟수 제한, 스케줄링 Hook, 문서 경고 등 여러 대안이 논의됐지만 구체적인 변경안이 채택되지는 않았다.
Node.js의 Benjamin Gruenbaum은 process.nextTick()의 재귀 호출이 I/O를 막을 수 있다고 문서에서 경고하는 것처럼, Promise에도 유사한 안내가 필요하다는 현실적인 대안을 제시했다. 일정 횟수 이상의 Promise가 처리되면 경고하는 방안도 언급했다.
“Promise 가이드에도 같은 종류의 경고를 제공해야 한다.”
이슈가 닫힌 이유도 문제가 해결됐기 때문이 아니라 해당 저장소가 아카이브됐기 때문이다.
결국 Promise는 현재와 같은 스케줄링 특성을 유지했고, 개발자가 긴 작업의 경계에서 명시적으로 Event Loop에 양보해야 한다는 현실적인 과제가 남았다.
이 이슈에 대한 내 생각
Promise의 예측 가능한 실행 순서를 바꾸기보다는, 공정성이 필요한 작업에서 개발자가 명시적으로 Event Loop에 실행권을 양보하는 편이 더 안전하다고 생각한다.





