[JS] 자바스크립트 Promise 객체 직접 구현해보기

황준승·2023년 1월 28일
72
post-thumbnail

위 글은 자바스크립트 Promise 객체를 직접 구현해보면서 내부 동작 과정을 상세히 알아보는 글입니다.

📌 들어가기에 앞서

콜백 지옥으로부터 저를 포함한 많은 개발자를 구원해준 이 프로미스를 당연하게 사용해왔다. Promise를 mdn과 다른 개발 유투브들을 보면서 사용방법을 익혔지만, 막상 사용하다보면 이해하지 못하는 에러들을 너무 많이 나왔다. 또 막상 구현은 했지만 왜 이렇게 되는거야라고 많이 생각했습니다ㅋㅋㅋ ㅜㅜ

"Promise는 도대체 어떻게 생겨먹은거지??" 라는 궁금증을 계속 가지고 있다가 구글링 중 자바스크립트로 Promise를 직접 구현한 블로그 글들을 보고 저도 직접해보았다.

📌 요구 사항 정리

Promise를 직접 구현하기 위해서는 Promise를 정확히 이해해야한다. 그래서 Promise가 어떻게 생겨먹은 놈인지 더 자세히 살펴보고 구현할 기능 구현 목록 을 작성하자.

1. Promise는 실행 상태를 나타낸다.

  • 실행 전: pending
  • 실행 후 성공했을 때 (resolve): fulfilled
  • 실행 후 실패했을 때 (rejected): rejected

2. Promise는 JS 이벤트 루프에서 Microtask Queue에서 비동기적으로 동작한다.

3. 후속 처리 메서드

  • Promise.prototype.then
    1) then함수는 첫번째 인자(fulfilled function), 두번째 인자(rejected function)를 넣어서 해당 함수를 동작시킨다.

  • Promise.prototype.catch
    1) catch메서드는 하나의 콜백함수를 인자로 받는다.
    2) catch메서드 이후에도 메서드 체이닝이 가능하다. (then과 동일)
    3) Promise에서 발생하는 모든 에러 처리를 담당한다.

  • Promise.prototype.finally
    1) catch메서드는 하나의 콜백함수를 인자로 받는다.
    2) finally는 Promise가 settled 된 상태에서 무조건 한 번 실행된다.
    3) 콜백함수의 리턴값이 적용 X

4. Promise 메서드 체이닝 구현

  • 후속 처리 메서드인 then, catch, finally의 리턴값Promise 객체로 구현

5. Promise 정적 메서드

  • Promise.race(), Promise.all() 함수 등 (구현 X)

위의 요구사항 정리한 토대로 구현을 하려고 했으나 사실 구현함에 있어서 감이 오질 않아 블로그 글들을 참고하여 다음과 같은 순서대로 구현해보았다. (위의 요구사항도 모두 지키주면서~)

  1. Simplest 프로미스
  2. Then, Resolve 함수 구현
  3. Promise 상태에 따른 동작 구현
  4. 프로미스 체이닝 구현
  5. 비동기 함수 구현
  6. catch 구현
  7. finally 구현
  8. 그 외 에러 사항 처리

📌 구현

1. Simplest Promise

class MyPromise {
  #value = null;
  
  constructor(executor) {
    executor((value) => {  
      this.#value = value;
    })
  }

  then(callback) {
    callback(this.#value);
             
    return this;
  }
}


// testing
function testMyPromise() {
  return new MyPromise((resolve) => {
    resolve('my resolve');
  });
}

testMyPromise().then((value) => console.log(value)); // my resolve

기존의 Promise의 경우 resolve 또는 reject 함수의 인자값으로 들어간 값들을 이어서 사용할 수 있다.
이를 정말 간단하게 구현해보았다.

2. 간단한 then, catch, resolve, reject 함수 구현

class MyPromise {
  #value = null;

  constructor(executor) {
    this.#value = null;

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

  #resolve(value) {
    this.#value = value;
  }

  #reject(error) {
    this.#value = error;
  }

