async/await의 실행 원리: Job, 마이크로태스크, 실행 컨텍스트

2026. 6. 23. 23:05Frontend/JavaScript

Promise와 async/await 깊이 이해하기 2/4

들어가며

async/await은 Promise 코드를 동기 코드처럼 읽을 수 있게 만든다.

 
async function loadUser() {
  const response = await fetch("/api/user");
  const user = await response.json();

  return user;
}
 

하지만 코드가 동기적으로 보이는 것과 실제로 동기적으로 실행되는 것은 다르다.

await은 스레드를 멈추지 않는다.

Promise를 제거하지도 않는다.

실제로는 다음과 같은 과정이 일어난다.

async 함수 실행
→ await 지점에서 실행 컨텍스트 중단
→ Promise 반응 등록
→ 호출자에게 제어권 반환
→ Promise 완료
→ Job을 통해 실행 컨텍스트 재개
 

이를 정확히 이해하려면 JavaScript 언어 명세와 브라우저 호스트 환경을 분리해서 봐야 한다.


1. ECMAScript와 브라우저는 서로 다른 층이다

JavaScript의 Promise 실행 순서를 설명할 때 흔히 “마이크로태스크 큐에 들어간다”고 말한다.

실무적으로는 맞지만, 명세 수준에서는 두 층이 존재한다.

ECMAScript
= Promise와 Promise Job의 의미를 정의

HTML 호스트 환경
= Job을 마이크로태스크 큐와 이벤트 루프에 통합
 

ECMAScript는 Promise 반응을 실행할 Job을 만든다.

그 Job을 실제로 언제 실행할지는 HostEnqueuePromiseJob이라는 호스트 훅에 맡긴다.

브라우저에서는 HTML 명세가 이 Job을 마이크로태스크 처리 모델과 연결한다.

따라서 다음 표현이 더 정확하다.

Promise handler는 ECMAScript의 Promise Reaction Job이다.

브라우저에서는 이 Job이 마이크로태스크로 처리된다.
 

이 분리 때문에 브라우저와 다른 JavaScript 런타임의 세부 큐 정책이 완전히 같을 필요는 없다.


2. task와 microtask

브라우저 이벤트 루프를 단순화하면 다음과 같다.

task 하나 실행
→ JavaScript 호출 스택이 비워짐
→ microtask checkpoint 수행
→ 렌더링 기회가 있으면 렌더링
→ 다음 task 실행
 

대표적인 task에는 다음이 있다.

초기 스크립트 실행
setTimeout callback
사용자 입력 이벤트
네트워크 이벤트
메시지 이벤트
 

대표적인 microtask에는 다음이 있다.

Promise reaction
queueMicrotask callback
일부 DOM 반응
 

HTML 명세의 microtask checkpoint는 큐에 있는 마이크로태스크를 하나만 실행하지 않는다.

큐가 빌 때까지 계속 실행한다. 실행 중 새로운 마이크로태스크가 추가되면 그 작업도 같은 checkpoint에서 처리된다.


3. 기본 실행 순서 분석

다음 코드를 보자.

 
console.log("A");

setTimeout(() => {
  console.log("D");
}, 0);

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

console.log("E");
 

출력은 다음과 같다.

A
E
B
C
D
 

실행 흐름을 나누면 다음과 같다.

현재 task

console.log("A")
setTimeout 등록
첫 번째 then 반응 등록
console.log("E")
 

현재 task가 끝났을 때 마이크로태스크 큐에는 첫 번째 then 반응이 있다.

microtask queue: [B]
 

B가 실행되면서 다음 then 반응인 C가 추가된다.

microtask queue: [C]
 

큐가 빌 때까지 실행하므로 C도 같은 checkpoint에서 처리된다.

그다음 새로운 task인 setTimeout callback이 실행된다.


4. Promise 체인은 원자적으로 실행되지 않는다

다음 코드를 보자.

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

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

출력 결과는 다음과 같다.

A
B
C
 

처음 등록되는 마이크로태스크는 A와 B다.

