자바스크립트 이벤트 루프와 마이크로태스크: setTimeout보다 Promise가 먼저 실행되는 이유

setTimeout보다 Promise가 먼저 실행되는 이유를 시뮬레이터로 직접 눌러보며 이해한다. 콜스택, 태스크 큐, 마이크로태스크 큐가 자바스크립트 비동기에서 어떻게 움직이는지.

조회
목차

백엔드 스터디에서 프론트엔드 쪽도 한 번씩 파보기로 해서, 내가 자바스크립트 비동기를 맡았다. 처음엔 이벤트 루프 따로, 프로미스 따로 정리하려고 했는데 하다 보니 이게 전부 하나의 이야기였다. 싱글스레드인 자바스크립트가 어떻게 안 멈추고 여러 일을 처리하는 척하는지 — 그 답이 이벤트 루프고, 그 위에 태스크 큐와 마이크로태스크 큐가 있고, 프로미스는 그 큐를 쓰는 문법이다.

글 중간중간에 시뮬레이터를 넣어놨다. 코드를 한 스텝씩 실행하면서 콜스택과 큐가 어떻게 움직이는지 직접 눌러보면서 따라오면 된다.

퀴즈부터 풀고 시작

일단 얼마나 헷갈리는 주제인지부터 확인하고 가자. 아래 코드, 출력 순서가 어떻게 될까?

console.log("A");
setTimeout(() => {
console.log("B");
}, 0);
Promise.resolve().then(() => {
console.log("C");
});
console.log("D");
이 코드를 실행하면 어떤 순서로 출력될까요?
예측을 제출하면 시뮬레이터가 열려요. 먼저 순서를 골라 보세요.

맞혔다면 이 글은 복습이 될 거고, 틀렸다면 잘 온 거다. 나도 처음엔 틀렸다.

제출하면 시뮬레이터가 열리는데, 패널이 다섯 개나 돼서 지금은 이해 안 되는 게 정상이다. 글을 다 읽고 나면 저 화면이 그냥 읽힌다. 그게 이 글의 목표다.

자바스크립트는 한 번에 하나만 한다

자바스크립트는 싱글스레드 언어다. 스레드가 하나라는 건, 코드를 실행하는 작업대가 하나뿐이라는 뜻이다. 그 작업대가 콜스택이다. 함수를 호출하면 스택에 쌓이고, 반환하면 빠진다. 스택 맨 위에 있는 함수가 지금 실행 중인 코드고, 그게 끝나기 전에는 다른 어떤 코드도 끼어들 수 없다.

말로 하면 심심하니까 직접 보자. 동기 함수 두 개가 호출되고 반환되는 과정이다.

동기 함수 호출과 콜스택

function greet(name) {
return "안녕, " + name;
}
function main() {
const message = greet("지니");
console.log(message);
}
main();
console.log("끝");
콜스택

비어 있어요

출력

아직 출력이 없어요

아직 아무것도 실행하기 전이에요. 콜스택이 비어 있어요.

1 / 10

여기까지는 특별할 게 없다. 위에서 아래로, 한 번에 하나씩. 이게 규칙의 전부다.

기다리는 동안엔 누가 일하나

그런데 이상하다. 작업대가 하나뿐이면 setTimeout으로 타이머를 걸었을 때 자바스크립트는 그 시간 동안 멈춰 있어야 할 것 같은데, 안 멈춘다. 다음 줄을 바로 실행한다.

비밀은 타이머를 자바스크립트가 직접 돌리지 않는다는 거다. setTimeout을 호출하면 콜백을 브라우저(또는 Node 런타임)에 맡기고 바로 반환한다. 시간을 재는 건 런타임이 자기 일꾼으로 알아서 한다. 브라우저에서는 이런 것들을 Web API라고 부른다. 타이머, 네트워크 요청, 파일 읽기 — 오래 걸리는 일은 전부 이쪽으로 넘어간다.

setTimeout과 Web API

console.log("첫 번째");
setTimeout(() => {
console.log("타이머 콜백");
}, 0);
console.log("두 번째");
콜스택

비어 있어요

Web APIs

비어 있어요

출력

아직 출력이 없어요

실행 전이에요.

1 / 8

setTimeout이 스택에 잠깐 올라왔다가 바로 빠지는 게 보인다. 기다림은 바깥에서 일어난다.

끝난 일은 태스크 큐에 줄을 선다

타이머가 끝났다고 콜백이 스택에 바로 뛰어들 수는 없다. 스택에서는 지금 다른 코드가 실행 중일 수 있으니까. 그래서 끝난 일은 태스크 큐라는 대기줄에 선다.

