TIL 87 - 바닐라 JS로 Promise를 구현해보자.

김영현·2024년 5월 9일
0

TIL

목록 보기
99/129

Promise란?

면접 단골 질문주제이자 JS 비동기처리의 핵심.

MDN에는 비동기 작업의 최종 완료 또는 실패를 나타내는 객체라고 정의되어있다.

const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));

크게 특별해보이는 문법은 아니지만, 핵심은 두가지다.

  • 비동기 작업의 상태를 알 수 있다.
  • resolve, reject시 작업 콜백을 넘겨줄 수 있다.

어떻게 보면 EventEmitter방식과 유사하다. 완료, 실패됐을때 콜백을 호출한다.

한번 구현해보자.

기본 구조

Promise를 분할-정복해보자.
먼저 함수를 받아 실행할 뿐인 단순한 구조다.

const MY_PROMISE_STATE_PENDING = "pending";
const MY_PROMISE_STATE_FULFILLED = "fulfilled";
const MY_PROMISE_STATE_REJECTED = "rejected";

class MyPromise {
  constructor(executor) {
    this.executor = executor;
    this.init();
    this.state = MY_PROMISE_STATE_PENDING;
  }
  init() {
    this.executor();
  }
}

const mp = new MyPromise(() => console.log("hi"));

이 구조에서 추가해야할 점은 어떤게 있을까?
일단 상태부터 처리하고싶다. 그렇게 하려면, executorresolve,reject파라미터를 받아야하고, 함수의 성공-실패를 알 수 있는 로직을 구현해야한다.

  1. resolve가 호출되면 상태가 fulfilled로 바뀐뒤, 데이터를 넘겨준다.
  2. reject가 호출되면 상태가 rejected로 바뀐뒤, 에러를 넘겨준다.
  3. 메서드는 체이닝되어야한다. 따라서 then,catch메서드는 this를 리턴해야한다.
  4. .then(null, errorHandlingFunction)catch(errorHandlingFunction)은 동의어다.

위 규칙에 의거하여 한번 프라미스를 만들어보자.

const MY_PROMISE_STATE = {
  pending: "pending",
  fulfilled: "fulfilled",
  rejected: "rejected",
};

class MyPromise {
  #state = MY_PROMISE_STATE.pending;
  #value = undefined;
  constructor(executor) {
    this.executor = executor;
    this.#init();
  }

  #resolve(val) {
    this.#state = MY_PROMISE_STATE.fulfilled;
    this.#value = val;
  }

  #reject(err) {
    this.#state = MY_PROMISE_STATE.rejected;
    this.#value = err;
  }

  #init() {
    try {
      this.executor(this.#resolve.bind(this), this.#reject.bind(this));
    } catch (err) {
      this.#reject(err);
    }
  }

  then(cbfn, errorHandlingFunction) {
    if (this.#state === MY_PROMISE_STATE.fulfilled) {
      cbfn(this.#value);
    } else if (this.#state === MY_PROMISE_STATE.rejected || cbfn === null) {
      errorHandlingFunction(this.#value);
    }

    return this;
  }

  catch(cbfn) {
    return this.then(null, cbfn);
  }

  finally(cbfn) {
    cbfn();

    return this;
  }
}

const mp = new MyPromise((res, rej) => {
  rej();
})
  .then(
    () => console.log("then"),
    () => console.log("rej")
  )
  .finally(() => console.log("finally"));

기본적인 구조는 완료되었다. 하지만, 비동기처리는 아직 적용하지 못하였다.
다음단계에서는 비동기처리를 해보자!

then,catch,finally 지연실행

세가지 체이닝메서드로 전달된 콜백함수는 Promise의 상태가 settled되야만 실행한다.
따라서 받아온 콜백함수를 settled되었을때 실행할 필요가 있다.

  1. then, catch, finally의 파라미터로 전달된 콜백 함수를 따로 저장한다!
  2. resolve, reject가 실행될 때, 즉 settled될 때 전달된 콜백함수를 실행한다.
  3. 주의할 점은, then,reject,finally는 계속 체이닝된다. (새로운 프라미스를 계속 반환한다)
const MY_PROMISE_STATE = {
  pending: "pending",
  fulfilled: "fulfilled",
  rejected: "rejected",
};

class MyPromise {
  #executor;
  #state = MY_PROMISE_STATE.pending;
  #value = undefined;

  #onFulFilledFunctions = [];
  #onRejectedFunctions = [];

  constructor(executor) {
    this.#executor = executor;
    this.#init();
  }

  #resolve(val) {
    this.#state = MY_PROMISE_STATE.fulfilled;
    this.#value = val;
    this.#onFulFilledFunctions.forEach((fn) => fn(this.#value));
  }

  #reject(err) {
    this.#state = MY_PROMISE_STATE.rejected;
    this.#value = err;
    this.#onRejectedFunctions.forEach((fn) => fn(this.#value));
  }

  #init() {
    try {
      this.#executor(this.#resolve.bind(this), this.#reject.bind(this));
    } catch (err) {
      this.#reject(err);
    }
  }

  then(onFulFilled, onRejected) {
    return new MyPromise((resolve, reject) => {
      this.#onFulFilledFunctions.push((value) => {
        if (!onFulFilled) {
          resolve(value);
          return;
        }

        try {
          resolve(onFulFilled(value));
        } catch (err) {
          reject(err);
        }
      });

      this.#onRejectedFunctions.push((value) => {
        if (!onRejected) {
          reject(value);
          return;
        }

        try {
          resolve(onRejected(value));
        } catch (err) {
          reject(err);
        }
      });
    });
  }

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

  finally(onFinally) {
    return this.then(
      (value) => {
        onFinally();
        return value;
      },
      (value) => {
        onFinally();
        throw value;
      }
    );
  }
}