queue: [A, B]
 

A를 실행한 후에야 C가 등록된다.

queue: [B, C]
 

따라서 B가 C보다 먼저 실행된다.

Promise 체인 전체는 하나의 원자적인 작업이 아니다.

각 then() 단계는 별도의 Promise Reaction Job이다.

then 1
→ 하나의 Job

then 2
→ 앞 단계가 완료된 뒤 등록되는 또 다른 Job
 

이 때문에 서로 다른 Promise 체인의 연속성이 중간에 섞여 실행될 수 있다.


5. 마이크로태스크 starvation

마이크로태스크는 우선순위가 높아 보이지만, 과도하게 생성하면 이벤트 루프를 굶길 수 있다.

 
function spin() {
  queueMicrotask(spin);
}

spin();
 

각 마이크로태스크가 다음 마이크로태스크를 예약한다.

microtask 실행
→ 새로운 microtask 추가
→ 큐가 비지 않음
→ 다음 task로 이동하지 못함
 

그 결과 다음 작업들이 지연될 수 있다.

사용자 입력 처리
타이머 callback
화면 렌더링
기타 task
 

다음 코드도 UI에 제어권을 양보하는 확실한 방법은 아니다.

 
async function processItems(items) {
  for (const item of items) {
    heavyCalculation(item);
    await Promise.resolve();
  }
}
 

await Promise.resolve() 이후의 실행은 마이크로태스크로 재개된다.

다음 task나 렌더링 기회로 반드시 넘어가는 것이 아니다.

await Promise.resolve()
= 마이크로태스크 경계

렌더링에 양보
= 반드시 보장되지 않음
 

CPU 집약 작업을 처리해야 한다면 다음 선택지가 더 적합할 수 있다.

작업을 작은 task 단위로 분할
requestAnimationFrame 활용
스케줄러 사용
Web Worker에서 계산
 

Promise는 CPU 작업을 별도 스레드에서 실행해주는 도구가 아니다.


6. await는 무엇을 하는가

await value의 의미를 단순화하면 다음과 같다.

1. value를 Promise 형태로 정규화한다.
2. 현재 async 실행 컨텍스트를 중단한다.
3. 성공 시 재개할 반응을 등록한다.
4. 실패 시 재개할 반응을 등록한다.
5. 호출자에게 제어권을 반환한다.
6. Promise가 완료되면 Job을 통해 중단 지점 이후를 재개한다.
 

ECMAScript의 Await(value) 추상 연산은 값을 intrinsic Promise로 정규화하고, 현재 async 실행 컨텍스트를 캡처한 fulfillment·rejection 함수를 만든다. 이후 PerformPromiseThen으로 반응을 등록하고 현재 컨텍스트를 중단한다.

다음 코드에서 중요한 것은 await이 현재 스레드를 멈추지 않는다는 점이다.

 
async function load() {
  const result = await fetchData();

  return result;
}
 

fetchData()가 완료될 때까지 JavaScript 엔진 전체가 멈추는 것이 아니다.

load 함수의 현재 실행만 중단된다.
이벤트 루프는 다른 작업을 처리할 수 있다.
 

7. async 함수는 첫 await 전까지 동기적으로 실행된다

 
console.log(1);

async function run() {
  console.log(2);

  await 0;

  console.log(4);
}

run();

console.log(3);
 

출력은 다음과 같다.

1
2
3
4
 

run() 호출 시 함수 본문은 즉시 시작한다.

 
console.log(2);
 

까지는 현재 호출 스택에서 실행된다.

await 0을 만나면 함수가 중단되고 이후 코드는 Promise 반응으로 예약된다.

현재 task

1
2
3

microtask

4
 

따라서 다음 두 문장은 모두 부정확하다.

async 함수는 전부 비동기로 실행된다.
async 함수는 호출 즉시 나중으로 밀린다.
 

정확한 설명은 다음과 같다.

async 함수는 호출 즉시 실행을 시작한다.
첫 await 전까지는 동기적으로 실행된다.
await 이후의 연속성이 비동기적으로 재개된다.
 

