[번역] JavaScript 이벤트 루프는 async/await와 함께 어떻게 동작하는가
Soshy·

async/await는 비동기 코드를 마치 동기 코드처럼 보이고 동작하게 만들어 주기 때문에, 현대 JavaScript에서 가장 널리 쓰이는 기능 중 하나입니다.
하지만 많은 개발자가 await를 전통적인 동기 연산처럼 JavaScript 실행을 멈추는 것으로 생각합니다. 실제로 await는 JavaScript 이벤트 루프(Event Loop), Promise, 마이크로태스크 큐(Microtask Queue)와 긴밀하게 맞물려 동작합니다.
그 이면에서 무슨 일이 벌어지는지 이해하면 더 효율적인 코드를 작성하고 예상치 못한 동작을 피하는 데 도움이 됩니다.
async/await란?
async 함수는 항상 Promise를 반환합니다.
async function greet() {
return "Hello";
}
greet().then(console.log);
출력
Hello
단순한 문자열을 반환했을 뿐인데도, JavaScript는 이를 자동으로 Promise로 감쌉니다.
await는 실제로 무슨 일을 하는가?
await 키워드는 Promise가 settle(이행 또는 거부로 확정)될 때까지 현재 async 함수의 실행을 일시 중지합니다.
많은 개발자가 이를 "여기서 JavaScript가 멈춘다"고 해석합니다.
하지만 이는 완전히 맞는 말이 아닙니다.
async 함수는 일시 중지되지만, JavaScript 엔진은 그동안 다른 작업을 자유롭게 실행할 수 있습니다.
예시:
async function demo() {
console.log("Before await");
await Promise.resolve();
console.log("After await");
}
demo();
console.log("Outside function");
출력
Before await
Outside function
After await
Promise가 즉시 이행되더라도, await 이후의 실행은 뒤로 미뤄집니다.
이벤트 루프의 역할
JavaScript에는 다음이 있습니다.
- 콜 스택(Call Stack)
- Web API(또는 Node.js API)
- 태스크 큐(Task Queue, 매크로태스크 큐)
- 마이크로태스크 큐
- 이벤트 루프
await를 만나면 다음과 같이 동작합니다.
- 현재 async 함수가 일시 중지됩니다.
- 남은 코드가 Promise의 이어지는 작업(continuation)으로 감싸집니다.
- 그 작업이 마이크로태스크 큐에 들어갑니다.
- 콜 스택이 비면 이벤트 루프가 이를 실행합니다.
흐름 시각화하기
다음 코드를 살펴봅시다.
console.log("Start");
async function run() {
console.log("Inside");
await Promise.resolve();
console.log("After await");
}
run();
console.log("End");
실행 순서
1단계
Start
2단계
Inside
3단계
JavaScript가 다음을 만납니다.
await Promise.resolve();
함수가 일시 중지됩니다.
남은 코드인
console.log("After await");
는 마이크로태스크 큐로 옮겨집니다.
4단계
현재의 동기 코드가 계속 실행됩니다.
End
5단계
콜 스택이 비게 됩니다.
이벤트 루프가 마이크로태스크를 처리합니다.
After await
최종 출력
Start
Inside
End
After await
async/await와 Promise는 같은 큐를 사용한다
await가 Promise 위에 세워진 기능이라는 사실을 모르는 개발자가 많습니다.
다음을 살펴봅시다.
Promise.resolve().then(() => {
console.log("Promise");
});
async function demo() {
await Promise.resolve();
console.log("Await");
}
demo();
출력
Promise
Await
두 작업 모두 마이크로태스크 큐를 사용합니다.
순서는 어느 마이크로태스크가 먼저 큐에 들어갔는지에 따라 결정됩니다.
마이크로태스크 vs setTimeout()
가장 중요한 개념 중 하나는 마이크로태스크가 매크로태스크보다 우선순위가 높다는 것입니다.
예시:
setTimeout(() => {
console.log("Timeout");
}, 0);
async function demo() {
await Promise.resolve();
console.log("Await");
}
demo();
console.log("End");
출력
End
Await
Timeout
왜 그럴까요?
이유는 다음과 같습니다.
console.log("End")는 동기적으로 실행됩니다.- await는 마이크로태스크를 예약합니다.
setTimeout()은 매크로태스크를 예약합니다.- 이벤트 루프는 항상 다음 매크로태스크를 처리하기 전에 모든 마이크로태스크를 비웁니다.
여러 개의 await 문
각각의 await는 새로운 중지 지점을 만듭니다.
async function demo() {
console.log("1");
await Promise.resolve();
console.log("2");
await Promise.resolve();
console.log("3");
}
demo();
console.log("4");
출력
1
4
2
3
함수는 각 Promise가 이행될 때마다 다시 이어집니다.
await는 이벤트 루프를 막는가?
아닙니다.
예시:
async function fetchData() {
console.log("Start");
await new Promise(resolve => {
setTimeout(resolve, 3000);
});
console.log("Finished");
}
fetchData();
console.log("Application still running");
즉시 출력
Start
Application still running
3초 후
Finished
Promise가 대기(pending) 상태인 동안에도 이벤트 루프는 다른 작업을 계속 처리합니다.
흔한 면접 질문
다음은 무엇을 출력할까요?
console.log("A");
setTimeout(() => {
console.log("B");
}, 0);
(async () => {
console.log("C");
await Promise.resolve();
console.log("D");
})();
console.log("E");
정답
A
C
E
D
B
설명
- A가 실행됩니다.
setTimeout()이 매크로태스크를 예약합니다.- C가 실행됩니다.
- await가 마이크로태스크를 예약합니다.
- E가 실행됩니다.
- 마이크로태스크가 실행됩니다 → D.
- 매크로태스크가 실행됩니다 → B.
내부적으로는 무슨 일이 일어나는가?
이 코드는
async function demo() {
await somePromise();
console.log("Done");
}
개념적으로 다음과 유사합니다.
function demo() {
return Promise.resolve(somePromise())
.then(() => {
console.log("Done");
});
}
await가 Promise 체이닝 위에 세워진 기능이기 때문에, 이벤트 루프는 두 코드를 매우 비슷한 방식으로 다룹니다.
정리
async/await는 JavaScript 실행을 막지 않습니다. JavaScript가 await를 만나면, 현재 async 함수만 일시 중지하고 남은 코드를 마이크로태스크 큐에 넣습니다.
그다음 이벤트 루프는 다음 순서로 동작합니다.
- 모든 동기 코드를 끝냅니다.
- 대기 중인 모든 마이크로태스크(Promise와 async/await의 이어지는 작업)를 실행합니다.
setTimeout(),setInterval()같은 매크로태스크를 처리합니다.
이것이 바로 await 이후의 코드가 종종 setTimeout(fn, 0)보다 먼저 실행되는 이유입니다. async/await, Promise, 마이크로태스크, 이벤트 루프 사이의 이런 관계를 이해하는 것은 예측 가능하고 효율적인 JavaScript 애플리케이션을 작성하는 데 반드시 필요합니다.