hyunmin!
개발자 정현민
hyunmin!
  • All (18)
    • Web (7)
      • JavaScript (3)
      • TypeScript (1)
      • React (3)
    • Life (11)
      • 부스트캠프 (11)

블로그 메뉴

  • 홈
  • 태그
  • 방명록
hELLO · Designed By 정상우.
hyunmin!

개발자 정현민

자바스크립트의 Promise 직접 구현하기
Web/JavaScript

자바스크립트의 Promise 직접 구현하기

2021. 9. 20. 20:45

사용할 줄 안다는 것과 이해했다는 것은 차이가 있다. 이번 포스트에서 Promise 객체를 직접 구현해보며 이해하도록 하겠다.

📒 사전 지식

  • Promise
  • Classes

🍽 요구사항

1. resolve, reject

new Promise((resolve, reject) => {
  //...
})

2. then, catch, finally

doSome()
  .then(() => {})
  .catch(() => {})
  .finally(() => {})

3. state

Promise는 다음 중 하나의 상태를 가진다.

  • 대기(pending): 이행하거나 거부되지 않은 초기 상태.
  • 이행(fulfilled): 연산이 성공적으로 완료됨.
  • 거부(rejected): 연산이 실패함.

4. chaining

doFirst()
  .then(firstResult => doSecond(firstResult))
  .then(secondResult => doFinal(secondResult))
  .then(finalResult => {
    console.log(`Got the final result: ${finalResult}`);
  })
  .catch(failureCallback);

🥱 1. Simplest 프로미스

class Promise {
  constructor(callback) {
    callback((value) => {
      this.value = value;
    });
  }

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

세상에서 가장 간단한 프로미스 클래스다.

function myFunc() {
  return new Promise((resolve) => {
    resolve('my resolve');
  });
}

myFunc().then((result) => console.log(result)); // my resolve

예상한 값이 잘 출력된다.

시작이 반이지만 아직 갈 길이 많이 남았다.

😲 2. Simpler 프로미스

class Promise {
  constructor(callback) {
    const resolve = (value) => {
      this.value = value;
    };
    const reject = (value) => {
      this.value = value;
    };
    callback(resolve, reject);
  }

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

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

reject를 추가했다. 바로 다음 단계로 넘어가겠다.

😒 3. Simple 프로미스

class Promise {
  constructor(callback) {
    this.state = 'pending';

    const resolve = (value) => {
      this.state = 'fulfilled';
      this.value = value;
    };
    const reject = (value) => {
      this.state = 'rejected';
      this.value = value;
    };
    callback(resolve, reject);
  }

  then(callback) {
    if (this.state === 'fulfilled') {
      callback(this.value);
    }
    return this;
  }

  catch(callback) {
    if (this.state === 'rejected') {
      callback(this.value);
    }
    return this;
  }
}

pending, fulfilled, rejected 상태를 추가했다. 그리고 then과 catch 메소드에서 this를 리턴한다.

그러면 아래와 같이 사용할 수 있다.

function myResolve() {
  return new Promise((resolve, reject) => {
    resolve('my resolve');
  });
}

function myReject() {
  return new Promise((resolve, reject) => {
    reject('my reject');
  });
}

myResolve()
    .then((result) => console.log(result)) // my resolve
    .catch((result) => console.log(result));

myReject()
    .then((result) => console.log(result))
    .catch((result) => console.log(result)); // my reject

보기에는 그럴싸한 프로미스가 완성됐지만, 큰 문제가 있다.

비동기를 지원하지 않는다는 점이다.

😅 4. 비동기 지원 프로미스

function myResolve() {
  return new Promise((resolve, reject) => {
    setTimeout(() => resolve('my resolve'), 1000);
  });
}

myResolve()
    .then((result) => console.log(result))
    .catch((result) => console.log(result));

위 코드를 실행하면 아무것도 출력되지 않는다.

1초 뒤 생성자에서 resolve 함수가 실행되어 fulfilled 상태로 변경되지만, 이미 then함수를 호출한 뒤의 이야기다. 즉, resolve가 then 보다 늦게 실행된다는 점이 문제를 일으킨다.

이 부분을 보완하겠다.

class Promise {
  constructor(callback) {
    this.state = 'pending';
    this.onFulfilledCallback = null;
    this.onRejectedCallback = null;
    const resolve = (value) => {
      this.state = 'fulfilled';
      this.value = value;
      if (this.onFulfilledCallback !== null) {
        this.onFulfilledCallback(value);
      }
    };
    const reject = (value) => {
      this.state = 'rejected';
      this.value = value;
      if (this.onRejectedCallback !== null) {
        this.onRejectedCallback(value);
      }
    };
    callback(resolve, reject);
  }

