Promise: 구현해보며 원리 살펴보기

Photo by Pat Whelen on Unsplash

이번 포스팅은 Promise가 어떤 원리로 동작하는지 직접 구현해보면서 살펴보는 내용을 담고 있다. 그렇기 때문에 기본적으로 Promise를 몇 번 사용해본 경험이 있다는 전제하에 글을 작성하고 있다. 그래서 Promise에 대해서 처음 들어본다면 다음과 같은 글을 미리 읽고 읽는 것을 추천한다.

Simple Promise

Promise state logical view
Promise state logical view

위와 같이 Promise는 간단히 보면 state machine으로 생각할 수 있다. 최초로 Promise가 생성되었을 때는 ‘pending’ 상태에 있다가 Promise가 resolve가 되면 ‘fulfilled’ 상태가 되고 reject되면 ‘rejected’ 상태가 된다.

더욱 복잡한 상태 변화가 존재하지만 우선은 이와 같은 상태 변화에 대해서만 살펴보자.

  • Promise는 최초로 생성되었을 시점에는 ‘pending’ 상태이다.
  • 만약 Promise가 v라는 값으로 resolved 되었다면 Promise는 ‘fulfilled’ 상태가 되고 v를 fulfillment value라고 부른다.
  • 만약 Promise가 e라는 에러로 rejected 되었다면, Promise는 ‘rejected’ 상태가 되고 e를 rejection value가 된다.
  • Promise가 ‘fulfilled’나 ‘rejected’ 상태가 되었다면 우리는 해당 Promise를 settled 되었다고 말할 수 있다.

여기서 말하는 fulfillment value, rejection value, settled와 같은 용어는 모두 ECMAScript의 Promise Object편에서 찾아볼 수 있다.

첫 번째로 만들어볼 Promise는 이와 같은 간단한 상태 변화를 구현한 Promise이다. 테스트 코드를 보면서 구체적으로 어떤 기능들을 만들지 살펴보자.

Test Case

describe('then() works before and after settlement', () => {
  it('should be enable to resolve before then()', (done) => {
    p.resolve('abc');
    p.then((value) => {
      assert.equal(value, 'abc');
      done();
    });
  });

  it('should be enable resolve after then()', (done) => {
    p.then((value) => {
      assert.equal(value, 'abc');
      done();
    });
    p.resolve('abc');
  });

  it('should be enable to reject before then()', (done) => {
    p.reject('ERROR');
    p.then(null, (value) => {
      assert.equal(value, 'ERROR');
      done();
    });
  });

  it('should be enable to reject after then()', (done) => {
    p.then(null, (value) => {
      assert.equal(value, 'ERROR');
      done();
    });
    p.reject('ERROR');
  });
});

테스트 코드를 보면서 느꼈을 수도 있는데 이번 포스팅에서 구현할 Promise는 정확히 표준 Promise의 형태를 띄지 않는다. 조금 다른 방식으로 간단하게 만들어보면서 내부적으로 어떤 방식으로 동작하는지에 대해 집중하고자 한다.

  • 우리가 만들어볼 Promise는 resolve(), reject() 함수 인자에 직접적으로 값을 넣음으로써 Promise가 resolved되거나 rejected 된다.
  • resolve(), reject() 를 통해 값을 전달받은 Promise는 then() 의 콜백 함수를 통해 fulfilled value나 rejected value를 전달 받을 수 있다.
  • 앞으로 then() 의 인자로 넘어가는 콜백 함수들을 reaction이라고 부를 것인데 fulfilled value를 전달받는 reaction은 fulfilled reaction이라고 부르고 rejected value의 경우는 rejected reaction이라고 부를 것이다.
  • resolve() 혹은 reject() 가 된 후 then() 을 통해 값이 넘어가는 것은 이벤트 루프를 통해 비동기적으로 실행된다.
  • then() 을 통하여 reactions들을 등록할 수 있는데 등록할 수 있는 시점은 Promise가 resolve 혹은 reject 되기 전과 후 모두 상관없다.