8. 일반 값을 await해도 실행 순서가 바뀐다

 
async function example() {
  console.log("before");

  await 42;

  console.log("after");
}

example();

console.log("outside");
 

출력은 다음과 같다.

before
outside
after
 

42는 비동기 계산이 아니다.

이미 존재하는 원시값이다.

하지만 await은 해당 값을 Promise처럼 정규화하고 후속 실행을 Promise 반응으로 연결한다.

따라서 다음 코드는 단순한 대입과 실행 시점이 다르다.

 
const value = 42;
 
 
const value = await 42;
 

결과값은 같지만 두 번째 코드에는 async 중단·재개 경계가 생긴다.

불필요한 await은 다음 변화를 만든다.

현재 실행의 분할
마이크로태스크 예약
다른 Promise 반응과의 실행 순서 변화
비동기 스택 경계 생성
 

9. async 함수는 항상 새로운 Promise를 반환한다

 
const original = Promise.resolve(1);

async function identity() {
  return original;
}

const returned = identity();

console.log(returned === original); // false
console.log(await returned);        // 1
 

identity()가 반환한 Promise와 original은 동일한 객체가 아니다.

async 함수가 호출되면 해당 함수의 완료를 나타내는 새로운 Promise가 만들어진다.

함수가 다른 Promise를 반환하면 새 Promise는 기존 Promise의 최종 상태를 채택한다.

객체 정체성
= 서로 다름

최종 fulfillment 값과 rejection
= 연결됨
 

ECMAScript async 함수는 호출마다 새로운 Promise 결과를 생성하며, 반환값이나 예외를 그 Promise의 완료 상태로 연결한다.


10. rejection이 await를 만나면 예외가 된다

다음 코드는 rejected Promise를 만들지만 현재 호출 스택에서 예외를 던지지는 않는다.

 
try {
  Promise.reject(new Error("failure"));
} catch (error) {
  console.log("실행되지 않는다.");
}
 

반면 다음 코드는 오류를 잡는다.

 
try {
  await Promise.reject(new Error("failure"));
} catch (error) {
  console.log(error.message); // failure
}
 

await은 Promise가 reject되면 중단된 async 실행 컨텍스트를 throw 완료 상태로 재개한다.

Promise rejection
        ↓ await
async 함수 내부의 throw
        ↓
try/catch로 관찰
 

try/catch가 Promise를 직접 이해하는 것은 아니다.

await이 rejection을 async 함수의 예외 제어 흐름으로 변환하는 것이다.


11. return promise와 return await promise

다음 두 함수의 최종 결과는 대체로 같다.

 
async function loadA() {
  return repository.load();
}

async function loadB() {
  return await repository.load();
}
 

하지만 현재 함수의 catch가 rejection을 처리해야 한다면 차이가 생긴다.

 
async function loadUser() {
  try {
    return repository.loadUser();
  } catch (error) {
    throw new Error("사용자를 불러오지 못했습니다.", {
      cause: error,
    });
  }
}
 

repository.loadUser()가 Promise를 반환하고 나중에 reject되면, 현재 try 블록은 해당 rejection을 잡지 못할 수 있다.

반환 시점에는 loadUser()의 동기 실행이 이미 끝났기 때문이다.

 
async function loadUser() {
  try {
    return await repository.loadUser();
  } catch (error) {
    throw new Error("사용자를 불러오지 못했습니다.", {
      cause: error,
    });
  }
}
 

이번에는 rejection이 await 지점에서 throw로 변환된다.

따라서 현재 catch가 오류를 변환할 수 있다.

과거에는 return await이 추가 마이크로태스크를 만든다는 이유로 피하라는 조언이 있었다. 현재 ESLint는 관련 규칙을 폐기했으며, return await이 추가 마이크로태스크를 만들지 않고 더 나은 비동기 스택 추적을 제공할 수 있다고 설명한다.

선택 기준은 다음과 같다.

현재 함수의 catch 또는 finally가 rejection을 처리해야 함
→ return await

