[JS] Promise 객체: ECMAScript 명세서를 통해 이해하기

soleil_lucy·2025년 12월 27일

글을 정리하게 된 계기

강의를 통해 Promise객체를 사용하여 자바스크립트의 비동기 처리를 할 수 있다는 것을 배웠습니다. Promise객체가 "비동기 처리를 도와준다", "콜백 지옥을 해결해 준다"는 이론은 이해했지만, 막상 Promise로 짜인 복잡한 코드를 마주하면 겁부터 나는 제 자신을 발견하곤 했습니다.

돌이켜보면 동작 원리를 제대로 이해하지 못한 채 사용하다 발생한 버그를 스스로 해결하지 못했던 기억 때문이었습니다. 그래서 이번 기회에 Promise를 제대로 정리하며, 그 막연한 두려움을 극복해보고자 합니다.

정의

먼저 자바스크립트의 설계도인 ECMAScript 명세서(ECMA-262)에서는 Promise를 어떻게 정의하고 있는지 살펴보겠습니다.

27.2 Promise Objects

A Promise is an object that is used as a placeholder for the eventual results of a deferred (and possibly asynchronous) computation.

[번역] 27.2 Promise Objects

Promise는 지연된(그리고 아마도 비동기적인) 계산의 최종 결과를 담기 위한 자리 표시자(placeholder) 역할을 하는 객체입니다.

말이 어렵게 되어 있어 이해하기가 쉽지 않습니다. 쉽게 해석하자면 Promise는 "아직 값이 도착하지 않았지만, 나중에 결과(성공 또는 실패)가 오면 채워 넣을 빈 그릇"이라고 이해할 수 있습니다.

우리가 자바스크립트 엔진에게 이렇게 부탁하는 것과 같습니다.

“이 작업은 시간이 좀 걸리니까, 결과가 나오면 이 객체(Promise)에 담아줘”

ECMAScript 명세서에서 살펴 본 Promise

명세서의 27.2 Promise Objects 부분을 보면 우리가 평소에 무심코 사용하던 Promise의 내부 동작이 상세히 기술되어 있습니다.

Promise의 3가지 상태 (States)

Promise 객체는 생성된 순간부터 소멸할 때까지 반드시 다음 세 가지 상태 중 하나를 가집니다.

  • pending (대기): 아직 이행되거나 거부되지 않은 초기 상태입니다.
  • fulfilled (이행): 비동기 연산이 성공적으로 완료된 상태입니다.
  • rejected (거부): 비동기 연산이 실패한 상태입니다.

이 중 이행(fulfilled) 또는 거부(rejected)된 상태를 합쳐서 결정된(settled) 상태라고 부릅니다.

생성과 결정 (Constructor & Resolving)

우리는 new Promise(executor)를 통해 Promise를 만듭니다. 이때 전달하는 executor 함수는 엔진에 의해 즉시 호출되며, resolve와 reject라는 두 가지 함수를 인자로 받습니다.

new Promise((resolve, reject) => {
    // 비동기 작업 수행...
    const isSuccess = true;

    if (isSuccess) {
        // 2. resolve 호출 -> [[PromiseState]]가 'fulfilled'로 변경
        resolve("성공 결과 값");
    } else {
        // 3. reject 호출 -> [[PromiseState]]가 'rejected'로 변경
        reject("실패 사유");
    }
});
  • resolve(value)를 호출하면 Promise의 [[PromiseState]]fulfilled가 되고 결과값이 저장됩니다.
  • reject(reason)를 호출하면 상태는 rejected가 되며 실패 이유가 저장됩니다.

내부 동작 (Reaction & Job)

Promise가 비동기 작업을 처리하는 객체라는 점은 명세서의 Reaction(반응)Job(작업) 시스템을 설명한 부분에서 알 수 있습니다.

Reaction 등록 (예약)

우리가 .then()이나 .catch()를 호출하면, 자바스크립트 엔진은 당장 코드를 실행하지 않습니다. 대신 PromiseReaction Record라는 기록을 만들어 내부 리스트(슬롯)에 저장해 둡니다. 일종의 "대기표 발권"입니다.

Job 예약 (큐 등록)

비동기 작업이 끝나고 resolve()가 호출되면, 엔진은 저장해 두었던 Reaction들을 꺼냅니다. 그리고 이를 NewPromiseReactionJob이라는 작업 단위로 변환하여 마이크로태스크 큐(Microtask Queue)에 집어 넣습니다.

실행 (Call Stack 비우기)

큐에 들어간 Job들은 현재 실행 중인 코드(Call Stack)가 모두 끝난 뒤에야 비로소 실행됩니다. 이 명세에 따라, then()으로 등록된 후속 작업은 현재 실행 중인 코드가 모두 종료된 후에야 비동기적으로 처리됩니다.

