Promise는 인스턴스를 생성할 때 executor라는 함수를 인수로 전달받는다. 여기서 executor 함수는 resolve, reject라는 함수를 인수로 전달받게 된다.

const p = new Promise((resolve, reject) => {
  setTimeout(() => {
    resolve('resolved');
  }, 1000);
});

따라서 Promise와 동일한 인터페이스를 갖는 객체를 만드려면 resolvereject라는 함수를 매개변수로 사용하는 executor를 전달받을 수 있도록 constructor를 구성해야 할 것이다.

인스턴스를 생성한 이후 Promise 내부의 값을 소비(consume)하려면 크게 세 가지 방법을 사용할 수 있다.

p.then(
  (res) => {
    console.log(res);
  },
  (err) => {
    console.log(err);
  },
);

p.catch((err) => {
  console.log(err);
});

p.finally(() => {
  console.log('finally');
});
  • .then((res) => onFulfilled(res), (err) => onRejected(err))

    • Promiseresolved거나 rejected 상태인 경우에 호출할 함수들을 전달받는다.
  • .catch((err) => onRejected(err))

    • Promiserejected 상태인 경우 호출할 함수를 전달받는다.
  • .finally(() => onSettled())

    • Promiseresolved 상태인지 rejected 상태인지 상관없이 호출할 함수를 전달받는데 이 함수는 .then().catch()에 전달한 함수들이 모두 실행된 이후에 호출된다.

그렇다면 객체를 구성하기 위한 많은 방법들이 있지만 편의를 위해 executor라는 함수를 전달받는 constructor를 갖게끔 클래스로 구성해보자.

constructor는 인스턴스 생성 시에 호출되므로 executor 역시 곧바로 호출된다.

new Promise((resolve) => {
  console.log('executor call');
  resolve('resolved');
});
// "executor call"

또한 Promise는 상태에 따라 전달받은 함수(reaction)를 실행하게 되는데, 여기서 Promise의 상태를 결정하는 것에는 규칙이 존재한다.

  1. 모든 Promise"pending" 상태에서 시작한다.

  2. Promise의 상태가 한번 "fulfilled" 혹은 "rejected"로 변경(settled)되면 이는 절대로 변하지 않는다.

const STATE = {
  PENDING: 'pending',
  FULFILLED: 'fulfilled',
  REJECTED: 'rejected',
};

class _Promise {
  _status = STATE.PENDING;
  _value = undefined;

  constructor(executor = () => {}) {
    try {
      executor(
        this._resolve.bind(this),
        this._reject.bind(this),
      );
    } catch (err) {
      this._reject(err);
    }
  }

  _resolve(value) {
    if (this._status === STATE.PENDING) {
      this._status = STATE.FULFILLED;
      this._value = value;
    }
  }

  _reject(value) {
    if (this._status === STATE.PENDING) {
      this._status = STATE.FULFILLED;
      this._value = value;
    }
  }

  then(onFulfilled, onRejected) {
    if (
      this._status === STATE.FULFILLED &&
      typeof onFulfilled === 'function'
    ) {
      onFulfilled(this._value);
    } else if (
      this.status === STATE.REJECTED &&
      typeof onRejected === 'function'
    ) {
      onRejected(this._value);
    }
  }
}
const p1 = new _Promise((resolve, reject) => {
  resolve('resolved');
});

const p2 = new _Promise((resolve, reject) => {
  reject('rejected');
});

p1.then(
  (res) => {
    console.log(`[p1]: ${res}`);
  },
  (err) => {
    console.log(`[p1]: ${err}`);
  },
);

p2.then(
  (res) => {
    console.log(`[p2]: ${res}`);
  },
  (err) => {
    console.log(`[p2]: ${err}`);
  },
);

// [p1]: resolved
// [p2]: rejected

하지만 현재의 구현에서 동기적인 resolve, reject는 핸들링할 수 있는 것은 확인할 수 있지만, 비동기적인 resolve, reject에 대해서는 제어할 수 없다는 한계가 존재한다.

const p3 = new _Promise((resolve, reject) => {
  setTimeout(() => resolve('resolved'), 1000);
});

