2026.06.04·5

JavaScript 비동기 흐름 다시 보기 (03) - Promise는 왜 나중에 실행될까: 이벤트 루프와 마이크로태스크 큐

Promise의 then과 await 이후 흐름이 언제 이어지는지 call stack, event loop, microtask queue 기준으로 정리합니다.

JavaScript 비동기 흐름 다시 보기 (03) - Promise는 왜 나중에 실행될까: 이벤트 루프와 마이크로태스크 큐 대표 이미지

Promise가 이미 fulfilled 상태가 되어도 then 콜백은 그 자리에서 바로 끼어들지 않습니다. 현재 동기 실행이 끝난 뒤 microtask로 이어집니다.

들어가며

앞선 두 글에서는 Promise를 상태를 가진 비동기 작업으로 보고, async/await가 그 Promise 흐름을 더 읽기 좋게 쓰는 문법이라는 점을 정리했습니다.

이제 남는 질문은 실행 순서입니다.

console.log("A");
 
Promise.resolve().then(() => {
  console.log("B");
});
 
console.log("C");

위 코드를 보면 Promise.resolve()는 이미 성공한 Promise를 만드는 것처럼 보입니다. 그렇다면 then 안의 "B"가 곧바로 찍혀야 할 것 같습니다.

하지만 실제 출력은 이렇습니다.

A
C
B

"B""C"보다 나중에 실행될까요. 이번 글은 이 질문에만 답합니다. 이벤트 루프 전체를 깊게 파고들기보다, Promise의 후속 흐름이 언제 실행되는지 이해하는 데 필요한 기준선만 잡겠습니다.

먼저 동기 코드는 call stack에서 끝까지 실행됩니다

JavaScript는 동기 코드를 실행할 때 현재 함수 호출을 call stack에 쌓고, 위에서 아래로 실행합니다.

call stack은 지금 실행 중인 함수들이 쌓이는 공간입니다. 함수가 호출되면 stack에 올라가고, 실행이 끝나면 빠져나갑니다.

아주 단순하게 보면 이런 코드입니다.

function first() {
  console.log("first");
}
 
function second() {
  first();
  console.log("second");
}
 
second();

second()가 호출되면 second가 stack에 올라갑니다. 그 안에서 first()가 호출되면 first가 그 위에 올라갑니다. first가 끝나면 빠져나오고, 다시 second의 남은 코드가 실행됩니다.

여기까지는 비동기와 무관한 동기 실행입니다. 중요한 점은 하나입니다. 현재 call stack에서 실행 중인 동기 코드는 중간에 Promise 콜백이 끼어들어 멈추지 않습니다.

Promise가 이미 fulfilled가 되어도, then에 넘긴 콜백은 지금 실행 중인 동기 코드 사이로 바로 들어오지 않습니다. 후속 작업으로 예약됩니다.

Promise 생성과 then 실행은 다릅니다

Promise를 이해할 때 자주 헷갈리는 지점이 있습니다. Promise executor는 바로 실행되지만, then 콜백은 바로 실행되지 않는다는 점입니다.

console.log("1");
 
const task = new Promise<string>((resolve) => {
  console.log("2");
  resolve("done");
});
 
task.then((value) => {
  console.log("4", value);
});
 
console.log("3");

출력은 다음과 같습니다.

1
2
3
4 done

new Promise에 넘긴 함수는 Promise를 만드는 순간 실행됩니다. 그래서 "2""3"보다 먼저 출력됩니다.

하지만 then에 넘긴 콜백은 다릅니다. resolve("done")이 호출되어 Promise가 fulfilled 상태가 되어도, then 콜백은 현재 동기 코드가 끝난 뒤 실행됩니다.

즉 여기서 분리해서 봐야 합니다.

  • Promise executor는 지금 실행됩니다.
  • Promise 상태는 지금 fulfilled가 될 수 있습니다.
  • then 콜백은 microtask로 예약됩니다.

