[JS] Promise (진행중...)

steven semyung oh·2023년 6월 5일
1

비동기여행

목록 보기
4/4

프로미스를 정확하게 이해하려는 건 어려운 것 같다. 그래도 나름 비동기 작업을 해봤다고 생각했는데 이 개념앞에서는 언제나 다음에 공부해야겠다고 약속했건만.. 이 장에서는 프로미스가 어떻게 돌아가는지, 그리고 이게 왜 비동기 여행에서 중요한 대목인지 설명하고자 한다. 토끼굴에도 빠져나갈 수 있는 때가 있더라!

프로미스는 어떻게 동작하는지..

알아보기 전에, 프로미스의 타입을 알아보자. 프로미스는 다음과 같은 내장 슬롯을 타입으로 가지는 객체이다.

type ResolveFunction: (resolution: any) => undefined;
type RejectFunction: (reason: any) => undefined;

type PromiseCapability = {
  "[[Promise]]": Promise;
  "[[Resolve]]": ResolveFunction;
  "[[Reject]]": RejectFunction;
};

type PromiseReaction = {
  "[[Capabilities]]": PromiseCapability;
  // onFulFilled, onRejected
  "[[Handler]]": "Identity"| "Thrower" |Function ;
}

type Promise = {
  "[[PromiseState]]" ?: "pending" | "fulfilled" | "rejected";
  "[[PromiseResult]]": any;
  "[[PromiseIsHandled]]"?: boolean;
  "[[PromiseFulfillReactions]]" ?: Array<PromiseReaction>;
  "[[PromiseRejectReactions]]" ?: Array<PromiseReaction>;
  __proto__: Promise.prototype;
}

1. new Promise(executor)

다시 한 번 API 보기 , 관련 ECMAScript 스펙

잘 알다시피, new 키워드로 프로미스를 만들 때 인자로 executor() 함수를 전달한다.
함수에서는 연산이 성공적으로 완료되었을 때 첫 번째 인자로 전달된 resolve()함수를 호출한다.
연산의 실패는 크게 2가지 경우가 있다.
1. 요청 보낸 일이 실패한경우: 두 번째 인자로 전달된 reject() 함수를 호출한다.
2. 함수 내부에서 예외가 터진경우: ???

executor() 을 인자로 전달하여 생성자 함수를 호출하면 다음과 같은 일들이 벌어지는데, 조금 길어서 쪼개볼까 한다. 뒤의 괄호는 ECMAScript 스펙의 연산 순서를 의미한다.

setup internal slot (1-3)

function Promise(promiseInstance, executor) {
  const promise = this;
  promise["[[PromiseState]]"] = undefined;
  promise["[[PromiseResult]]"] = undefined;
  promise["[[PromiseFulfillReactions]]"] = undefined;
  promise["[[PromiseRejectReactions]]"] = undefined;
  promise["[[PromiseIsHandled]]"] = undefined;
  return initializePromise(promise, executor);
}

먼저 프로미스 인스턴스에 있는 인터널 슬롯들을 초기화한다. 여기까지는 인터널 슬롯을 초기화하는 과정이기 때문에, 실제 프로미스 객체를 초기화하는 과정이 필요하다. initializePromise() 에서는 프로미스를 초기화 하고 해당 프로미스 인스턴스를 리턴한다.

initialize promise (4-7)

executor 함수에 전달되는 resolve()reject()을 만들고 executor를 호출하는 과정이다. new Promise(executor) 으로 프로미스를 만들 때 바로 함수가 실행된다는 것은 이 연산 때문이다.

