Promise Resolution Procedure

작성일:2026.06.11|수정일:2026.06.11|조회수:48

Promise Resolution Procedure

async function에서 Promise를 반환하면 조금 이상한 일이 일어난다. 아래의 코드를 보면 async function은 원래 Promise를 반환하고, 그 안에서 다시 Promise.resolve(1)을 반환했으니 결과가 Promise<Promise<number>>처럼 남아야 할 것 같다. 하지만 실제로는 다음 then이 Promise 객체를 받지 않는다. 그냥 1을 받는다.

JS
async function getNumber() {
  return Promise.resolve(1)
}

getNumber().then(console.log)
// 1

비슷한 장면은 더 짧게도 만들 수 있다.

JS
Promise.resolve(Promise.resolve(1)).then(console.log)
// 1

Promise가 값을 담는 상자라면, 이 코드는 상자 안에 상자가 들어 있는 모양이어야 한다. 그런데 JavaScript의 Promise는 그렇게 행동하지 않는다. Promise는 중첩된 Promise를 보관하지 않고, 안쪽 Promise의 상태를 따라간다. 이 동작을 설명하는 규칙이 ECMAScript 표준의 Promise Resolution Procedure다. 이름은 조금 거창하지만, 출발점은 단순하다.

resolvefulfill이 아니다.

Promise는 값을 하나 들고 있는 객체가 아니다

Promise를 처음 배울 때는 pending, fulfilled, rejected 세 상태로 설명한다. 이 설명은 틀리지 않지만, Promise가 내부적으로 무엇을 들고 있는지까지 보여주지는 않는다. ECMAScript 표준에서 Promise 객체는 몇 가지 내부 슬롯을 가진다.

대표적으로 다음 슬롯들이 있다.

내부 슬롯의미
[[PromiseState]]pending, fulfilled, rejected 중 하나
[[PromiseResult]]fulfilled 값 또는 rejected 이유
[[PromiseFulfillReactions]]fulfilled 되었을 때 실행할 reaction 목록
[[PromiseRejectReactions]]rejected 되었을 때 실행할 reaction 목록
[[PromiseIsHandled]]rejection이 처리되었는지 추적하는 플래그

pending 상태의 Promise는 아직 결과를 갖지 않는다. 대신 나중에 상태가 바뀌었을 때 실행할 reaction들을 모아 둔다. 우리가 .then()이나 .catch()를 붙이면, Promise가 이미 settled 되었는지에 따라 바로 Job을 예약하거나, 아직 pending이면 reaction 목록에 등록한다.

여기서 Promise를 “값 상자”로만 이해하면 곧 막힌다. Promise는 값 하나를 보관하는 객체라기보다, 아직 끝나지 않은 계산과 그 계산을 기다리는 reaction들을 묶어 둔 상태 기계에 가깝다. 값은 마지막에 들어온다. 그전까지 Promise는 기다리는 쪽과 끝내는 쪽 사이의 약속을 보관한다. 이름값을 하기는 한다.

resolve는 바로 값을 넣지 않는다

Promise 생성자 안에서 우리는 보통 resolve를 성공 함수처럼 부른다.

JS
const promise = new Promise((resolve) => {
  resolve(1)
})

이 코드에서는 결과적으로 Promise가 fulfilled 된다. 그래서 resolvefulfill을 같은 말처럼 느끼기 쉽다. 하지만 표준에서 resolve 함수가 하는 일은 더 복잡하다. CreateResolvingFunctions가 만드는 resolve function은 대략 다음 흐름을 가진다.

JS
function resolve(resolution) {
  if (alreadyResolved.value) return
  alreadyResolved.value = true

  if (resolution === promise) {
    reject(new TypeError('A promise cannot resolve to itself'))
    return
  }

  if (resolution === null || typeof resolution !== 'object' && typeof resolution !== 'function') {
    fulfill(resolution)
    return
  }

  let then

  try {
    then = resolution.then
  } catch (error) {
    reject(error)
    return
  }

  if (typeof then !== 'function') {
    fulfill(resolution)
    return
  }

  enqueuePromiseResolveThenableJob(promise, resolution, then)
}

실제 표준 알고리즘은 JavaScript 코드가 아니고, 세부 단계도 더 엄격하다. 하지만 중요한 갈림길은 이 정도로 볼 수 있다.