단순히 하위 Promise 결과를 전달
→ return 또는 return await 모두 가능

명확한 async stack trace가 중요
→ return await 고려
 

12. async Promise executor가 위험한 이유

다음 코드는 문법적으로 가능하다.

 
const promise = new Promise(async (resolve, reject) => {
  const value = await loadValue();

  resolve(value);
});
 

하지만 구조적으로는 두 개의 Promise가 생긴다.

1. new Promise가 만든 외부 Promise
2. async executor 호출이 반환한 내부 Promise
 

Promise 생성자는 executor의 반환값을 사용하지 않는다.

따라서 다음 코드는 외부 Promise를 reject시키지 못한다.

 
const promise = new Promise(async () => {
  throw new Error("failure");
});
 

실행 구조는 다음과 같다.

async executor 실행
→ 별도의 Promise 반환
→ throw로 내부 Promise reject

외부 Promise
→ resolve도 reject도 호출되지 않음
→ 계속 pending
 

ESLint도 async executor 내부에서 발생한 오류가 새로 생성한 외부 Promise를 reject시키지 못할 수 있다는 이유로 no-async-promise-executor 규칙을 권장한다.

이미 Promise를 반환하는 작업에는 new Promise()가 필요하지 않다.

 
async function load() {
  return await loadValue();
}
 

new Promise()는 callback 기반 API를 Promise로 변환하는 경계에서 주로 사용한다.

 
function readFile(path) {
  return new Promise((resolve, reject) => {
    legacyReadFile(path, (error, data) => {
      if (error) {
        reject(error);
        return;
      }

      resolve(data);
    });
  });
}
 

13. 처리되지 않은 rejection은 시간의 문제이기도 하다

다음 Promise는 생성 즉시 reject된다.

 
const promise = Promise.reject(
  new Error("failure"),
);
 

같은 실행 흐름에서 바로 handler를 등록하면 처리된 rejection으로 관찰된다.

 
promise.catch(handleError);
 

그러나 다음 task에서 늦게 handler를 등록하면 호스트가 먼저 처리되지 않은 rejection으로 보고할 수 있다.

 
const promise = Promise.reject(
  new Error("failure"),
);

setTimeout(() => {
  promise.catch(handleError);
}, 0);
 

unhandled rejection은 단순히 “Promise가 reject되었다”는 상태만으로 결정되지 않는다.

Promise가 reject되었는가?
적절한 시간 안에 handler가 연결되었는가?
호스트가 언제 이를 검사했는가?
 

ECMAScript는 HostPromiseRejectionTracker로 호스트가 rejection 처리 여부를 추적할 수 있게 하며, HTML 명세는 microtask checkpoint와 처리되지 않은 rejected Promise 보고 절차를 연결한다.


마무리

async/await은 Promise를 없애지 않는다.

Promise 위에 실행 컨텍스트 중단과 재개라는 구조를 제공한다.

async 함수
= 호출 시 새로운 Promise를 생성하는 함수

await
= 현재 async 실행을 중단하고
  Promise 반응을 통해 나중에 재개하는 연산
 

이를 이해하면 다음 현상이 자연스럽게 설명된다.

async 함수가 첫 await 전까지 동기 실행되는 이유
await 42도 실행 순서를 바꾸는 이유
rejection을 try/catch로 잡을 수 있는 이유
Promise 체인 사이에 다른 microtask가 들어오는 이유
microtask를 반복해도 렌더링에 양보되지 않을 수 있는 이유
async executor가 외부 Promise를 pending으로 남길 수 있는 이유
 

async/await은 비동기를 동기식 실행으로 바꾸는 문법이 아니다.

비동기 연속성을 사람이 읽기 쉬운 선형 코드로 표현하는 문법이다.

참고 자료

  • ECMA-262, Promise Objects, Async Functions, Await 추상 연산.
  • WHATWG HTML, 이벤트 루프와 microtask checkpoint.
  • ESLint, no-return-await.
  • ESLint, no-async-promise-executor.
반응형