JavaScript 이벤트 루프: Promise와 await 실행 순서 추적하기

2026.09.12·수정 2026.09.14·약 16분·작성: 해비·블로그 소개

이 글에서 완성하는 것

동기 코드, Promise 마이크로태스크, 타이머의 실행 순서를 표와 assert로 확인합니다. await 이후의 재개와 마이크로태스크 재등록 문제를 안전한 예제로 실습합니다.

현재 동기 코드가 끝나면 대기 중인 마이크로태스크를 처리합니다. 마이크로태스크 처리가 끝난 뒤 실행 가능한 다음 태스크로 넘어갑니다.
현재 동기 코드가 끝나면 대기 중인 마이크로태스크를 처리합니다. 마이크로태스크 처리가 끝난 뒤 실행 가능한 다음 태스크로 넘어갑니다.

학습 수준은 중급입니다. 함수, 실행 컨텍스트의 콜 스택, Promise의 then과 async 함수 문법을 알고 시작하세요. Node.js 22 이상에서 .mjs 파일로 실행하며 외부 요청과 패키지를 사용하지 않습니다. fetch 성공·실패 처리를 배운 다음, 응답 처리 코드가 언제 실행되는지 이해하고 싶은 분을 위한 연결 실습입니다.

실행 순서를 나누는 세 가지 질문

비동기 코드를 읽을 때 코드의 위아래 위치만 보고 순서를 정하면 틀리기 쉽습니다. 먼저 지금 실행하는 동기 코드인지, Promise 반응이나 queueMicrotask로 예약한 마이크로태스크인지, 타이머처럼 나중의 태스크에서 실행될 콜백인지 표시하세요. 함수가 비동기 API를 호출했다고 그 함수의 나머지 줄이 자동으로 뒤로 밀리는 것은 아닙니다. 현재 실행 중인 동기 코드는 먼저 계속 진행합니다.

브라우저의 단순한 예제에서는 현재 태스크가 끝나고 실행 스택이 비면 마이크로태스크 체크포인트에서 대기 작업을 처리합니다. 그 과정에서 추가된 마이크로태스크도 이어서 처리합니다. 그 뒤 이벤트 루프가 다음 태스크를 선택하거나 렌더링할 기회를 가질 수 있습니다. 렌더링이 모든 태스크 사이에 반드시 한 번 일어난다는 뜻은 아닙니다. 정확한 화면 표시 시점을 이 순서만으로 단정하지 마세요.

Promise 생성자에 넘긴 executor는 생성 시 동기로 실행됩니다. 반면 then 콜백은 이행 결과에 대한 반응으로 나중에 실행됩니다. 이미 이행된 Promise에 then을 붙여도 콜백이 그 줄에서 바로 실행되지는 않습니다. “Promise 안은 전부 나중에 실행된다”라는 문장 대신 executor와 반응 콜백을 구분하면 실수를 줄일 수 있습니다.

한 프로그램을 끝까지 추적하기

실습 파일

파일 경로를 확인하고 같은 프로젝트 안에 저장하세요. 이미지 등 소스 목록에 없는 파일과 실행 안내는 실습 ZIP에 포함되어 있습니다.

전체 코드

await-order.mjs

import assert from 'node:assert/strict';
const trace = [];
async function load() {
  trace.push('load: before await');
  await 42;
  trace.push('load: after await');
  return 'complete';
}
trace.push('caller: before');
const result = load();
trace.push('caller: after');
const value = await result;
assert.equal(value, 'complete');
assert.deepEqual(trace, [
  'caller: before',
  'load: before await',
  'caller: after',
  'load: after await',
]);
console.log(trace.join(' -> '));

chunked-sum.mjs

import assert from 'node:assert/strict';
const waitTurn = () => new Promise((resolve) => setTimeout(resolve, 0));
async function sumInChunks(values, chunkSize = 1000) {
  if (!Number.isInteger(chunkSize) || chunkSize < 1) {
    throw new RangeError('chunkSize must be a positive integer');
  }
  let total = 0;
  for (let index = 0; index < values.length; index += 1) {
    total += values[index];
    if ((index + 1) % chunkSize === 0 && index + 1 < values.length) {
      await waitTurn();
    }
  }
  return total;
}
assert.equal(await sumInChunks([1, 2, 3, 4, 5], 2), 15);
assert.equal(await sumInChunks([], 2), 0);
await assert.rejects(sumInChunks([1], 0), RangeError);
console.log('chunked sum: all checks passed');

