📝

Promise

1. Promise

JavaScript는 비동기 처리를 할 때 콜백 함수를 사용한다. 하지만 함수 호출이 중첩되면서 가독성이 떨어져 코드를 이해하는데 어려움이 생기고 복잡도가 증가하였다. 콜백 지옥에서 벗어날 수 있도록 ES6에서 Promise가 등장했다. 프로미스는 성공적으로 수행된다면 그 결괏값을, 실패한다면 에러를 전달한다. 코드를 간결하게 만들어 처리의 성공과 실패를 더욱 쉽게 관리할 수 있기 때문에 프로미스를 사용한다.
프로미스는 제작 코드와 소비 코드로 이루어져 있다. 제작 코드(Producing code)는 정보를 전달하는 코드로 네트워크에서 사용자 데이터를 로드하는 코드이다. 소비코드(Consuming code)는 생산 코드가 전달한 정보를 받는 코드로 로드된 사용자 데이터를 받는 코드이다.
예를 들어 놀이동산에 갔다고 가정해보자. 인기 있는 놀이기구는 기다리는 줄이 길어 몇 시간씩 기다려야 한다고 한다. 그래서 시간별로 예약받는데 휴대폰 번호를 적고 가면 탑승 가능한 시간에 알림을 보내준다고 한다.
notion imagenotion image
예약한 손님이 탑승할 수 있는 시간까지 시간이 걸리는 ‘놀이기구’가 제작코드이다. 놀이기구를 탈 수 있다고 알림을 받는 ‘손님’이 소비코드이다. 예약한 손님의 차례가 왔을 때 탑승 알림을 보내는 것이 프로미스로 제작코드와 소비코드를 연결해 준다.
 

1.1 Promise의 생성