이 차이를 알아야 Promise가 "이미 끝났는데 왜 then은 나중에 실행되지?"라는 질문이 풀립니다.

microtask queue는 Promise 후속 흐름이 기다리는 줄입니다

Promise의 then, catch, finally 콜백은 microtask queue에 들어갑니다. async 함수에서 await 뒤에 이어지는 코드도 같은 기준으로 이해할 수 있습니다.

queue는 줄을 서는 공간이라고 생각하면 됩니다. 다만 여기서 중요한 것은 어떤 줄에 서느냐입니다.

브라우저나 JavaScript 런타임에는 여러 종류의 후속 작업이 있습니다. 타이머, 사용자 이벤트, 네트워크 이벤트처럼 큰 단위 작업은 보통 task queue에 들어갑니다. 반면 Promise 후속 처리는 microtask queue에 들어갑니다.

이번 글에서는 세부 스펙 용어보다 아래 기준만 잡겠습니다.

Keypoint

Promise의 후속 처리는 현재 동기 코드가 끝난 뒤 실행됩니다. 그리고 일반 task보다 먼저 처리되는 microtask queue에 들어갑니다.

그래서 아래 코드의 출력 순서가 설명됩니다.

console.log("A");
 
Promise.resolve().then(() => {
  console.log("B");
});
 
console.log("C");

동기 코드인 "A""C"가 먼저 실행됩니다. 그 뒤 call stack이 비면 microtask queue에 있던 then 콜백이 실행되고 "B"가 출력됩니다.

setTimeout보다 Promise가 먼저 보이는 이유

이제 setTimeout과 비교해 보겠습니다.

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

대부분의 브라우저 환경에서 출력은 다음 순서로 보입니다.

A
D
C
B

setTimeout(..., 0)은 0초 뒤에 즉시 실행하겠다는 뜻처럼 보입니다. 하지만 실제 의미는 "현재 동기 실행이 끝난 뒤 실행 가능한 task로 예약한다"에 가깝습니다.

Promise의 then 콜백은 microtask queue에 들어갑니다. 현재 동기 코드가 끝나면 microtask queue가 먼저 비워지고, 그다음 task queue의 작업이 이어집니다.

그래서 "C""B"보다 먼저 출력됩니다.

여기서 조심할 점도 있습니다. setTimeout(..., 0)은 정확히 0밀리초 뒤에 실행된다는 약속이 아닙니다. 최소 지연 시간이 0에 가깝다는 뜻이지, 현재 call stack과 microtask 처리가 끝나기 전에는 실행될 수 없습니다.

await 뒤의 코드는 어디로 갈까

2편에서 async/await는 Promise 흐름을 읽기 쉽게 만드는 문법이라고 했습니다. 이제 실행 순서 기준으로 다시 보겠습니다.

async function run() {
  console.log("A");
 
  await Promise.resolve();
 
  console.log("B");
}
 
run();
 
console.log("C");

출력은 다음과 같습니다.

A
C
B

run()을 호출하면 async 함수 안의 "A"까지는 바로 실행됩니다. 그런데 await Promise.resolve()를 만나면, 그 뒤의 "B"는 현재 동기 흐름에서 이어지지 않습니다. Promise가 결정된 뒤 microtask로 이어질 후속 흐름이 됩니다.

그래서 함수 밖의 "C"가 먼저 실행되고, 그 뒤 "B"가 실행됩니다.

이 코드에서 async/await는 동기 흐름을 만든 것이 아닙니다. 오히려 async 함수의 실행을 둘로 나눴다고 보는 편이 정확합니다.

  • await 이전 코드는 현재 call stack에서 실행됩니다.
  • await 이후 코드는 Promise가 결정된 뒤 microtask로 이어집니다.

이 기준을 잡으면 async/await의 실행 순서도 덜 낯설어집니다.

UI 코드에서는 왜 이 기준이 중요할까

