비동기 처리

keemsebeen·2025년 12월 7일

자바스크립트로 개발을 하다 보면 Promise, async/await를 매일 쓰지만, 정작 “안에서 정확히 어떻게 돌아가는지”는 막연하게만 알고 넘어가는 경우가 많았습니다.
특히 에러 처리를 하다 보면, then 안에서 던진 에러는 왜 try-catch로 안 잡히는지, 같은 비동기인데 async/await에서는 왜 잘 잡히는지, 자연스럽게 이런 궁금증이 생깁니다.
이번 글에서는 이런 질문들에 답하기 위해 Promise의 내부 슬롯과 에러 전파 방식을 중심으로 정리해 보았습니다.

비동기 개념

MDN에서는 비동기를 둘 이상의 객체 또는 이벤트가 동시에 존재하지 않거나 발생하지 않는 경우 라고 정의힙니다. 저는 여기서 중요한 키워드가 동시에 라는 키워드라고 생각했습니다. 이를 중점으로 뒷 내용들을 이어나가도록 하겠습니다.

컴퓨팅에서는 비동기를 두가지 맥락에서 사용하고 있습니다.

  1. 네트워크 통신

    비동기 통신은 둘 이상의 통신자 사이에서 메시지를 교환하는 방법으로 메시지를 받음과 동시에 처리할 필요없이 각자 처리할 수 있는 적절한 때에 메시지를 받고 처리하는 방식입니다.

  2. 소프트웨어 설계

    작업이 완료되는걸 프로그램이 기다릴 필요 없이 기존의 작업과 함께 처리되도록 요청할 수 있게 코드를 작성하여 그 개념을 확장합니다.

왜 비동기가 필요하게 됐을까?

현대 웹은 하나의 화면 안에 텍스트, 이미지, 동영상, 애니메이션 등 다양한 정보를 동시에 보여주며 사용자 경험을 극대화합니다.

예를 들어 로딩스패너에 0.2초, 텍스트 노출에 0.2초, 다량의 이미지 총 5초, 비디오에 7초라는 시간이 걸린다고 가정해보겠습니다. 비동기가 없다면, 비디오 데이터를 가져오는 7초 동안 화면 전체가 멈춰 있을 것입니다. 사용자는 아무 반응이 없는 화면을 보게 되고, 이를 ‘오류’로 인식하여 이탈할 가능성이 커집니다.

자바스크립트에서 비동기를 처리하는 방법

Promise

Promise는 지연된(그리고 아마도 비동기적인) 연산의 최종 결과를 위한 플레이스홀더로 사용되는 객체입니다.

Promise는 내부적으로 [[PromiseState]], [[PromiseResult]], [[PromiseFulfillReactions]], [[PromiseRejectReactions]], [[PromiseIsHandled]] 5가지 슬롯을 갖고 있고, 서로 협력하여 비동기 작업을 관리합니다. 이번에는 각각의 슬롯들이 실제 코드와 어떻게 연결되는지, PromiseReaction이 어떤 역할을 하는지, 그리고 마지막으로 Promise.resolve vs new Promise 차이까지 이어서 보겠습니다.

Promise 생성과 초기 상태