const 프로미스 = new Promise(function(resolve, reject) { // 실행함수 });
먼저 new 연산자를 사용해 프로미스 객체를 만든다. 생성자 함수 new Promise()의 매개변수로 작성하는 함수를 실행 함수(excutor)라고 한다. 실행 함수는 resolve(), reject()를 매개변수로 받는다. 따로 만들지 않은 resolve()reject()가 어떻게 사용될 수 있나 싶지만, Promise가 만들어질 때 자바스크립트 엔진에 의해 자동으로 실행된다. resolve()는 비동기 작업이 성공적으로 처리되었을 때 결괏값(최종 데이터)과 함께 호출되고, reject()는 실패했을 때 에러 객체를 전달한다.
const 프로미스 = new Promise(function(resolve, reject) { resolve('🍓'); resolve('🍌'); }); console.log(프로미스);
1.1.1 예제
notion imagenotion image
반드시 실행 함수는 resolve(), reject() 둘 중 하나를 호출해야 한다. resolve()나 reject()를 각각 중복해서 호출하면 처음 호출한 게 적용되고 그 이후의 호출된 함수들은 무시된다.
 

1.2 Promise의 상태

프로미스 객체는 비동기 작업에 대한 상태와 결과를 관리한다. 프로미스가 생성된 직후에는 보류, 즉 pending이라는 상태를 갖는다. 그 이후 비동기 작업의 성공 여부에 따라 fulfilled 혹은 rejected라는 상태를 갖게 된다. 비동기 처리가 성공적으로 이행되어 resolve()가 호출되면 pending에서 fulfilled 상태로 전환되며, 만약 처리 도중 에러가 발생해 reject()가 호출되면 pending에서 rejected 상태로 전환된다. 이렇게 pending에서 fulfilled 혹은 rejected로 바뀌면 settled(해결된, 안정된) 상태가 되었다고 말하며, 한번 상태가 정해지면 다른 상태로 절대 변경될 수 없다. 프로미스는 하나의 결과를 갖기 때문이다.
notion imagenotion image
 
프로미스의 상태와 결과는 콘솔창에서 내부 슬롯을 통해 확인할 수 있다.
const success = new Promise(resolve => resolve('✅ 프로미스 처리 완료!'));
1.2.1 예제
notion imagenotion image
const failure = new Promise((_, reject) => reject(new Error('🚨 에러 발생!')));
1.2.2 예제
notion imagenotion image

2. Promise 처리 메서드

프로미스의 비동기 처리 상태가 변화하면 즉 state가 fulfilled나 rejected로 변화하면, 이에 따른 후속처리를 해야 하는데, 이를 위한 메서드로 then, catch, finally가 있다. 이 세가지 메서드는 프로미스를 반환하는데, 콜백함수가 프로미스가 아닌 값을 반환하더라도 반드시 resolve나 reject 하여 프로미스를 생성해 반환한다.

2.1 then

then 메서드는 프로미스를 반환한 후 두 개의 콜백함수를 인자로 받는다. 하나는 프로미스가 fulfilled 되었을 때의 콜백함수, 다른 하나는 rejected 되었을 때 콜백함수를 받는다.
test라는 프로미스를 생성하고, fulfilled 되었을 경우 다음과 같이 첫번째 콜백함수가, rejected 되었을 경우 두번째 콜백함수가 실행되도록 해보자.
let test = new Promise(resolve => setTimeout(() => resolve('fulfilled!'),5000)) test.then( () => console.log('i am fulfilled!'), () => console.error('not fulfilled!'))
2.1.1 예제
notion imagenotion image
처음엔 pending된 상태의 프로미스 객체가 콘솔창에 뜬다. 이후 resolve 함수가 실행되어 fulfilled 된 상태를 지니게 되었기 때문에 자동으로 첫번째 함수가 실행되어 5초 후 콘솔창에 ‘i am fulfilled!’가 출력되는 것을 확인할 수 있다.
rejected 되었을 경우도 똑같은 원리로 다음과 같이 나타난다.
let test2 = new Promise((_, reject) => setTimeout(() => reject("rejected"), 5000) ); test2.then( () => console.log("i am fulfilled!"), () => console.error("not fulfilled!") );
2.1.2 예제
notion imagenotion image
그런데 이번에는 프로미스의 상태가 fulfilled로 나타난다. 왜 그런 것일까?
앞에서 말했듯이 프로미스의 메서드들은 프로미스 체이닝을 위해서 반드시 프로미스를 반환하는데, 이때 Error가 throw되거나 reject 함수가 호출되지 않는 한 fulfilled된 프로미스를 반환하기 때문이다. 그럼 에러를 던져보자.
let test2 = new Promise((_, reject) => setTimeout(() => reject("rejected"), 5000) ); test2.then( () => console.log("i am fulfilled!"), () => {throw Error("not fulfilled!");} );
2.1.3 예제
notion imagenotion image
처음에 pending된 프로미스가 반환된 후 5초가 지나고 나서 저렇게 Error가 뜨는 것을 볼 수 있다. 다시 test2를 콘솔창에 입력해보면 아래와 같이 이제는 상태가 rejected인 프로미스로 잘 뜨는 것을 확인할 수 있다.
notion imagenotion image

2.2 catch

catch 메서드는 프로미스에서 발생한 에러를 다룬다. catch는 하나의 콜백 함수를 인자로 전달받고 콜백 함수는 프로미스가 실패(rejected) 상태일 때 호출된다. catch 역시 프로미스를 반환한다. catch 메서드를 이용한 에러 처리 방법을 알아보기 전에, 앞에서 살펴본 then 메서드를 이용해서 에러를 처리해 보자.
const promise = new Promise((resolve, reject) => { setTimeout(() => { reject(new Error("에러 발생💥")); }, 3000); }); promise .then(value => console.log(value));
2.2.1 예제
notion imagenotion image
 
코드를 실행하면 3초 후에 에러가 발생한다. Uncaught Error인 이유는 then에서 받아온 value는 프로미스가 성공(fulfilled) 했을 경우에만 받아올 수 있기 때문에 잡히지 않는 에러가 발생한 것이다. 따라서 then 메서드로 에러를 처리하려면 프로미스가 rejected 상태일 때 실행되는 then의 두 번째 콜백 함수로 에러를 잡아야 한다.
const promise = new Promise((resolve, reject) => { setTimeout(() => { reject(new Error("에러 발생💥")); }, 3000); }); promise.then(value => { console.log(value); }, error => { console.error(error); });
2.2.2 예제
notion imagenotion image
 
그런데 여기서 잠깐! 만약 then의 첫 번째 콜백 함수에서 에러가 발생하면 어떻게 될까?
const promise = new Promise((resolve, reject) => { setTimeout(() => { resolve("성공✨"); }, 3000); }); promise.then(value => { console.log(value); throw new Error("에러 발생💥"); }, error => { console.error(error); });
2.2.3 예제
notion imagenotion image
 
코드를 실행하면 위에서 언급했던 에러를 잡지 못한 Uncaught Error가 발생한다. 프로미스에서 resolve()가 정상적으로 호출되었지만, then의 첫 번째 콜백 함수 내부에서 발생한 에러를 두 번째 콜백 함수가 제대로 잡아내지 못하는 것이다. 하지만 똑같은 에러를 catch로 처리하면 다른 결과가 나온다.
const promise = new Promise((resolve, reject) => { setTimeout(() => { resolve("성공✨"); }, 3000); }); promise .then(value => { console.log(value); throw new Error("에러 발생💥"); }) .catch(error => console.error(error))
2.2.4 예제
notion imagenotion image
 
더 이상 Uncaught Error가 발생하지 않고 then의 첫 번째 콜백 함수에서 발생한 에러를 콘솔 창에 출력하는 것을 확인할 수 있다. 다시 돌아가서 프로미스가 rejected 상태일 때 catch로 에러를 처리해 보자.
const promise = new Promise((resolve, reject) => { setTimeout(() => { reject(new Error("에러 발생💥")); }, 3000); }); promise .then(value => console.log(value)) .catch(error => console.error(error))
2.2.5 예제
notion imagenotion image
 
reject()를 통해 전달받은 값이 catch에 인자로 전달되고 에러를 잡은 것을 확인할 수 있다. catch를 사용하면 프로미스의 rejected 상태를 처리할 수 있을 뿐만 아니라 then 메서드 내부의 에러 처리도 가능하다.

2.3 finally

finally 메서드는 하나의 콜백 함수를 인자로 전달받고 콜백 함수는 프로미스의 성공(resolve), 실패(rejected) 여부와 상관없이 반드시 마지막에 호출된다. finally는 공통적으로 수행해서 처리할 내용이 있을 때 유용하다. finally 역시 then, catch와 마찬가지로 프로미스를 반환한다.
const promise = new Promise((resolve, reject) => { setTimeout(() => { reject(new Error("에러 발생💥")); }, 3000); }); promise .then(value => console.log(value)) .catch(error => console.error(error)) .finally(() => console.log("종료🔥"))
2.3.1 예제
notion imagenotion image
코드를 실행하면 finally가 마지막으로 수행된 것을 확인할 수 있다.

2.4 Promise chainning

프로미스 체이닝은 '프로미스를 엮는다’라는 뜻이다. 어떠한 비동기 함수의 결괏값을 통해 또 다른 비동기 함수를 실행해야 하는 경우. 즉 비동기 작업 중에서도 순차가 필요한 경우가 존재한다. 그리고 이러한 순차는 지금 배우고있는 프로미스에서는 then, catch, finally메서드를 통해 여러 개의 프로미스를 연결함으로써 수행할 수 있고, 이것을 바로 프로미스 체이닝이라 한다.
예시를 통해 좀 더 쉽게 파악해보자. 자취생인 개리는 오랜만에 집 청소를 하려고 한다. 여러 작업 중 하나인 빨래를 돌리는 데에는 다음과 같은 순차적인 과정이 필요하며, 이 안에서 순서가 바뀌면 빨래가 실패하기 때문에 꼭 순서를 지켜서 하고자 한다.
1. 세탁기 안에 빨래와 세제를 넣는다. 2. 세탁기를 돌린다. 3. 세탁기에서 빨래를 꺼낸다. 4. 빨래를 넌다.
이 과정을 프로미스 체이닝으로 풀어내면 아래와 같다.
new Promise(function (resolve, reject) { setTimeout(() => resolve('앗...💦 입을 옷이 없네ㅠㅠ세탁기 돌려야지')); }) .then(function (result) { console.log(result); result = '1️⃣ 세탁기 안에 빨래와 세제를 넣는다' return result }) .then(function (result) { console.log(result) result = '2️⃣ 세탁기를 돌린다' return result }) .then(function (result) { console.log(result) result = '3️⃣ 세탁기에서 빨래를 꺼낸다' return result }) .then(function (result) { console.log(result) result = '4️⃣ 빨래를 넌다' return result }) .then(function (result) { console.log(result) return '다했다!🙆‍♀️' }) .finally(function (result) { console.log(result) })
2.4.1 예제
notion imagenotion image
근데 어떻게 프로미스를 연결해서 사용하는 것이 가능할까? 이에 대한 대답은 간단하다. 바로 프로미스의 메서드는 새로운 프로미스 객체를 return 즉, 반환하기 때문이다. then()의 인자로 프로미스 결괏값을 받아서 필요한 작업을 수행한 뒤, 새로운 결괏값을 다음 then()으로 넘겨주는 것을 반복하는 메서드들의 연결된 집합. 이것이 바로 프로미스 체이닝이다.

2.5 promise 메서드 종합 예제

우리는 위에서 세가지 메서드 then(), catch(), finally()를 배웠다. 이제 이 메서드들을 활용하여 프로미스 체이닝한 예시를 살펴보자.
엄마가 개리에게 당근, 양파 그리고 고등어를 사오라는 심부름을 시키셨다. 그런데 양파를 사려고 보니 양파가 다 팔렸다고 한다. 이 경우에 코드가 어떻게 작성되는지 함께 살펴보자.
let 심부름 = new Promise(resolve => setTimeout(() => { console.log('엄마👨‍🦱: 개리야~~심부름 갔다오렴~') resolve('개리🐸: 넵~! 다녀오겠습니다~') })) 심부름 .then(첫번째재료 => { 첫번째재료 = '당근🥕' console.log(`${첫번째재료} 구입완료`) 다음구입재료 = '양파🧅' return 다음구입재료 }) .then(두번째재료 => { throw Error(`${두번째재료}가 다 소진되었습니다ㅠㅠ😥`) }) .catch((err) => { console.error(err) console.log('양파는 내일 와서 사야겠다😞') }) .then(세번째재료 => { 세번째재료 = '고등어🐟' console.log(`${세번째재료} 구입완료`) return '다샀다!' }) .then(result => { console.log(result) console.log('심부름완료!✌') return ('심부름완료') }) .finally(() => console.log('심부름 다녀왔습니다!🙋‍♀️'))
2.5.1 예제
notion imagenotion image

3. Promise API

3.1 Promise.resolve

resolve(value)는 프로미스가 정상적으로 수행될 때 동작하는 메서드로, value라는 결괏값을 가지는 프로미스 객체를 반환한다. 이때, 프로미스는 fulfilled라는 상태를 가진다. resolve()로 반환된 값 역시 프로미스 객체이기 때문에 then() 메서드로 값을 넘기고, then() 안에서 용도에 따라 가공할 수 있다. 아래 예제는 성공해서 resolve()가 호출되는 경우만을 작성한 코드이다. value에는 스트링, 배열 등의 모든 타입의 값을 넣을 수 있다.
// 예제1 Promise.resolve("성공!").then(function(value) { console.log("예제 1: " + value); // "성공!" 이 출력된다 }, function(value) { console.log("실패🤦‍♀️");// 호출되지 않는다. }); // 예제 2 let promiseA = Promise.resolve("promiseA 성공!!"); let promiseB = Promise.resolve(promiseA); promiseB.then(function(value) { console.log("예제 2: " + value); // promiseA가 출력된다. }); // 예제 3 let fruits = Promise.resolve(['🥕', '🍑', '🍒', '🍉', '🍇']); fruits.then(function(value) { console.log("예제 3: " + value[0]); // 🥕이 출력된다. });
3.1.1 예제
notion imagenotion image

3.2 Promise.reject

reject(value)value라는 이유로 에러가 발생한, 즉 실패한(rejected) 상태의 프로미스 객체를 반환한다. value에는 개발자가 에러를 직접 정의하여 넣는다. 어떠한 상황을 에러로 만들 것인지 개발자가 스스로 정할 수 있기 때문에 흐름 제어에 용이하다. 이때, value에는 Error 객체 외에도 다른 값(string, array 등)을 넣을 수 있지만, Error 객체를 사용하는 것을 권장한다. 다른 값과는 달리 Error 객체는 에러에 대한 상세 내용을 담고 있어 디버깅에 도움을 주기 때문이다. 아래 예제는 회원 가입을 할 때 아이디가 중복일 경우 에러를 발생시키는 사용자 정의 에러를 간단하게 표현해 보았다.
Promise.reject(new Error("이미 있는 아이디입니다.🤦‍♀️")).then(function() { console.log("회원가입 성공! 🙋‍♀️"); // 호출되지 않는다. }, function(error) { console.error(error); // 에러에 대한 내용을 출력 });
3.2.1 예제
notion imagenotion image

3.3 Promise.all

Promise.all은 팀플레이다.
Promise.all은 여러 개의 프로미스를 동시에 실행해 처리하는 메서드로 모든 프로미스가 실행이 끝나면 각 프로미스의 값이 배열로 반환한다. 하지만 프로미스 중에 하나라도 에러가 난다면 진행 중인 프로미스가 있더라도 그 즉시 실행을 멈추고 에러가 Promise.all의 반환 값이 된다.
Promise.all에 대한 예시로 요리 대회 상황을 가정해 보자. 요리 대회 1라운드는 팀전으로 한 상차림을 만들어 내야 한다. 여기서 빨강, 파랑, 노랑이 한 팀이 되었고 각각의 참가자가 요리를 성공적으로 마쳤으며, 완료되기까지 총 4초가 걸렸습니다. Promise.all을 사용해 작성해 본다면 다음과 같다.
notion imagenotion image
const 빨강 = new Promise((resolve, reject) => { setTimeout(() => { resolve('매콤달콤 양념치킨🍗'); }, 1000); }); const 파랑 = new Promise((resolve, reject) => { setTimeout(() => { resolve('통베이컨 포테이토피자🍕'); }, 4000); }); const 노랑 = new Promise((resolve, reject) => { setTimeout(() => { resolve('육즙 가득 수제 햄버거🍔'); }, 2000); }); const 요리 = Promise.all([빨강 , 파랑 , 노랑]).then((result) => { console.log(result) });
3.3.1 예제
notion imagenotion image
이처럼 각각의 Promise가 성공적으로 fullfiled 되고 각각 프로미스의 결괏값이 배열로 담겨 저 반환 된다. 하지만 언제나 요리가 성공적으로 끝나지 않는다. 요리 대회의 예시로 돌아가 보면, 만약 노랑이 불 조절을 실패해 결국 팀 요리를 완성하지 못했다면 어떻게 됐을까?
notion imagenotion image
const 빨강 = new Promise((resolve, reject) => { setTimeout(() => { resolve('매콤달콤 양념치킨🍗'); }, 1000); }); const 파랑 = new Promise((resolve, reject) => { setTimeout(() => { resolve('통베이컨 포테이토피자🍕'); }, 4000); }); const 노랑 = new Promise((resolve, reject) => { setTimeout(() => { reject(new Error('🔥불 조절 실패🔥')); }, 2000); }); const 요리 = Promise.all([빨강, 파랑, 노랑]).catch((error) => { console.error(error) });
3.3.2 예제
notion imagenotion image
Promise.all은 에러가 발생되자마자 실행 중이던 다른 프로미스를 무시하고 에러가 나온 순간 에러를 반환하고 끝난다. 이처럼 Promise.all은 프로미스를 동시에 실행시켜 결과를 받을 수 있으며, 만약 에러가 생긴다면 그 즉시 에러 결과를 반환해 다른 프로미스를 기다릴 필요 없이 빠르게 에러를 잡을 수 있다.

3.4 Promise.allSettled

Promise.allSettled는 각개전투이다.
앞서 설명한 Promise.all처럼 Promise.allSettled은 여러 개의 프로미스를 병렬적으로 실행해 처리할 수 있다. 그렇다면 무엇이 다를까? Promise.allSettled는 동시에 프로미스를 처리하되 에러가 발생해도 멈추지 않고 모든 프로미스가 끝날 때까지 기다렸다가 각각의 결과와 결괏값이 객체에 담겨 배열로 반환한다. 에러가 나면 그 순간 멈춰버리는 Promise.all과 비교해 모든 프로미스가 Settled 상태가 된 것을 볼 수 있다. 각각의 프로미스 결과 객체는 성공과 에러 결과에 따라 다음과 같은 구성을 가지게 된다.
// Promise 응답이 성공할 경우 { status: "fulfilled", value: promise return 값 } // Promise 에러가 발생한 경우 { status: "rejected", reason: 에러}
앞서 예제에서 요리 대회 팀 전을 예시로 들었다면 이번에는 개인전으로 예시를 들 수 있다. 요리 대회 2라운드는 개인전으로 각자 요리를 만들었다. 3명의 참가자 모두 성공적으로 요리를 마쳤으며 각각의 요리를 만들어 냈다. Promise.allSettled를 사용해 코드로 작성해 본다면 다음과 같다.
notion imagenotion image
const 빨강 = new Promise((resolve, reject) => { setTimeout(() => { resolve('종갓집 5첩 도시락🍱'); }, 1000); }); const 파랑 = new Promise((resolve, reject) => { setTimeout(() => { resolve('대왕 연어 초밥🍣'); }, 4000); }); const 노랑 = new Promise((resolve, reject) => { setTimeout(() => { resolve('부드러운 크림 카레🍛'); }, 2000); }); const 요리 = Promise.allSettled([ 빨강, 파랑, 노랑 ]).then((results) => { console.log(results) });
3.4.1 예제
notion imagenotion image
각자의 현재 상태와 결괏값이 객체로 담겨 있고 이 객체들을 배열로 담아 반환된 것을 볼 수 있다. 그렇다면 만약 처리 도중 에러가 발생하는 상황에서는 어떻게 될까? 요리 대회 중 노랑이 불 조절을 실패해 음식이 다 타버려서 실패했다고 가정해 보자.
notion imagenotion image
const 빨강 = new Promise((resolve, reject) => { setTimeout(() => { resolve('종갓집 5첩 도시락🍱'); }, 1000); }); const 파랑 = new Promise((resolve, reject) => { setTimeout(() => { resolve('대왕 연어 초밥🍣'); }, 4000); }); const 노랑 = new Promise((resolve, reject) => { setTimeout(() => { reject(new Error('🔥불 조절 실패🔥')); }, 2000); }); const 요리 = Promise.allSettled([ 빨강, 파랑, 노랑 ]).then((results) => { console.log(results) });
3.4.2 예제
notion imagenotion image
노랑이 중간에 에러가 나타났음에도 불구하고 모든 프로미스를 완료해 4초 후 결과가 나왔으며 {status: 'rejected', reason: Error: 🔥불 조절 실패🔥} 로 노랑의 결과가 에러에 대한 내용으로 나온 걸 볼 수 있다.
Promise.allSettled을 사용하면 중간에 프로미스가 에러가 나더라도 멈추지 않고 결과를 얻을 수 있다. 다만 Promise.allSettled 는 최신에 나온 문법으로 지원하지 않는 브라우저라면 폴리필이 필요하다.
Promise.allSettled 지원 브라우저 버전 (출처: https://caniuse.com/)Promise.allSettled 지원 브라우저 버전 (출처: https://caniuse.com/)
Promise.allSettled 지원 브라우저 버전 (출처: https://caniuse.com/)

3.5 Promise.race

자, 프로미스들 다들 레디~! 누가 가장 먼저 처리되나 레이스 시~작!
Promise.race는 달리기 경주(race)를 한다고 비유를 들면 쉽게 다가온다. 가장 먼저 레이스 도착지점에 오는 즉, 가장 먼저 처리되는 프로미스의 결과나 에러를 반환한다.
Promise.race([ new Promise((resolve, reject) => setTimeout(() => resolve('내가 1등! 내 밑으론 다 무시되지 음하하😝'), 1000)), new Promise((resolve, reject) => setTimeout(() => reject(new Error("난 에러지만 2등!")), 2000)), new Promise((resolve, reject) => setTimeout(() => resolve('난 꼴찌네ㅜㅜ'), 3000)) ]).then(alert);
3.5.1 예제
notion imagenotion image
첫번째로 처리된 프로미스가 resolve 메서드를 호출하였으므로 state는 fulfilled로 나타난다. 만약, 첫번째로 처리된 프로미스가 reject 메서드를 호출한다면 state는 rejected로 나타난다.