setTimeout(fn, 0)보다 Promise.resolve().then(fn)이 항상 먼저 실행됩니다. 둘 다 "바로 실행"인 것 같은데, 왜 순서가 다를까요?

이벤트 루프에는 마이크로태스크 큐 와 매크로태스크 큐 라는 두 종류의 대기열이 있습니다. Promise 콜백은 마이크로태스크 큐에, setTimeout 콜백은 매크로태스크 큐에 들어가고, 마이크로태스크가 항상 우선 처리됩니다.

Promise의 세 가지 상태

Promise는 비동기 작업의 최종 완료 또는 실패를 나타내는 객체 입니다.

세 가지 상태를 가집니다.

  • pending: 초기 상태, 아직 완료되지 않음
  • fulfilled: 작업이 성공적으로 완료됨
  • rejected: 작업이 실패함

한 번 fulfilled나 rejected로 바뀌면 다시 변경되지 않습니다. 이 상태를 settled 라고 합니다.

JS
const p = new Promise((resolve, reject) => {
  // pending 상태
  setTimeout(() => {
    resolve('완료'); // fulfilled로 전환
    reject('실패');  // 무시됨 — 이미 settled
  }, 1000);
});

resolved vs fulfilled

"resolved"라는 용어는 "fulfilled"와 혼동되기 쉽습니다. 정확하게는 이렇습니다.

  • fulfilled: 값으로 이행된 상태
  • resolved: 결과가 결정된 상태 (fulfilled이거나, 다른 Promise에 연결된 상태)

resolve(anotherPromise)를 호출하면 resolved이지만 아직 pending일 수 있습니다.

JS
const inner = new Promise((resolve) => setTimeout(() => resolve('done'), 5000));
const outer = new Promise((resolve) => resolve(inner));
// outer는 resolved이지만, inner가 pending이므로 outer도 pending

then 체이닝의 내부 동작

then()은 ** 항상 새로운 Promise를 반환 **합니다. 이것이 체이닝이 가능한 핵심입니다.

JS
Promise.resolve(1)
  .then((v) => v + 1)    // 새 Promise(fulfilled: 2)
  .then((v) => v * 3)    // 새 Promise(fulfilled: 6)
  .then((v) => {
    console.log(v);       // 6
  });

콜백에서 Promise를 반환하면?

then 콜백이 Promise를 반환하면, 다음 then은 그 Promise가 settle될 때까지 기다립니다.

JS
Promise.resolve('start')
  .then((v) => {
    return new Promise((resolve) => {
      setTimeout(() => resolve(v + ' → middle'), 1000);
    });
  })
  .then((v) => {
    console.log(v); // "start → middle" (1초 후)
  });

에러 전파

체인 중간에서 에러가 발생하면, 가장 가까운 catch로 전파됩니다.

JS
Promise.resolve(1)
  .then((v) => {
    throw new Error('에러 발생');
  })
  .then((v) => {
    console.log('실행 안 됨');
  })
  .catch((err) => {
    console.log(err.message); // "에러 발생"
  })
  .then(() => {
    console.log('catch 뒤는 실행됨'); // 실행됨
  });

catch도 새 Promise를 반환하므로, catch 이후의 then은 정상적으로 실행됩니다.

마이크로태스크 vs 매크로태스크

이벤트 루프에는 두 종류의 태스크 큐가 있습니다.

매크로태스크 (Task Queue)

  • setTimeout, setInterval
  • setImmediate (Node.js)
  • I/O 콜백
  • UI 렌더링

마이크로태스크 (Microtask Queue)

  • Promise.then/catch/finally
  • queueMicrotask()
  • MutationObserver
  • process.nextTick (Node.js)

실행 순서의 핵심 규칙

매 매크로태스크가 끝날 때마다, 마이크로태스크 큐를 전부 비운 뒤 다음 매크로태스크로 넘어갑니다.

JS
console.log('1');                              // 동기

setTimeout(() => console.log('2'), 0);         // 매크로태스크

Promise.resolve()
  .then(() => console.log('3'))                // 마이크로태스크
  .then(() => console.log('4'));               // 마이크로태스크

console.log('5');                              // 동기

// 출력: 1, 5, 3, 4, 2

실행 흐름을 단계별로 보면 이렇습니다.

  1. ** 콜 스택 **: console.log('1') 실행 → 출력: 1
  2. setTimeout 콜백 → 매크로태스크 큐에 등록
  3. Promise.then 콜백 → 마이크로태스크 큐에 등록
  4. ** 콜 스택 **: console.log('5') 실행 → 출력: 5
  5. ** 콜 스택 비어짐** → 마이크로태스크 큐 처리
  6. console.log('3') 실행 → 출력: 3 → 두 번째 then이 마이크로태스크 큐에 등록
  7. console.log('4') 실행 → 출력: 4
  8. 마이크로태스크 큐 비어짐 → 매크로태스크 처리
  9. console.log('2') 실행 → 출력: 2

더 복잡한 예제

JS
setTimeout(() => console.log('A'), 0);

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

queueMicrotask(() => console.log('E'));

console.log('F');

