면접 단골 질문주제이자 JS 비동기처리의 핵심.
MDN에는 비동기 작업의 최종 완료 또는 실패를 나타내는 객체라고 정의되어있다.
const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
크게 특별해보이는 문법은 아니지만, 핵심은 두가지다.
어떻게 보면 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"));
이 구조에서 추가해야할 점은 어떤게 있을까?
일단 상태부터 처리하고싶다. 그렇게 하려면, executor
가 resolve,reject
파라미터를 받아야하고, 함수의 성공-실패를 알 수 있는 로직을 구현해야한다.
.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"));
기본적인 구조는 완료되었다. 하지만, 비동기처리는 아직 적용하지 못하였다.
다음단계에서는 비동기처리를 해보자!
세가지 체이닝메서드로 전달된 콜백함수는 Promise
의 상태가 settled
되야만 실행한다.
따라서 받아온 콜백함수를 settled되었을때 실행할 필요가 있다.
then, catch, finally
의 파라미터로 전달된 콜백 함수를 따로 저장한다!resolve, reject
가 실행될 때, 즉 settled
될 때 전달된 콜백함수를 실행한다.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
를 활용하는 모습을 볼 수 있다.
지금까지 제작된 코드도 얼핏 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를 리턴할 시 인스턴스가 그대로 리턴된다.
따라서 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
을 한번 구현해보자!
내부는 얼추 끝났으니 정적메서드를 한번 구현해보자!
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));
이제 구현해볼 시간이다.
핵심은 모든 promise가 resolve되면 값을 반환하는 점이다. 단, 하나라도 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);
}
});
});
}
핵심은 두가지다
Promise
가 아닌 값은 바로 반환한다.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}`));
Promise.race()
는 가장 먼저 settled된 결과를 가져온다.
이번에는 모든원소를 MyPromise
의 인스턴스로 만들어보자.
MyPromise.resolve
로 감싼다.then
에 현재 settled
되어있지 않으면, 값을 resolve
한 뒤 settled
를 true
로 만든다.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자체의 이해도도 낮은 탓에 수정하기가 어렵다.😭
그래도 잘 작동하겠지?
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}`));
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()