resolve는 값을 받자마자 무조건 fulfilled 상태로 만들지 않는다. 먼저 자기 자신으로 resolve하려는지 확인한다. 그다음 받은 값이 객체나 함수인지 본다. 객체라면 then 프로퍼티를 읽고, 그 값이 callable한지 확인한다. callable한 then이 있으면 그 객체를 일반 값으로 취급하지 않고 thenable로 취급한다.

이 순간 fulfill로 바로 가지 않는다. 표준은 NewPromiseResolveThenableJob이라는 Promise Job을 만들어 나중에 그 then을 호출하도록 예약한다.

그래서 resolve(Promise.resolve(1))는 “Promise 객체를 결과값으로 넣는다”가 아니다. “이 Promise의 결과를 저 Promise가 끝나는 방식에 맞춘다”에 가깝다.

fulfill은 진짜로 상태를 바꾼다

fulfillresolve보다 훨씬 직접적이다. 표준의 FulfillPromise는 Promise의 상태를 fulfilled로 바꾸고, [[PromiseResult]]에 값을 기록한 뒤, fulfillment reaction들을 실행하도록 트리거한다.

TXT
[[PromiseState]]  → fulfilled
[[PromiseResult]] → value

반대로 RejectPromise는 상태를 rejected로 바꾸고, [[PromiseResult]]에 rejection reason을 기록한다.

TXT
[[PromiseState]]  → rejected
[[PromiseResult]] → reason

이 둘은 최종 상태를 확정하는 작업이다. 한 번 fulfilled 또는 rejected 된 Promise는 다시 바뀌지 않는다. 그래서 resolve function 내부에는 [[AlreadyResolved]]에 해당하는 기록이 있다. 같은 resolve나 reject가 여러 번 호출되어도 첫 번째 호출만 의미가 있다.

JS
const promise = new Promise((resolve, reject) => {
  resolve(1)
  resolve(2)
  reject(new Error('fail'))
})

promise.then(console.log)
// 1

이 코드는 resolve(1) 이후의 호출들이 무시된다. Promise가 한 번 운명을 정하면, 뒤늦게 도착한 결정권자는 할 일이 없다. 회의가 끝난 뒤 회의실에 들어온 사람 같은 것이다.

resolved인데 pending일 수 있다

여기서 헷갈리는 말이 하나 더 나온다. resolved와 fulfilled는 같은 말이 아니다. fulfilled는 [[PromiseState]]가 실제로 fulfilled가 된 상태다. 반면 resolved는 더 넓은 말이다. Promise가 이미 settled 되었거나, 다른 Promise나 thenable의 상태를 따르기로 잠긴 상태까지 포함한다.

다음 코드를 보자.

JS
let resolveOuter

const inner = new Promise((resolve) => {
  setTimeout(() => resolve(1), 1000)
})

const outer = new Promise((resolve) => {
  resolveOuter = resolve
})

resolveOuter(inner)
resolveOuter(2)

outer.then(console.log)
// 1초 뒤 1

resolveOuter(inner)를 호출한 순간 outer는 이미 inner를 따르기로 결정했다. 그래서 바로 이어서 resolveOuter(2)를 호출해도 결과는 바뀌지 않는다. 하지만 inner는 아직 pending이므로 outer도 사용자 관점에서는 아직 fulfilled 되지 않았다.

이 상태를 말로 풀면 조금 어색하다.

outer는 resolved 되었지만 아직 fulfilled 되지 않았다.

이 문장이 Promise를 이해하는 데 꽤 중요하다. resolve는 완료 상태를 뜻하지 않는다. 더 정확히는 “이 Promise의 운명을 확정하는 절차”다. 그 운명이 다른 Promise에 묶여 있다면, 최종 결과는 아직 미래에 남아 있을 수 있다.

thenable은 Promise가 아니어도 흡수된다

Promise Resolution Procedure가 흥미로운 이유는 native Promise만 다루지 않는다는 데 있다. 표준은 값이 진짜 Promise인지보다, 그 값에서 가져온 then이 callable한지를 본다.

JS
const thenable = {
  then(resolve) {
    resolve(123)
  }
}

Promise.resolve(thenable).then(console.log)
// 123