p3.then(
  (res) => {
    console.log(`[p3]: ${res}`);
  },
  (err) => {
    console.log(`[p3]: ${err}`);
  },
);
// 아무것도 출력되지 않는다.

이렇게 동작하는 이유는 .then() 메서드를 호출하는 시점에서 Promise의 상태는 "pending"이기 때문이다. 따라서 onFulfilled, onRejected 모두 실행되지 않는 것이다.

그렇다면 그 당시의 상태를 바라보는 것이 아니라 Promise의 상태가 변경되는 시점에 미리 전달해 둔 reactions를 호출할 수 있게끔 이를 별도의 자료구조에 저장해두어야 할 것이다.

class _Promise {
  _status = STATE.PENDING;
  _value = undefined;
  _fulfillmentTasks = [];
  _rejectionTasks = [];

  constructor(executor = () => {}) {
    try {
      executor(
        this._resolve.bind(this),
        this._reject.bind(this),
      );
    } catch (err) {
      this._reject(err);
    }
  }

  then(onFulfilled, onRejected) {
    const fulfillmentTask = () => {
      if (typeof onFulfilled === 'function') {
        onFulfilled(this._value);
      }
    };

    const rejectionTask = () => {
      if (typeof onRejected === 'function') {
        onRejected(this._value);
      }
    };

    switch (this._status) {
      case STATE.PENDING:
        this._fulfillmentTasks.push(fulfillmentTask);
        this._rejectionTasks.push(rejectionTask);
        break;
      case STATE.FULFILLED:
        queueMicrotask(fulfillmentTask);
        break;
      case STATE.REJECTED:
        queueMicrotask(rejectionTask);
        break;
    }
  }
}

이전 구현과 달라진 점은 .then()을 호출한 시점에 Promise의 상태가 "pending"이라면 onFulfilledonRejected에 전달한 reaction들을 큐에 추가한다. 아직 Promise가 settled 상태가 아니므로 즉시 실행시키면 안되기 때문이다.

만약 .then()을 호출한 시점에 해당 Promise가 이미 settled 상태라면 reaction을 브라우저에게 마이크로태스크 큐에 등록하도록 스케줄링을 요청하게 된다.

class _Promise {
  // ...
  _consumeAllTasks(tasks) {
    // this._fulfillmentTasks -> [...] <- tasks
    // this._fulfillmentTasks -> []
    //                          [...] <- tasks
    this._fulfillmentTasks = [];
    this._rejectionTasks = [];
    tasks.forEach(queueMicrotask);
  }

  _resolve(value) {
    if (this._status !== STATE.PENDING) {
      return this;
    }
    this._status = STATE.FULFILLED;
    this._value = value;
    this._consumeAllTasks(this._fulfillmentTasks);
    return this;
  }

  _reject(value) {
    if (this._status !== STATE.PENDING) {
      return this;
    }
    this._status = STATE.REJECTED;
    this._value = value;
    this._consumeAllTasks(this._rejectionTasks);
    return this;
  }

  static resolve(value) {
    return new _Promise((resolve) => resolve(value));
  }

  static reject(value) {
    return new _Promise((_, reject) => reject(value));
  }
}

resolvereject의 경우에는 Promise가 settled 상태라면 아무것도 하지 않게 되고, 그 외의 경우에는 상태를 fulfilled(또는 rejected)로 변경시킨 후 전달된 value를 저장하게 된다.

마지막으로 남은 tasks를 모두 마이크로태스크 큐에 등록될 수 있도록 요청을 보내고 reactions를 저장하고 있던 큐는 비우게 된다. 따라서 .then() 메서드가 호출되는 시점에 Promise"pending" 상태였다 하더라도 settled되는 시점에 전달한 reactions를 모두 실행시킬 수 있게 되는 것이다.

거기에 Promise.resolve, Promise.reject와 같은 형태로 인스턴스를 생성하지 않고도 settled value를 갖고 있는 Promise를 생성할 수 있도록 static 키워드를 사용하여 정적 메서드까지 추가한 상황이다.

Promise Chaining