describe('Promise can settle only once', () => {
  it('should resolve only once', (done) => {
    p.resolve('FIRST');
    p.then((value) => {
      assert.equal(value, 'FIRST');
      p.resolve('SECOND');
      p.then((value) => {
        assert.equal(value, 'FIRST');
        done();
      });
    });
  });

  it('should reject only once', (done) => {
    p.reject('FIRST_ERROR');
    p.then(null, (value) => {
      assert.equal(value, 'FIRST_ERROR');
      p.reject('SECOND_ERROR');
      p.then(null, (value) => {
        assert.equal(value, 'FIRST_ERROR');
        done();
      });
    })
  })
});

다음으로 살펴볼 테스트 케이스는 Promise의 settled의 특성과 관련된 것이다. ECMAScript에 따르면 Promise는 한 번 settled가 되면 그 때의 settled value는 바뀔 수 없다: Attempting to resolve or reject a resolved promise has no effect.

Implementation

Promise initial state logical view
Promise initial state logical view
Promise resolve value logical view
Promise resolve value logical view
Promise run fulfillment task logical view
Promise run fulfillment task logical view
Promise retrieve value s from task logical view
Promise retrieve value s from task logical view
const PromiseStateEnum = Object.freeze({
  PENDING: 'pending',
  FULFILLED: 'fulfilled',
  REJECTED: 'rejected',
});

/**
 * @constructor
 * */
export function Promise() {
  this._fulfillmentTasks = [];
  this._rejectionTasks = [];
  this._promiseResult = undefined;
  this._promiseState = PromiseStateEnum.PENDING;
}

Promise의 상태를 정의하는 변수와 생성자 부분의 코드이다. 생성자에서는 현재 Promise가 비동기적으로 실행해야하는 fulfillment task들과 작업을 실행하다 실패했을 때 어떻게 해야할지에 대해서 정의한 rejection task들을 가지고 있다.

그리고 Promise의 작업 결과(settled value)를 내부적으로 가지고 있고 작업 실행 상태에 따른 현재 상태에 대한 변수를 가지고 있다.

Promise.prototype.then = function(onFulfilled, onRejected) {
  const fulfillmentTask = function() {
    if (typeof onFulfilled === 'function') {
      onFulfilled(this._promiseResult);
    }
  }.bind(this);

  const rejectionTask = function() {
    if (typeof onRejected === 'function') {
      onRejected(this._promiseResult);
    }
  }.bind(this);

  switch (this._promiseState) {
    case PromiseStateEnum.PENDING:
      this._fulfillmentTasks.push(fulfillmentTask);
      this._rejectionTasks.push(rejectionTask);
      break;
    case PromiseStateEnum.FULFILLED:
      addToTaskQueue(fulfillmentTask);
      break;
    case PromiseStateEnum.REJECTED:
      addToTaskQueue(rejectionTask);
      break;
    default:
      throw new Error();
  }
}

function addToTaskQueue(task) {
  setTimeout(task, 0);
}

then()을 호출한 시점에 현재 Promise가 ‘pending’ 상태라면 인자로 주어진 onFulfilled, onRejected reaction들을 task queue에 넣어둔다. 다시 말해 아직까지 Promise가 어떤 값으로 settled 되지 않았기 때문에 이를 곧바로 실행시키지 않는 것이다.

만약 then()을 호출한 시점에 이미 Promise가 resolved든 rejected든 settled된 상태라면 onFulfilledonRejected reaction이 바로 실행된다.

addToTaskQueue() 함수에서 눈여겨볼 부분은 바로 인자로 주어진 task 곧바로 task() 와 같이 곧장 실행시키는 것이 아니라 setTimeout()을 통해 이벤트 루프(Event Loop)에 task를 넣은 뒤 비동기적으로 실행하게 된다. 이벤트 루프에 대해서도 코드 레벨에서 이해를 한 뒤에 포스트를 작성해볼 예정이다.