  then(callback) {
    if (this.state === 'pending') { // 🌟
      this.onFulfilledCallback = callback;
    }
    if (this.state === 'fulfilled') {
      callback(this.value);
    }
    return this;
  }

  catch(callback) {
    if (this.state === 'pending') { // 🌟
      this.onRejectedCallback = callback;
    }
    if (this.state === 'rejected') {
      callback(this.value);
    }
    return this;
  }
}

pending 상태일 때 콜백 함수를 멤버 변수에 저장한다. 그리고, 비동기 처리 후 resolve 또는 reject가 호출되면 콜백을 실행하도록 변경했다.

function myResolve() {
  return new Promise((resolve, reject) => {
    setTimeout(() => resolve('my resolve'), 1000);
  });
}

myResolve()
    .then((result) => console.log(result)); // my resolve

1초 뒤 콜백들이 잘 실행 되는 것을 볼 수 있다.

몇 가지 기능을 더 추가해보자.

😡 5. 체이닝 지원 프로미스

myResolve()
    .then((result) => `next ${result}`)
    .then((result) => console.log(result)) // next my resolve

지금까지 만든 프로미스 객체로 위와 같은 체이닝은 불가능하다.

이 부분을 고쳐보자.

  then(callback) {
    return new Promise((resolve, reject) => {
      if (this.state === 'pending') {
        this.onFulfilledCallback = () => {
          const result = callback(this.value);
          resolve(result);
        };
      }
      if (this.state === 'fulfilled') {
        const result = callback(this.value);
        resolve(result);
      }
    });
  }

  catch(callback) {
    return new Promise((resolve, reject) => {
      if (this.state === 'pending') {
        this.onRejectedCallback = () => {
          const result = callback(this.value);
          resolve(result);
        };
      }
      if (this.state === 'rejected') {
        const result = callback(this.value);
        resolve(result);
      }
    });
  }

then과 catch 메소드에서 새로운 프로미스 객체를 반환함으로써 체이닝이 가능해졌다.

반환하는 프로미스 객체의 resolve 또는 reject에 이전 콜백 함수의 결괏값을 넣는 것이 핵심이다.

catch 메소드 에서 reject가 아닌 resolve를 호출한 것에 의문을 품을 수 있다. 실제 프로미스 객체의 프로미스 체이닝 작동방식을 보면 catch 뒤의 then 콜백이 실행되는 것을 볼 수 있다. 그 부분과 똑같이 작동하기 위해서는 catch에서 resolve를 호출해야 한다. (이 부분은 현재 문제가 있다. 7번 과정에서 해결된다.)

거의 다 왔다. 몇 가지 문제점을 더 해결할 것이다.

💀 6. 비동기 체이닝 지원 프로미스

지금까지 비동기와 체이닝을 가능하도록 했다. 하지만 이 둘을 합친 비동기 체이닝은 아직 지원하지 않는다.

비동기 체이닝의 예시를 들겠다.

getUserId(username)
    .then((id) => {
      return getUserAge(id);
    })
    .then((age) => console.log(username + ' is ' + age));

다음과 같이 비동기 처리를 해야 할 경우가 있다.

어떤 데이터를 가져오고, 그 데이터를 기반으로 다른 데이터를 불러와야 하는 바로 그 상황 말이다.

이 기능을 가능하도록 보완하겠다.

  then(callback) {
    return new Promise((resolve, reject) => {
      if (this.state === 'pending') {
        this.onFulfilledCallback = () => {
          this.handleCallback(callback, resolve);
        };
      }
      if (this.state === 'fulfilled') {
        this.handleCallback(callback, resolve);
      }
    });
  }

