본문 바로가기
프론트엔드 관련/기초

Promise의 구동 원리

by ash9river 2026. 2. 24.

Promise란 무엇인가

우리가 흔히 프론트엔드에서 다루는 비동기는 async/await을 통해 형성된다.

그러나 이 async/await은 단순하게 Promise를 이용하기 편한, syntax sugar에 불과하다.

 

프론트엔드 개발자들은 async/await은 쉽게 사용할 수 있지만, 직접 Promise를 통한 비동기 제어에는 어려움을 겪는다.

단순하게 await 이후를 Promise로 wrap되었다고 이해했다면, 본 글에서는 그 안들 들여다보아 어떻게 unwrap되어 동작하는지 Promise의 원리를 차근차근 파악해보겠다.

 

Promise는 state machine이다

Promise는 단순히 비동기 흐름을 제어하는 도구가 아니다.

엄밀하게 Promise는 state machine이고, 이 state machine이 microtask queue와 연결되어서 동작하는 구조를 갖고 있다.

 

Promise는 pending, fulfilled, rejected의 세가지 상태가 존재하고, 상태는 단 한번만 바뀐다.

           resolve(value)
pending ------------------> fulfilled

           reject(reason)
pending ------------------> rejected

 

 

본 글에서는 상태머신이 무엇인지에 대해 알아보지 않기에 이하의 링크를 첨부한다.

 

Status Machina: Writing arguably better code when you have a field called status (or type)

TLDR; Any object with a status field is a state machines. By making state machines explicit in code you can eliminate bad state and certain…

medium.com

 

Promise의 argument는 resoled시 value이고, reject시 reason이다.

const promise = new Promise((resolve, reject) => {
  // pending

  // ~~ 로직 생략
  
  if(성공) { // fulfilled
      resolve("성공"); // "성공" is value
  }
  else { // rejected
    reject("이유"); // "이유" is reason
  }
});
 

Promise - JavaScript | MDN

 

developer.mozilla.org

 

 

Promise의 상태 transition과 reaction

Promise가 가진 상태의 transition은 단 한번만 일어난다.

상태의 transition이 발생하면, 즉시 해당 상태에 연결된 reaction이 microtask queue에 push된다. (여기서 reaction은 Promise에 연결된 then, catch, finally이다.)

 

아래의 예시를 보자.

const promise = new Promise((resolve, reject) => {
  resolve(1);
});

 

내부 동작은 다음과 같다.

1. Promise 객체 생성(pending)

2. Executor 즉시 실행(Executor란?)

2-1. resolve 호출로 Promise state transition(fulfilled)

3. reaction이 존재하면 microtask queue에 push

4. call stack이 비어있으면 이벤트 루프가 microtask queue를 pop하면서 reaction 실행

 

여기서 중요한 점은 Promise의 Executor는 동기이지만, reaction을 실행하는 과정은 비동기이다.

 

아래의 예시를 보자.

console.log("1. 시작");

const promise = new Promise((resolve, reject) => {
  console.log("2. Promise 내부 실행");

  resolve("3. 성공");
})
  .then((value) => {
    console.log(value);
    // throw new Error("reason"); 보통 흔히 throw 사용하지만 전통적 Promise 기법은 return
    return Promise.reject("4. 실패");
  })
  .catch((reason) => {
    console.log(reason);
  })
  .finally(() => console.log("5. finally 최종"));

console.log("6. 끝");

 

다음의 결과을 예측해보자.

 

async/await에만 익숙하고, Promise에 익숙하지 않은 사람들은 보통 1, 6, 2, 3, 4, 5라 생각하기 쉽다.

하지만 위에서 말했듯이, 비동기 작업의 시작은 동기이고, 그 작업의 결과를 처리하는 과정은 비동기이다.

 

정답은 1, 2, 6, 3, 4, 5

 

resolve를 호출하여, reaction을 등록하는 것까지가 callstack의 동작이고, 등록된 reaction은 microtask queue에 들어간다.

 

그런데 여기서 잘 생각해보면, Promise의 Executor는 동기적으로 실행되는데, 등록된 reaction의 내부 코드는 어떻게 실행되는지 의문이 들 수 있다. 이를 자바스크립트 엔진 수준에서 파악해보자

Promise chain의 비밀: 이벤트 루프와 Execution Context를 통하여 분석

console.log("1. 시작");

