Promise
는 인스턴스를 생성할 때 executor
라는 함수를 인수로 전달받는다. 여기서 executor
함수는 resolve
, reject
라는 함수를 인수로 전달받게 된다.
const p = new Promise((resolve, reject) => {
setTimeout(() => {
resolve('resolved');
}, 1000);
});
따라서 Promise
와 동일한 인터페이스를 갖는 객체를 만드려면 resolve
와 reject
라는 함수를 매개변수로 사용하는 executor
를 전달받을 수 있도록 constructor
를 구성해야 할 것이다.
인스턴스를 생성한 이후 Promise
내부의 값을 소비(consume)하려면 크게 세 가지 방법을 사용할 수 있다.
p.then(
(res) => {
console.log(res);
},
(err) => {
console.log(err);
},
);
p.catch((err) => {
console.log(err);
});
p.finally(() => {
console.log('finally');
});
-
.then((res) => onFulfilled(res), (err) => onRejected(err))
Promise
가resolved
거나rejected
상태인 경우에 호출할 함수들을 전달받는다.
-
.catch((err) => onRejected(err))
Promise
가rejected
상태인 경우 호출할 함수를 전달받는다.
-
.finally(() => onSettled())
Promise
가resolved
상태인지rejected
상태인지 상관없이 호출할 함수를 전달받는데 이 함수는.then()
과.catch()
에 전달한 함수들이 모두 실행된 이후에 호출된다.
그렇다면 객체를 구성하기 위한 많은 방법들이 있지만 편의를 위해 executor
라는 함수를 전달받는 constructor
를 갖게끔 클래스로 구성해보자.
constructor
는 인스턴스 생성 시에 호출되므로executor
역시 곧바로 호출된다.
new Promise((resolve) => {
console.log('executor call');
resolve('resolved');
});
// "executor call"
또한 Promise
는 상태에 따라 전달받은 함수(reaction)를 실행하게 되는데, 여기서 Promise의 상태를 결정하는 것에는 규칙이 존재한다.
-
모든
Promise
는"pending"
상태에서 시작한다. -
Promise
의 상태가 한번"fulfilled"
혹은"rejected"
로 변경(settled)되면 이는 절대로 변하지 않는다.
const STATE = {
PENDING: 'pending',
FULFILLED: 'fulfilled',
REJECTED: 'rejected',
};
class _Promise {
_status = STATE.PENDING;
_value = undefined;
constructor(executor = () => {}) {
try {
executor(
this._resolve.bind(this),
this._reject.bind(this),
);
} catch (err) {
this._reject(err);
}
}
_resolve(value) {
if (this._status === STATE.PENDING) {
this._status = STATE.FULFILLED;
this._value = value;
}
}
_reject(value) {
if (this._status === STATE.PENDING) {
this._status = STATE.FULFILLED;
this._value = value;
}
}
then(onFulfilled, onRejected) {
if (
this._status === STATE.FULFILLED &&
typeof onFulfilled === 'function'
) {
onFulfilled(this._value);
} else if (
this.status === STATE.REJECTED &&
typeof onRejected === 'function'
) {
onRejected(this._value);
}
}
}
const p1 = new _Promise((resolve, reject) => {
resolve('resolved');
});
const p2 = new _Promise((resolve, reject) => {
reject('rejected');
});
p1.then(
(res) => {
console.log(`[p1]: ${res}`);
},
(err) => {
console.log(`[p1]: ${err}`);
},
);
p2.then(
(res) => {
console.log(`[p2]: ${res}`);
},
(err) => {
console.log(`[p2]: ${err}`);
},
);
// [p1]: resolved
// [p2]: rejected
하지만 현재의 구현에서 동기적인 resolve
, reject
는 핸들링할 수 있는 것은 확인할 수 있지만, 비동기적인 resolve
, reject
에 대해서는 제어할 수 없다는 한계가 존재한다.
const p3 = new _Promise((resolve, reject) => {
setTimeout(() => resolve('resolved'), 1000);
});
p3.then(
(res) => {
console.log(`[p3]: ${res}`);
},
(err) => {
console.log(`[p3]: ${err}`);
},
);
// 아무것도 출력되지 않는다.
이렇게 동작하는 이유는 .then()
메서드를 호출하는 시점에서 Promise
의 상태는 "pending"
이기 때문이다. 따라서 onFulfilled
, onRejected
모두 실행되지 않는 것이다.
그렇다면 그 당시의 상태를 바라보는 것이 아니라 Promise
의 상태가 변경되는 시점에 미리 전달해 둔 reactions
를 호출할 수 있게끔 이를 별도의 자료구조에 저장해두어야 할 것이다.
class _Promise {
_status = STATE.PENDING;
_value = undefined;
_fulfillmentTasks = [];
_rejectionTasks = [];
constructor(executor = () => {}) {
try {
executor(
this._resolve.bind(this),
this._reject.bind(this),
);
} catch (err) {
this._reject(err);
}
}
then(onFulfilled, onRejected) {
const fulfillmentTask = () => {
if (typeof onFulfilled === 'function') {
onFulfilled(this._value);
}
};
const rejectionTask = () => {
if (typeof onRejected === 'function') {
onRejected(this._value);
}
};
switch (this._status) {
case STATE.PENDING:
this._fulfillmentTasks.push(fulfillmentTask);
this._rejectionTasks.push(rejectionTask);
break;
case STATE.FULFILLED:
queueMicrotask(fulfillmentTask);
break;
case STATE.REJECTED:
queueMicrotask(rejectionTask);
break;
}
}
}
이전 구현과 달라진 점은 .then()
을 호출한 시점에 Promise
의 상태가 "pending"
이라면 onFulfilled
와 onRejected
에 전달한 reaction들을 큐에 추가한다. 아직 Promise
가 settled 상태가 아니므로 즉시 실행시키면 안되기 때문이다.
만약 .then()
을 호출한 시점에 해당 Promise
가 이미 settled 상태라면 reaction을 브라우저에게 마이크로태스크 큐에 등록하도록 스케줄링을 요청하게 된다.
class _Promise {
// ...
_consumeAllTasks(tasks) {
// this._fulfillmentTasks -> [...] <- tasks
// this._fulfillmentTasks -> []
// [...] <- tasks
this._fulfillmentTasks = [];
this._rejectionTasks = [];
tasks.forEach(queueMicrotask);
}
_resolve(value) {
if (this._status !== STATE.PENDING) {
return this;
}
this._status = STATE.FULFILLED;
this._value = value;
this._consumeAllTasks(this._fulfillmentTasks);
return this;
}
_reject(value) {
if (this._status !== STATE.PENDING) {
return this;
}
this._status = STATE.REJECTED;
this._value = value;
this._consumeAllTasks(this._rejectionTasks);
return this;
}
static resolve(value) {
return new _Promise((resolve) => resolve(value));
}
static reject(value) {
return new _Promise((_, reject) => reject(value));
}
}
resolve
와 reject
의 경우에는 Promise
가 settled 상태라면 아무것도 하지 않게 되고, 그 외의 경우에는 상태를 fulfilled(또는 rejected)로 변경시킨 후 전달된 value
를 저장하게 된다.
마지막으로 남은 tasks를 모두 마이크로태스크 큐에 등록될 수 있도록 요청을 보내고 reactions를 저장하고 있던 큐는 비우게 된다. 따라서 .then()
메서드가 호출되는 시점에 Promise
가 "pending"
상태였다 하더라도 settled되는 시점에 전달한 reactions를 모두 실행시킬 수 있게 되는 것이다.
거기에 Promise.resolve
, Promise.reject
와 같은 형태로 인스턴스를 생성하지 않고도 settled value를 갖고 있는 Promise
를 생성할 수 있도록 static
키워드를 사용하여 정적 메서드까지 추가한 상황이다.
Promise Chaining
Promise
의 주요한 특징 중 하나는 바로 체이닝이 가능하다는 점이다. 체이닝이 가능하려면 Promise 인스턴스의 메서드가 다시 Promise
인스턴스를 반환해야 할 것이다. 또한 .catch()
메서드를 구현하여 rejected reaction을 보다 명시적으로 제공할 수 있게끔 해보자.
우선 .catch()
메서드의 경우 .then()
메서드의 두 번째 인수에 rejected reaction을 전달한 것과 동일하므로 쉽게 구현이 가능하다.
p.catch(rejectedReaction);
p.then(null, rejectedReaction);
class _Promise {
// ...
catch(onRejected) {
return this.then(null, onRejected);
}
}
그렇다면 .then()
메서드가 Promise
인스턴스를 반환하게끔 하면 될텐데, .then()
의 인수로 fulfilled reaction이 전달된 경우에는 reaction이 반환한 값을 새로운 인스턴스의 value
로 resolve
를 시켜주고, fulfilled reaction 전달되지 않은 경우(.catch()
호출)에는 이전 Promise 인스턴스의 value
를 그대로 resolve
시키면 된다.
주의할 점은 .catch()
메서드에 전달한 reaction을 통해 유효한 값으로 예외 처리를 한 경우라면 reject
를 호출하는 것이 아니라 resolve
를 호출하게끔 하여 .catch()
이후의 .then()
에 전달한 reaction에서 처리된 값을 사용할 수 있게 해주어야 한다. 또한 settled 상태와 부합하는 reaction이 제공되지 않은 경우에는 인스턴스가 갖고 있는 값을 그대로 흘려보내는 방식으로 구현하여 필요하지 않은 reaction들은 생략할 수 있게 된다.
class _Promise {
// ...
then(onFulfilled, onRejected) {
const nextPromise = new _Promise();
const fulfillmentTask = () => {
if (typeof onFulfilled === 'function') {
nextPromise._resolve(onFulfilled(this._value));
} else {
nextPromise._resolve(this._value);
}
};
const rejectionTask = () => {
if (typeof onRejected === 'function') {
nextPromise._resolve(onRejected(this._value));
} else {
nextPromise._reject(this._value);
}
};
switch (this._status) {
case STATE.PENDING:
this._fulfillmentTasks.push(fulfillmentTask);
this._rejectionTasks.push(rejectionTask);
break;
case STATE.FULFILLED:
queueMicrotask(fulfillmentTask);
break;
case STATE.REJECTED:
queueMicrotask(rejectionTask);
break;
}
return nextPromise;
}
}
const log = (n) => (value) => {
console.log(`${n}: ${value}`);
return value;
};
const p = _Promise.reject('err');
p.then(log(1))
.then(log(2))
.then(log(3))
.catch((err) => {
console.log(err);
return 'catched';
})
.then(log(5));
// err
// 5 catched
Flattening from Promise
하지만 Promise API
와 비교해보면 현재의 구현에서는 아쉬운 점이 하나 있는데 바로, .then()
에 전달한 reaction에서 Promise
를 반환하는 경우 다음 reaction에 전달되는 인수는 Promise
가 래핑하고 있는 값 자체가 아닌 값을 래핑하고 있는 Promise
가 그대로 전달된다는 점이다.
const p = new Promise((resolve, reject) => {
setTimeout(() => resolve(0), 0);
});
p.then((res) => {
console.log('res 1: ', res);
return Promise.resolve(res + 1);
}).then((res) => {
console.log('res 2: ', res);
});
// res 1: 0
// res 2: 1
const _p = new _Promise((resolve, reject) => {
setTimeout(() => resolve(0), 0);
});
_p.then((res) => {
console.log('res 1: ', res);
return _Promise.resolve(res + 1);
}).then((res) => {
console.log('res 2: ', res);
res.then(console.log);
});
// res 1: 0
// res 2: _Promise { value: 1 }
// 1
따라서 .then()
에 전달한 reaction이 Promise
를 반환하더라도 Promise
가 래핑하고 있는 값을 다음 reaction에 전달할 수 있게끔 만들어보자.
[Promise P]
{
value: Promise Q,
status: STATE.FULFILLED
}
┌───────────────┐
↓ │
P.then((res) => { ... }) │
│ ↑ └──────────────────────────┐
│ └───────────────────────┐ │
└─────→ Q.then((res) => { ... }, (err) => { ... })
문제를 단순화시켜보면 라는 Promise
의 fulfilled value로 라는 Promise
가 전달된 상황에서 라는 Promise
자체를 직접 전달하는 것이 아닌 라는 Promise
가 resolve
혹은 reject
된 결과를 그 다음 Promise
에 전달해야 하는 상황이다.
즉 인 상황에서 최종적인 동작은 가 되어야 하므로 가 resolve
혹은 reject
되는 시점에 의 settled value가 결정되는 것이다. 따라서 특정 Promise
가 fulfilled 상태인 경우 고려해야 할 점이 늘어난 것이다.
-
가 fulfilled 상태라 해서 역시 fulfilled 된다는 보장을 할 수 없다. 의 settled value가 rejected 상태의 가 될 수 있다.
-
는 fulfilled 상태이지만 는 pending 상태인 경우가 발생할 수 있다. 즉 자체가 settled value를 결정하기 전까지는 는 pending 상태인 것과 마찬가지이다.
-
가 fulfilled 상태가 되면 의 fulfilled value가 가 rejected 상태가 되면 의 rejected value가 전달되어야 한다.
class _Promise {
_alreadyResolved = false;
_resolve(value) {
if (this._alreadyResolved) return this;
this._alreadyResolved = true;
if (value instanceof _Promise) {
value.then(
(res) => this._doFulfill(res),
(err) => this._doReject(err),
);
} else {
this._doFulfill(value);
}
return this;
}
_reject(value) {
if (this._alreadyResolved) return this;
this._alreadyResolved = true;
this._doReject(value);
return this;
}
_doFulfill(value) {
this._status = STATE.FULFILLED;
this._value = value;
this._consumeAllTasks(this._fulfillmentTasks);
}
_doReject(value) {
this._status = STATE.REJECTED;
this._value = value;
this._consumeAllTasks(this._rejectionTasks);
}
}
const P = new _Promise();
const Q = new _Promise();
P.resolve(Q);
Q.resolve(1);
P.then((res) => {
console.log('res1', res);
return _Promise.resolve(res + 1);
})
.then((res) => {
console.log('res2', res);
return _Promise.resolve(res + 1);
})
.then((res) => {
console.log('res3', res);
});
// res1 1
// res2 2
// res3 3
이제 가 resolve
된 값이 다시 Promise
()인 경우에는 Promise
() 자체를 resolve
하는 것이 아니라 Promise
()가 resolve
혹은 reject
한 값을 의 settled value로 결정하게 된다.
Handling Exceptions thrown in reaction callbacks
마지막으로는 .then()
혹은 .catch()
에 전달한 reaction 내부에서 throw
된 에러를 그 다음 catch reaction에서 포착할 수 있게끔 해보자.
그러기 위해서는 일단 reaction을 먼저 실행해보고, 에러가 catch되면 해당 에러를 rejected value로 전달하면 될 것이다.
class _Promise {
// ...
then(onFulfilled, onRejected) {
const nextPromise = new _Promise();
const fulfillmentTask = () => {
if (typeof onFulfilled === 'function') {
this._runReactionSafely(nextPromise, onFulfilled);
} else {
nextPromise._resolve(this._value);
}
};
const rejectionTask = () => {
if (typeof onRejected === 'function') {
this._runReactionSafely(nextPromise, onRejected);
} else {
nextPromise._reject(this._value);
}
};
switch (this._status) {
case STATE.PENDING:
this._fulfillmentTasks.push(fulfillmentTask);
this._rejectionTasks.push(rejectionTask);
break;
case STATE.FULFILLED:
queueMicrotask(fulfillmentTask);
break;
case STATE.REJECTED:
queueMicrotask(rejectionTask);
break;
}
return nextPromise;
}
_runReactionSafely(nextPromise, reaction) {
try {
nextPromise._resolve(reaction(this._value));
} catch (err) {
nextPromise._reject(err);
}
}
}
all, race, any
마지막으로 Promise.all
, Promise.race
, Promise.any
를 정의해보자.
Promise.all()
주어진 모든 Promise
가 fulfilled되면 resolve
하는 Promise
를 반환한다. 만약 하나라도 rejected라면 첫 번째로 rejected된 Promise
의 값을 이용해 rejected 인 Promise
를 반환하게 된다.
class _Promise {
// ...
static all(collection) {
return new _Promise((resolve, reject) => {
let counter = collection.length;
const resolvedCollection = [];
const tryResolve = (value, index) => {
counter -= 1;
resolvedCollection[index] = value;
if (count !== 0) return;
return resolve(resolvedCollection);
};
collection.forEach((item, index) => {
return _Promise
.resolve(item)
.then((value) => {
tryResolve(value, index);
})
.catch(reject);
});
});
}
}
_Promise
.all([
_Promise.resolve(1),
_Promise.resolve(2),
_Promise.reject(3),
])
.then((collection) => {
console.log(collection);
})
.catch((err) => {
console.log(err);
});
// [1, 2]
_Promise
.all([
_Promise.resolve(1),
_Promise.resolve(2),
_Promise.reject(3),
])
.then((collection) => {
console.log(collection);
})
.catch((err) => {
console.log(err);
});
// 3
Promise.race()
주어진 Promise
중 가장 먼저 settled가 되는 Promise
의 결과로 resolve
혹은 reject
를 수행한다.
class _Promise {
// ...
static race(collection) {
return new _Promise((resolve, reject) => {
collection.forEach((item) =>
item.then(resolve).catch(reject),
);
});
}
}
Promise.any()
주어진 Promise
중 가장 먼저 fulfilled가 되는 Promise
의 결과 값으로 resolve
를 수행하고, 모든 Promise
가 rejected라면 AggregateError
를 이용하여 reject
를 수행한다.
class _Promise {
// ...
static any(collection) {
return new _Promise((resolve, reject) => {
let isFulfilled = false;
const errors = [];
let errorCount = 0;
collection.forEach((item, index) => {
_Promise
.resolve(item)
.then((item) => {
if (!isFulfilled) {
resolve(item);
isFulfilled = true;
}
})
.catch((err) => {
errors[index] = err;
errorCount += 1;
if (errorCount === collection.length) {
reject(new AggregateError(err));
}
});
});
});
}
}
Conclusion
export default function createStore(reducer) {
let state;
let listeners = [];
function getState() {
return state;
}
function subscribe(newListener) {
listeners = [...listeners, newListener];
return function unsubscribe() {
listeners = listeners.filter(
(listener) => listener !== newListener,
);
};
}
function dispatch(action) {
const newState = reducer(state, action);
if (!newState)
throw new Error(
'reducer should always return newState',
);
if (newState === state) return;
state = newState;
invokeAllSubscribers();
}
function invokeAllSubscribers() {
listeners.forEach((listener) => listener());
}
return { getState, dispatch, subscribe };
}
-
공부를 진행하면서 Promise를 막연하게만 알고 있었다는 생각이 많이 들었다. 특히 reaction들이 실행되는 시점에 대해 명확하게 이해하지 못하고 있었다.
-
Promise에서 reaction들을 큐에 등록해두고 상태가 변경될 때 호출하듯이 redux에서도 dispatch가 호출될 때마다 상태를 구독하고 있던 listener들을 호출하는 것을 알 수 있었다.