Promise의 주요한 특징 중 하나는 바로 체이닝이 가능하다는 점이다. 체이닝이 가능하려면 Promise 인스턴스의 메서드가 다시 Promise 인스턴스를 반환해야 할 것이다. 또한 .catch() 메서드를 구현하여 rejected reaction을 보다 명시적으로 제공할 수 있게끔 해보자.

우선 .catch() 메서드의 경우 .then() 메서드의 두 번째 인수에 rejected reaction을 전달한 것과 동일하므로 쉽게 구현이 가능하다.

p.catch(rejectedReaction);

p.then(null, rejectedReaction);

class _Promise {
  // ...
  catch(onRejected) {
    return this.then(null, onRejected);
  }
}

그렇다면 .then() 메서드가 Promise 인스턴스를 반환하게끔 하면 될텐데, .then()의 인수로 fulfilled reaction이 전달된 경우에는 reaction이 반환한 값을 새로운 인스턴스의 valueresolve를 시켜주고, fulfilled reaction 전달되지 않은 경우(.catch() 호출)에는 이전 Promise 인스턴스의 value를 그대로 resolve 시키면 된다.

주의할 점은 .catch() 메서드에 전달한 reaction을 통해 유효한 값으로 예외 처리를 한 경우라면 reject를 호출하는 것이 아니라 resolve를 호출하게끔 하여 .catch() 이후의 .then()에 전달한 reaction에서 처리된 값을 사용할 수 있게 해주어야 한다. 또한 settled 상태와 부합하는 reaction이 제공되지 않은 경우에는 인스턴스가 갖고 있는 값을 그대로 흘려보내는 방식으로 구현하여 필요하지 않은 reaction들은 생략할 수 있게 된다.

class _Promise {
  // ...
  then(onFulfilled, onRejected) {
    const nextPromise = new _Promise();

    const fulfillmentTask = () => {
      if (typeof onFulfilled === 'function') {
        nextPromise._resolve(onFulfilled(this._value));
      } else {
        nextPromise._resolve(this._value);
      }
    };

    const rejectionTask = () => {
      if (typeof onRejected === 'function') {
        nextPromise._resolve(onRejected(this._value));
      } else {
        nextPromise._reject(this._value);
      }
    };

    switch (this._status) {
      case STATE.PENDING:
        this._fulfillmentTasks.push(fulfillmentTask);
        this._rejectionTasks.push(rejectionTask);
        break;
      case STATE.FULFILLED:
        queueMicrotask(fulfillmentTask);
        break;
      case STATE.REJECTED:
        queueMicrotask(rejectionTask);
        break;
    }
    return nextPromise;
  }
}
const log = (n) => (value) => {
  console.log(`${n}: ${value}`);
  return value;
};

const p = _Promise.reject('err');

p.then(log(1))
  .then(log(2))
  .then(log(3))
  .catch((err) => {
    console.log(err);
    return 'catched';
  })
  .then(log(5));
// err
// 5 catched

Flattening from Promise

하지만 Promise API와 비교해보면 현재의 구현에서는 아쉬운 점이 하나 있는데 바로, .then()에 전달한 reaction에서 Promise를 반환하는 경우 다음 reaction에 전달되는 인수는 Promise가 래핑하고 있는 값 자체가 아닌 값을 래핑하고 있는 Promise가 그대로 전달된다는 점이다.

const p = new Promise((resolve, reject) => {
  setTimeout(() => resolve(0), 0);
});

p.then((res) => {
  console.log('res 1: ', res);
  return Promise.resolve(res + 1);
}).then((res) => {
  console.log('res 2: ', res);
});

// res 1: 0
// res 2: 1

const _p = new _Promise((resolve, reject) => {
  setTimeout(() => resolve(0), 0);
});

_p.then((res) => {
  console.log('res 1: ', res);
  return _Promise.resolve(res + 1);
}).then((res) => {
  console.log('res 2: ', res);
  res.then(console.log);
});

// res 1: 0
// res 2: _Promise { value: 1 }
// 1

따라서 .then()에 전달한 reaction이 Promise를 반환하더라도 Promise가 래핑하고 있는 값을 다음 reaction에 전달할 수 있게끔 만들어보자.