  catch(callback) {
    return new Promise((resolve, reject) => {
      if (this.state === 'pending') {
        this.onRejectedCallback = () => {
          this.handleCallback(callback, resolve);
        };
      }
      if (this.state === 'rejected') {
        this.handleCallback(callback, resolve);
      }
    });
  }

  handleCallback(callback, resolve, reject) { // 🌟
    const result = callback(this.value);
    if (result instanceof Promise) {
      result.then(resolve);
    } else {
      resolve(result);
    }
  }

instaceof 함수를 통해 콜백의 결과가 프로미스 객체이면 객체의 then 메소드를 호출함으로써 비동기 체이닝을 지원한다.

눈치챘겠지만, 위 코드에는 큰 오점이 있다.

아무런 조건 없이 then을 호출한다는 것이다. 이 부분을 수정해서 handleCallback 메소드를 다시 작성하겠다.

  handleCallback(callback, resolve, reject) { // 🌟
    const result = callback(this.value);
    if (result instanceof Promise) {
      if (result.state === 'fulfilled') {
        result.then(resolve);
      }
      if (result.state === 'rejected') {
        result.catch(reject);
      }
      if (result.state === 'pending') {
        result.onFulfilledCallback = () => result.then(resolve);
        result.onRejectedCallback = () => result.catch(reject);
      }
    } else {
      resolve(result);
    }
  }

주안점은 pending 상태일 경우, 실행해야할 함수를 result 인스턴스에 저장했다가 실행한다는 점이다.

이제 프로미스 객체의 핵심 기능을 모두 구현했다.

😈 7. 해치웠나?

수리해야 할 부분이 생겼다.

첫 번째로, reject의 고장이다.

then과 catch를 호출할 때마다 새로운 프로미스 인스턴스가 만들어진다. 그리고 1초 뒤 첫 번째 인스턴스에서 reject를 호출하기 때문에 마지막 인스턴스의 콜백은 당연히 실행되지 않는다.

따라서 현재 인스턴스에서 실행할 콜백이 없을 시 fall through 하도록 해주어야 한다.

또한 동기적 프로미스도 위와 같은 이유로 fall through 기능이 필요하다. 해당 부분을 다음과 같이 수정했다.

  resolve = (value) => {
    if (this.state === 'pending') {
      this.state = 'fulfilled';
      this.value = value;
      if (this.onFulfilledCallback !== null) {
        this.onFulfilledCallback(value);
      } else {
        this.child?.resolve(value); // 🌟
      }
    }
  };

  reject = (value) => {
    if (this.state === 'pending') {
      this.state = 'rejected';
      this.value = value;
      if (this.onRejectedCallback !== null) {
        this.onRejectedCallback();
      } else {
        this.child?.reject(value); // 🌟
      }
    }
  };

  then(callback) {
    this.child = new Promise((resolve, reject) => {
      if (this.state === 'pending') {
        this.onFulfilledCallback = () => {
          this.handleCallback(callback, resolve, reject);
        };
      }
      if (this.state === 'fulfilled') {
        this.handleCallback(callback, resolve, reject);
      }
      if (this.state === 'rejected') { // 🌟
        reject(this.value);
      }
    });
    return this.child;
  }

  catch(callback) {
    this.child = new Promise((resolve, reject) => {
      if (this.state === 'pending') {
        this.onRejectedCallback = () => {
          this.handleCallback(callback, resolve, reject);
        };
      }
      if (this.state === 'rejected') {
        this.handleCallback(callback, resolve, reject);
      }
      if (this.state === 'fulfilled') { // 🌟
        resolve(this.value);
      }
    });
    return this.child;
  }

두 번째로, 콜백에서의 예외 발생이다.

myResolve()
    .then((result) => {
      return 'next ' + result;
    })
    .then((result) => {
      throw 'error';
    })
    .catch((error) => {
      console.error(error);
    });

다음과 같은 콜백 내부 예외 상황을 지원하기 위해, handleCallback 메소드에 try catch를 추가했다.

세 번째로, 한 인스턴스에 여러개의 콜백이 있을 경우이다.

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

promise
    .then((result) => {
      console.log('1', result);
    });
promise
    .then((result) => {
      console.log('2', result);
    });
promise
    .then((result) => {
      console.log('3', result);
    });

여러개의 콜백을 실행하기 위해 기존에 변수로 저장하던 콜백을 배열로 변경했다.

🪦 8. 마무리 작업

지금까지 만든 프로미스 객체를 마무리하기 위해 아래와 같은 과정을 거치겠다.

1. finally 메소드 추가

2. 접근 제한자 추가

최종 코드

더보기
export default class Promise {
  #value = null;
  #state = 'pending';
  #child = null;
  #onFulfilledCallbacks = [];
  #onRejectedCallbacks = [];
  #onFinallyCallbacks = [];