  then(callback) {
    callback(this.#value);

    return this;
  }

  catch(callback) {
    callback(this.#value);

    return this;
  }
}

function testMyPromise() {
  return new MyPromise((resolve) => {
    resolve('my resolve');
  });
}

testMyPromise().then((value) => console.log(value)); // my resolve

reject함수와 catch함수를 추가했다.

코드를 보시면 알지만 resolvereject와 동작이 동일, thencatch와 동작이 동일하다

bind 함수...?
resolve와 reject가 실제로 실행되는 구간은 testMyPromise함수이다. ( 즉, this는 testMyPromise를 가리키고 있다. )

따라서 MyPromise 내부의 resolve, reject함수를 실행시키기 위해서는 bind함수를 무조건 적용해야한다.

3. Promise 상태에 따른 동작 구현

앞서 위의 예제에서 다음과 같은 코드를 실행해보자.

function myPromiseFn2(input) {
  return new MyPromise((resolve, reject) => {
    if (input === 1) {
      resolve('성공');
    } else {
      reject('실패');
    }
  });
}

myPromiseFn2(1)
  .then((v) => console.log(v)) // 성공
  .catch((v) => console.log(v)); // 성공 ... ??

앞서 이때까지 구현한 코드는 성공(resolve)과 실패(reject)에 대한 정보가 아예 없으므로 then구문과 catch구문 모두 실행되는 것을 알 수 있다.

Promise의 상태(pending, fulfilled, rejected) 를 설정해줌으로서 이를 방지시켜보자.

const { PROMISES_STATE } = require('./utils/constants');

class MyPromise {
  #value = null;
  
  #state = PROMISES_STATE.PENDING;
  
  constructor(executor) {
    try {
      executor(this.#resolve.bind(this), this.#reject.bind(this));
    } catch (error) {
      this.#reject(error);
    }
  }

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

  #reject(error) {
    this.#state = PROMISES_STATE.rejected;
    this.#value = error;
  }

  then(callback) {
    if (this.#state === PROMISES_STATE.fulfilled) {
      callback(this.#value);
    }

    return this;
  }

  catch(callback) {
    if (this.#state === PROMISES_STATE.rejected) {
      callback(this.#value);
    }

    return this;
  }
}

Promsie 상태를 다음과 같이 선언하고

const PROMISES_STATE = Object.freeze({
  pending: 'PENDING',
  fulfilled: 'fulfilled',
  rejected: 'rejected',
});

해당 상태에 맞게 함수가 실행되도록 하였다.

  • resolve -> fulfilled -> then 실행 가능
  • reject -> rejected -> catch 실행 가능

4. Promise 체이닝 구현 (then함수만)

앞서 위의 예제에서 다음과 같은 코드를 실행해보자.

function myPromiseFn2(input) {
  return new MyPromise((resolve, reject) => {
    if (input === 1) {
      resolve('성공');
    } else {
      reject('실패');
    }
  });
}

myPromiseFn2(1)
  .then((v) => {
    console.log(v); // 성공
  	return '체이닝 되나??'
  }) 
  .then((v) => console.log(v))  // 성공 ... ??
  .catch((v) => console.log(v)); 

프로미스 체이닝 구현 시 성공 - 체이닝 되나?? 로 출력이 되어야 하지만 성공 이 두 번 출력되었다.

then 함수에 리턴값으로 My Promise 객체 인스턴스를 두어서 프로미스 체이닝을 구현하도록 하자. (요구사항에도 있으니)

const { PROMISES_STATE } = require('./utils/constants');

class MyPromise {
  #value = null;
  
  #state = PROMISES_STATE.PENDING;
  
  constructor(executor) {
    try {
      executor(this.#resolve.bind(this), this.#reject.bind(this));
    } catch (error) {
      this.#reject(error);
    }
  }

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

  #reject(error) {
    this.#state = PROMISES_STATE.rejected;
    this.#value = error;
  }

  then(callback) {
    if (this.#state === PROMISES_STATE.fulfilled) {
	  return new MyPromise((resolve) => resolve(callback(this.#value)));
    }
  }

  catch(callback) {
    if (this.#state === PROMISES_STATE.rejected) {
      callback(this.#value);
    }

    return this;
  }
}

myPromiseFn2(1)
  .then((v) => {
    console.log(v); // 성공
    return '체이닝 되나??';
  })
  .then((v) => console.log(v)) // 체이닝 되나?? 
  .catch((v) => console.log(v));

then 함수를 유심히 살펴보자

리턴값으로 새로운 MyPromise 객체 인스턴스를 두었다.

value값(this.#value)에다가 callback함수를 실행시킨 결과값을 resolve에 넣어서 프로미스 체이닝이 가능하도록 만들었다.

5. 비동기 구현 (then함수만)

앞서 위의 예제에서 다음과 같은 코드를 실행해보자.

function myPromiseFn2() {
  return new MyPromise((resolve, reject) => {
	setTimeout(() => {
      resolve('1초 뒤 실행됨')
    }, 1000);
  });
}

myPromiseFn2()
  .then((v) => {
    console.log(v);
  	return '1초 뒤 체이닝 되나??'
  }) 
  .then((v) => console.log(v))  

위와 같은 코드를 실행시키면 Cannot read properties of null (reading 'then') 의 에러가 발생한다.

즉, myPromiseFn2()를 실행시키면 1초 뒤에 resolve함수가 실행됨으로 then에 대한 리턴값이 undefined라 다음과 같은 에러가 발생하게 되는 것이다.

그래서 Promise 상태가 pending일 때(비동기)와 fulfilled일 때(동기)를 구분지어 구현하였다.

const { PROMISES_STATE } = require('./utils/constants');

class MyPromise {
  #state = PROMISES_STATE.pending;

  #value = null;

  #lastcalls = [];

  constructor(executor) {
    try {
      executor(this.#resolve.bind(this), this.#reject.bind(this));
    } catch (error) {
      this.#reject(error);
    }
  }

  #resolve(value) {
    this.#state = PROMISES_STATE.fulfilled;
    this.#value = value;
    this.#lastcalls.forEach((lastcall) => lastcall());
  }

  #reject(error) {
    this.#state = PROMISES_STATE.rejected;
    this.#value = error;
    this.#lastcalls.forEach((lastcall) => lastcall());
  }

  #asyncResolve(callback) {
    if (this.#state === PROMISES_STATE.pending) {
      return new MyPromise((resolve) =>
        this.#lastcalls.push(() => resolve(callback(this.#value)))
      );
    }

    return null;
  }

  #syncResolve(callback) {
    if (this.#state === PROMISES_STATE.fulfilled) {
      return new MyPromise((resolve) => resolve(callback(this.#value)));
    }

    return null;
  }

  then(callback) {
    return this.#asyncResolve(callback) || this.#syncResolve(callback);
  }

  catch(callback) {
    if (this.state === PROMISES_STATE.rejected) {
      callback(this.#value);
    }

    return this;
  }
}

비동기일 때 (PROMISES_STATE.pending) 다음 실행할 함수를 lastcalls라는 배열에 넣어 함수 실행을 지연시킨다.

( 클로저와 스코프 개념과 관련해서 접근하면서 이해하자 )

마이크로 테스크 큐도 적용해보자.

앞서 위의 예제로 아래 코드를 실행시켜보자.

function myPromiseFn() {
  return new MyPromise((resolve, reject) => {
    resolve('Promise 실행');
  });
}

const testLogic = () => {
  console.log('콜스택 실행 - 1');

  setTimeout(() => console.log('태스크 큐 실행'), 0);

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

  console.log('콜스택 실행 - 2');
};

testLogic();

/*
  콜스택 실행 - 1
  Promise 실행 
  콜스택 실행 - 2
  태스크 큐 실행
*/

예상되는 정답은 콜스택 실행 - 1 - 콜스택 실행 - 2 - Promise 실행 - 태스크 큐 실행 이지만 실제 결과는 그렇지 않다.

( 위의 결과가 이해되지 않는다면 이 글을 읽어보자 )

다행히도 자바스크립트에는 마이크로태스크큐를 지원해주는 메서드가 존재했고 이를 활용했습니다.
mdn - queueMicrotask

const { PROMISES_STATE } = require('./utils/constants');

class MyPromise {
  #state;

  #value;

  #lastcalls;

  constructor(executor) {
    this.#state = PROMISES_STATE.pending;
    this.#value = null;
    this.#lastcalls = [];

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

  #update(state, value) {
    queueMicrotask(() => {
      this.#state = state;
      this.#value = value;
      this.#lastcalls.forEach((lastcall) => lastcall());
    });
  }

  #resolve(value) {
    this.#update(PROMISES_STATE.fulfilled, value);
  }

  #reject(error) {
    this.#update(PROMISES_STATE.rejected, error);
  }

...

6. catch함수 구현

실제 Promise의 에러 처리 방법은 catch 함수로도 가능하지만 then의 두번째 인자에 onRejected Function을 넣음으로써 구현이 가능하다.

// mdn - Promise 예제입니다.
const p1 = new Promise((resolve, reject) => {
  resolve("Success!");
  // or
  // reject(new Error("Error!"));
});

p1.then(
  (value) => {
    console.log(value); // Success!
  },
  (reason) => {
    console.error(reason); // Error!
  },
);

어떻게 구현해야할까...?

앞서 비동기 처리 이후 then 함수를 실행시키기 위해서는 함수의 지연을 위해 lastcalls라는 배열에 실행시킬 함수를 저장, 실행시켜 구현하였다. (lastcalls -> thenCallbacks로 변경하였습니다.)

catch 함수도 마찬가지로 catch 함수를 실행시키기 위한 배열 catchCallbacks라는 배열에 실행시킬 함수를 저장, 실행시키자.

const { PROMISES_STATE } = require('./utils/constants');

class MyPromise {
  #value = null;

  #state = PROMISES_STATE.pending;

  #catchCallbacks = [];

  #thenCallbacks = [];

  constructor(executor) {
    try {
      executor(this.#resolve.bind(this), this.#reject.bind(this));
    } catch (error) {
      this.#reject(error);
    }
  }

  #runCallbacks() {
    if (this.#state === PROMISES_STATE.fulfilled) {
      this.#thenCallbacks.forEach((callback) => callback(this.#value));
      this.#thenCallbacks = [];
    }

    if (this.#state === PROMISES_STATE.rejected) {
      this.#catchCallbacks.forEach((callback) => callback(this.#value));
      this.#catchCallbacks = [];
    }
  }

  #update(state, value) {
    queueMicrotask(() => {
      if (this.#state !== PROMISES_STATE.pending) return;
      this.#state = state;
      this.#value = value;
      this.#runCallbacks();
    });
  }

  #resolve(value) {
    this.#update(PROMISES_STATE.fulfilled, value);
  }

  #reject(error) {
    this.#update(PROMISES_STATE.rejected, error);
  }

  then(thenCallback, catchCallback) {
    return new MyPromise((resolve, reject) => {
      this.#thenCallbacks.push((value) => {
        if (!thenCallback) {
          resolve(value);
          return;
        }

        try {
          resolve(thenCallback(value));
        } catch (error) {
          reject(error);
        }
      });

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

        try {
          // catch함수 실행 이후에도 메서드 체이닝이 가능하다. 
          resolve(catchCallback(value));
        } catch (error) {
          reject(error);
        }
      });
    });
  }

  catch(catchCallback) {
    return this.then(undefined, catchCallback);
  }
}

catch함수를 살펴보기

catch(callback) === then(undefined, catchCallback) 같기 때문에 다음과 같이 선언하였다.

catch(catchCallback) {
  return this.then(undefined, catchCallback);
}

then함수 살펴보기

  1. catch함수 구현을 위해 catchCallbacks 배열에도 에러 처리 동작을 저장한다.
  • 만약에 Promise의 상태가 fulfilled라면 thenCallbacks 배열 안에있는 함수를 실행, rejected라면 catchCallbacks 배열 안에 있는 함수를 실행한다.
    (runCallbacks 함수 참고 )
  1. then 함수 실행 시 발생할 에러에 대해서도 try ~ catch 구문을 통해서 에러 처리도 구현하였다.

  2. then 함수 실행 시 인자를 모두 다 선언하지 않아도 동작하게끔 하였다.

  3. catch함수 동작 이후에도 메서드 체이닝이 가능하게 하였다.

// 예시 코드
function myPromiseFn2(input) {
  return new MyPromise((resolve, reject) => {
    if (input === 1) {
      resolve('성공');
    } else {
      reject('실패');
    }
  });
}

myPromiseFn2(2)
  .then((v) => {
    console.log(v);
    return v;
  })
  .then((v) => console.log(v))
  .catch((v) => {
    console.log(v);
    return '이게 무람?';
  })
  .then((v) => console.log(v));

/*
  -- 출력 -- 
  실패
  이게무람
*/

7. finally함수 구현

finally 메서드는 callback 인자를 받아 실행합니다.
이 때 실행되는 시점은 Promise의 값이 settled된 이후 실행됩니다.
finally의 반환 값은 then, catch와 마찬가지로 Promise입니다.

// 예시코드
function myPromiseFn2(input) {
  return new MyPromise((resolve, reject) => {
    if (input === 1) {
      resolve('성공');
    } else {
      reject('실패');
    }
  });
}

myPromiseFn2(1) 
  .then((v) => {
  	console.log(v);
  	throw new Error('실패');
  })
  .finally(() => {
  	console.log('finally');
  })
  .catch((error) => {
    console.log(error.message);
    return '이게 무람?';
  })
  .then((v) => console.log(v));

/*
  -- 출력 -- 
  성공
  finally
  실패
  이게무람
*/

finally 역시 catch함수와 마찬가지로 then함수로 구현이 가능합니다.
이때 finally로 넘어온 callback함수는 settled 된 시점이기 때문에 Promise 상태가 fulfilled인지 rejected임에 따라 다르게 동작이 가능합니다.

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

8. 그 외 예외처리

then(callback)의 callback 함수 내부에서 새로운 프로미스를 만들어 리턴한다면??

new MyPromise((resolve, reject) => {
  setTimeout(() => {
    resolve('첫번째 프라미스');
  }, 1000);
})
  .then((res) => {
    console.log(res);
    return new MyPromise((resolve, reject) => {
      setTimeout(() => {
        resolve('두번째 프라미스');
      }, 1000);
    });
  })
  .then((res) => {
    console.log(res);
  });

/*
  -- 실제 Promise 동작 -- 
  첫번째 프로미스
  두번째 프로미스
 
  -- 내가 만든 Promise 동작 --
  첫번째 프로미스
  MyPromise{}
*/

즉, 내가 구현한 코드에서는 단순히 return 한 값을 넘겨주는 행위만 하므로 MyPromise 객체가 출력되었다.

해결
return 결과값(resolve할 값)이 instance가 Promise일 경우 해당 프로미스에서 나온 값을 도출하고 다음 동작으로 넘어간다.

  ...
  
  #update(state, value) {
    queueMicrotask(() => {
      if (this.#state !== PROMISES_STATE.pending) return;
      if (value instanceof MyPromise) {
        value.then(this.#resolve.bind(this), this.#reject.bind(this));
        return;
      }
      this.#state = state;
      this.#value = value;
      this.#runCallbacks();
    });
  }

  #resolve(value) {
    this.#update(PROMISES_STATE.fulfilled, value);
  }

  #reject(error) {
    this.#update(PROMISES_STATE.rejected, error);
  }
  
  ...

설명

if (value instanceof MyPromise) {
  return value.then(this.#resolve.bind(this), this.#reject.bind(this));
}

핵심은 두번째로 생성되는 resolve 함수의 지연실행 이다.

처음 작성했던 callback의 지연실행을 참고하면 첫번째 프로미스의 resolve 내부에서 callback 대신에 두번째로 생성되는 Promise의 resolve를 실행하게 만들면 된다.

callback 이 아닌 두번째 promise(then 메소드의 실행으로 탄생한)의 resolve 함수의 지연실행 인 것이다.

해당 코드에서도 리턴값이 Promise 객체일 경우 해당 리턴값이 Promise의 두번째 resolve함수 실행하게 만든다.

䷛ 최종 코드

Promise 직접 구현한 레포

👏 끝으로

사실 나의 온전히 나의 힘으로 Promise를 직접 커스텀해보고 싶었다. 하지만 그 과정이 매우 험난해 블로그 글들과 코드를 참고하여 완성하였다.

이 프로젝트를 하면서 클로저스코프 그리고 재귀적 알고리즘을 다시 한번 복습하고 비동기 프로그래밍과 콜백함수에 대해서도 더 깊게 이해할 수 있는 시간이었다.

참고자료
Promise 직접 구현하기
개발자 정현민님의 Promise 직접 구현
문서포트님의 Promise 만들기

profile
다른 사람들이 이해하기 쉽게 기록하고 공유하자!!

4개의 댓글

comment-user-thumbnail
2023년 1월 28일

와!! 수고하셨습니다! 함수형 프로그래밍 스터디 바로 다음 시간 주제가 Promise를 직접 구현해보는 것이었는데 이렇게 훌륭한 글을 만나게 되어 놀랬습니다. Promise를 한번 구현해보면 정말 많은 것들을 몸소 느낄수가 있죠. 객체와 비동기큐와 콜백이 조화를 이뤄내서 정말 멋진 것을 만들어 낸다고 생각합니다. 이걸 한번 구현하고 나면 그 뒤로 Promise와 async await에 대해서 보는 시각이 정말 달라진다는 것을 느꼈을 거라고 생각합니다. 수고 많으셨습니다. 좋은 글 잘 읽었습니다.

1개의 답글
comment-user-thumbnail
2023년 2월 1일

우와 Promise에 대해 이해가 부족해서 고민 중이었는데 너무 좋은 글을 만났네요!
직접 구현해보며 저 또한 비동기, Promise에 대해 이해할 수 있는 계기가 되었습니다 :)

Promise도 callback으로 이루어진 것을 보면 이것 또한 문법적 설탕은 아닐까... 생각이 드네요...!
잘 읽었습니다 😄

1개의 답글