그리고 여기서 드디어 이벤트 루프가 등장한다. 하는 일은 허무할 정도로 단순하다. "콜스택이 비었나? 비었으면 큐 맨 앞의 작업을 스택에 올린다." 이걸 무한히 반복하는 게 이벤트 루프다.

태스크 큐와 이벤트 루프

setTimeout(() => {
console.log("콜백 A");
}, 0);
setTimeout(() => {
console.log("콜백 B");
}, 0);
console.log("동기 코드 끝");
콜스택

비어 있어요

Web APIs

비어 있어요

태스크 큐

비어 있어요

출력

아직 출력이 없어요

실행 전이에요.

1 / 12

타이머 두 개가 거의 동시에 끝나도 콜백은 큐에 선 순서대로, 한 번에 하나씩 처리된다.

새치기 전용 줄, 마이크로태스크 큐

여기까지면 깔끔했을 텐데, 큐가 하나 더 있다. 마이크로태스크 큐다. Promise.then의 콜백과 queueMicrotask로 등록한 작업이 여기로 들어간다.

왜 줄이 두 개나 필요할까. 프로미스 콜백은 "방금 끝난 일의 뒷처리"인 경우가 많아서, 렌더링이나 다른 태스크에 밀리지 않고 최대한 빨리 이어지는 게 좋기 때문이다. 그래서 이벤트 루프는 태스크를 하나 처리할 때마다 마이크로태스크 큐를 먼저, 그것도 다 비울 때까지 처리한다. 태스크 큐 입장에서는 새치기 전용 줄이 따로 있는 셈이다.

마이크로태스크는 다 비울 때까지 우선

setTimeout(() => {
console.log("태스크");
}, 0);
Promise.resolve()
.then(() => {
console.log("마이크로 1");
})
.then(() => {
console.log("마이크로 2");
});
console.log("동기");
콜스택

비어 있어요

Web APIs

비어 있어요

마이크로태스크 큐

비어 있어요

태스크 큐

비어 있어요

출력

아직 출력이 없어요

실행 전이에요.

1 / 11

0ms짜리 setTimeout이 제일 늦게 실행되는 거, 이제 이유가 보인다. 지연이 짧아서가 아니라 서 있는 줄이 달라서다.

프로미스와 await로 다시 보기

이제 재료가 다 모였다. 프로미스를 이 그림 위에 올려보자.

.then(콜백)은 "프로미스가 이행되면 이 콜백을 마이크로태스크 큐에 넣어줘"라는 예약이다. 그게 전부다. 프로미스가 특별한 실행 엔진을 갖고 있는 게 아니라, 지금까지 본 큐 시스템을 그대로 쓰는 거다.

그럼 await는? await는 then의 문법 설탕인데, 동작이 꽤 재밌다. async 함수가 await를 만나면 함수가 거기서 반으로 잘린다. 앞부분은 그 자리에서 끝나고(스택에서 내려간다), 나머지 반쪽은 "이어서 할 일"이 되어 프로미스가 이행될 때 마이크로태스크 큐로 들어간다.

await는 함수를 반으로 갈라요

async function order() {
console.log("주문 접수");
await Promise.resolve();
console.log("주문 처리");
}
order();
console.log("다음 손님");
콜스택

비어 있어요

Web APIs

비어 있어요

마이크로태스크 큐

비어 있어요

태스크 큐

비어 있어요

출력

아직 출력이 없어요

실행 전이에요. 함수 선언은 실행이 아니라 7줄부터 시작해요.

1 / 9

order가 스택에서 내려갔다가 마이크로태스크 큐를 거쳐 await 지점부터 복귀하는 게 핵심이다. await가 "멈춰서 기다린다"처럼 읽히지만 실제로 멈추는 건 그 함수 하나뿐이고, 나머지 코드는 계속 달린다.

이쯤에서 맨 처음 퀴즈를 다시 보자. 이번엔 시뮬레이터를 처음부터 끝까지 읽을 수 있을 거다.

출력 순서 맞히기

console.log("A");
setTimeout(() => {
console.log("B");
}, 0);
Promise.resolve().then(() => {
console.log("C");
});
console.log("D");
콜스택

비어 있어요

Web APIs

비어 있어요

마이크로태스크 큐

비어 있어요

태스크 큐

비어 있어요

출력

아직 출력이 없어요

실행 전이에요.

1 / 12

A는 동기, D도 동기, C는 마이크로태스크(새치기 줄), B는 태스크. 그래서 A → D → C → B. 처음에 틀렸던 문제가 이제는 당연해 보이면 성공이다.