이 객체는 Promise 인스턴스가 아니다. [[PromiseState]] 같은 내부 슬롯도 없다. 하지만 callable한 then 메서드를 가지고 있기 때문에 Promise-like 객체, 즉 thenable로 취급된다.

표준에는 IsPromise라는 추상 연산도 있다. 이것은 객체가 [[PromiseState]] 내부 슬롯을 가진 진짜 Promise인지 확인한다. 하지만 resolve function의 핵심 경로는 “Promise 브랜드 검사”만으로 끝나지 않는다. 객체의 then을 읽고, callable하면 thenable assimilation 경로로 들어간다.

이 설계 덕분에 native Promise가 아닌 라이브러리의 Promise-like 객체도 JavaScript Promise 체인 안으로 들어올 수 있다. 그만큼 조심할 점도 있다. then은 그냥 프로퍼티 접근이다. getter가 있을 수도 있고, 접근 중 예외가 날 수도 있다.

JS
const broken = {
  get then() {
    throw new Error('then을 보려면 통행료를 내야 합니다')
  }
}

Promise.resolve(broken).catch((error) => {
  console.log(error.message)
})
// then을 보려면 통행료를 내야 합니다

표준의 resolve function은 then을 가져오다 예외가 발생하면 Promise를 reject한다. Promise가 thenable을 받아들이는 친절함은 공짜가 아니다. 친절한 코드는 보통 이상한 사람도 문 앞까지 들인다.

thenable 처리는 즉시 실행되지 않는다

thenable을 발견했다고 해서 그 자리에서 바로 then을 호출하는 것도 아니다. 표준은 NewPromiseResolveThenableJob을 만들고, 이 Job을 enqueue한다. 우리가 흔히 microtask queue라고 부르는 흐름에 올라간다고 이해하면 된다.

정확히 말하면 ECMAScript 표준 용어는 microtask가 아니라 Job, 그중에서도 Promise Job이다. 브라우저나 Node.js 같은 host 환경에서는 이 Promise Job들이 microtask queue와 연결되어 설명되는 경우가 많다. 실무에서는 microtask라고 불러도 대체로 통하지만, 표준 문서를 읽을 때는 용어가 조금 다르다는 점을 알고 있는 편이 좋다.

JS
const thenable = {
  then(resolve) {
    console.log('thenable then')
    resolve(1)
  }
}

Promise.resolve(thenable).then((value) => {
  console.log('resolved', value)
})

console.log('sync')

이 코드의 출력은 다음과 같다.

TXT
sync
thenable then
resolved 1

thenable의 then 호출은 현재 실행 중인 동기 코드가 끝난 뒤 Promise Job으로 처리된다. 그리고 그 결과로 Promise가 fulfilled 되면, 등록된 reaction 역시 Promise Job으로 실행된다. 이 비동기 경계는 Promise의 일관성을 지키는 데 중요하다. 사용자가 넘긴 thenable이 어떤 식으로 구현되어 있든, Promise 체인은 현재 콜스택 중간에 갑자기 끼어들지 않는다.

.then()이 평평해지는 이유

Promise Resolution Procedure가 없었다면 .then() 체이닝은 지금보다 훨씬 불편했을 것이다.

JS
Promise.resolve(1)
  .then(() => Promise.resolve(2))
  .then((value) => {
    console.log(value)
  })

이 코드는 2를 출력한다. 두 번째 .then()valuePromise.resolve(2)가 아니다. 안쪽 Promise의 fulfillment value인 2다.

.then()은 호출될 때 새 Promise를 만든다. 그리고 handler가 반환한 값을 이용해 그 새 Promise의 상태를 정한다. handler가 일반 값을 반환하면 그 값으로 fulfilled 된다. handler가 Promise나 thenable을 반환하면, 새 Promise는 그 Promise나 thenable의 상태를 따라간다.

개념적으로는 이런 흐름이다.

JS
const nextPromise = new Promise((resolve, reject) => {
  try {
    const result = handler(value)
    resolve(result)
  } catch (error) {
    reject(error)
  }
})

여기서 resolve(result)가 바로 Promise Resolution Procedure를 탄다. 그래서 handler가 Promise를 반환해도 다음 체인에는 중첩된 Promise가 넘어가지 않는다. 체인은 계속 평평하게 이어진다. 이 규칙이 없었다면 Promise 코드는 금방 아래와 같은 계단이 되었을 것이다. Promise는 callback hell을 없애려고 왔는데, 자기 안에 Promise hell을 만들었다면 조금 민망했을 것이다.