function initializePromise(promise, executor) {
  promise["[[PromiseState]]"] = "pending"; // {1}
  promise["[[PromiseIsHandled]]"] = false; // {2}
  promise["[[PromiseFulfillReactions]]"] = []; // {3}
  promise["[[PromiseRejectReactions]]"] = []; // {3}
}
  1. 처음 프로미스가 초기화되는 것이기 때문에 상태값을 pending으로 변경한다. 이 전에 assertion 으로 상태값이 undefined 인지 확인하는 과정을 선행하기도 한다. 프로미스 인스턴스의 최초 생성 과정에서 PromiseStateundefined으로 먼저 초기화되어야 하기 때문!
  2. 이 프로미스 인스턴스는 아직 처리되지 않았기 때문에 해당 슬롯의 값을 false 로 둔다.
  3. PromiseReactions 프로퍼티를 빈 배열로 초기화한다. PromiseReaction은 프로미스가 fulfilled 되거나 rejected될 때 반응하는 정보를 담은 객체이다. 이 객체는 후술할 then, catch 메서드에 의하여 각각의 배열에 담겨진다!

initialize Resolving functions and run executor (8)

executor를 호출할때 전달하는 resolve, reject 함수를 만드는 연산이다. CreateResolvingFunctions 이라는 함수를 호출하여 만든다! 코드를 보자!

function CreateResolvingFunctions(promise) {
  const alreadyResolved = { "[[value]]": false };

  const reject = CreateBuiltInRejectFunction();
  reject["[[Promise]]"] = promise;
  reject["[[AlreadyResolved]]"] = alreadyResolved;

  const resolve = CreateBuiltInResolveFunction();
  resolve["[[Promise]]"] = promise;
  resolve["[[AlreadyResolved]]"] = alreadyResolved;

  return { "[[Resolve]]": resolve, "[[Reject]]": reject };
}

function initializePromise(promise, executor) {
  /*
  ** CreateResolvingFunctions(promise)를 전달하기 전 값들
  ** PromiseState: "pending"
  ** PromiseIsHandled: false,
  ** PromiseFulfillReactions: []
  ** PromiseRejcetReactions: []
  */
  
  // resolvingFunctions을 만든다.
  // - CreateResolvingFunctions는
  //   [[Resolve]], [[Reject]]을 프로퍼티로 가지는 객체를 반환한다.
  // - 이 프로퍼티에 바인딩 된 값은 각각 resolve()와 reject()이다!
  const resolvingFunctions = CreateResolvingFunctions(promise);
  
  // executor을 실행한다!
  try {
    executor.call(
      undefined,
      resolvingFunctions["[[Resolve]]"],
      resolvingFunctions["[[Reject]]"]
    ); // {1}
  } catch(completionErrorReason) {
    resolvingFunctions["[[Reject]]"].call(
      undefined,
      completionErrorReason
    ); // {2}
  }
  
  return promise; // {3}
}

잘 알다시피 resolvingFunctionsresolve()reject() 으로 이루어져 있다. 각각 함수는 관련된 프로미스와 이 프로미스가 특정한 결과로 결정됐는지를 나타내는 값을 할당한다.

  1. 이렇게 만든 함수들은 executor()를 호출하면서 인자로 전달된다. 이게 executor 가 동기적으로 실행되는 이유다. (내부 코드는 비동기적으로 돌아갈 수도 있겠지만!!!) 우리는 executor()에서 연산을 최종적으로 완료하였을 때 resolve()reject()을 직접 호출하는걸 잘 알고 있다!
  2. executor() 가 실행될 때 작동하는 작업이 동기/비동기 관련 없이 처리가 될때 예외가 발생할 수 있다. 이경우를 대비하기 위해 try...catch 문으로 감싼것이다. 만약 모종의 이유로 예외가 발생했다면 reject() 함수가 내부적으로 실행된다. 여기에서는 직접 인자로 예외 사유를 전달하는 것을 볼 수 있다.
  3. 프로미스를 바로 리턴한다.

reject()

아까 설명에서도 봤듯이 executor() 를 실행하다가 무슨 이유이던간에 예외가 던져질 수가 있다. 이경우 프로미스는 resolvingFunctions[["Reject"]] 함수에 사유를 전달하여 호출한다. 저 함수는 어떤 역할을 하는걸까? 코드로 보자!


