사용할 줄 안다는 것과 이해했다는 것은 차이가 있다. 이번 포스트에서 Promise 객체를 직접 구현해보며 이해하도록 하겠다.
📒 사전 지식
🍽 요구사항
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);
}
}
}
마무리
프로미스를 그대로 클론 하려 했지만, 완벽하지 못했다.
해당 포스트의 전체 코드는 깃허브 저장소에서 볼 수 있다.
참조
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 |