마지막 퀴즈

배운 걸 전부 섞은 문제다. async 함수, setTimeout, then 체인이 다 들어간다.

async function run() {
console.log("A");
await Promise.resolve();
console.log("B");
}
setTimeout(() => {
console.log("C");
}, 0);
run().then(() => {
console.log("D");
});
console.log("E");
이번엔 async/await까지 섞였어요. 출력 순서는?
예측을 제출하면 시뮬레이터가 열려요. 먼저 순서를 골라 보세요.

풀이 요령은 하나다. 동기 코드를 끝까지 따라가면서 각 콜백이 어느 줄에 서는지만 적어두면, 나머지는 "마이크로태스크 먼저, 다 비우고 태스크"라는 규칙이 알아서 정해준다.

Node.js에서는 조금 다른 점

본문은 브라우저와 Node가 공유하는 공통 원리였는데, 백엔드 입장에서 알아두면 좋은 차이들이 있다. 궁금한 것만 열어보면 된다.

Node.js 이벤트 루프는 페이즈가 6개다

Node의 이벤트 루프(libuv)는 태스크 큐가 하나가 아니라 용도별 페이즈 6개를 돈다. 타이머(setTimeout·setInterval) → 펜딩 콜백 → 유휴/준비 → 폴(I/O 콜백, 대부분의 시간을 여기서 보낸다) → 체크(setImmediate) → 클로즈 콜백 순서다. 마이크로태스크는 페이즈 경계가 아니라 콜백 하나가 끝날 때마다 비운다(Node 11부터 브라우저와 같아졌다). 그래서 같은 페이즈에 타이머 콜백 두 개가 대기 중이어도, 첫 콜백이 등록한 then 콜백이 두 번째 타이머 콜백보다 먼저 실행된다. "마이크로태스크 우선" 규칙은 Node에서도 그대로 성립하고, 태스크 쪽이 여러 줄로 쪼개져 있다고 보면 된다.

process.nextTick은 마이크로태스크보다도 먼저다

Node에는 새치기 줄보다 더 앞에 서는 줄이 하나 더 있다. process.nextTick으로 등록한 콜백은 프로미스 마이크로태스크보다도 먼저, 지금 실행 중인 콜백이 끝나는 즉시 처리된다. 그래서 nextTick이 자기 자신을 재등록하면 프로미스조차 굶는다. Node 문서도 웬만하면 setImmediate를 쓰라고 권한다. 참고로 이름이 반대 같지만 setImmediate가 nextTick보다 늦다. 역사적인 작명 실수다. 한 가지 예외 — ES 모듈(type: module)의 최상위 코드는 그 자체가 마이크로태스크 큐 안에서 돌아서, 거기서는 이미 줄 서 있던 then 콜백이 nextTick보다 먼저일 수 있다. CommonJS 최상위나 일반 콜백 안에서는 nextTick이 먼저라는 규칙 그대로다.

await 한 번은 마이크로태스크 몇 개일까

await x는 대략 Promise.resolve(x).then(나머지)로 변환된다. x가 이미 이행된 프로미스면 재개까지 마이크로태스크 1개면 되는데, 예전 스펙에서는 여기에 2개가 더 들어가서 await가 then보다 항상 한 박자 늦었다. 2019년에 스펙이 바뀌어서 지금은 차이가 거의 없다. 시뮬레이터에서 "이어서 할 일"이 큐에 딱 한 번 들어가는 걸로 표현한 게 현재 스펙 기준이다. 면접 단골 문제였던 "await vs then 순서"가 런타임 버전에 따라 답이 달랐던 이유이기도 하다.


정리하면 이렇다. 자바스크립트는 한 번에 하나만 실행하고(콜스택), 기다리는 일은 런타임에 맡기고(Web API), 끝난 일은 줄을 서고(태스크 큐), 프로미스 뒷처리는 새치기 줄로 들어가고(마이크로태스크 큐), 이 줄들을 순서대로 비워주는 게 이벤트 루프다. 다섯 문장이 전부인데, 이걸 그림 없이 글로만 배우려니 그렇게 헷갈렸던 것 같다.

스터디에서 이 글로 발표할 예정이라, 각 섹션의 "생각해볼 점"은 토론 주제로 그대로 써먹어도 된다. 시뮬레이터 눌러보다가 이상한 점을 찾으면 알려달라. 스텝 데이터는 실제 실행 결과와 자동 대조하고 있긴 한데, 사람이 쓴 설명이 틀릴 수는 있으니까.

사이트 내 검색

제목·태그·설명·본문으로 발행 글을 찾습니다