Promise.prototype.resolve = function(value) {
  if (this._promiseState !== PromiseStateEnum.PENDING) {
    return this;
  }
  this._promiseState = PromiseStateEnum.FULFILLED;
  this._promiseResult = value;
  this._clearAndEnqueueTasks(this._fulfillmentTasks);
  return this;
}

Promise.prototype._clearAndEnqueueTasks = function(tasks) {
  this._fulfillmentTasks = undefined;
  this._rejectionTasks = undefined;
  tasks.map(addToTaskQueue);
}

resolve()의 경우 현재 Promise가 이미 settled 된 경우라면 아무 것도 하지 않는다. 아직 settled 되지 않았다면 상태를 ‘fulfilled’로 바꾸고 resolve()를 통해 넘어온 value_promiseResult에 저장한다.

마지막으로 남은 task들을 비우는데 비우기 전에 한 번씩 모두 비동기적으로 실행시킨다. 이를 통해 then()으로 먼저 reaction들을 정의한 뒤 resolve()로 값을 넘기더라도 빠짐없이 실행시킬 수 있다.

Promise.prototype.reject = function(error) {
  if (this._promiseState !== PromiseStateEnum.PENDING) {
    return this;
  }
  this._promiseState = PromiseStateEnum.REJECTED;
  this._promiseResult = error;
  this._clearAndEnqueueTasks(this._fulfillmentTasks);
  return this;
}

reject()의 전체적인 로직은 resolve()와 비슷하다. 바뀐 상태 값만 ‘rejected’로 다르다.

Promise Chaining

다음으로 추가해볼 기능은 Promise의 체이닝(chaining)이다. 이제 then()에서 Promise를 리턴하여 연달아 then()을 정의하여 사용할 수 있다.

또한 catch()함수를 만들어서 rejection reaction에 대해서 사용자가 좀 더 직관적으로 정의할 수 있도록 도와줄 것이다.

마지막으로 구현할 기능은 reaction을 생략할 수 있도록 하는 것인데 이것이 어떤 use-case에서 유용한지 예를 들어 살펴보자.

asyncCall()
  .then(fulfillmentReaction1)
  .then(fulfillmentReaction2)
  .catch(rejectionReaction)

위와 같은 상황에서 만약 asyncCall()을 실행하는 동안 예외가 발생한다면 다음으로 실행되어야할 reaction은 fulfillmentReaction1이 아니라 rejectionReaction이어야 할 것이다.

asyncCall()
  .catch(rejectionReaction)
  .then(fulfillmentReaction)

위와 같은 상황에서도 asyncCall()을 실행할 때 정상적으로 실행이되었다면 다음으로 실행되어야할 reaction은 rejectionReaction이 아니라 곧바로 fulfillmentReaction이 실행되어야 할 것이다.

이러한 기능을 위해 우리는 Promise chaining과 더불어 reaction을 적절한 때에 생략할 수 있도록 만들어야한다.

Test Case

describe('Promise enable simple chaining', () => {
  it('should return fulfilled value via onFulfilled reaction', (done) => {
    p.resolve();
    p.then((value1) => {
      assert.equal(value1, undefined);
      return 123;
    }).then((value2) => {
      assert.equal(value2, 123);
      done();
    });
  });

  it('should return fulfilled value via onRejected reaction', (done) => {
    p.reject();
    p.catch((reason) => {
      assert.equal(reason, undefined);
      return 123;
    }).then((value) => {
      assert.equal(value, 123);
      done();
    });
  });

  it('should pass catch() when fulfilled value is returned', (done) => {
    p.resolve('a');
    p.then((value1) => {
      assert.equal(value1, 'a');
      return 'b';
    }).catch((reason) => {
      // Never called
      assert.fail();
    }).then((value2) => {
      assert.equal(value2, 'b');
      done();
    })
  });
});

