저번 시간에는 ‘왜 프로미스(Promise)를 사용할까?’ 라는 질문에 답해보았습니다. 우리는 프로미스를 사용하면서 비동기 코드의 순차성(sequentiality), 믿음성(trustability)을 확보할 수 있었죠. 우린 이제 콜백 식 코드보다 프로미스 식 코드가 사용하기 좋다는 것은 이제 알게 됐습니다.
근데, 이런 마법같은 프로미스는 어떻게 작동하는 걸까요? 우리는 그저 new Promise(...)
로 편리하게 인스턴스를 만들어 사용할 뿐, 추상화의 커튼 뒤에 어떤 일이 벌어지고 있는지 알기 어렵고, 알 생각도 못 하는 경우가 많습니다. (애초에 그러라고 추상화를 해놓았겠지만요)
하지만 프로미스의 내부 동작을 연구하고 간단히 구현해보는 것도 가치 있다고 생각합니다. 프로미스의 사용법과 장단점을 외우는 것이 아니라 이해하며 자신감 있게 사용할 수 있습니다. 마법이 아니라 그저 코드로 받아들일 수 있게 됩니다.
그럼 이제 시작해봅시다!
기본적인 것부터 시작해봅시다. 일단 우리는 프로미스 인스턴스부터 만들어내야 합니다.
프로미스 인스턴스는 실행자(excecutor)라고 불리는 콜백을 인자로 갖는 생성자(constructor)에 의해 만들어집니다. 이 실행자의 로직 결과에 따라 프로미스 인스턴스는 세 가지 상태를 갖습니다.
이 중 이행, 거부 상태로 실행자의 로직이 마무리되면 귀결(settled)되었다고 합니다. 귀결을 수행하기 위해 resolve
, reject
메서드가 필요할 것입니다.
프로미스 인스턴스는 귀결되면서 귀결 값을 갖게 됩니다. 인스턴스 내에 귀결 값을 저장할 수 있어야겠습니다.
그럼 우린 이제 아주 간단한 코드를 작성할 수 있습니다.
/** 프로미스 클래스 */
export class MyPromise implements CustomPromise {
state: string;
value: settledValue;
constructor(excecutor: excecutor) {
this.state = PromiseStates.PENDING;
this.value = undefined;
// excecutor는 프로미스 인스턴스의 resolve와 reject를 인자로 받는다.
// 그 결과, excecutor 내에서 프로미스 인스턴스의 귀결을 결정할 수 있게 된다.
try {
excecutor(this.resolve.bind(this), this.reject.bind(this));
} catch (error) {
this.reject(error);
}
}
/** 프로미스 인스턴스를 이행(fulfilled) 상태로 귀결(settled) 시키는 메소드 */
resolve(value: settledValue) {}
/** 프로미스 인스턴스를 거절(rejected) 상태로 귀결시키는 메소드 */
reject(value: settledValue) {}
}
프로미스 인스턴스의 상태와 귀결 값은 각각 대기 상태와 undefined
로 초기화됩니다.
실행자(excecutor)는 프로미스 인스턴스의 resolve
와 reject
를 인자로 받게 됩니다. 이후 동기적으로 resolve
혹은 reject
를 실행하여 프로미스 인스턴스의 상태와 귀결 값을 확정시킵니다.(귀결시킵니다.)
이렇게 귀결된 프로미스 인스턴스는 계속 또 다른 프로미스 인스턴스를 연계하여 비동기 로직을 순차적으로 구성할 수 있게 됩니다. 이를 프라미스 체이닝이라고 합니다.
프라미스 체이닝은 프로미스 인스턴스에 새로운 프로미스 인스턴스를 반환하는 메소드를 체이닝하면서 이루어집니다. 이 메소드(then()
, catch()
, finally()
)들 까지 구현해봅시다.
/** 프로미스 클래스 */
export class MyPromise implements CustomPromise {
state: string;
value: settledValue;
constructor(excecutor: excecutor) {
this.state = PromiseStates.PENDING;
this.value = undefined;
// excecutor는 프로미스 인스턴스의 resolve와 reject를 인자로 받는다.
// 그 결과, excecutor 내에서 프로미스 인스턴스의 귀결을 결정할 수 있게 된다.
try {
excecutor(this.resolve.bind(this), this.reject.bind(this));
} catch (error) {
this.reject(error);
}
}
/** 프로미스 인스턴스를 이행(fulfilled) 상태로 귀결(settled) 시키는 메소드 */
resolve(value: settledValue) {}
/** 프로미스 인스턴스를 거절(rejected) 상태로 귀결시키는 메소드 */
reject(value: settledValue) {}
/** 프로미스 귀결 시 호출될 callback 들을 등록하는 메소드
* 체이닝을 위해 프로미스 인스턴스를 반환해야한다.
* 등록된 콜백의 결과값은 반환된 프로미스가 또 다시 귀결시킨다.
*/
then(onFulfilled?: Function, onRejected?: Function): MyPromise {}
/** 프로미스 체이닝 중 발생한 rejected 및 에러를 처리하는 메소드
* 내부적으로 then 메소드를 호출한다.
*/
catch(onRejected: Function): MyPromise {}
/** 프로미스 체이닝 마지막에 반드시 실행시켜야할 콜백을 등록하는 메소드
* 등록된 콜백의 결과값은 반환된 프로미스가 또 다시 귀결시킨다.
*/
finally(onFinally: Function): MyPromise {}
}
스켈레톤 코드에 작성된 메소드들의 상세 구현입니다. 구현의 결과를 확인하기 위해서 Jest로 작성된 테스트 코드도 첨부하였습니다.
/** 프로미스 클래스 */
export class MyPromise implements CustomPromise {
state: string;
value: settledValue;
callbacks: Array<thenCallbacks>;
constructor(excecutor: excecutor) {
this.state = PromiseStates.PENDING;
this.value = undefined;
this.callbacks = [];
try {
excecutor(this.resolve.bind(this), this.reject.bind(this));
} catch (error) {
this.reject(error);
}
}
/** 프로미스 인스턴스를 이행(fulfilled) 상태로 귀결(settled) 시키는 메소드 */
resolve(value: settledValue) {
this.updateData(value, PromiseStates.FULFILLED);
}
/** 프로미스 인스턴스를 거절(rejected) 상태로 귀결시키는 메소드 */
reject(value: settledValue) {
this.updateData(value, PromiseStates.REJECTED);
}
updateData(value: any, state: PromiseStates) {
// setTimeout으로 설정하여 지연시켜주기
setTimeout(
function () {
// 프로미스 인스턴스가 이미 귀결 상태라면 무시해주기
if (this.state !== PromiseStates.PENDING) return;
// value가 프로미스 인스턴스라면 이들을 먼저 귀결할 것
if (MyPromise.prototype.isPrototypeOf(value)) {
return value.then(this.resolve.bind(this), this.reject.bind(this));
}
// 프로미스 인스턴스의 상태와 귀결값 설정
this.value = value;
this.state = state;
this.executeCallbacks.call(this);
}.bind(this),
0
);
}
/** then으로 등록된 콜백을 실행시켜주는 메소드
* 비동기적으로 실행되므로 이미 동기적으로 then의 콜백들이 등록된 상태
*/
executeCallbacks() {
// 아직 대기 상태라면 끝낸다
if (this.state === PromiseStates.PENDING) return;
// then으로 등록된 callback들을 실행시켜준다.
this.callbacks.forEach((callback) => {
if (this.state === PromiseStates.FULFILLED) return callback.onFulfilled(this.value);
return callback.onRejected(this.value);
});
// 사용한 콜백들을 비워준다.
this.callbacks = [];
}
}
프로미스 인스턴스가 생성되면 곧바로 실행자가 실행됩니다. 실행자는 인자로 받은 프로미스 인스턴스의 resolve()
나 reject()
를 호출할 것입니다.
그러면 곧바로 프로미스 인스턴스를 귀결시키기 위해 updateData()
를 호출합니다. 이 메소드가 프로미스 인스턴스의 상태, 귀결 값을 확정합니다.
setTimeout()
을 사용해 비동기적 실행을 보장합시다. 이는 이후에 있을 메소드 체이닝을 위해서입니다. then()
, catch()
등의 메소드는 인자로 콜백을 받고 자신과 체이닝된 프로미스 인스턴스의 콜백 배열에 등록합니다. 이 과정은 프로미스 인스턴스가 귀결되기 전에 이루어져야 합니다. 먼저 귀결되어 executeCallbacks()
으로 콜백을 실행하려 하여도, 등록된 콜백이 존재할 수 없으니까요!
// value가 프로미스 인스턴스라면 이들을 먼저 귀결할 것
if (MyPromise.prototype.isPrototypeOf(value)) {
return value.then(this.resolve.bind(this), this.reject.bind(this));
}
귀결시키려는 값이 사실은 또 다른 프로미스 인스턴스일 수 있습니다. 이 경우 값으로 들어온 또 다른 프로미스 인스턴스를 먼저 귀결시켜야 합니다.
먼저 귀결된 후 귀결 값으로 자신의 resolve()
나 reject()
를 호출할 수 있도록 then()
의 콜백 인자로 넘겨줍시다. 그러면 then()
의 콜백을 호출하는 시점에 자신의 상태와 귀결 값을 업데이트할 수 있습니다. 일종의 클로저(closure)가 사용됐다고 볼 수 있겠네요!
test("excecutor에서 프로미스 인스턴스를 귀결시키려 하면 먼저 프로미스 인스턴스를 내부적으로 귀결시킨 후 귀결된다(resolve).", (done) => {
const promise = new MyPromise((resolve, reject) => {
resolve(
new MyPromise((resolve, reject) => {
resolve(4);
})
);
});
promise.then((value) => {
try {
expect(value).toBe(4);
done();
} catch (error) {
done(error);
}
});
});
// 테스트 성공!
// √ excecutor에서 프로미스 인스턴스를 귀결시키려 하면
// 먼저 프로미스 인스턴스를 내부적으로 귀결시킨 후 귀결된다(resolve). (30 ms)
이해를 돕기 위한 예제 & 테스트입니다.
/** 프로미스 귀결 시 호출될 callback 들을 등록하는 메소드
* 체이닝을 위해 프로미스 인스턴스를 반환해야한다.
* 등록된 콜백의 결과값은 반환된 프로미스가 또 다시 귀결시킨다.
*/
then(onFulfilled?: Function, onRejected?: Function): MyPromise {
return new MyPromise(
function (resolve, reject) {
// 이 콜백 배열은 지금 생성되는 프로미스 인스턴스의 것이 아니라 이전 프로미스 인스턴스의 것
// 여기 추가되는 콜백들은 이전 프로미스 인스턴스가 귀결될 때 작동한다.
this.callbacks.push({
onFulfilled: function (value: settledValue) {
try {
// onFulfilled 콜백이 주입되지 않더라도 귀결된 결과 다음 체이닝에 전달
// 즉, 다음 프로미스를 귀결시킨다.
if (!onFulfilled) {
return resolve(value);
}
// 이행 콜백의 결과값 구하기
return resolve(onFulfilled(value));
} catch (error) {
return reject(error);
}
},
onRejected: function (value: settledValue) {
try {
if (!onRejected) {
return reject(value);
}
return reject(onRejected(value));
} catch (error) {
return reject(error);
}
},
});
this.executeCallbacks.call(this);
}.bind(this)
);
}
/** 프로미스 체이닝 중 발생한 rejected 및 에러를 처리하는 메소드
* 내부적으로 then 메소드를 호출한다.
*/
catch(onRejected: Function) {
return this.then(null, onRejected);
}
then()
은 프로미스 체이닝을 위해서 새로운 프로미스 인스턴스를 반환합니다. 그래서 비동기 로직을 원하는 만큼 계속 순차적으로 연계할 수 있는 것이죠.
새롭게 생성된 프로미스 인스턴스의 실행자는 then()
에 인자로 온 콜백(onFulfilled
, onRejected
)을 실행하고 그 결과값을 resolve()
나 reject()
로 귀결하는 함수들을 콜백 배열에 등록합니다.
주의할 것은 이 콜백 배열은 현재 만들어진 프로미스 인스턴스가 아니라 이전 프로미스 인스턴스의 콜백 배열이라는 것입니다. 그 결과, 이전 프로미스 인스턴스의 executeCallbacks()
에 의해 실행되게 됩니다.
하지만 앞선 updateData()
내의 setTimeout()
때문에 executeCallbacks()
로 실행되더라도 비동기적으로 실행되는 것이 보장됩니다.
catch()
문은 구현한 then()
을 활용하여 쉽게 구현할 수 있습니다.
test("then 메서드의 첫 번째 인자로 들어간 콜백은 항상 비동기적으로 호출된다.", (done) => {
let a = 0;
new MyPromise((resolve, reject) => {
resolve(4);
}).then(() => {
try {
expect(a).toBe(1);
done();
} catch (error) {
done(error);
}
});
a++;
});
test("then 메서드의 두 번째 인자로 들어간 콜백은 항상 비동기적으로 호출된다.", (done) => {
let a = 0;
new MyPromise((resolve, reject) => {
reject(4);
}).then(null, () => {
try {
expect(a).toBe(1);
done();
} catch (error) {
done(error);
}
});
a++;
});
test("catch 메서드를 체이닝하면 에러 발생 시 예외처리할 수 있다.", (done) => {
const promise = new MyPromise((resolve, reject) => {
throw new Error("에러 발생!");
});
promise.catch((error) => {
try {
expect(error).toEqual(new Error("에러 발생!"));
done();
} catch (error) {
done(error);
}
});
});
test("catch 메서드를 체이닝하면 거부로 귀결 시 예외처리할 수 있다.", (done) => {
// reject 하는 경우
const promise = new MyPromise((resolve, reject) => {
reject("거부");
});
promise.catch((error) => {
try {
expect(error).toBe("거부");
done();
} catch (error) {
done(error);
}
});
});
// √ then 메서드의 첫 번째 인자로 들어간 콜백은 항상 비동기적으로 호출된다. (7 ms)
// √ then 메서드의 두 번째 인자로 들어간 콜백은 항상 비동기적으로 호출된다. (6 ms)
// √ catch 메서드를 체이닝하면 에러 발생 시 예외처리할 수 있다. (12 ms)
// √ catch 메서드를 체이닝하면 거부로 귀결 시 예외처리할 수 있다. (13 ms)
이해를 돕기 위한 예제 & 테스트입니다.
/** 프로미스 체이닝 마지막에 반드시 실행시켜야할 콜백을 등록하는 메소드
* 등록된 콜백의 결과값은 반환된 프로미스가 또 다시 귀결시킨다.
*/
finally(onFinally: Function): MyPromise {
return new MyPromise(
function (resolve, reject) {
// finally의 경우 무조건 적으로 onFinally를 실행한다.
this.callbacks.push({
onFulfilled: function (value: settledValue) {
try {
const callbackResult = onFinally();
// onFinally의 값이 undefined 여도 지난 귀결 값을 귀결한다.
if (typeof callbackResult === "undefined") resolve(value);
else resolve(callbackResult);
} catch (error) {
reject(error);
}
},
onRejected: function (value: settledValue) {
try {
const callbackResult = onFinally();
if (typeof callbackResult === "undefined") reject(value);
else reject(callbackResult);
} catch (error) {
reject(error);
}
},
});
}.bind(this)
);
}
finally()
의 구현은 then()
과 크게 다르지 않습니다. 다만 인자에 콜백으로 단 하나의 함수(onFinally
)만 받습니다.
그리고 프로미스 체이닝의 맨 마지막에 위치하고 입력된 콜백이 무조건 실행되어야 합니다. 또한 onFinally
의 귀결 값이 undefined
여도 지난 귀결 값을 고스란히 가져와 귀결합니다.
test("finally 메서드를 체이닝하면 콜백 내 로직은 무조건 실행된다.", (done) => {
const promise = new MyPromise((resolve, reject) => {
throw new Error("에러 발생!");
});
promise.finally(() => {
try {
expect(2 + 2).toBe(4);
done();
} catch (error) {
done(error);
}
});
});
// √ finally 메서드를 체이닝하면 콜백 내 로직은 무조건 실행된다. (12 ms)
이해를 돕기 위한 예제 & 테스트입니다.
지난 글에서 프로미스의 대표적 장점 중 하나가 콜백에 비해 믿음성이 보장된다는 것이었죠. 프로미스 식 코드는 콜백 식 코드에서 발생하는 통제가 어려운 상황들을 통제할 수 있습니다.
그런 의미에서 저희의 프로미스가 믿음성을 충분히 제공하는지 테스트해 봅시다!
같은 작업인데도 콜백이 어떨 때는 동기적으로, 어떨 때는 비동기적으로 호출되고 종결되어 경합 조건(race condition)에 이르는 경우가 있습니다. 이런 경우, 비동기 호출처럼 작동하게끔 통일하여 로직을 예상 가능하도록 가져가는 것이 좋습니다.
프로미스는 복잡한 관용코드(boilerplate code)없이 로직을 비동기적으로 가져갈 수 있게끔 설계되었다고 말씀드렸었습니다. 정말 그런지 볼까요?
test("then 메서드의 첫 번째 인자로 들어간 콜백은 항상 비동기적으로 호출된다.", (done) => {
let a = 0;
new MyPromise((resolve, reject) => {
resolve(4);
}).then(() => {
try {
expect(a).toBe(1);
done();
} catch (error) {
done(error);
}
});
a++;
});
test("then 메서드의 두 번째 인자로 들어간 콜백은 항상 비동기적으로 호출된다.", (done) => {
let a = 0;
new MyPromise((resolve, reject) => {
reject(4);
}).then(null, () => {
try {
expect(a).toBe(1);
done();
} catch (error) {
done(error);
}
});
a++;
});
// √ then 메서드의 첫 번째 인자로 들어간 콜백은 항상 비동기적으로 호출된다. (12 ms)
// √ then 메서드의 두 번째 인자로 들어간 콜백은 항상 비동기적으로 호출된다. (3 ms)
동기적으로 a++
연산이 실행되어 a
는 1
이 됩니다. 그래서 나중에 비동기적으로 호출된 콜백에서도 a
는 1
인 거죠.
만약 로직이 잘못되었거나, 네트워크의 상태가 좋지 않다는 등의 이유로 비동기 로직이 종결되지 않아 프로미스가 귀결되지 않는 경우가 있습니다. 이 경우 then()
에 걸려있는 콜백이 호출되지 않습니다.
이 경우 경합(race)를 추상화한 Promise.race()
를 사용한 프로미스 타임아웃 패턴을 이용해 해결할 수 있습니다. 그럼 Promise.race()
를 구현해 봅시다!(앞으로 MyPromise.race()
라고 칭하겠습니다.)
/** 정적 race 메소드
* 가장 먼저 귀결된 프로미스 인스턴스의의 값을
* 자신의 귀결 값으로 삼는 프로미스 인스턴스를 반환한다.
*/
static race(promises: Array<MyPromise>) {
return new MyPromise((resolve, reject) => {
promises.forEach((promise) => {
promise.then(resolve, reject);
});
});
}
MyPromise.race()
는 프로미스 인스턴스를 반환합니다. 그리고 인자로 들어온 프로미스 인스턴스 중 가장 먼저 귀결된 인스턴스의 귀결 값을 자신의 귀결 값으로 삼습니다. 나머지 인스턴스의 귀결은 조용히 무시합니다.
test("Promise.race 정적 메서드는 인수로 받은 배열 내의 프로미스 인스턴스 중 최초로 귀결된 프로미스 인스턴스의 값을 자신의 귀결 값으로 갖는 프로미스 인스턴스를 반환한다. (resolve)", (done) => {
MyPromise.race([
new MyPromise((resolve, reject) => {
const timerId = setTimeout(() => {
clearTimeout(timerId);
resolve(22);
}, 2000);
}),
new MyPromise((resolve, reject) => {
const timerId = setTimeout(() => {
clearTimeout(timerId);
resolve(33);
}, 4000);
}),
]).then((value) => {
try {
expect(value).toBe(22);
done();
} catch (error) {
done(error);
}
});
});
test("Promise.race 정적 메서드는 인수로 받은 배열 내의 프로미스 인스턴스 중 최초로 귀결된 프로미스 인스턴스의 값을 자신의 귀결 값으로 갖는 프로미스 인스턴스를 반환한다.(reject)", (done) => {
MyPromise.race([
new MyPromise((resolve, reject) => {
const timerId = setTimeout(() => {
clearTimeout(timerId);
reject(22);
}, 2000);
}),
new MyPromise((resolve, reject) => {
const timerId = setTimeout(() => {
clearTimeout(timerId);
reject(33);
}, 4000);
}),
]).then(null, (value) => {
try {
expect(value).toBe(22);
done();
} catch (error) {
done(error);
}
});
});
// √ Promise.race 정적 메서드는 인수로 받은 배열 내의 프로미스 인스턴스 중 최초로 귀결된 프로미스 인스턴스의 값을 자신의
// 귀결 값으로 갖는 프로미스 인스턴스를 반환한다. (resolve) (2040 ms)
// √ Promise.race 정적 메서드는 인수로 받은 배열 내의 프로미스 인스턴스 중 최초로 귀결된 프로미스 인스턴스의 값을 자신의
// 귀결 값으로 갖는 프로미스 인스턴스를 반환한다.(reject) (2038 ms)
2초의 딜레이를 갖고 귀결되는 프로미스 인스턴스의 귀결 값만 살아남게 됩니다!
프로미스 인스턴스는 이행이든 거부든 최초 단 한 번만 귀결됩니다. 어떤 이유로 프로미스 인스턴스가 여러 번 이행, 거부로 귀결되려고 들면 최초의 귀결만 취하고 나머지는 조용히 무시합니다.
test("프로미스 인스턴스는 최초 단 한 번만 귀결된다.", (done) => {
const promise = new MyPromise((resolve, reject) => {
let value = 0;
const timerId = setInterval(() => {
value++;
if (value === 2) clearInterval(timerId);
resolve(value);
}, 1000);
});
setTimeout(() => {
promise.then((value) => {
try {
expect(value).toBe(1);
done();
} catch (error) {
done(error);
}
});
}, 2000);
});
setInterval
내 콜백으로 계속 프로미스 인스턴스를 다시 귀결시키려 하여도 귀결 값은 1
로 고정됩니다.
이렇게 저희만의 프로미스를 구현해보고, 나름대로의 테스트도 진행해보았습니다. 프로미스를 더욱 잘 이해하는 데 도움이 되셨길 바랍니다.
적어도 이글의 목적처럼 앞으로는 프로미스란 내부를 알 수 없는 블랙박스가 아니며, 누가 프로미스를 물어본다면 자신 있게 설명할 수 있는 모두가 되었으면 좋겠습니다!
다음 글에선 마법 같던 프로미스의 단점을 살펴보고, 이터레이션 프로토콜(Iteration protocols)과 제네레이터(Generator) 에 대해 알아보는 시간을 갖겠습니다.
의견이 있으시면 댓글로 달아주시고, 좋게 읽으셨다면 하트를 눌러주세요. 댓글과 하트는 제게 힘이 됩니다!
긴 글 읽어주셔서 감사합니다!
최종 코드는 원격 저장소에서 확인하실 수 있습니다!
줌인터넷에서 블로그 스터디를 운영하고 있습니다.
https://developer.mozilla.org/ko/docs/Web/JavaScript/Reference/Global_Objects/Promise
https://www.promisejs.org/implementing/
https://medium.com/swlh/implement-a-simple-promise-in-javascript-20c9705f197ahttps://medium.com/nerd-for-tech/implement-your-own-promises-in-javascript-68ddaa6a5409
와우 직접 구현해본적이 없었는데 대단하십니다 역시 빛!