const promise = new Promise((resolve, reject) => {
  setTimeout(() => resolve(42), 1000);
});
  1. Promise가 생성되는 순간, 내부 슬롯들이 다음과 같이 초기화됩니다.
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)   
);
  1. Promise.then(onFulfilled, onRejected) 호출 시 프로미스는 아직 pending이므로, 바로 콜백을 실행하지 않고 두 개의 PromiseReaction 레코드를 만듭니다.

    (PromiseReaction은 Promise가 settled될 때 무엇을 실행할지를 기록해두는 객체입니다.)

  • fulfillReaction: 성공했을 때 실행할 내용
    {
      [[Capability]]: {
        [[Promise]]: 새로운 Promise (then이 반환할 promise),
        [[Resolve]]: resolve 함수,
        [[Reject]]: reject 함수
      },
      [[Type]]: "fulfill",
      [[Handler]]: value => console.log('성공:', value)  // 실제 콜백
    }
    
    promise.[[PromiseFulfillReactions]] = [fulfillReaction]
  • rejectReaction: 실패했을 때 실행할 내용
    {
      [[Capability]]: {
        [[Promise]]: 새로운 Promise,
        [[Resolve]]: resolve 함수,
        [[Reject]]: reject 함수
      },
      [[Type]]: "reject",
      [[Handler]]: error => console.log('실패:', error)
    }
    
    promise.[[PromiseRejectReactions]] = [rejectReaction]
  1. 나중에 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
  2. 이벤트 루프가 돌면서 마이크로태스크 큐를 처리할 때 각 Reaction Job이 실행되고, 내부에 [[Handler]]를 호출하고 그 결과에 따라 [[Capability]]의 [[Resolve]] / [[Reject]]를 호출해 새 Promise(then이 반환한 값)를 resolve/reject합니다.

[[PromiseState]] - Promise의 현재 상태

Promise가 현재 어떤 상태인지를 나타내는 값입니다. pending, fulfilled, rejected 상태를 갖고 있습니다.

then의 호출될 때 어떤 상태를 갖고 있냐에 따라 내부 처리 방식이 달라집니다.

  1. pending 상태일 때 then을 호출할 경우

    지금은 결과가 없으니, 나중에 상태가 바뀌면 실행해야 할 reaction 을 [[PromiseFulfillReactions]] 혹은 [[PromiseRejectReactions]] 리스트에 등록만 해두고 즉시 실행하지는 않습니다.

  2. fulfilled, rejected 상태일 때 then을 호출할 경우

    이미 결과가 있으니, 마이크로태스크 큐에 잡을 넣어서 onFulfilled 또는 onRejected를 큐에 등록해 비동기로 실행을 예약합니다.

    따라서, 이미 끝난 Promise에 then을 붙여도 잘 작동하는 이유는, 상태를 보고 이미 settled됐다면 등록된 핸들러를 바로 큐에 넣어주기 때문입니다.

비동기의 에러 전파 (콜백 방식 vs async/await 방식)

비동기 코드에서 에러가 어디까지 전파되느냐는 비동기 작업이 언제, 어떤 실행 컨텍스트에서 수행되느냐에 따라 달라집니다. 이를 이해하기 위해 먼저 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 종료 ===');

  1. try-catch 블록 내부에서 Promise.then()을 마이크로 태스큐에 등록
  2. then 콜백은 즉시 실행되지 않음
  3. try-catch 문은 계속 진행되고 종료됨
  4. 현재 콜스택이 모두 끝난 후
  5. 마이크로태스크 큐에 있던 then 콜백이 다른 콜스택에서 실행됨
  6. 이 시점에는 try-catch 컨텍스트가 이미 사라졌음. 따라서 throw가 발생해도 catch에서 잡을 수 없음

then() 콜백은 현재 콜스택이 끝난 뒤, 새로운 마이크로태스크에서 실행되기 때문에 이미 try/catch 블록이 종료된 상태다. (then 내부에서 던진 에러는 try/catch가 아닌 .catch()에서 잡힙니다~!)

async/await

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();

  1. outer() 실행 → 콜스택에서 동기 코드 실행
  2. await inner()에서 함수가 일시 정지
  3. 하지만 현재 실행 컨텍스트(try-catch 정보)는 저장됨
  4. inner()에서 throw 발생 → Promise rejected
  5. 이어서 저장된 outer의 컨텍스트로 돌아와 재개
  6. catch 블록으로 점프

따라서 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 rejectioncatch 가능
내부 구현.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

profile
프론트엔드 공부 중인 김세빈입니다. 👩🏻‍💻

0개의 댓글