  constructor(callback) {
    callback(this.#resolve, this.#reject);
  }

  #resolve = (value) => {
    if (this.#state === 'pending') {
      this.#state = 'fulfilled';
      this.#value = value;
      this.#onFinallyCallbacks.forEach((callback) => callback());
      if (this.#onFulfilledCallbacks.length !== 0) {
        this.#onFulfilledCallbacks.forEach((callback) => callback(value));
      } else {
        this.#child?.#resolve(value);
      }
    }
  };

  #reject = (value) => {
    if (this.#state === 'pending') {
      this.#state = 'rejected';
      this.#value = value;
      this.#onFinallyCallbacks.forEach((callback) => callback());
      if (this.#onRejectedCallbacks.length !== 0) {
        this.#onRejectedCallbacks.forEach((callback) => callback(value));
      } else {
        this.#child?.#reject(value);
      }
    }
  };

  then(callback) {
    this.#child = new Promise((resolve, reject) => {
      if (this.#state === 'pending') {
        this.#onFulfilledCallbacks.push(() => {
          this.#handleCallback(callback, resolve, reject);
        });
      }
      if (this.#state === 'fulfilled') {
        this.#handleCallback(callback, resolve, reject);
      }
      if (this.#state === 'rejected') {
        reject(this.#value);
      }
    });
    return this.#child;
  }

  catch(callback) {
    this.#child = new Promise((resolve, reject) => {
      if (this.#state === 'pending') {
        this.#onRejectedCallbacks.push(() => {
          this.#handleCallback(callback, resolve, reject);
        });
      }
      if (this.#state === 'rejected') {
        this.#handleCallback(callback, resolve, reject);
      }
      if (this.#state === 'fulfilled') {
        resolve(this.#value);
      }
    });
    return this.#child;
  }

  finally(callback) {
    this.#child = new Promise((resolve, reject) => {
      if (this.#state === 'pending') {
        this.#onFinallyCallbacks.push(() => {
          this.#handleCallback(callback, resolve, reject);
        });
      }
      if (this.#state === 'fulfilled' || this.#state === 'rejected') {
        this.#handleCallback(callback, resolve, reject);
      }
    });
    return this.#child;
  }

  #handleCallback(callback, resolve, reject) {
    try {
      const result = callback(this.#value);
      if (result instanceof Promise) {
        if (result.#state === 'fulfilled') {
          result.then(resolve);
        }
        if (result.#state === 'rejected') {
          result.catch(reject);
        }
        if (result.#state === 'pending') {
          result.#onFulfilledCallbacks.push(() => result.then(resolve));
          result.#onRejectedCallbacks.push(() => result.catch(reject));
        }
      } else {
        resolve(result);
      }
    } catch (error) {
      reject(error);
    }
  }
}

마무리

프로미스를 그대로 클론 하려 했지만, 완벽하지 못했다.

해당 포스트의 전체 코드는 깃허브 저장소에서 볼 수 있다.

 

GitHub - hyunmindev/Web_Promise: 프로미스 객체를 직접 구현

프로미스 객체를 직접 구현. Contribute to hyunmindev/Web_Promise development by creating an account on GitHub.

github.com

참조

https://github.com/stefanpenner/es6-promise

https://developer.mozilla.org/ko/docs/Web/JavaScript/Reference/Global_Objects/Promise

https://medium.com/swlh/implement-a-simple-promise-in-javascript-20c9705f197a

'Web > JavaScript' 카테고리의 다른 글

자바스크립트의 Data Binding  (0) 2021.10.07
자바스크립트의 bind, call, apply  (0) 2021.09.11
    'Web/JavaScript' 카테고리의 다른 글
    • 자바스크립트의 Data Binding
    • 자바스크립트의 bind, call, apply
    hyunmin!
    hyunmin!

    티스토리툴바