function CreateBuiltInRejectFunction() {
  // {1}
  const reject = (reason) => { 
    const promise = reject["[Promise]"];
    const alreadyResolved = reject["[[AlreadyResolved]]"];
    
    if (alreadyResolved["[[value]]"] === true) {
      return undefined;
    }
    alreadyResolved["[[value]]"] = true;
    RejectPromise(promise, reason); // {5}
  }

  reject["[[Promise]]"] = undefined;
  reject["[[AlreadyResolved]]"] = undefined;
  return reject;
}

function CreateResolvingFunctions(promise) {
  const alreadyResolved = { "[[value]]": false };

  const reject = CreateBuiltInRejectFunction();
  reject["[[Promise]]"] = promise; // {2}
  reject["[[AlreadyResolved]]"] = alreadyResolved; // {3}

  // 편의상 resolve는 잠시 지웠다! reject만 신경쓰자!
  return { "[[Reject]]": reject };
}

function initializePromise(promise, executor) {
  const resolvingFunctions = CreateResolvingFunctions(promise);
  //...
  try {
    executor.call(
      undefined,
      resolvingFunctions["[[Resolve]]"],
      resolvingFunctions["[[Reject]]"] // {4}
    ); 
  } catch(completionErrorReason) {
    resolvingFunctions["[[Reject]]"].call(
      undefined,
      completionErrorReason
    ); // {4}
  }
  //...
}
  1. CreateResolvingFunctions(promise) 을 호출하면 CreateBuiltInRejectFunction() 을 호출하여 reject 함수의 인스턴스를 만든다. 그리고 함수의 [[Promise]]프로퍼티와 [[AlreadyResolved]] 프로퍼티를 초기화한다. [[Promise]]는 함수 자신이 결정을 내릴 대상을 가리킨다. [[AlreadyResolved]]는 프로미스가 해당 reject() 또는 resolve()가 호출되었는지를 나타내는 플래그 값이다.

  2. 함수를 만들자마자 promise 를 인터널 슬롯 [[Promise]]에 할당한다.

  3. alreadyResolved 를 인터널 슬롯 [[AlreadyResolved]]에 할당한다.
    이 시점까지 reject()의 타입은 다음과 같다.

    type RejectFunction = {
      "[[Promise]]": Promise
      "[[AlreadyResolved]]": {
        "[[value]]": boolean,
      }
    };
  4. 개발자가 만든 executor()에서 reject()을 호출하든, 작업을 만드는 과정에서 나든 관계없이 뭔가가 잘못되었을 때 이 함수를 호출한다.

  5. 함수 내부에서 인터널슬롯 [[Promise]][[AlreadyResolved]]를 참조한다. [[AlreadyResolved]]가 가리키는 객체의 프로퍼티[[value]]에 따라 RejectPromise를 조건부로 호출한다.

    프로미스의 운명을 결정짓는 함수는 resolve()reject() 이다. 둘중 하나가 호출되면 프로미스는 결정되기 때문에 호출 시점에서 플래그 값을 변경하여 차후에 저 함수들 중 하나가 또다시 호출되어도 프로미스의 상태를 변경하지 못하도록 막는 테크닉이 숨겨져 있다. 이 테크닉은 아래와 같은 코드에 대해 미연의 버그를 방지할 수 있다.

    new Promise((resolve, reject) => {
      for (let i = 0; i < 1000; i++) {
        if (i % 2 === 0) {
          resolve();
        } else {
          reject();
        }
      }
    });

RejectPromise는 무엇인가?

너무 코드가 길어질 것 같아서 RejectPromise는 따로 작성해보려고한다.