[Promise P]
{
  value: Promise Q,
  status: STATE.FULFILLED
}
          ┌───────────────┐
          ↓               │
P.then((res) => { ... })  │
        │ ↑               └──────────────────────────┐
        │ └───────────────────────┐                  │
        └─────→ Q.then((res) => { ... }, (err) => { ... })

문제를 단순화시켜보면 PP라는 Promise의 fulfilled value로 QQ라는 Promise가 전달된 상황에서 QQ라는 Promise 자체를 직접 전달하는 것이 아닌 QQ라는 Promiseresolve 혹은 reject 된 결과를 그 다음 Promise에 전달해야 하는 상황이다.

PQsettledvalueP \rightarrow Q \rightarrow settled\,value인 상황에서 최종적인 동작은 PsettledvalueP \rightarrow settled\,value가 되어야 하므로 QQresolve 혹은 reject 되는 시점에 PP의 settled value가 결정되는 것이다. 따라서 특정 Promise가 fulfilled 상태인 경우 고려해야 할 점이 늘어난 것이다.

  • PP가 fulfilled 상태라 해서 QQ 역시 fulfilled 된다는 보장을 할 수 없다. PP의 settled value가 rejected 상태의 QQ가 될 수 있다.

  • PP는 fulfilled 상태이지만 QQ는 pending 상태인 경우가 발생할 수 있다. 즉 QQ 자체가 settled value를 결정하기 전까지는 PP는 pending 상태인 것과 마찬가지이다.

  • QQ가 fulfilled 상태가 되면 QQ의 fulfilled value가 QQ가 rejected 상태가 되면 QQ의 rejected value가 전달되어야 한다.

class _Promise {
  _alreadyResolved = false;

  _resolve(value) {
    if (this._alreadyResolved) return this;
    this._alreadyResolved = true;

    if (value instanceof _Promise) {
      value.then(
        (res) => this._doFulfill(res),
        (err) => this._doReject(err),
      );
    } else {
      this._doFulfill(value);
    }
    return this;
  }

  _reject(value) {
    if (this._alreadyResolved) return this;
    this._alreadyResolved = true;
    this._doReject(value);
    return this;
  }

  _doFulfill(value) {
    this._status = STATE.FULFILLED;
    this._value = value;
    this._consumeAllTasks(this._fulfillmentTasks);
  }

  _doReject(value) {
    this._status = STATE.REJECTED;
    this._value = value;
    this._consumeAllTasks(this._rejectionTasks);
  }
}
const P = new _Promise();
const Q = new _Promise();

P.resolve(Q);
Q.resolve(1);

P.then((res) => {
  console.log('res1', res);
  return _Promise.resolve(res + 1);
})
  .then((res) => {
    console.log('res2', res);
    return _Promise.resolve(res + 1);
  })
  .then((res) => {
    console.log('res3', res);
  });

// res1 1
// res2 2
// res3 3

이제 PPresolve된 값이 다시 Promise(QQ)인 경우에는 Promise(QQ) 자체를 resolve하는 것이 아니라 Promise(QQ)가 resolve 혹은 reject한 값을 PP의 settled value로 결정하게 된다.

Handling Exceptions thrown in reaction callbacks

마지막으로는 .then() 혹은 .catch()에 전달한 reaction 내부에서 throw된 에러를 그 다음 catch reaction에서 포착할 수 있게끔 해보자.

그러기 위해서는 일단 reaction을 먼저 실행해보고, 에러가 catch되면 해당 에러를 rejected value로 전달하면 될 것이다.

class _Promise {
  // ...
  then(onFulfilled, onRejected) {
    const nextPromise = new _Promise();

    const fulfillmentTask = () => {
      if (typeof onFulfilled === 'function') {
        this._runReactionSafely(nextPromise, onFulfilled);
      } else {
        nextPromise._resolve(this._value);
      }
    };

    const rejectionTask = () => {
      if (typeof onRejected === 'function') {
        this._runReactionSafely(nextPromise, onRejected);
      } else {
        nextPromise._reject(this._value);
      }
    };

    switch (this._status) {
      case STATE.PENDING:
        this._fulfillmentTasks.push(fulfillmentTask);
        this._rejectionTasks.push(rejectionTask);
        break;
      case STATE.FULFILLED:
        queueMicrotask(fulfillmentTask);
        break;
      case STATE.REJECTED:
        queueMicrotask(rejectionTask);
        break;
    }
    return nextPromise;
  }