finite-microtasks.mjs

import assert from 'node:assert/strict';
const trace = [];
let count = 0;
const timerDone = new Promise((resolve) => {
  setTimeout(() => {
    trace.push(`timer: ${count}`);
    resolve();
  }, 0);
});
function step() {
  count += 1;
  trace.push(`microtask: ${count}`);
  if (count < 3) queueMicrotask(step);
}
queueMicrotask(step);
await timerDone;
assert.deepEqual(trace, ['microtask: 1', 'microtask: 2', 'microtask: 3', 'timer: 3']);
console.log(trace.join(' -> '));

order.mjs

import assert from 'node:assert/strict';
const trace = [];
const record = (label) => trace.push(label);
record('A: sync start');
const timerDone = new Promise((resolve) => {
  setTimeout(() => {
    record('F: timer');
    resolve();
  }, 0);
});
Promise.resolve().then(() => {
  record('C: promise');
  queueMicrotask(() => record('E: nested microtask'));
});
queueMicrotask(() => record('D: microtask'));
record('B: sync end');
await timerDone;
assert.deepEqual(trace, [
  'A: sync start',
  'B: sync end',
  'C: promise',
  'D: microtask',
  'E: nested microtask',
  'F: timer',
]);
console.log(trace.join(' -> '));

solution.mjs

import assert from 'node:assert/strict';
const trace = [];
trace.push('A');
const done = new Promise((resolve) => {
  setTimeout(() => {
    trace.push('T');
    resolve();
  }, 0);
});
Promise.resolve().then(() => {
  trace.push('B');
  Promise.resolve().then(() => trace.push('D'));
});
queueMicrotask(() => trace.push('C'));
trace.push('E');
await done;
assert.deepEqual(trace, ['A', 'E', 'B', 'C', 'D', 'T']);
console.log(trace.join(' -> '));

order.mjs 전체 코드 보기

order.mjs로 저장하고 node order.mjs를 실행하세요. 출력은 A: sync start → B: sync end → C: promise → D: microtask → E: nested microtask → F: timer입니다. 코드는 긴 지연 시간을 기다려 우연히 결과를 확인하지 않고, 타이머가 자기 작업을 마친 뒤 resolve한 Promise를 기다려 결과 배열 전체를 검증합니다.

A 이후 timerDone의 executor가 즉시 실행되어 타이머를 등록합니다. 이 등록 자체는 동기 작업이지만 F를 기록하는 콜백은 아직 실행하지 않습니다. 그다음 이미 이행된 Promise의 then 반응 C를 예약하고 queueMicrotask로 D를 예약합니다. B가 기록될 때까지 C와 D는 실행되지 않습니다. 마지막 await는 테스트가 타이머 완료를 기다리기 위한 코드입니다.

진행 시점 기록 대기 마이크로태스크
동기 코드 종료 A, B C, D
C 실행 후 A, B, C D, E
D 실행 후 A, B, C, D E
E 실행 후 A, B, C, D, E 없음
타이머 콜백 A, B, C, D, E, F 검증 재개가 예약됨

C 안에서 E를 예약해도 D보다 앞으로 끼어들지 않습니다. D는 이미 대기 중이고 E는 뒤에 추가되기 때문입니다. 여기서 중요한 것은 Promise라는 이름이 queueMicrotask보다 우선한다는 규칙이 아니라, 이 예제에서 각 작업이 언제 대기열에 들어갔는지입니다. 두 등록 줄의 순서를 바꾸면 D가 C보다 먼저 기록됩니다. 그때도 E는 C가 실행된 후에야 예약됩니다.

0밀리초가 지금이라는 뜻은 아니다

setTimeout(fn, 0)은 현재 줄을 중단하고 fn을 즉시 실행하라는 요청이 아닙니다. 타이머가 실행 가능한 시점에 도달해도 현재 실행 중인 코드와 처리해야 할 작업에 따라 실제 시작은 늦어질 수 있습니다. 따라서 정확히 0밀리초 후라는 시간 보장으로 사용하지 않습니다. 본 실습도 경과 시간을 비교하지 않고 관찰 가능한 순서를 비교합니다.