// 출력: F, B, E, D, A, C

핵심은 "마이크로태스크는 매크로태스크보다 항상 먼저 실행된다"입니다.

queueMicrotask

queueMicrotask()는 마이크로태스크 큐에 직접 콜백을 추가하는 API입니다.

JS
queueMicrotask(() => {
  console.log('마이크로태스크');
});
console.log('동기');

// 출력: 동기, 마이크로태스크

Promise를 만들지 않고도 마이크로태스크를 등록할 수 있어서, 특정 작업을 현재 태스크 뒤에 바로 실행하고 싶을 때 유용합니다.

Promise.resolve().then() vs queueMicrotask()

둘 다 마이크로태스크를 등록하지만, queueMicrotask()가 더 가볍습니다. Promise 객체를 생성하지 않기 때문입니다.

JS
// 불필요한 Promise 생성
Promise.resolve().then(() => doSomething());

// 더 나은 방법
queueMicrotask(() => doSomething());

async/await의 실체

async/awaitPromise 기반 코드를 동기적으로 보이게 작성하는 문법 입니다.

async 함수

JS
async function fetchData() {
  return 'data';
}

// 위와 동일
function fetchData() {
  return Promise.resolve('data');
}

async 함수는 항상 Promise를 반환합니다.

await의 동작

await는 Promise가 settle될 때까지 함수 실행을 일시 중단 합니다. 내부적으로는 then 체이닝으로 변환됩니다.

JS
async function example() {
  console.log('1');
  const result = await Promise.resolve('2');
  console.log(result);
  console.log('3');
}

// 대략 이렇게 변환됨
function example() {
  console.log('1');
  return Promise.resolve('2').then((result) => {
    console.log(result);
    console.log('3');
  });
}

await와 이벤트 루프

JS
async function foo() {
  console.log('foo start');
  await bar();
  console.log('foo end');  // 마이크로태스크로 실행됨
}

async function bar() {
  console.log('bar');
}

console.log('start');
foo();
console.log('end');

// 출력: start, foo start, bar, end, foo end

await 이후의 코드는 마이크로태스크 큐에 들어갑니다. 따라서 endfoo end보다 먼저 출력됩니다.

에러 처리: try/catch vs .catch()

async/await에서는 try/catch로 에러를 처리합니다.

JS
// async/await 방식
async function fetchUser() {
  try {
    const response = await fetch('/api/user');
    const data = await response.json();
    return data;
  } catch (error) {
    console.error('요청 실패:', error);
    throw error; // 상위로 전파하고 싶을 때
  }
}

// Promise 방식
function fetchUser() {
  return fetch('/api/user')
    .then((res) => res.json())
    .catch((error) => {
      console.error('요청 실패:', error);
      throw error;
    });
}

Promise 유틸리티 메서드

메서드동작
Promise.all모두 fulfilled → 결과 배열, 하나라도 rejected → 즉시 reject
Promise.allSettled모두 settled → 각 결과와 상태를 배열로 반환
Promise.race가장 먼저 settled된 결과를 반환
Promise.any가장 먼저 fulfilled된 결과, 모두 rejected → AggregateError
JS
// Promise.all — 하나라도 실패하면 전체 실패
const results = await Promise.all([
  fetch('/api/users'),
  fetch('/api/posts'),
]);

// Promise.allSettled — 실패해도 모든 결과를 확인
const results = await Promise.allSettled([
  fetch('/api/users'),
  fetch('/api/posts'),
]);
// [{ status: 'fulfilled', value: ... }, { status: 'rejected', reason: ... }]

주의할 점

마이크로태스크가 무한히 추가되면 매크로태스크가 영원히 실행되지 않는다

마이크로태스크 콜백 안에서 또 다른 마이크로태스크를 등록하면, 마이크로태스크 큐가 비어지지 않아 매크로태스크(setTimeout, UI 렌더링)가 영원히 실행되지 않습니다. 브라우저 UI가 멈추는 원인이 됩니다.

await 없이 Promise를 반환하면 에러가 삼켜진다

async 함수에서 Promise를 반환만 하고 await하지 않으면, 해당 Promise에서 발생한 에러가 try/catch로 잡히지 않습니다. unhandledrejection 이벤트로만 감지할 수 있습니다.

Promise.all은 하나가 실패하면 나머지 결과를 버린다

Promise.all에서 하나가 reject되면 즉시 전체가 reject되지만, 나머지 Promise가 **취소되는 것은 아닙니다 **. 이미 시작된 요청은 계속 진행됩니다. 모든 결과가 필요하면 Promise.allSettled를 사용합니다.

정리

항목설명
Promise 상태pending → fulfilled/rejected (한 번 settled되면 불변)
then()항상 새로운 Promise 반환 → 체이닝 가능
마이크로태스크Promise.then, queueMicrotask, MutationObserver
매크로태스크setTimeout, setInterval, I/O 콜백
실행 순서동기 → 마이크로태스크 전부 → 매크로태스크 하나 → 반복
async/awaitPromise 체이닝의 문법적 설탕, await 이후는 마이크로태스크
댓글 로딩 중...