const promise = new Promise((resolve, reject) => {
  console.log("2. Promise 내부 실행");

  resolve("3. 성공");
})
  .then((value) => {
    console.log(value);
    // throw new Error("reason"); 보통 흔히 throw 사용하지만 전통적 Promise 기법은 return
    return Promise.reject("4. 실패");
  })
  .catch((reason) => {
    console.log(reason);
  })
  .finally(() => console.log("5. finally 최종"));

console.log("6. 끝");

 

1. 자바스크립트 파일을 실행하면 자바스크립트 엔진은 가장 먼저 Global Execution Context를 생성하고 실행하기 시작한다.

 

2. 동기적으로 코드를 실행하다가 new Promise(executor)를 만나면, Local Execution Context(executor)를 생성하여, call stack에 push한다.

 

3. Local Execution Context(executor)를 실행하여, 내부의 console.log를 실행하고, resolve를 호출하여 reaction을 microtask queue에 push한다.

 

4. call stack에서 Local Execution Context(executor)가 pop되고, console.log("6. 끝")이 실행된 다음에서야 Global Execution Context 또한 pop된다.

 

5. 동기 코드의 실행이 끝나 call stack이 비어있어, 이벤트 루프는 microtask queue의 reaction(then)을 call stack에 push하면서 Local Execution Context(then)을 생성한다.

 

6. Local Execution Context(then)을 실행하면서 다시 microtask queue에 catch를 push한다.

 

7. 이하 반복

 

이를 통해 Promise chain이 한번에 microtask queue로 push되는게 아니라는 것을 알 수 있다.

 

참고로, Global Execution Context가 call stack에서 pop된다고 하여서, 전역 변수 등이 사라지는 것은 아니다. 실행 컨텍스트는 제어의 흐름일 뿐이며, 데이터는 Lexical Environment의 Environment Record에 보존된다.

reaction은 외부의 데이터를 참조할 때에는 Outer Environment 참조를 통하여 탐색한다.

또한, mark and sweep을 통하여 도달가능성이 없는 것들만이 GC의 대상이 된다.

 

Promise chain의 내부 규칙

Promise의 동작 원리를 상세히 분석하였으니, 이제 단순히 내부 규칙을 알아보자.

1. return 값은 다음 Promise의 resolve 값이 된다.

Promise.resolve(1)
  .then(value => value + 1) // 2
  .then(value => console.log(value));

 

 

2. return Promise는 flatten된다

 

.then 내부에서 자바스크립트 엔진이 return Promise를 자동으로 감지하여 평탄화시킨다.

 

.then 내부에서 리턴 값이 Thenable인 것을 감지하여 해당 비동기 작업이 완료될 때까지 기다렸다가, 그 결과값을 다음 Promise chain으로 넘겨주는 것이다.

.then(() => Promise.resolve(10))

 

결국, 이는 다음과 같이 여겨진다.

.then(10)

 

3. finally는 return을 무시한다.

finally는 인자를 받지도 않지만, 원칙적으로 return을 해도 무시된다.(에러 제외)

Promise.resolve("성공")
  .finally(() => {
    return "무시"; // 이 리턴값은 무시됨
  })
  .then((value) => {
    console.log(value); // "성공" 출력
  });

 

 

async/await는 Promise로의 변환은 다음과 같다

async function foo() {
  const a = await poo;
  return a + 1;
}

 

function foo() {
  return poo.then(a => a + 1);
}

 

await는 단순하게 .then으로 변환된다.

 

async/await는 try/catch의 연속 구조를 표현할 수 있고, 동기 코드처럼 작성할 수 있다는 이점이 있다.

그러나 async/await의 본질은 Promise chain의 또다른 형태이다.

 

 

결국 async/await는 Promise의 syntax sugar이다

async/await는 본질적으로 Promise를 더 쉽게 작성하기 위한 syntax sugar일뿐이고, Promise를 정확히 알고 있으면, 단순히 await를 타이핑하는 것을 넘어서 비동기 제어를 설계할 수 있다.

 

Promise 상태의 transition이 일어났을 때, reaction이 microtask queue로 예약되는 시스템이다.

이 reaction은 즉시 실행되지 않고, Global Execution Context가 종료되어서 call stack이 완전히 비워져야 비동기로 실행된다.

 

Promise는 상태머신이며, 이 Promise의 reaction을 실행하는 과정이 비동기이다.