위 글은 자바스크립트 Promise 객체를 직접 구현해보면서 내부 동작 과정을 상세히 알아보는 글입니다.
콜백 지옥으로부터 저를 포함한 많은 개발자를 구원해준 이 프로미스를 당연하게 사용해왔다. Promise
를 mdn과 다른 개발 유투브들을 보면서 사용방법을 익혔지만, 막상 사용하다보면 이해하지 못하는 에러들을 너무 많이 나왔다. 또 막상 구현은 했지만 왜 이렇게 되는거야라고 많이 생각했습니다ㅋㅋㅋ ㅜㅜ
"Promise는 도대체 어떻게 생겨먹은거지??" 라는 궁금증을 계속 가지고 있다가 구글링 중 자바스크립트로 Promise를 직접 구현한 블로그 글들을 보고 저도 직접해보았다.
Promise를 직접 구현하기 위해서는 Promise를 정확히 이해해야한다. 그래서 Promise가 어떻게 생겨먹은 놈인지 더 자세히 살펴보고 구현할
기능 구현 목록
을 작성하자.
1. Promise는 실행 상태를 나타낸다.
fulfilled
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 메서드 체이닝 구현
리턴값
을 Promise 객체로 구현5. Promise 정적 메서드
Promise.race()
, Promise.all()
함수 등 (구현 X)위의 요구사항 정리한 토대로 구현을 하려고 했으나 사실 구현함에 있어서 감이 오질 않아 블로그 글들을 참고하여 다음과 같은 순서대로 구현해보았다.
(위의 요구사항도 모두 지키주면서~)
- Simplest 프로미스
- Then, Resolve 함수 구현
- Promise 상태에 따른 동작 구현
- 프로미스 체이닝 구현
- 비동기 함수 구현
- catch 구현
- finally 구현
- 그 외 에러 사항 처리
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
함수의 인자값으로 들어간 값들을 이어서 사용할 수 있다.
이를 정말 간단하게 구현해보았다.
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
함수를 추가했다.
코드를 보시면 알지만 resolve
는 reject
와 동작이 동일, then
은 catch
와 동작이 동일하다
bind 함수...?
resolve와 reject가 실제로 실행되는 구간은 testMyPromise함수이다. ( 즉,this
는 testMyPromise를 가리키고 있다. )따라서 MyPromise 내부의 resolve, reject함수를 실행시키기 위해서는 bind함수를 무조건 적용해야한다.
앞서 위의 예제에서 다음과 같은 코드를 실행해보자.
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',
});
해당 상태에 맞게 함수가 실행되도록 하였다.
앞서 위의 예제에서 다음과 같은 코드를 실행해보자.
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에 넣어서 프로미스 체이닝이 가능하도록 만들었다.
앞서 위의 예제에서 다음과 같은 코드를 실행해보자.
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);
}
...
실제 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함수 살펴보기
fulfilled
라면 thenCallbacks 배열 안에있는 함수를 실행, rejected
라면 catchCallbacks 배열 안에 있는 함수를 실행한다.runCallbacks
함수 참고 )then 함수 실행 시 발생할 에러에 대해서도 try ~ catch
구문을 통해서 에러 처리도 구현하였다.
then 함수 실행 시 인자를 모두 다 선언하지 않아도 동작하게끔 하였다.
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));
/*
-- 출력 --
실패
이게무람
*/
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;
}
);
}
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를 직접 구현해보는 것이었는데 이렇게 훌륭한 글을 만나게 되어 놀랬습니다. Promise를 한번 구현해보면 정말 많은 것들을 몸소 느낄수가 있죠. 객체와 비동기큐와 콜백이 조화를 이뤄내서 정말 멋진 것을 만들어 낸다고 생각합니다. 이걸 한번 구현하고 나면 그 뒤로 Promise와 async await에 대해서 보는 시각이 정말 달라진다는 것을 느꼈을 거라고 생각합니다. 수고 많으셨습니다. 좋은 글 잘 읽었습니다.