function RejectPromise(promise, reason) {
  if (promise["[[PromiseState]]"] !== "pending") { // {1}
    throw new Error();
  }
  const reactions = promise["[[PromiseRejectReactions]]"]; // {2}
  promise["[[PromiseResult]]"] = reason;
  promise["[[PromiseState]]"] = "rejected";
  promise["[[PromiseFulfillReactions]]"] = undefined;
  promise["[[PromiseRejectReactions]]"] = undefined;

  return TriggerPromiseReactions(reactions, reason);
}
  1. RejectPromisepending 상태일 때만 호출되는 함수이다. 이는 어떤 프로미스가 다른 상태로 결정이 된 이후에는 Reject 이나 Resolve를 할 수 없다는 스펙을 반영해주고 있다!
  2. 각종 내부 슬롯의 상태를 Reject에 맞게 바꿔주고 TriggerPromiseReactions 함수를 호출한다.

TriggerPromiseReactions, Reaction 등등.. 복잡한 구조로 되어있어서 잠시 끊고 지금까지 내용을 종합해서 정리해보자.

어떤 Promisenew키워드와 executor 을 인자로 전달하여 호출하여 초기화된다!

  1. 프로미스의 각종 내부 슬롯을 초기화한다.
  2. executor에 전달되는 resolvereject 함수의 인스턴스를 만든다.
    2.1. 프로미스의 운명을 결정짓기 위해 이 함수들이 가리키는 프로미스와 둘중 하나가 호출되었는지의 값들을 내부슬롯에 초기화한다.
    2.2. executorresolvereject 인스턴스를 전달하여 호출한다
    2.3. 프로미스를 리턴한다.

executor에서 모종의 이유로 작업이 실패했다!

  1. reject() 이나 resolve() 둘 중에 하나가 이미 호출되었는지 확인한다!
  2. 호출되었다면, 얼리리턴!
  3. 호출이 안되었다면 호출 했다는 플래그를 세워주고 내부 슬롯을 Rejection 상태에 맞게 변경!
  4. TriggerPromiseReactions를 호출!
queueMicrotask(() => {
  console.log("microtask1");
});
const promise1 = new Promise((resolve, reject) => {
  reject("Hi!");
});

console.log(promise1); 

queueMicrotask(() => {
  console.log("microtask2");
});

const promise2 = new Promise((resolve, reject) => {
  throw new Error("Hi");
});

console.log(promise2);




/* 프로미스 promise1과 promise2는 동기적으로 그 운명이 결정된다. 
** 1. Promise { <rejected>: "Hi" }
** 2. Promise { <rejected>: Error }
** 3. microtask1
** 4. microtask2
*/

resolve()

위에서는 executor 에서 예외가 발생하였을 때 어떤식으로 프로미스의 운명이 결정되었는지 확인하였다. resolve()resolvingFunctions[[resolve]]를 호출하여 결정짓는데, 이 때 발생하는 작업들을 살펴보자!

function initializePromise(promise, executor) {
  // 1. executor에 전달되는 함수들을 만들자..
  const resolvingFunctions = CreateResolvingFunctions(promise);
  try {
    // 3. executor의 작업이 성공적으로 완료되었을 때 resolve 함수를 호출한다..
    executor.call(
      undefined,
      resolvingFunctions["[[Resolve]]"],
      resolvingFunctions["[[Reject]]"]
    ); 
  } catch(completionErrorReason) {
    //...
  }
  // 2. 프로미스를 리턴하고..
  return promise;
}

function CreateResolvingFunctions(promise) {
  const alreadyResolved = { "[[value]]": false };
  /* 가독성을 위해 reject 부분은 생략.... */
  
  // 4. CreateBuiltInResolveFunction
  const resolve = CreateBuiltInResolveFunction();
  resolve["[[Promise]]"] = promise;
  resolve["[[AlreadyResolved]]"] = alreadyResolved;
  return { "[[Resolve]]": resolve, "[[Reject]]": reject };
}

CreateBuiltInResolveFunction()과 FulfillPromise()

프로미스의 운명이 결정되는 케이스는 다음과 같다.
1. 프로미스의 상태가 fulfilled 되거나 rejected 될 때,
2. 프로미스가 다른 프로미스의 상태를 반영하기 위해 잠겨질 때(locked-in)