const mp = new MyPromise((resolve) => {
  setTimeout(() => resolve(), 2000);
})
  .finally(() => console.log("finally"))
  .then(() => console.log("then"))
  .then(() => {
    throw new Error("hi");
  })
  .catch((err) => console.log(err))
  .then(() => console.log("then2"));

핵심은 catch, finally메서드가 결국 then으로 실행된다는 점이다.
또한 then에서 에러를 던질 수 있기에, try-catch를 계속 사용하는 모습을 볼 수 있다.
이를 통해 Promise도 결국 try-catch를 잘 이용한 비동기 처리 문법이라고 예상할 수 있다.
실제로 Promise의 폴리필을 보면 try-catch를 활용하는 모습을 볼 수 있다.

microtask queue 활용하기

지금까지 제작된 코드도 얼핏 Promise의 흉내를 잘 내지만, 제일 핵심 부분이 빠져있다.
바로 Promise의 핸들러는 기타 함수들과 다르게 microtask queue에 들어가 기존 task queue보다 높은 우선순위를 갖고 있다는 것이다.

다행히도 이를 내부적으로 지원하는 메서드queueMicrotask()가 존재한다.

const MY_PROMISE_STATE = {
  pending: "pending",
  fulfilled: "fulfilled",
  rejected: "rejected",
};

class MyPromise {
  #executor;
  #state = MY_PROMISE_STATE.pending;
  #value = undefined;

  #onFulFilledFunctions = [];
  #onRejectedFunctions = [];

  constructor(executor) {
    this.#executor = executor;
    this.#init();
  }

  #setState(state, value) {
  	//이 부분이 핵심이다. 상태와 값 업데이트, 콜백 실행을 모두 microtaskqueue에 집어넣는다.
    queueMicrotask(() => {
      this.#state = state;
      this.#value = value;
      if (this.#state === MY_PROMISE_STATE.fulfilled) {
        this.#onFulFilledFunctions.forEach((fn) => fn(this.#value));
      }
      if (this.#state === MY_PROMISE_STATE.rejected) {
        this.#onRejectedFunctions.forEach((fn) => fn(this.#value));
      }
    });
  }

  #resolve(value) {
    this.#setState(MY_PROMISE_STATE.fulfilled, value);
  }

  #reject(err) {
    this.#setState(MY_PROMISE_STATE.rejected, err);
  }

  #init() {
    try {
      this.#executor(this.#resolve.bind(this), this.#reject.bind(this));
    } catch (err) {
      this.#reject(err);
    }
  }

  then(onFulFilled, onRejected) {
    return new MyPromise((resolve, reject) => {
      this.#onFulFilledFunctions.push((value) => {
        if (!onFulFilled) {
          resolve(value);
          return;
        }

        try {
          resolve(onFulFilled(value));
        } catch (err) {
          reject(err);
        }
      });

      this.#onRejectedFunctions.push((value) => {
        if (!onRejected) {
          reject(value);
          return;
        }

        try {
          resolve(onRejected(value));
        } catch (err) {
          reject(err);
        }
      });
    });
  }

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

  finally(onFinally) {
    return this.then(
      (value) => {
        onFinally();
        return value;
      },
      (value) => {
        onFinally();
        throw value;
      }
    );
  }
}