우리는 위에서 언급한 기능들이 제대로 동작하는지 확인하기 위하여 위와 같은 테스트 코드를 작성하였다.

  • 첫 번째 테스트 케이스에서는 첫 번째 then()에서 fulfilled value를 리턴했을 때 다음 Promise에 제대로 전달되는지를 확인해본다.
  • 두 번째 테스트 케이스에서는 첫 번째 catch()에서 fulfilled value를 리턴했을 때 다음 Promise에 제대로 전달되는지를 확인해본다.
  • 마지막 테스트에서는 첫 번째 then()에서 fulfilled value를 리턴했을 때 다음에 catch()가 있다면 이를 무시하고 세 번째의 then()으로 fulfilled value를 전달하는지를 확인해본다.

Implementation

Promise chaining logical view
Promise chaining logical view
Promise.prototype.then = function(onFulfilled, onRejected) {
  // chaining을 위해 다음 Promise를 만들어 준다.
  const resultPromise = new Promise();

  const fulfillmentTask = function() {
    if (typeof onFulfilled === 'function') {
      // 다음 Promise에 fulfilled value를 전달하기 위해
      // settled value를 가져온 다음, 다음 Promise에 전달한다.
      const returned = onFulfilled(this._promiseResult);
      resultPromise.resolve(returned);
    } else {
      // onFulfillment reaction이 없을 때는 다음 Promise로 
      // fulfillment value를 넘겨줘야한다.
      resultPromise.resolve(this._promiseResult);
    }
  }.bind(this);

  const rejectionTask = function() {
    if (typeof onRejected === 'function') {
      // 다음 Promise에 rejected value를 전달하기 위해
      // settled value를 가져온 다음, 다음 Promise에 전달한다.
      const returned = onRejected(this._promiseResult);
      resultPromise.resolve(returned);
    } else {
      // onRejected reaction이 없을 때는 다음 Promise로
      // rejection value를 넘겨줘야한다.
      resultPromise.reject(this._promiseResult);
    }
  }
  ...
  return resultPromise; // 다음 Promise를 리턴한다. 
}

Promise.prototype.catch = function(onRejected) {
  return this.then(null, onRejected);
}

우선 Promise chaining을 가능하게하기 위해서 다음에 올 Promise를 생성한다.(resultPromise)

그 다음 현재 Promise가 실행할 fulfillment task와 rejection task를 정의하는데 fulfillment task를 먼저 살펴보면 현재 Promise의 settled value(_promiseResult)를 다음 Promise로 전달하고 있다.

rejection task도 마찬가지이다. rejection reaction에서 에러 핸들링을 한 뒤 어떤 값을 리턴했다면 그것은 rejected value가 아니라 fulfilled value이기 때문에 resultPromise.reject(returned)가 아니라 resultPromise.resolve(returned)가 되어야한다.

asyncCall()
  .catch(rejectionReaction)
  .then(fulfillmentReaction)

그래야 위와 같은 상황에서 에러 핸들링을 마치고 난 후 fulfilled value를 다음 Promise로 넘길 수 있다.

그리고 fulfillment task와 rejection task에서 만약 reaction이 주어지지 않았다면 자신이 어떠한 처리도 하지 않고 바로 다음 Promise로 넘기는데 이를 통해 불필요한 reaction을 생략할 수 있다.

Promise Flattening

많이 발전한 것 같지만 아직 부족한 부분이 많다. 그 중에 하나가 다음과 같은 use-case에서 살펴볼 수 있다.

asyncCall1()
  .then(result1 => {
    return asyncCall2();
  })
  .then(result2Promise => {
    result2Promise
      .then(result2 => {
        ...
      })
  })

아직까진 Promise의 reaction에서 Promise를 리턴하면 다음 Promise에서는 reaction에서 리턴한 Promise의 결과값이 fulfilled value로 전달되는 것이 아니라 리턴한 Promise 그 자체가 다음 Promise의 fulfilled value로 전달된다.

만약 아래와 같으면 얼마나 아름다울까?

asyncCall1()
  .then(result1 => {
    return asyncCall2();
  })
  .then(result2 => { // asyncCall2()의 결과 값이 인자로 주어진다.
    ...
  })

그렇다면 어떻게 이 기능을 구현할 것인가에 대해서 생각해보자.