2번의 경우 then 에 대해 언급을 해야하기 때문에 1번에 집중하고자 한다. 1의 pending -> rejected 케이스는 위에서 설명한 것과 같고, 여기에서는 1의 pending -> fulfilled 케이스에 관하여 알아보겠다!

function CreateBuiltInResolveFunction() {
  const resolve = function (resolution) {
    const promise = resolve["[Promise]"];
    const alreadyResolved = resolve["[[AlreadyResolved]]"];
    
    if (alreadyResolved["[[value]]"] === true) {
      return undefined;
    }
    
    alreadyResolved["[[value]]"] = true;
 
 	// 1.   
    if (Object.is(resolution, promise) === true) {
      const error = new TypeError(
        isSafari() 
          ? 'Cannot resolve a promise with itself'
          : 'Chaining cycle detected for promise'
      );
      return RejectPromise(promise, error);
    }
    
    // 2.
    if (isResolutionPrimitive(resolution)) {
      return FulfillPromise(promise, resolution);
    }
    
    /* to many more... after understanding then!*/
  };
  
  resolve["[[Promise]]"] = undefined;
  resolve["[[AlreadyResolved]]"] = undefined;
  return resolve;
}

function FulFillPromise(promise, value) {
  if (promise["[[PromiseState]]"] !== "pending") { // {1}
    throw new Error();
  }
  const reactions = promise["[[PromiseFulfillReactions]]"]; // {2}
  promise["[[PromiseResult]]"] = value;
  promise["[[PromiseState]]"] = "fulfilled";
  promise["[[PromiseFulfillReactions]]"] = undefined;
  promise["[[PromiseRejectReactions]]"] = undefined;
  return TriggerPromiseReactions(reactions, value);
}

executor 에 전달된 함수는 CreateBuiltInResolveFunction() 이 리턴한 함수이다. 이 함수는 다음의 순서로 실행된다!

  • reject 처럼 이미 결정되었는지 결정되지 않았는지 확인하여 얼리리턴 처리한다!
  • 함수에 전달된 인자가 프로미스 자신을 참조하는 경우 타입에러를 사유로 reject 처리한다! 이는 다음의 엣지케이스를 대비한 것으로, 결정되지 않은 프로미스가 자신을 참조하려는 무한 루프에서 빠져나가게 도와준다!
  let deferedResolve = undefined;
  const promise = new Promise((resolve) => {
    deferedResolve = resolve;
  });
  
  deferedResolve(promise);
  • 함수에 전달된 인자가 원시 타입이라면 FulFillPromise 를 호출하여 리턴한다. 자바스크립트 세계에서는 프로미스를 결정할 때 값이 원시 타입인지, 객체 타입인지에 따라 다르게 처리하고, 객체 타입이라면 그 객체가 then 메서드를 가지고 있는지, 그렇지 않은지에 따라 또 다르게 처리한다. 객체 타입을 받은 프로미스가 결정되는 방식은 then 을 설명한 다음에 이어나가는게 더 정리하기 좋을 것 같아, 원시 타입을 전달하였다고 가정하겠다!
  • FulFillPromise 는 프로미스의 상태를 fulfilled 으로, 프로미스 결과를 전달된 인자로 할당하고 TriggerPromiseReactions에 리액션 배열과 결과값을 전달하여 호출한다.

여기까지의 내용을 종합해보자!

어떤 Promise 가 new키워드와 executor 을 인자로 전달하여 호출하여 초기화될 때 executor 내부에서 resolve에 원시 타입의 값을 전달하였다면

  1. reject() 이나 resolve() 둘 중에 하나가 이미 호출되었는지 확인한다!
    2.호출되었다면, 얼리리턴
  2. 호출이 안되었다면 호출 했다는 플래그를 세워준다.
  3. 인자가 프로미스 값과 같은 값이라면, 타입에러와 함께 Reject!
  4. 인자의 타입이 원시타입이라면, FulFillPromise 호출!
  5. 내부 슬롯을 FulFilled 상태에 맞게 변경!
  6. TriggerPromiseReactions를 호출!