네트워크 응답이나 사용자 입력처럼 다른 종류의 작업이 섞이면 위의 여섯 글자 순서만으로 전체 애플리케이션을 설명할 수 없습니다. 특히 동시에 시작한 두 fetch 요청은 완료 순서가 바뀔 수 있습니다. 이벤트 루프를 이해했다고 요청 시작 순서대로 화면에 반영해도 되는 것은 아닙니다. 그 문제는 최신 요청을 식별하는 별도의 상태 규칙으로 해결해야 합니다.

await는 함수의 나머지를 나중에 재개한다

await-order.mjs 전체 코드 보기

await-order.mjs의 출력은 caller: before → load: before await → caller: after → load: after await입니다. load()를 호출하면 첫 await 이전까지는 즉시 진행합니다. await 42는 일반 값이라도 함수 실행을 잠시 멈추고 이후 부분을 나중에 재개합니다. 호출자는 Promise를 받아 caller: after를 기록합니다. load 안의 다음 줄이 끝나면 result도 complete로 이행됩니다.

await는 자바스크립트 전체를 멈추는 명령이 아닙니다. 현재 async 함수의 이어지는 실행을 보류합니다. 다른 동기 코드와 대기 작업은 계속 진행할 수 있으므로 await 이전에 읽은 공유 상태가 재개 시점에도 같다고 가정하면 안 됩니다. 반대로 CPU를 오래 사용하는 동기 반복문에 async 키워드만 붙여도 자동으로 여러 스레드에서 처리되는 것은 아닙니다.

이 실행 재개 규칙은 MDN await 문서의 제어 흐름 설명과 연결됩니다. 실습의 await 42는 외부 작업의 완료 시간을 제거하기 위한 선택입니다. 실제 fetch에서는 기다리는 Promise가 언제 이행되는지에 따라 재개가 예약되는 시점도 달라집니다. 항상 “어떤 Promise가 지금 이미 이행되어 있는가”를 먼저 확인하세요.

마이크로태스크를 계속 추가하면 생기는 문제

finite-microtasks.mjs 전체 코드 보기

finite-microtasks.mjs는 세 번만 재등록하므로 안전하게 끝납니다. microtask: 1 → microtask: 2 → microtask: 3 → timer: 3이 출력됩니다. 타이머는 첫 마이크로태스크만 끝난 뒤 실행되는 것이 아니라, 이 예제에서 이어서 추가한 마이크로태스크까지 처리된 뒤 실행됩니다. 종료 조건을 없애는 실험은 하지 마세요. 작업이 계속 쌓이면 타이머와 화면 갱신이 진행할 기회를 오래 얻지 못할 수 있습니다.

이 현상을 마이크로태스크 기아 문제라고 부를 수 있습니다. queueMicrotask를 쓰면 작은 일을 빠르게 처리할 수 있지만, 무거운 계산을 계속 재예약하는 해결책으로 쓰면 응답성을 악화시킬 수 있습니다. await Promise.resolve()를 반복하는 것도 마이크로태스크로 재개하므로 브라우저가 화면을 갱신할 기회를 반드시 주는 방법은 아닙니다. MDN의 마이크로태스크 가이드에서 체크포인트의 처리와 재등록 시 주의점을 확인할 수 있습니다.

작업을 나눠 다음 태스크로 넘기는 예제

chunked-sum.mjs 전체 코드 보기

chunked-sum.mjs는 정해진 개수마다 타이머 Promise를 기다려 다음 태스크로 작업을 나눕니다. 이 방식은 다른 작업이 진행할 기회를 주지만 정확한 프레임 속도나 입력 처리 시간을 보장하지는 않습니다. 타이머를 너무 자주 예약하면 부가 비용도 늘어납니다. 실무에서는 작업의 크기와 환경을 측정하고, 큰 계산은 Worker 같은 별도 실행 수단도 검토합니다. 이 함수의 입력은 숫자 배열이라고 제한했습니다.

마지막 덩어리 뒤에는 할 일이 없으므로 추가 타이머를 예약하지 않습니다. 빈 배열에서는 0을 반환하고 chunkSize가 0이면 예외로 거부합니다. 확인 명령은 node chunked-sum.mjs입니다. 이 검증은 합계와 입력 계약을 확인할 뿐, 브라우저 렌더링 성능을 측정하는 벤치마크는 아닙니다. 동기 함수의 정확성과 화면 응답성 검증은 서로 다른 관찰이 필요합니다.

브라우저 설명을 Node 전체 규칙으로 확대하지 않기