JS
Promise.resolve(1).then(() => {
  return Promise.resolve(2).then((value) => {
    return value
  })
})

async function은 Promise를 그대로 돌려주지 않는다

async function에서도 같은 규칙이 작동한다. 다만 여기에는 하나 더 볼 만한 점이 있다.

JS
const inner = Promise.resolve(1)

async function getInner() {
  return inner
}

console.log(getInner() === inner)
// false

getInner()inner를 그대로 반환하지 않는다. async function은 호출될 때 자기 자신이 반환할 Promise를 새로 만든다. ECMAScript의 EvaluateAsyncFunctionBody 흐름에서는 NewPromiseCapability(%Promise%)를 만들고, 함수 본문 실행을 시작한 뒤, 그 capability의 Promise를 반환한다.

함수 본문이 return inner로 끝나면, 그 반환값이 바깥 Promise의 resolve 경로로 들어간다. 그리고 inner는 thenable이므로 바깥 Promise가 inner의 상태를 따른다. 때문에 두 Promise는 같은 객체가 아닌 것이다.

TXT
getInner() !== inner

하지만 상태는 연결된다.

inner가 fulfilled(1) 되면 getInner()가 반환한 Promise도 fulfilled(1) 된다.

inner가 rejected(error) 되면 getInner()가 반환한 Promise도 rejected(error) 된다.

그래서 다음 코드는 rejected Promise를 값으로 fulfilled 하는 것이 아니라, 바깥 Promise 자체가 rejected 된다.

JS
async function fail() {
  return Promise.reject(new Error('fail'))
}

fail().catch((error) => {
  console.log(error.message)
})
// fail

이 동작을 “async 함수가 Promise를 펼친다”고 말할 수는 있다. 다만 더 정확히는 async 함수가 만든 바깥 Promise가 반환값을 resolve하면서, Promise Resolution Procedure에 의해 반환된 Promise의 상태를 adopt한다고 보는 편이 좋다.

Promise는 박스보다 연결에 가깝다

Promise를 Promise<T>라는 타입 모양으로 자주 보다 보면, Promise를 값이 담긴 컨테이너처럼 생각하게 된다. 물론 타입 수준에서는 꽤 유용한 직관이다. 하지만 런타임에서 Promise는 단순한 박스가 아니다.

Promise에는 상태가 있고, 결과가 있고, reaction 목록이 있다. resolve function은 값을 바로 넣는 대신, 그 값이 thenable인지 확인한다. thenable이면 Job을 예약해 그 객체의 then을 호출하고, 그 결과에 따라 바깥 Promise의 상태를 정한다. fulfilled와 rejected는 최종 상태지만, resolved는 그 최종 상태에 도달하는 운명이 확정되었다는 말에 더 가깝다.

그래서 다음 문장이 이 글의 거의 전부다.

resolve(promise)는 “promise를 값으로 넣어라”가 아니라, “내 운명을 저 promise에 연결하라”에 가깝다.

이 관점으로 보면 Promise.resolve(Promise.resolve(1))이 왜 1로 이어지는지, .then() 체인이 왜 중첩되지 않는지, async function에서 return Promise.reject(error)가 왜 바깥 Promise를 reject시키는지 같은 장면들이 한 줄로 연결된다.

Promise Resolution Procedure는 Promise를 평평하게 만드는 친절한 규칙이면서, JavaScript가 외부 thenable과 공존하기 위해 마련한 연결 장치다. Promise를 값 상자로만 보면 이상해 보이던 동작들이, 운명을 연결하는 장치로 보면 꽤 일관된 설계로 보인다.

물론 이름은 여전히 딱딱하다. Promise Resolution Procedure라니, 주말에 읽고 싶은 제목은 아니다. 그런데 막상 뜯어보면 이 절차 덕분에 우리가 매일 쓰는 then, catch, async, await가 지금처럼 덜 삐걱거리며 이어진다. JavaScript가 가끔 이상해 보여도, 적어도 이 부분에서는 꽤 성실하게 약속을 지키고 있다.

댓글

댓글을 불러오는 중...