queueMicrotask(() => {
  console.log("microtask1");
});
const promise1 = new Promise((resolve, reject) => {
  resolve(1);
});

console.log(promise1); 

/* 프로미스 promise1은 동기적으로 그 운명이 결정된다. 
** 1. Promise { <fulfilled>: 1 }
** 2. microtask1
*/

2. Promise.prototype.then(onfulfilled, onrejected)

then 메서드를 한 번도 안써본 사람은 있어도 한 번만 써본 사람은 없다. 그만큼 이 메서드는 프로미스의 핵심적인 기능이기도 하다. 따라서 메서드의 매개변수, 리턴값, 그리고 메서드 내부의 연산 모두를 잘 이해하는게 중요하다고 생각한다.

2.1. 리턴값: 새로운 프로미스

MDN의 내용을 인용하여 정리해보면 then 메서드는 새로운 프로미스를 즉시 반환하고, 이 프로미스의 상태는 현재 프로미스의 상태와 관련없이 pending 상태를 갖는다고 한다.
그렇다면 then 을 호출할 때 언제나 새로운 프로미스를 만들어야 하고, 이 프로미스의 로직은 위에서 설명한 것을 반영하여야 한다. 그리고 새로운 프로미스는 then 을 호출한 시점의 프로미스 인스턴스의 상태가 결정지어지기 전까지(settled) 자신의 운명을 결정짓지 말아야 한다. 이러한 스펙은 PromiseCapability 라는 객체와 PromiseReactionJob 이라는 객체, 그리고 이 둘과 관련된 연산으로 구현되어 있다.

PromiseCapability Record

type PromiseCapability = {
  "[[Promise]]": Promise | Thenable;
  "[[Resolve]]": Function
  "[[Reject]]": Function
}

Capability라는 단어는 사전으로 뭔가를 할 수 있는 능력 을 의미한다. 프로그래밍의 맥락에서는 어떤 작업을 할 수 있는 컴포넌트를 가리킨다. PromiseCapability는 프로미스(또는 Thenable 객체), 그리고 이 프로미스의 운명을 결정짓는 Resolve, Reject 함수를 내장한 객체이다. 다시말해 프로미스와 그 프로미스를 운명지을 수 있는 작업들이 담긴 값이라고 볼 수 있다. 이 객체는 NewPromiseCapability 라는 연산에 의해 만들어진다.

NewPromiseCapability(Constructor)

function NewPromiseCapability() {
  // resolve: CreateBuiltInResolveFunction()
  // reject: CreateBuiltInRejectFunction()
  function executor(resolve, reject) {
    executor["[[Resolve]]"] = resolve;
    executor["[[Reject]]"] = reject;
    return;
  }
  executor["[[Resolve]]"] = undefined;
  executor["[[Reject]]"] = undefined;
  
  const newPromise = new Promise(executor);
  // 이 시점에서 반드시 [[Resolve]], [[Reject]]은 반드시 함수를 가리킨다!
  return {
    "[[Resolve]]": executor["[[Resolve]]"],
    "[[Reject]]": executor["[[Reject]]"],
    "[[Promise]]": newPromise
  };
}

NewPromiseCapability()은 내부에서 직접 정의한 executor을 담아 새로운 프로미스를 만드는 것이며, executor 내부 로직은 resolve()reject() 을 인터널 슬롯에 할당시키는 역할을 하기만 한다. 그리고 Capability의 목적에 맞게 프로미스, 프로미스를 조작하는 것들을 각각 인터널 슬롯으로 할당한다.


then 에서 만들어지는 새로운 프로미스는 then 을 호출한 시점의 프로미스의 운명이 결정되기 전까지는 자신의 운명을 결정지어서는 안된다. 따라서 운명을 결정짓는 작업을 담아놓고 이 작업을 이용해서 자신의 운명을 결정짓는다.

profile
네명입니다

0개의 댓글