이번 .mjs 실습은 Node에서도 관찰 가능한 기본 Promise·queueMicrotask·타이머 조합에 한정합니다. Node 이벤트 루프에는 I/O 단계와 setImmediate, process.nextTick 같은 별도 API가 있습니다. CommonJS와 ES 모듈의 최상위 실행 맥락도 비교 결과에 영향을 줄 수 있습니다. 이 글의 표를 모든 Node 콜백의 절대 우선순위 표로 사용하지 마세요.

Node 공식 이벤트 루프 문서는 단계와 타이머의 실행 조건을 별도로 설명합니다. 환경을 비교하려면 같은 파일 형식과 API 조합을 명시하고 결과를 다시 검증해야 합니다. 여기에는 nextTick과 setImmediate를 섞지 않았으므로 이들의 상대 순서는 학습 결과에 포함하지 않습니다. 브라우저에서 실행할 때는 node:assert import와 검증 줄을 제거하고 기록 부분을 관찰할 수 있습니다.

연습: 안쪽 Promise가 어디에 들어가는가

다음 정답을 열기 전에 A, B, C, D, E, T의 순서를 적으세요. 동기 부분에서 A를 기록하고 타이머 T를 등록합니다. 첫 then에서 B를 기록하고 안쪽 then으로 D를 예약합니다. 바깥에서는 queueMicrotask로 C를 등록한 뒤 동기로 E를 기록합니다. 각 작업이 실행되는 때와 등록되는 때를 구분해 두 줄짜리 대기열을 적어 보세요.

정답 코드와 이유 펼치기

solution.mjs 전체 코드 보기

정답은 A → E → B → C → D → T입니다. E는 동기 코드이므로 B보다 먼저 기록됩니다. B가 실행되기 전에 C는 이미 대기 중입니다. 따라서 B에서 새로 예약한 D는 C 다음입니다. 타이머가 끝났을 때 검증을 재개하므로 아직 실행되지 않은 로그를 너무 일찍 비교하지 않습니다.

solution.mjs를 실행한 다음 바깥 queueMicrotask 등록 줄을 첫 Promise 등록 줄 앞으로 옮겨 보세요. 그 경우 예상은 A → E → C → B → D → T입니다. assert의 예상 배열도 그 순서로 바꾸고 성공을 확인하세요. 변경 후 B와 D는 여전히 순서가 유지되는 이유를 설명하면 대기열 추적 연습을 마친 것입니다.

실습 파일 다운로드

실행 예제와 연습 정답 ZIP 다운로드. 압축을 푼 폴더에서 README의 명령을 실행하세요. 외부 패키지는 설치하지 않습니다.

이 글이 도움이 되었나요?

조회 중

JavaScript 학습 순서

필수 13개 · 전체 14개

읽음 기록 관리

전체 과정 목차 (14개)
  1. 필수 학습 · JavaScript 조건문과 반복문: 변수 값의 흐름부터 추적하기
  2. 필수 학습 · JavaScript 함수와 객체, import export로 모듈 나누기
  3. 필수 학습 · JavaScript 배열 메서드: map filter forEach reduce 차이
  4. 필수 학습 · JavaScript 객체 참조와 불변 갱신: 중첩 객체를 안전하게 바꾸기
  5. 필수 학습 · JavaScript reduce 사용법: 배열 누적 계산을 이해하는 기준
  6. 필수 학습 · JavaScript DOM 폼 만들기: 입력 검증과 접근성 처리
  7. 필수 학습 · JavaScript 투두리스트 만들기: 상태와 이벤트 위임으로 완성하기
  8. 필수 학습 · JavaScript URLSearchParams 사용법: URL 파라미터 읽고 수정하기
  9. 필수 학습 · JavaScript Date UTC KST 차이: 시간대 변환 기준 잡기
  10. 필수 학습 · JavaScript 실행 컨텍스트 기준: 스코프 호이스팅 클로저 연결하기
  11. 필수 학습 · JavaScript fetch 오류 처리: Promise부터 404까지
  12. 필수 학습 · JavaScript 이벤트 루프: Promise와 await 실행 순서 추적하기 현재 글
  13. 필수 학습 · JavaScript 고급 비동기: AbortController와 최신 요청 경쟁 제어
  14. 선택 참고 · GSAP이 처음일 때 기초 사용법: 설치부터 기본 애니메이션까지

새 글 받아보기

RSS 리더에서 BlogFlow의 새 글을 확인할 수 있습니다.

RSS 피드 구독하기

댓글 남기기