개발자 홍창기

Software Developer

async/await 실행 순서가 Node.js 버전마다 달랐던 이유

작성 · 수정

V8 블로그의 예제를 버전별로 실행하고, 관찰한 결과와 JavaScript 명세 변경의 관계를 확인한 기록입니다.

같은 예제에서 다른 결과를 발견하다

2023년 JavaScript의 Promise와 async/await를 공부하며 발표를 준비하던 중, V8 블로그의 Faster async functions and promises를 읽었다. 2018년에 작성된 이 글은 async 함수의 최적화 과정과 실행 순서를 다룬다.

다음 예제에서 설명과 실제 실행 결과가 달라 의문이 생겼다.

const p = Promise.resolve()

;(async () => {
  await p
  console.log('after: await')
})()

p.then(() => console.log('tick: a')).then(() => console.log('tick: b'))

당시 사용하던 Node.js 20에서는 다음 순서로 출력됐다.

after: await
tick: a
tick: b

원문은 Node.js 8의 위 결과를 당시 명세에 맞지 않는 동작으로 설명하고, Node.js 10의 다음 결과를 올바른 동작으로 소개했다.

tick: a
tick: b
after: await

처음에는 문서의 오류라고 생각했다. 그러나 실행 결과만으로 문서가 작성될 당시의 명세까지 판단할 수는 없었다.

버전별 실행 결과와 그 의미

당시 Node.js 8, 10, 11, 12, 18, 20에서 예제를 실행하고, Chrome 콘솔에서도 결과를 확인했다. 문의 이슈에 기록한 결과는 다음과 같다.

Node.js 버전출력 순서
8after: await → tick: a → tick: b
10·11tick: a → tick: b → after: await
12·18·20after: await → tick: a → tick: b

이 표는 2024년 조사 당시의 관찰 기록이다. 원인은 이후 받은 유지보수자의 답변을 통해 확인했다.

  • Node.js 8까지의 동작은 당시 JavaScript 명세에 맞지 않았다.
  • Node.js 10에서는 당시 명세를 따르도록 수정해 실행 순서가 달라졌다.
  • 동시에 V8 팀이 명세 변경을 제안했고, 이 제안이 받아들여지면서 Node.js 12에서는 이전과 같은 출력 순서가 다시 올바른 동작이 됐다.

즉, Node.js 8과 12에서 같은 결과가 나왔어도 명세와의 관계는 달랐다. 원문의 설명을 이해하려면 작성 시점과 명세 변경의 맥락을 함께 봐야 했다.

네이티브 Promise 예제의 실행 흐름

현재 동작을 이해할 때는 await 이후의 실행 재개와 Promise 체이닝을 구분해야 한다. 다음 설명은 위 예제처럼 이미 이행된 네이티브 Promise를 사용하는 경우를 다룬다.

  1. p는 이미 이행된 Promise다. async 함수가 await p를 만나면 함수의 실행을 중단하고, 이후 실행을 재개할 작업을 예약한다. 이미 이행된 값이어도 await 다음 문장이 즉시 실행되지는 않는다.
  2. 호출한 쪽의 동기 코드가 계속 실행된다. 첫 번째 p.then(...)은 tick: a를 출력할 콜백을 예약하고, 새로운 Promise를 반환한다.
  3. 두 번째 .then(...)은 그 새로운 Promise에 연결된다. 이 Promise는 아직 대기 상태이므로, tick: b 콜백은 이 시점에 실행 가능한 작업으로 큐에 들어가지 않는다.
  4. 동기 코드가 끝나면 먼저 예약된 async 함수의 실행 재개 작업이 처리되어 after: await이 출력된다.
  5. 첫 번째 .then() 콜백이 실행되어 tick: a를 출력한다. 반환된 Promise가 이행되면서 두 번째 콜백이 예약되고, 이후 tick: b가 출력된다.

따라서 세 출력이 처음부터 한꺼번에 큐에 들어간다고 설명하면 Promise 체이닝의 동작을 놓치게 된다. async 함수가 호출 스택에 즉시 복귀한 뒤 나머지 마이크로태스크를 처리하는 것처럼 설명하는 것도 정확하지 않다. 실행 재개 자체가 마이크로태스크 처리 과정에서 일어난다.

이 흐름은 MDN의 await 설명과 then의 반환값 및 체이닝 설명을 함께 참고했다.

관찰과 원인 설명을 구분하기

이번 조사에서 얻은 교훈은 실행 결과가 같아도 그 이유까지 같다고 볼 수 없다는 것이다. 오래된 기술 자료를 읽을 때는 실행 환경뿐 아니라 명세의 적용 시점도 확인해야 한다.

의문을 재현 가능한 예제로 정리해 질문한 덕분에, 단순한 출력 순서 비교에서 명세 변경의 배경까지 이해할 수 있었다. 이 문의를 바탕으로 원문에 안내를 추가한 과정은 V8 블로그의 async/await 설명을 보완한 과정에 이어서 정리했다.