
Promise에 대해서 알아봅니다. 지식 전달보단, 개인적인 생각을 담고 있습니다.
프로미스는 ECMAScript 사양에 정의된 표준 빌트인 객체이다.
💡 호스트 객체와 표준 built-in 객체의 차이
호스트 객체는 JavaScript를 실행하는 환경(브라우저, Node.js )이 제공하는 객체
ex) setInterval, setTimeout, window, document, global ...표준 빌트인 객체
ECMAScript 스펙에 직접 정의되어 있는 JavaScript 언어의 일부로 JavaScript 스펙에 포함되기 때문에 실행 환경과 무관하게 같은 동작을 예측할 수 있다.
ex) Symbol, Map, Set, Date …setTimeout 등의 함수는 Node.js나 Browser에서 동일하게 동작해 자바스크립트 스펙에 포함되어 있다고, 착각하는 경우가 있지만 Node.js나 Browser에서 똑같이 동작하도록 api 형태로 제공되는 함수다.
❓ 왜 타입으로 전반적인 이해를 하려고 할까
자바스크립트의 표준 빌트인 객체들은 선언적이다. 선언적인 코드는 input이 무엇이고, output이 무엇인지만 확인하면 사용할 수 있다. 내부적으로 어떻게 돌아가는 지는 몰라도 된다(?). 따라서 input 타입과 output 타입만 분석해서 아주 빠르게 이해해보자.
class Promise {
constructor(executor: (resolve: Function, reject: Function) => void): Promise;
then(onFulfilled: Function, onRejected: Function): Promise;
catch(onRejected: Function): Promise;
finally(onFinally: Function): Promise;
static all(iterable: Iterable): Promise;
static allSettled(iterable: Iterable): Promise;
static any(promises: Iterable): Promise;
static race(iterable: Iterable): Promise;
static reject(r: any): Promise;
static resolve(x: any): Promise;
static withResolvers():
{ promise: Promise, resolve: function, reject: function };
}
Promise 생성자 함수를 new 연산자 를 앞에 붙여 호출하는 식으로 Promise 객체를 만들 수 있다. 인수로는 executor라는 콜백 함수를 받는데, executor는 resolve 함수, reject 함수를 인수로 받는다. resolve에는 비동기 처리가 성공했을 때 실행할 로직을, reject에는 실패했을 때 실행할 로직을 담는다. 간단한 예시로 알아보자.
const myPromise = new Promise((resolve,reject) => {
const xhr = new XMLHttpRequest();
xhr.open('GET','https://naver.com');
xhr.send();
xhr.onload = () => {
if(xhr.status ===200) resolve('성공했어!');
else reject(new Error('실패했어'));
}
})
프로미스의 메서드 리턴 타입을 분석해보자. 정적 메서드인 withResolvers를 제외하고는 모두 Promise를 리턴하는 것을 알 수 있다. 실제로 Promise를 리턴하는 지 확인해보자
class Promise {
...
static race(iterable: Iterable): Promise;
static reject(r: any): Promise;
static resolve(x: any): Promise;
}
const p1 = Promise.resolve()
const p2 = Promise.reject()
const p3 = Promise.race([p1,p2])
p1 instanceof Promise // true
p2 instanceof Promise // true
p3 instanceof Promise // true
프로미스의 메서드가 프로미스 객체를 리턴한다는 사실은 매우 중요한데, 그 이유는 후속 처리를 가능케 하기 때문이다. 프로미스는 후속 메서드로 then을 제공한다. then은 리턴값이 Promise라면 그 값을 그대로 리턴하고, 프로미스가 아닌 경우에 암묵적으로 Promise 객체화한 후에 return 한다. catch, finally를 then과 함께 사용한다면, 비동기 작업에 대한 Error를 편하게 처리할 수 한다.
💡 일반적으로 프로미스 메서드들은 리턴 값을 암묵적으로 Promise 객체화 해준다.
p.then((v)=>v); 는
p.then((v)=>Promise.resolve(v)); 이다.
const p1 = new Promise((res,rej)=>{...})
p1
.then((reponse)=>{
// resolve가 수행됐으면 실행해야하는 코드
})
.catch((err)=>{
// reject가 수행됐으면 실행해야하는 코드
})
.finally(()=>{
// 성공, 실패 여부와 관계없이 마지막으로 실행됐으면 하는 코드
})
💡 then 매서드에 두 번째 매개변수로 콜백 함수를 넘김으로 에러처리를 할 수도 있지만, 코드가 복잡해지기도 하고, 굳이 catch가 있는데 then(성공함수, 실패함수) 하는 형태로 작성해야 할까? resolve면 then, reject면 catch로 핸들링 하는 것이 좋다고 생각한다.
catch로 비동기 Error를 잡기로 결정하면 에러 핸들링 면에서도 장점이 있다. 여러 then 메소드 호출 뒤에 catch를 호출하면 해당 로직에서 발생하는 모든 Error를 catch할 수 있다.
❓ then, catch 등으로 Promise에 대한 후속처리나 연속 비동기 작업을 할 수 있다는 사실을 알았다. 그렇다면 then, catch는 어떻게 해당 Promise 객체의 상태를 인지할 수 있을까
| 상태 정보 | 의미 | 상태 변경 트리거 |
|---|---|---|
| pending | 비동기 처리가 아직 수행되지 않음 | new 키워드로 생성한 직후 |
| fulfilled | 비동기 처리가 성공적으로 수행됨 | resolve 함수 호출 |
| rejected | 비동기 처리가 실패적으로 수행됨 | reject 함수 호출 |
프로미스는 생성되지 마자 pending이라는 status 값을 갖게된다. resolve가 호출됐다면 해당 status는 fulfilled로, reject가 호출됐다면 해당 status는 rejected가 된다. 예시로 알아보자
const onlySuccess = new Promise((resolve) => {
console.log('프로미스 생성 시작');
setTimeout(()=>{
console.log('비동기 처리 후 resolve 호출')
resolve('성공성공');
},1000)
});
onlySuccess.then((response)=>{
console.log('Promise state가 fulfilled~!')
})
console.log('코드의 끝')
"프로미스 생성 시작"
"코드의 끝"
// 1초 뒤에 아래 콘솔 실행됨.
"비동기 처리 후 resolve 호출"
"Promise state가 fulfilled~!"
해당 코드의 동작을 따라가자. 먼저 new 연산자가 프로미스 객체를 생성한다. 객체가 생성될 때, 콜백 함수가 실행되기 때문에 프로미스 생성 시작이 콘솔에 찍힌다. 그 아래 코드는 setTimeout으로 비동기 로직을 포함하기 때문에 setTimeout의 콜백함수는 실행되기 전에 서브스레드로 이동하고 이행될 수 있을 때, task Queue를 거쳐 call Stack으로 돌아오는 과정을 거친다. 현재 Promise의 status는 pending이다.
그 아래 코드는 Promise의 then 메서드를 사용하고 있는데, then은 Promise 객체의 status가 fulfilled 됐을 경우에 실행되기 때문에 어떠한 작업도 일어나지 않고 넘어간다. 그 아래의 코드의 끝이 콘솔에 찍힌다.
서브 스레드에서 1초를 기다리던 setTimeout의 콜백 함수가 1초가 지난 후에 태스트 큐로 이동하고, 이벤트 루프는 콜스택이 비었는지 확인한 후에 콜스택으로 콜백 함수를 이동 시킨다. 이동된 콜백 함수가 실행되어 비동기 처리 후 resolve 호출을 콘솔에 찍게 된다. 그 후에 resolve 함수를 호출한다. resolve 함수는 호출됨과 동시에 프로미스의 status를 fulfilled로 변경한다.
프로미스의 상태가 fulfilled로 변경되길 기다리던 then 메서드는 이후에 바로 실행되고, Promise stats가 fulfilled~!를 콘솔에 찍게 된다.
알아볼 메서드들의 종류는 아래와 같다.
해당 메서드들을 사용하기 위해서 공통으로 사용할 Promise 생성 함수를 만들자.
function genPromise(sec, type = "success") {
return new Promise((res, rej) => {
setTimeout(() => {
type === "success" ? res() : rej();
}, sec * 1000);
});
}
interface PromiseConstructor {
...
all<T>(values: Iterable<T | PromiseLike<T>>): Promise<Awaited<T>[]>
}
여러 비동기 처리를 병렬 처리할 때 사용한다. 예시로 살펴보자.
병렬 처리 없이 수행한 코드
const p1 = () => genPromise(1);
const p2 = () => genPromise(2);
const p3 = () => genPromise(3);
console.time('비동기 작업');
p1().then(() => p2()).then(() =>p3()).then(()=> console.timeEnd('비동기 작업'));
// 비동기 작업: 6013.006103515625 ms
Promise.all 사용한 코드
const p1 = () => genPromise(1);
const p2 = () => genPromise(2);
const p3 = () => genPromise(3);
console.time('비동기 작업')
Promise.all([p1(),p2(),p3()]).then(()=>{console.timeEnd('비동기 작업')});
// 비동기 작업: 3003.05615234375 ms
💡 올바른 병렬 처리를 수행하기 위해서는 각각의 비동기 작업이 독립적이어야 한다.
Promise.all은 인수로 받은 프로미스 배열을 병렬적으로 처리하기 때문에 작업 시간이 가장 긴 비동기 작업의 수행 시간보다 조금 더 긴 시간이 소요된다. 주의할 점은 인수로 받은 프로미스 배열의 요소들이 모두 fulfilled 상태가 되어야 처리 결과를 새로운 프로미스로 반환한다는 것이다. 반환되는 Promise 배열은 인수로 넣은 프로미스 순서를 보장한다.
💡 Promise.all은 다른 메서드와 마찬가지로 리턴할 배열의 요소가 Promise가 아닐 경우 Promise로 리턴한다.
[1,’s’] ⇒ [Promise.resolve(’1’), Promise.resolve(’s’)]
Promise.all의 치명적인 단점은 인수로 받은 Promise 요소가 하나라도 rejected 된다면 즉시 종료되는 것이다. 대부분의 비동기 작업은 수행을 보장할 수 없다.
💡 Promise.all 이 유용하게 사용되려면 아래의 조건을 만족해야한다.
- 비동기 작업에 대한 수행 가능성이 높은 확률로 보장되어야 한다.
- 각 비동기 작업은 반드시 독립적이어야 한다.
위 조건을 만족하는 병렬 작업이 흔하지 않기도 하고, 만약 있더라도 api 설계를 개선하는 등의 작업을 하는 쪽이 더 올바르지 않을까? 10개의 비동기 작업을 Promise.all로 수행하기보단 서버 쪽에서 10개의 비동기 작업에 사용될 데이터를 묶어서 한 번에 보내주는 쪽이 더 좋아보인다. ( 서버 쪽에서 데이터 가공까지 해준다면 행복(?)한 프론트엔드 개발을 할 수 있지 않을까 )
api 설계를 개선하지 못 하는 경우라면 각각의 비동기 작업을 그냥 실행하고 에러 처리로 재요청하는 등의 작업이 낫지 않을까. 물론 모든 작업이 완료됐을 경우에만 특정 작업을 할 수 있다면 비동기 작업이 완료 됐는지에 대한 정보를 저장할 컬렉션이 필요할 수도 있지만, Promise.all을 사용하는 과정에서 생기는 에러를 처리하는 작업보다는 나은 것 같다.
interface PromiseConstructor {
...
race<T>(values: Iterable<T | PromiseLike<T>>): Promise<Awaited<T>>;
}
Promise.race는 Promise.all과 마찬가지로 이터러블을 받지만 리턴하는 값이 Promise라는 점이 중요하다. Promise.race는 Promise.all이 모든 이러터블 요소들의 fulfilled를 기다리는 것과 달리 하나의 비동기 작업이 pending 상태가 아니게 되면 해당 상태에 맞는 Promise 객체를 생성한 후에 return 한다.
💡 Promise.all 과 마찬가지로 Promise.race가 유용하게 사용한 경험은 아직 없다.
interface PromiseConstructor {
...
allSettled<T extends readonly unknown[] | []>(values: T): Promise<{
status: 'fulfilled';
value: T extends Array<infer U> ? U : never;
} | {
status: 'rejected';
reason: any;
}
<Awaited<T>>[]>;
}
타입이 복잡해 보이지만, 실제로 복잡하진 않다. 하나씩 뜯어가며 타입을 분석해보자
<T extends readonly unknown[] | []>(values: T)
Promise<{
status: 'fulfilled';
value: T extends Array<infer U> ? U : never;
} | {
status: 'rejected';
reason: any;
}
<Awaited<T>>[]>;
💡 이제 메서드가 어떻게 동작하는 지 알아보자
Promise.allSetteld는 인수로 받은 Promise 객체들이 모두 처리가 끝날 때까지 기다린다. 모든 객체들의 처리가 끝난다면 status가 fulfilled이면 status,value를 key로 갖는 프로미스 객체로, rejected면 status, reason를 key로 갖는 프로미스 객체로 변경한 후에 배열에 넣어 리턴하게 된다.
console.time("Promise.allSettled()");
Promise.allSettled([
genPromise(2),
genPromise(1, "실패", "fail"),
genPromise(3),
]).then((res) => {
console.log(res);
console.timeEnd("Promise.allSettled()");
});
// 실행 결과
[
{ status: 'fulfilled', value: '성공' },
{ status: 'rejected', reason: '실패' },
{ status: 'fulfilled', value: '성공' }
]
// Promise.allSettled(): 3.009s
해당 결과로 알 수 있는 내용
순서를 보장해준다.
rejeceted가 발생해도 병렬처리가 중지되지 않는다.