해결해야할 문제는 현재 Promise P의 fulfilled value로 Promise가 넘어온 상황이다. 이 때 fulfilled value로 넘어온 Promise를 Q라고 하자.

그리고 이전에는 P의 settled value로 Q를 다음 Promise로 넘겼지만 이제는 Q를 resolved 혹은 rejected 한 뒤 그 결과 값을 다음 Promise로 넘겨야한다.

한 가지 방법은 P가 다시 Q가 되는 것이다. Q의 상태가 다시 P의 상태가 되고 Q의 settlement value가 P의 settlement value가 되는 것이다.

Q의 Promise가 resolved 혹은 rejected 되었을 때 P의 settlement value가 결정되는 것이다. 이러한 메커니즘을 ECMAScript에서는 P를 Q에 “lock-in” 한다고 표현하고 있다. P의 결과 값이 Q에 의해 결정되는 것이다: A promise is resolved if it is settled or if it has been “locked in” to match the state of another promise.

그리고 thenable이라는 컨셉이 등장하는데 thenable은 Promises/A+에서 Promise의 then() 함수를 구현한 객체로 정의되고 있다. 우리가 지금 구현하고 있는 Promise도 어떻게 보면 좁은 의미의 thenable이다.

thenable이 등장한 배경은 같은 스펙의 then() 함수를 구현한 서로 다른 Promise들이 서로 호환되며 사용하기 위해서이다. thenable이라는 인터페이스 아래 다양한 구현체가 존재한다고 생각하면 쉽다.

구현으로 넘어가기 전에 이제 ‘Promise가 resolved 되었다’는 상황이 조금 복잡해졌다.

  • P가 resolved 했다고 해서 Q가 resolved 한다는 보장이 없다. 다시 말해서 P가 rejected된 Q를 리턴할 수 있다.
  • P가 resolved 했다고 해서 Q가 settlement value를 결정했다는 보장이 없다. 다시 말해 P는 아직 ‘pending’ 상태는 Q에 의해 결정된다.

Test Case

describe('flattening promises', () => {
  it('should resolve Promise when returned Promise is resolved', (done) => {
    const p1 = new Promise();
    const p2 = new Promise();

    p1.resolve(p2);
    p2.resolve(123);

    p1.then((value) => {
      assert.equal(value, 123);
      done();
    });
  });

  it('should reject Promise when returned Promise is rejected', done => {
    const p1 = new Promise();
    const p2 = new Promise();

    p1.resolve(p2);
    p2.reject(new Error('ERROR'));

    p1.then(value => {
      assert.fail();
    }).catch(error => {
      assert.equal(error.message, 'ERROR');
      done();
    });
  });
});

이번에 테스트할 기능은 위와 같다.

  • p1이 리턴하는 p2가 resolved 되면 p2의 resolved value가 다음 Promise로 전달되어야 한다.
  • p1이 리턴하는 p2가 rejected 되면 p2의 rejected value가 다음 Promise로 전달되어야 한다.

Implementation

Promise fulfillment task return Promise logical view
Promise fulfillment task return Promise logical view
Promise run result Promise again logical view
Promise p result s’ create new Promise logical view
Promise.prototype.resolve = function(value) {
  if (this._alreadyResolved) {
    return this;
  }
  this._alreadyResolved = true;
	
  if (isThenable(value)) {
    value.then(
      (result) => this._doFulfill(result),
      (error) => this._doReject(error)
    );
  } else {
    this._doFulfill(value);
  }
  return this;
}

Promise.prototype.reject = function(error) {
  if (this._alreadyResolved) {
    return this;
  }
  this._alreadyResolved = true;
  this._doReject(error);
  return this;
}

Promise.prototype._doFulfill = function(value) {
  assert.ok(!isThenable(value));
  this._promiseState = PromiseStateEnum.FULFILLED;
  this._promiseResult = value;
  this._clearAndEnqueueTasks(this._fulfillmentTasks);
}

Promise.prototype._doReject = function(error) {
  this._promiseState = PromiseStateEnum.REJECTED;
  this._promiseResult = error;
  this._clearAndEnqueueTasks(this._rejectionTasks);
}

