자바스크립트로 개발을 하다 보면 Promise, async/await를 매일 쓰지만, 정작 “안에서 정확히 어떻게 돌아가는지”는 막연하게만 알고 넘어가는 경우가 많았습니다.
특히 에러 처리를 하다 보면, then 안에서 던진 에러는 왜 try-catch로 안 잡히는지, 같은 비동기인데 async/await에서는 왜 잘 잡히는지, 자연스럽게 이런 궁금증이 생깁니다.
이번 글에서는 이런 질문들에 답하기 위해 Promise의 내부 슬롯과 에러 전파 방식을 중심으로 정리해 보았습니다.
MDN에서는 비동기를 둘 이상의 객체 또는 이벤트가 동시에 존재하지 않거나 발생하지 않는 경우 라고 정의힙니다. 저는 여기서 중요한 키워드가 동시에 라는 키워드라고 생각했습니다. 이를 중점으로 뒷 내용들을 이어나가도록 하겠습니다.
컴퓨팅에서는 비동기를 두가지 맥락에서 사용하고 있습니다.
네트워크 통신
비동기 통신은 둘 이상의 통신자 사이에서 메시지를 교환하는 방법으로 메시지를 받음과 동시에 처리할 필요없이 각자 처리할 수 있는 적절한 때에 메시지를 받고 처리하는 방식입니다.
소프트웨어 설계
작업이 완료되는걸 프로그램이 기다릴 필요 없이 기존의 작업과 함께 처리되도록 요청할 수 있게 코드를 작성하여 그 개념을 확장합니다.
현대 웹은 하나의 화면 안에 텍스트, 이미지, 동영상, 애니메이션 등 다양한 정보를 동시에 보여주며 사용자 경험을 극대화합니다.
예를 들어 로딩스패너에 0.2초, 텍스트 노출에 0.2초, 다량의 이미지 총 5초, 비디오에 7초라는 시간이 걸린다고 가정해보겠습니다. 비동기가 없다면, 비디오 데이터를 가져오는 7초 동안 화면 전체가 멈춰 있을 것입니다. 사용자는 아무 반응이 없는 화면을 보게 되고, 이를 ‘오류’로 인식하여 이탈할 가능성이 커집니다.

Promise는 지연된(그리고 아마도 비동기적인) 연산의 최종 결과를 위한 플레이스홀더로 사용되는 객체입니다.
Promise는 내부적으로 [[PromiseState]], [[PromiseResult]], [[PromiseFulfillReactions]], [[PromiseRejectReactions]], [[PromiseIsHandled]] 5가지 슬롯을 갖고 있고, 서로 협력하여 비동기 작업을 관리합니다. 이번에는 각각의 슬롯들이 실제 코드와 어떻게 연결되는지, PromiseReaction이 어떤 역할을 하는지, 그리고 마지막으로 Promise.resolve vs new Promise 차이까지 이어서 보겠습니다.

const promise = new Promise((resolve, reject) => {
setTimeout(() => resolve(42), 1000);
});
promise.[[PromiseState]] = "pending" // 아직 대기 중
promise.[[PromiseResult]] = undefined // 아직 결과 없음
promise.[[PromiseFulfillReactions]] = [] // 성공 콜백 대기 큐 (빈 배열)
promise.[[PromiseRejectReactions]] = [] // 실패 콜백 대기 큐 (빈 배열)
promise.[[PromiseIsHandled]] = false // 아직 핸들러 없음
const promise = new Promise((resolve) => {
setTimeout(() => resolve(42), 1000);
});
promise.then(
value => console.log('성공:', value),
error => console.log('실패:', error)
);
Promise.then(onFulfilled, onRejected) 호출 시 프로미스는 아직 pending이므로, 바로 콜백을 실행하지 않고 두 개의 PromiseReaction 레코드를 만듭니다.
(PromiseReaction은 Promise가 settled될 때 무엇을 실행할지를 기록해두는 객체입니다.)
{
[[Capability]]: {
[[Promise]]: 새로운 Promise (then이 반환할 promise),
[[Resolve]]: resolve 함수,
[[Reject]]: reject 함수
},
[[Type]]: "fulfill",
[[Handler]]: value => console.log('성공:', value) // 실제 콜백
}
promise.[[PromiseFulfillReactions]] = [fulfillReaction]{
[[Capability]]: {
[[Promise]]: 새로운 Promise,
[[Resolve]]: resolve 함수,
[[Reject]]: reject 함수
},
[[Type]]: "reject",
[[Handler]]: error => console.log('실패:', error)
}
promise.[[PromiseRejectReactions]] = [rejectReaction]나중에 resolve(42)가 호출되면 상태를 [[PromiseState]]를 fulfilled로 바꾸고 [[PromiseResult]]를 42로 설정한 뒤 [[PromiseFulfillReactions]] 리스트에 담긴 각 reaction에 대해 Reactions Job을 만들어 마이크로태스크 큐에 넣습니다.
promise.[[PromiseState]] = "fulfilled"
promise.[[PromiseResult]] = 42
// FulfillPromise 알고리즘 실행
// - [[PromiseFulfillReactions]] 리스트의 각 reaction에 대해
// NewPromiseReactionJob을 생성하여 마이크로태스크 큐에 추가
promise.[[PromiseFulfillReactions]] = undefined
promise.[[PromiseRejectReactions]] = undefined
이벤트 루프가 돌면서 마이크로태스크 큐를 처리할 때 각 Reaction Job이 실행되고, 내부에 [[Handler]]를 호출하고 그 결과에 따라 [[Capability]]의 [[Resolve]] / [[Reject]]를 호출해 새 Promise(then이 반환한 값)를 resolve/reject합니다.
Promise가 현재 어떤 상태인지를 나타내는 값입니다. pending, fulfilled, rejected 상태를 갖고 있습니다.
then의 호출될 때 어떤 상태를 갖고 있냐에 따라 내부 처리 방식이 달라집니다.
pending 상태일 때 then을 호출할 경우
지금은 결과가 없으니, 나중에 상태가 바뀌면 실행해야 할 reaction 을 [[PromiseFulfillReactions]] 혹은 [[PromiseRejectReactions]] 리스트에 등록만 해두고 즉시 실행하지는 않습니다.
fulfilled, rejected 상태일 때 then을 호출할 경우
이미 결과가 있으니, 마이크로태스크 큐에 잡을 넣어서 onFulfilled 또는 onRejected를 큐에 등록해 비동기로 실행을 예약합니다.
따라서, 이미 끝난 Promise에 then을 붙여도 잘 작동하는 이유는, 상태를 보고 이미 settled됐다면 등록된 핸들러를 바로 큐에 넣어주기 때문입니다.
비동기 코드에서 에러가 어디까지 전파되느냐는 비동기 작업이 언제, 어떤 실행 컨텍스트에서 수행되느냐에 따라 달라집니다. 이를 이해하기 위해 먼저 Promise 콜백 방식부터 보겠습니다.
function promiseTest() {
console.log('1. try 시작');
try {
console.log('2. Promise 등록');
Promise.resolve().then(() => {
console.log('5. then 실행 (나중에)');
console.trace('then 콜백의 콜스택');
throw new Error('에러!');
});
console.log('3. try 끝');
} catch (e) {
console.log('여기 안 옴:', e.message);
}
console.log('4. catch도 끝');
}
promiseTest();
console.log('=== promiseTest 종료 ===');