  _runReactionSafely(nextPromise, reaction) {
    try {
      nextPromise._resolve(reaction(this._value));
    } catch (err) {
      nextPromise._reject(err);
    }
  }
}

all, race, any

마지막으로 Promise.all, Promise.race, Promise.any를 정의해보자.

Promise.all()

주어진 모든 Promise가 fulfilled되면 resolve하는 Promise를 반환한다. 만약 하나라도 rejected라면 첫 번째로 rejected된 Promise의 값을 이용해 rejected 인 Promise를 반환하게 된다.

class _Promise {
  // ...
  static all(collection) {
    return new _Promise((resolve, reject) => {
      let counter = collection.length;
      const resolvedCollection = [];

      const tryResolve = (value, index) => {
        counter -= 1;
        resolvedCollection[index] = value;

        if (count !== 0) return;
        return resolve(resolvedCollection);
      };

      collection.forEach((item, index) => {
        return _Promise
          .resolve(item)
          .then((value) => {
            tryResolve(value, index);
          })
          .catch(reject);
      });
    });
  }
}
_Promise
  .all([
    _Promise.resolve(1),
    _Promise.resolve(2),
    _Promise.reject(3),
  ])
  .then((collection) => {
    console.log(collection);
  })
  .catch((err) => {
    console.log(err);
  });
// [1, 2]

_Promise
  .all([
    _Promise.resolve(1),
    _Promise.resolve(2),
    _Promise.reject(3),
  ])
  .then((collection) => {
    console.log(collection);
  })
  .catch((err) => {
    console.log(err);
  });
// 3

Promise.race()

주어진 Promise 중 가장 먼저 settled가 되는 Promise의 결과로 resolve 혹은 reject를 수행한다.

class _Promise {
  // ...
  static race(collection) {
    return new _Promise((resolve, reject) => {
      collection.forEach((item) =>
        item.then(resolve).catch(reject),
      );
    });
  }
}

Promise.any()

주어진 Promise 중 가장 먼저 fulfilled가 되는 Promise의 결과 값으로 resolve를 수행하고, 모든 Promise가 rejected라면 AggregateError를 이용하여 reject를 수행한다.

class _Promise {
  // ...
  static any(collection) {
    return new _Promise((resolve, reject) => {
      let isFulfilled = false;
      const errors = [];
      let errorCount = 0;

      collection.forEach((item, index) => {
        _Promise
          .resolve(item)
          .then((item) => {
            if (!isFulfilled) {
              resolve(item);
              isFulfilled = true;
            }
          })
          .catch((err) => {
            errors[index] = err;
            errorCount += 1;

            if (errorCount === collection.length) {
              reject(new AggregateError(err));
            }
          });
      });
    });
  }
}

Conclusion

export default function createStore(reducer) {
  let state;

  let listeners = [];

  function getState() {
    return state;
  }

  function subscribe(newListener) {
    listeners = [...listeners, newListener];
    return function unsubscribe() {
      listeners = listeners.filter(
        (listener) => listener !== newListener,
      );
    };
  }

  function dispatch(action) {
    const newState = reducer(state, action);
    if (!newState)
      throw new Error(
        'reducer should always return newState',
      );
    if (newState === state) return;
    state = newState;
    invokeAllSubscribers();
  }

  function invokeAllSubscribers() {
    listeners.forEach((listener) => listener());
  }

  return { getState, dispatch, subscribe };
}
  • 공부를 진행하면서 Promise를 막연하게만 알고 있었다는 생각이 많이 들었다. 특히 reaction들이 실행되는 시점에 대해 명확하게 이해하지 못하고 있었다.

  • Promise에서 reaction들을 큐에 등록해두고 상태가 변경될 때 호출하듯이 redux에서도 dispatch가 호출될 때마다 상태를 구독하고 있던 listener들을 호출하는 것을 알 수 있었다.

Reference