자바스크립트의 비동기 처리는 프로미스가 있기 전과 후로 나뉠 정도로, 프로미스라는 개념은 자바스크립트 진형에 많은 영향을 주었습니다.
지금이야 async
await
이라는 더 직관적이고 깔끔한 문법이 나왔지만, 아직도 promise 는 꾸준한 사랑을 받고 있습니다.
오늘은 이 Promise 를 어떻게 구현할 수 있는지 알아보겠습니다.
해당 코드는 많은 선지자분들이 작성하신 좋은 선례들을 보고 작성되었고, 실제 프로미스의 구현체와는 다른, 이해를 위한 멘탈 모델정도라고 생각하시면 좋겠습니다.
코드를 작성하기 위해 참고한 링크는 아래와 같습니다.
const asyncJob = (f) => setTimeout(f, 3000);
new Promise((res, rej) => {
asyncJob(() => {
res("tei");
});
}).then((e) => console.log("나는", e));
console.log("3초후에 나올것");
해당 코드의 실행 결과는 다음과 같을 것입니다.
3초뒤에 나올것
(...3초라는 시간의 벽)
나는 tei
오류케이스의 경우는 어떨까요?
new Promise((res, rej) => {
asyncJob(() => {
rej(new Error("Request is failed"));
});
})
.then((e) => console.log("나는", e))
.catch((e) => console.log(e));
then 이 실행되지 않고, catch 에서 받아줘야 합니다.
> 3초 후에 나올것
(...3초라는 시간의 벽)
> Error: Request is failed
그렇다면 이 예제를 가지고, 이와 동일하게 동작하는 _Promise
클래스를 한번 작성해봅시다.
해야할 작업은 명확해 졌습니다. 우리는 _Promise
라는 클래스를 만들고, 그 안에 resolve
,reject
,then
,catch
4개의 함수를 구현하면 됩니다.
프로미스 클래스는 resolve 함수와 reject 함수를 인자로 하는 함수를 인자로 받습니다.
new Promise((res, rej) => {
asyncJob(() => {
res("tei");
});
});
프로미스 클래스의 생성자에서 초기화해줘야 할 변수는 4개 입니다.
먼저 프로미스의 상태값을 객체로 하나 만들어주도록 하겠습니다.
const PromiseStatus = {
PENDING: "pending", // 비동기 로직이 완료되지 않은 상태
FULFILLED: "fulfilled", // 성공, 프로미스의 결과값이 리턴됨
REJECTED: "rejected", // 실패, 오류발생,
};
위의 내용을 토대로 생성자 함수를 만들어 보도록 하겠습니다.
const _Promise = class {
constructor(callBack) {
this.status = PromiseStatus.PENDING;
this.promiseResult = undefined;
this.fulfilledFunc = ()=> {};
this.rejectedFunc = ()=> {};
callBack(
(v) => this.resolve(v),
(v) => this.reject(v)
);
}
}
크게 설명할 것 없이, 처음 상태는 pending 이고, 처음 값은 undefined, 그리고 아직 성공 실패시 어떤 일을 할지 정해지지 않았습니다. 생성자의 인자로 받은 함수는 resolve 함수와 reject 함수를 부르는데요, 이 두 함수를 구현한 후에 한번 보도록 하겠습니다.
then 의 구현에 앞서, 만들어줘야 할 함수가 있습니다.
const addToTaskQueue = (t) => setTimeout(t, 0); //콜스택이 아니고, 태스크 큐에 넣기 위해
addTaskQueue
라는 한줄짜리 간단한 함수이고, 콜스택이 아닌 태스크 큐에서 실행시키기 위한 함수입니다.
태스크 큐와 이벤트 루프에 대한 내용은(https://www.youtube.com/watch?v=8aGhZQkoFbQ&ab_channel=JSConf) 라는 명 영상이 있으니, 혹시 잘 알고계시지 못한다면 해당 영상을 참고해주세요.
then
을 구현한 코드를 살펴보면
...여기는 Promise 클래스 내부입니다.
then(onFulfilled) {
const fulfilledTask = () => {
onFulfilled(this.promiseResult);
};
switch (this.status) {
case PromiseStatus.PENDING: {
this.fulfilledFunc = fulfilledTask;
break;
}
case PromiseStatus.FULFILLED: {
addToTaskQueue(fulfilledTask);
break;
}
case PromiseStatus.REJECTED: {
break;
}
}
return this;
}
fulfilledTask
변수에 onFulfilled(this.promiseResult)
를 넣어주고 있습니다.
그리고 현재 status 가 pending
이면 성공시 실행할 함수인 fulfilledFunc
에 fulfilledTask
를 넣어줍니다.
그리고 만일 FULFILLED
되었다면 바로 태스크 큐에 넣어줍니다. (여기서는 사실 체이닝 되지 않고 1단계만 받을 수 있는 프로미스기에 해당 상태로 오는 경우는 없을것이긴 합니다..)
그리고 REJECTED
일 경우, then 에서 처리하지 않으니 아무 일도 하지 않습니다.
catch 는 then 과 결을 같이합니다. 다만, reject 상황이라는점만 다릅니다.
대부분이 중복 코드이지만, 이해를 돕기 위해 중복되게 작성해 보았습니다.
...여기는 Promise 클래스 내부입니다.
catch(onRejected) {
const rejectedTask = () => {
onRejected(this.promiseResult);
};
switch (this.status) {
case PromiseStatus.PENDING: {
this.rejectedFunc = rejectedTask;
break;
}
case PromiseStatus.FULFILLED: {
break;
}
case PromiseStatus.REJECTED: {
addToTaskQueue(this.rejectedFunc);
}
}
return this;
}
모든것이 반대로, 같은 로직이라는 것을 확인할 수 있습니다.
resolve 함수는 프로미스의 상태를 성공으로 바꾸고, promiseResult 의 값을 결정합니다. 그리고 resolve 되었을 때 실행해야 할 함수를 실행합니다.
...여기는 Promise 클래스 내부입니다.
_doTask(t){
addToTaskQueue(t)
}
resolve(v) {
if (this.status !== PromiseStatus.PENDING) {
return this;
}
this.status = PromiseStatus.FULFILLED;
this.promiseResult = v;
this._doTask(this.fulfilledFunc)
}
...
함수의 동작을 이해하기 위해 실행 순서를 생각해 볼 필요가 있습니다.
다시 아까의 예제를 한번 볼까요?
new Promise((res, rej) => {
asyncJob(() => {
res("tei");
});
}).then((e) => console.log("나는", e));
먼저 Promise 가 생성됩니다. then 을 만나기 전이니 생성자만 불린 상태입니다.
then 이 실행되었습니다. 현재 상태는 pending 상태이니,
const fulfilledTask = () => {
onFulfilled(this.promiseResult);
};
case PromiseStatus.PENDING: {
this.fulfilledFunc = fulfilledTask;
break;
}
이 케이스문을 타게 되어 fulfilledFunc 의 값이 변경됩니다. 여기서는 그 값이 (e) => console.log("나는", e)
입니다.
reject 함수는 catch 에서 잡는다는점만 제외하면 resolve 함수와 완전히 같습니다.
reject(err) {
if (this.status !== PromiseStatus.PENDING) {
return this;
}
this.status = PromiseStatus.REJECTED;
this.promiseResult = err;
this._doTask(this.rejectedFunc)
}
잠시 예제를 다시 가져와보면
new _Promise((res,rej) => {
asyncJob(() => {
rej(new Error("Request is failed"))
});
})
.then((e) => console.log("나는", e))
.catch((e)=>console.log(e))
console.log("3초후에 나올것")
실행결과는 다음과 같습니다.
reject 되었기때문에, 나는 ~
이 출력되지 않는것을 확인할 수 있습니다.
primoise 의 기본적인 형태를 구현해 보았는데, 사실 Promise 의 구현은 이렇게 간단하지는 않습니다.
가령 Promise.then 을 통해서 프로미스간 체이닝이 가능해야 하고,
Promise.all 이나 race 등의 메소드도 지원해야 합니다. 해당 구현의 경우 나중에 시간이 생기면 한번 정리해 보도록 하겠습니다.
다음 글도 기대되네요. 🤭