만약에 resolve()로 넘어온 값이 thenable이라면 그것을 다시 실행시켜서 value의 결과 값에 따라서 현재 Promise가 fulfilled 할 지 아니면 rejected 할 지를 결정한다.

만약에 value가 thenable이 아니라면 간단하게 value를 현재 Promise의 resolved value로 결정한다.

Catch Exception

마지막으로 추가해볼 기능은 에러 핸들링 부분인데 좀 더 구체적으로 말하면 reaction에서 던져진 에러들을 다음 catch()에서 잡을 수 있도록 하는 것이다.

Test Case

describe('catch exceptions thrown in reactions', () => {
  it('should reject via onFulfilled', (done) => {
    let ERROR;
    p.resolve();
    p
      .then((value) => {
        assert.equal(value, undefined);
        throw ERROR = new Error();
      })
      .catch((reason) => {
        assert.equal(reason, ERROR);
        done();
      })
  });

  it('should reject via onRejected', (done) => {
    let ERROR;
    p.reject();
    p
      .catch((reason1) => {
        assert.equal(reason1, undefined);
        throw ERROR = new Error();
      })
      .catch((reason2) => {
        assert.equal(reason2, ERROR);
        done();
      });
  });
});

테스트 코드를 통해 이번에 구현할 기능들을 살펴보면 then(), catch()안의 reaction에서 던져진 에러를 다음 catch() 함수에서 핸들링 할 수 있어야 한다.

구현에 대한 아이디어는 우선 reaction을 실행시킨 뒤 혹시 reaction에서 에러가 잡힌다면 그것을 다음 Promise의 rejected value로 결정하면 될 것이다.

Promise.prototype.then = function(onFulfilled, onRejected) {
  const resultPromise = new Promise();
	
  const fulfillmentTask = function() {
    if (typeof onFulfilled === 'function') {
      this._runReactionSafely(resultPromise, onFulfilled);
    } else {
      resultPromise.resolve(this._promiseResult);
    }
  }.bind(this);

  const rejectionTask = function() {
    if (typeof onRejected === 'function') {
      this._runReactionSafely(resultPromise, onRejected);
    } else {
      resultPromise.reject(this._promiseResult);
    }
  }
  ...
}

Promise.prototype._runReactionSafely = function(resultPromise, reaction) {
  try {
    const returned = reaction(this._promiseResult);
    resultPromise.resolve(returned);
  } catch (e) {
    resultPromise.reject(e);
  }
}

이전과 달라진 부분은 then() 함수이다. 위에서 말했듯이 onFulfilled, onRejected reaction이 함수라면 그것을 실행시키고 에러가 잡히면 해당 에러를 다음 Promise의 rejected value로 넘긴다. 해당 부분은 _runReactionSafely()에 구현되어있다.

마치며

소스 코드는 여기서 살펴볼 수 있다.

실제 ECMAScript에서 정의된 Promise와 완전히 동일하진 않지만 핵심기능들에 대해 비슷하게 구현해보며 어떻게 실제로 동작하는지 살펴보았다.

정리하면 Promise는 잘 정의된 어떤 stateful한 객체인데 그 객체 안에서는 사용자가 정의한 비동기 작업이 수행된다. then()이라는 추상화된 함수를 통해서 사용자는 자신이 정의한 작업이 어떻게 비동기적으로 수행되는지 알 필요가 없다. 그저 작업의 결과물을 then()을 통해서 받으면 되고 에러 핸들링은 catch()에서 자신이 핸들러만 정의해주면 Promise 내부에서 알아서 해준다.

다음에 찾아볼 비밀은 아마 setTimeout()이 될 것 같다. 결국 비동기 작업을 가능하게 해주는 함수가 setTimeout()이었고 이미 리서치를 좀 한 상태이지만 험난한 여정이 될 거 같다.

Event loop를 코드레벨에서 살펴보는 것이 다음 포스팅의 후보 중 하나일 거 같다: libuv

Reference