then() 콜백은 현재 콜스택이 끝난 뒤, 새로운 마이크로태스크에서 실행되기 때문에 이미 try/catch 블록이 종료된 상태다. (then 내부에서 던진 에러는 try/catch가 아닌 .catch()에서 잡힙니다~!)
async function outer() {
console.log("outer 시작");
try {
await inner();
} catch (e){
console.log(e)
}
console.log("outer 재개");
}
async function inner() {
console.log("inner 실행");
throw new Error ("ㅎㅇ");
}
outer();

따라서 async/await를 사용할 때도 try-catch는 정상적으로 동작합니다. await에서 한 번 끊겼던 실행 흐름이, 이후 비동기 작업이 완료되면 다시 이어져서 같은 컨텍스트 안에서 재개되기 때문입니다.
학습 초반에는 “async/await는 비동기로 동작하니까 콜스택이 완전히 비워진 상태에서 다시 시작하겠지”라고 생각했는데, 실제로 브라우저의 호출 스택을 확인해 보니 기존 실행 컨텍스트가 그대로 이어지는 형태에 가깝다는 것을 확인했습니다.(gif의 오른 쪽 하단에 호출스택이 있는데, 함수가 계속 쌓이는 걸 볼 수 있습니다.) 따라서, 브라우저가 보여주는 Call Stack은 엔진 내부 동작을 완전히 드러내지 못하고 있다고 생각했습니다.
제 추측으로는 엔진 내부에서는 await 지점 이후의 코드를 continuation(연속)으로 저장해두고, Promise가 settled되면 해당 continuation을 마이크로태스크로 스케줄링하여 기존 실행 컨텍스트를 복원한 상태에서 이어서 실행하지 않을까 생각 중입니다!
그래서 개념적으로는 아래처럼 await inner() 이후에 실행될 두 가지 continuation이 어딘가에 저장되어 있다가,
성공/실패에 따라 선택적으로 호출된다고 생각하고 있습니다.
// await inner() 이후의 continuation을 두 가지 경로로 저장
{
onFulfilled: () => {
console.log("outer 재개"); // inner()가 성공하면 실행할 코드 (try 블록 이후)
},
onRejected: (e) => {
console.log(e); // inner()가 실패하면 실행할 코드 (catch 블록)
console.log("outer 재개");
}
}
| 방식 | then 콜백 방식 | async/await |
|---|---|---|
| 비동기 작업 실행 위치 | 새로운 콜스택에서 실행 | 일시 중단된 기존 콜스택을 다시 이어서 실행 |
| try-catch 범위 | 이미 종료 → 컨텍스트 소멸 | 유지됨 (재개 시 동일 컨텍스트) |
| throw 전파 | catch 불가 → promise rejection | catch 가능 |
| 내부 구현 | .then() 체인 | .then() + 실행 컨텍스트 저장/복귀 |
참고 자료
https://yozm.wishket.com/magazine/detail/3034/
https://jinyisland.kr/post/react-awesome-fetching/
https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/async_function
https://tc39.es/ecma262/multipage/control-abstraction-objects.html#sec-promise-objects