이해를 돕기 위한 비유: 배달 앱 주문

우리가 흔히 사용하는 배달 앱을 예로 들어보겠습니다.

상황: 당신은 짬뽕이 너무 먹고 싶어서 배달 앱으로 주문을 넣었습니다.

  1. 주문 완료 (new Promise & Pending)
    • 주문 버튼을 누르는 순간, 앱은 "주문 접수 대기 중" 또는 "조리 중" 상태가 됩니다.
    • 아직 짬뽕(결과 값)은 내 손에 없지만, 앱 화면(Promise 객체)을 통해 주문이 진행되고 있다는 것을 알 수 있습니다. 이것이 바로 대기(Pending) 상태입니다.
  2. 배달 도착 (Fulfilled / Resolve)
    • 조리가 끝나고 라이더가 도착했습니다. "배달이 완료되었습니다"라는 알림과 함께 문 앞에 짬뽕이 놓입니다.
    • 이것이 이행(Fulfilled) 상태입니다. 이제 짬뽕(결과 값, Value)을 맛있게 먹으면 됩니다.
  3. 주문 취소 (Rejected / Reject)
    • 갑자기 앱에서 알림이 뜹니다. "죄송합니다. 재료 소진으로 주문을 취소합니다."
    • 기다렸던 짬뽕은 오지 않았고, 대신 취소 사유(에러 메시지)를 받았습니다. 이것이 거부(Rejected) 상태입니다. 우리는 이 사유를 보고 다른 가게를 찾거나 포기해야 합니다.

실제 활용 사례: GitHub API 데이터 통신

Promise 객체를 활용해 GitHub 사용자 정보를 가져오는 예제입니다. 실무에서는 내장 함수인 fetch를 사용하면 훨씬 간단하지만, 이번에는 Promise의 내부 동작 원리를 확실히 이해하기 위해 직접 구현해봤습니다. 코드는 Gemini에게 도움을 받아 작성했습니다.

코드

코드 보러가기

function getData(url) {
  return new Promise((resolve, reject) => {
    const xhr = new XMLHttpRequest();
    xhr.open("GET", url);

    xhr.onload = () => {
      if (xhr.status === 200) {
        resolve(JSON.parse(xhr.response));
      } else {
        reject(new Error(`요청 실패: ${xhr.status}`));
      }
    };

    xhr.onerror = () => {
      reject(new Error("네트워크 오류 발생"));
    };

    xhr.send();
  });
}

getData("https://api.github.com/users/facebook")
  .then((user) => console.log(`성공: ${user.name}`))
  .catch((err) => console.error(`실패: ${err.message}`));

콘솔 화면

GitHub API 호출 후 화면

코드 해석

  • Executor의 즉시 실행: getData 함수가 호출되는 순간, new Promise의 Executor 함수가 즉시 실행됩니다. 그 안의 xhr.send()가 실행되어 요청이 서버로 날아가고, 이때 Promise 객체는 Pending(대기) 상태가 됩니다.
  • Resolve (성공 처리): 서버로부터 정상적인 응답(HTTP 상태 코드 200)이 오면 resolve(data)를 호출합니다. 이때 Promise는 Fulfilled(이행) 상태가 되고, 우리는 .then()을 통해 그 데이터를 받아볼 수 있습니다.
  • Reject (실패 처리): 서버가 에러를 반환하거나(HTTP 상태 코드 404 등), 네트워크 연결이 끊기는 등의 문제(onerror)가 발생하면 reject(Error)를 호출합니다. 이때 Promise는 Rejected(거부) 상태가 되고, .catch()가 실행되어 에러를 처리하게 됩니다.

회고

이번 포스팅을 정리하며 Promise는 비동기 처리를 위한 객체임을 이해하게 되었습니다. "Job이 등록되고 호출 스택이 모두 비워진 뒤, 마이크로태스크 큐를 통해 실행된다"는 동작 원리를 알게 되었습니다. 이제 Promise로 짜인 복잡한 코드를 마주해도 더 이상 겁먹지 않고 그 내부 흐름을 명확히 읽어낼 수 있을 것 같습니다.

이번 학습 과정에서 Notebook LM의 도움을 받았습니다. ECMAScript 명세서를 소스로 등록해 필요한 내용만 빠르게 찾아냄으로써 학습 효율을 높일 수 있었습니다. 앞으로도 명세서를 분석할 때 종종 활용하게 될 것 같습니다.

참고 자료

profile
여행과 책을 좋아하는 개발자입니다.

0개의 댓글