실무에서는 단순히 로그 순서를 맞히는 것보다 UI 상태를 해석할 때 이 기준이 더 중요합니다.

예를 들어 아래 코드는 loading 상태를 켠 뒤 데이터를 요청합니다.

type ProfileState =
  | { status: "idle" }
  | { status: "pending" }
  | { status: "fulfilled"; name: string }
  | { status: "rejected"; message: string };
 
let state: ProfileState = { status: "idle" };
 
async function loadProfile() {
  state = { status: "pending" };
 
  try {
    const profile = await fetchProfile();
 
    state = { status: "fulfilled", name: profile.name };
  } catch (error) {
    state = {
      status: "rejected",
      message: error instanceof Error ? error.message : "알 수 없는 오류",
    };
  }
}

여기서 await fetchProfile() 이후의 성공/실패 처리는 지금 당장 이어지는 동기 코드가 아닙니다. fetchProfile()이 반환한 Promise가 결정된 뒤 이어지는 후속 흐름입니다.

이 차이를 알면 몇 가지 질문을 더 정확히 할 수 있습니다.

  • pending 상태는 언제 설정되는가
  • fulfilled 상태는 어떤 Promise가 끝난 뒤 설정되는가
  • rejected 상태는 어느 await 지점에서 넘어오는가
  • 현재 함수 밖에서는 이 작업을 기다리고 있는가

React Query 같은 라이브러리는 이 흐름을 내부 상태 머신으로 감싸 줍니다. 하지만 그 밑에서는 여전히 Promise 후속 작업이 microtask로 이어지고, UI는 그 결과를 상태로 번역합니다.

너무 많은 microtask도 흐름을 막을 수 있습니다

microtask는 일반 task보다 먼저 처리됩니다. 이 말은 Promise 후속 처리가 빠르게 이어진다는 뜻이기도 하지만, microtask를 너무 많이 만들면 다른 작업이 뒤로 밀릴 수 있다는 뜻이기도 합니다.

예를 들어 Promise 체인을 길게 이어 붙이면 각 단계가 microtask로 이어집니다.

Promise.resolve()
  .then(() => {
    console.log("step 1");
  })
  .then(() => {
    console.log("step 2");
  })
  .then(() => {
    console.log("step 3");
  });

이 정도는 문제가 아닙니다. 하지만 큰 반복 작업을 Promise 체인으로 쪼개 계속 microtask에 밀어 넣으면, 사용자 이벤트나 렌더링 같은 다른 작업이 늦어질 수 있습니다.

여기서 깊게 최적화 이야기까지 갈 필요는 없습니다. 기준만 기억하면 됩니다.

Note

microtask는 "바로 실행되는 것"이 아닙니다. 현재 동기 실행이 끝난 뒤, 다음 task로 넘어가기 전에 우선 처리되는 후속 작업입니다.

마치며

Promise가 "나중에 실행되는 것처럼" 보이는 이유는 Promise 후속 처리의 위치 때문입니다. Promise가 fulfilled가 되는 시점과 then 콜백이 실행되는 시점은 같지 않습니다.

현재 동기 코드는 call stack에서 끝까지 실행됩니다. 그 뒤 Promise의 then, catch, finally, 그리고 await 이후 흐름은 microtask queue에서 이어집니다. setTimeout 같은 일반 task보다 Promise 후속 처리가 먼저 보이는 이유도 여기에 있습니다.

이 글에서 잡을 기준은 하나입니다.

Keypoint

Promise는 값을 나중에 주기만 하는 객체가 아닙니다. 그 후속 흐름도 현재 동기 실행이 끝난 뒤 microtask로 예약되어 이어집니다.

다음 글에서는 이 기준을 에러 처리로 이어 가겠습니다. try/catch가 어떤 에러는 잡고, 어떤 에러는 놓치는지 Promise와 async/await 흐름 위에서 다시 보겠습니다.