const mp = new MyPromise((resolve) => {
  resolve("promise 리졸브됨");
});

const testFunction = () => {
  console.log("콘솔로그 1");

  setTimeout(() => console.log("태스크 큐에 들어감"), 0);

  mp.then((result) => console.log(result));

  console.log("콘솔로그 2");
};

testFunction();

원하는 순서대로 잘 나왔다.

then,catch 내부에서 MyPromise를 리턴할 때

지금까지의 로직대로라면, then,catch 내부에서 MyPromise를 리턴할 시 인스턴스가 그대로 리턴된다.
따라서 Promise에서 호출된 결과를 반환해야한다.

    queueMicrotask(() => {
      this.#state = state;
      this.#value = value;
      ////////////아래 부분추가////////////
      //만약 resolve, reject가 Promise를 리턴한다면, then을 이용하여 체이닝해준다.
      if (value instanceof MyPromise) {
        value.then(this.#resolve.bind(this), this.#reject.bind(this));
        return;
      }
      ////////////////////////////////////
      if (this.#state === MY_PROMISE_STATE.fulfilled) {
        this.#onFulFilledFunctions.forEach((fn) => fn(this.#value));
      }
      if (this.#state === MY_PROMISE_STATE.rejected) {
        this.#onRejectedFunctions.forEach((fn) => fn(this.#value));
      }
    });


new MyPromise((resolve) => {
  setTimeout(() => {
    resolve("첫번째 프라미스");
  }, 1000);
})
  .then((res) => {
    console.log(res);
    return new MyPromise((_, reject) => {
      setTimeout(() => {
        reject("두번째 프라미스에서 에러발생");
      }, 1000);
    });
  })
  .then((res) => {
    console.log(res);
  })
  .catch((err) => console.log(err));

여기까지 했으면 잘 된 것 같다.
이제 Promise.all을 한번 구현해보자!


MyPromise의 정적메서드 구현하기

내부는 얼추 끝났으니 정적메서드를 한번 구현해보자!

MyPromise.resolve

all을 구현하기전에 resolve가 선행되어야한다. 왜냐면...(장시간의 삽질끝에 얻어낸 결론)
특히 빈 객체가 들어왔을때 바로 종료하여 리턴하려면 resolve메서드가 필요하다고 생각했다.
또한, MyPromise.all에서 프라미스를 순회할때, resolve하여 then을 사용할 수 있게 만들어야한다.

기능은 아래와 같다. Promise가 아닌 값을 넣으면 Promise로 감싸주고 Promise가 들어오면, 원본 값을 그대로 리턴한다.

구현은 매우 간단!

...
  static resolve(value) {
    if (value instanceof MyPromise) return value;
    return new MyPromise((resolve) => resolve(value));
  }

  static reject(value) {
    if (value instanceof MyPromise) return value;
    return new MyPromise((_, reject) => reject(value));
  }


const promise1 = MyPromise.resolve(123);
promise1.then((value) => console.log(value));

MyPromise.all

이제 구현해볼 시간이다.
핵심은 모든 promiseresolve되면 값을 반환하는 점이다. 단, 하나라도 reject되면 바로 에러를 반환한다.

참고로 .all메서드는 인스턴스없이 사용하는 정적 메서드임을 유의하자.
따라서 내부에서 private 메서드는 사용할 수 없다.

 static all(promises) {
    return new MyPromise((resolve, reject) => {
      let count = promises.length;

      const returnArr = [];

      //이 부분의 로직을 모든 promises인자를 MyPromise.resolve()로 감싸도 잘 동작한다.
      promises.forEach((ps, i) => {
        if (ps instanceof MyPromise) {
          ps.then((value) => {
            returnArr[i] = value;
            count--;
            !count && resolve(returnArr);
          }).catch(reject);
        } else {
          returnArr[i] = ps;
          count--;
          !count && resolve(returnArr);
        }
      });
    });
  }

핵심은 두가지다

  1. Promise가 아닌 값은 바로 반환한다.
  2. Promise인 값은, then의 콜백에 로직을 전달해준다.

모든 값이 정상일때 잘 작동한다.

const p1 = MyPromise.resolve("첫번째 Promise");

const p2 = "두번째 string";

const p3 = new MyPromise((resolve, reject) => {
  setTimeout(() => {
    resolve("3초뒤 resolve되는 Promise");
  }, 3000);
});

const p = MyPromise.all([p1, p2, p3])
  .then((values) => console.log(values))
  .catch((err) => console.log(`이행되지 않음 : ${err}`));

만약 아래와 같이 reject된 값을 넣으면...

const p4 = new MyPromise((resolve, reject) => {
  setTimeout(() => {
    reject("2초뒤 reject");
  }, 2000);
});

const p = MyPromise.all([p1, p2, p3, p4])
  .then((values) => console.log(values))
  .catch((err) => console.log(`이행되지 않음 : ${err}`));

MyPromise.race

Promise.race()가장 먼저 settled된 결과를 가져온다.
이번에는 모든원소를 MyPromise의 인스턴스로 만들어보자.

  1. 전달된 promises를 순회하면서, 원소들을 MyPromise.resolve로 감싼다.
  2. 래핑된 원소의 then에 현재 settled되어있지 않으면, 값을 resolve 한 뒤 settledtrue로 만든다.
  3. 에러 또한 잡아야하니, 래핑된 원소의 catch문에 2번과 resolve - reject만 바뀐 작업을 해준다.
static race(promises) {
    return new MyPromise((resolve, reject) => {
      let settled = false;

      promises.forEach((ps) => {
        MyPromise.resolve(ps)
          .then((value) => {
            if (!settled) {
              resolve(value);
              settled = true;
            }
          })
          .catch((err) => {
            if (!settled) {
              reject(err);
              settled = true;
            }
          });
      });
    });
  }

중복로직이 자꾸 발생한다. 이를 잘 해결하고싶은데 아직 Promise자체의 이해도도 낮은 탓에 수정하기가 어렵다.😭

그래도 잘 작동하겠지?

  • resolve먼저 됐을 때
const p3 = new MyPromise((resolve, reject) => {
  setTimeout(() => {
    resolve("3초뒤 resolve되는 Promise");
  }, 3000);
});

const p4 = new MyPromise((resolve, reject) => {
  setTimeout(() => {
    reject("4초뒤 reject");
  }, 4000);
});

const p = MyPromise.race([p3, p4])
  .then((value) => console.log(value))
  .catch((err) => console.log(`이행되지 않음 : ${err}`));

  • reject먼저 됐을 때

const p3 = new MyPromise((resolve, reject) => {
  setTimeout(() => {
    resolve("3초뒤 resolve되는 Promise");
  }, 3000);
});

const p4 = new MyPromise((resolve, reject) => {
  setTimeout(() => {
    reject("2초뒤 reject");
  }, 2000);
});

const p = MyPromise.race([p3, p4])
  .then((value) => console.log(value))
  .catch((err) => console.log(`이행되지 않음 : ${err}`));

그래도 생각한대로는 잘 동작하는 모습을 볼 수 있다.


느낀점

Promise는 뜬구름 잡듯 알던 지식이었는데, 이번기회로 많이 알게되서 다행이다.
비동기처리의 핵심이자 꽃! 자주 사용하는 fetch, axios등 api통신용 메서드는 대부분 Promise객체를 반환하니...이를 꼭 명심하자.

또한 Promise는 결국 비동기 처리의 상태를 나타내는 객체이며 콜백try-catch를 응용한 것 일 뿐이다!

내일은 자체 제작한 MyPromise와 generator를 이용하여 async/await 패턴을 구현해 보겠습니다!


참고한 출처들은 아래와 같습니다.

개발자 이영창님의 구현
MDN문서 Using_Promises
MDN문서 Promise.all()
MDN문서 Promise.resolve()

profile
모르는 것을 모른다고 하기

0개의 댓글