Promise, async / await
Promise
자바스크립트와 노드에서는 주로 비동기를 접합니다. 특히 이벤트 리스너를 사용할 때 콜백 함수를 자주 사용합니다. 최근에는 자바스크립트와 노드의 API 들이 콜백 대신 프로미스(Promise) 기반으로 재구서오디며, 콜백 지옥(callback hell) 현상을 극복했다는 평가를 받고 있습니다. 때문에 프로미스는 반드시 알아둬야 하는 객체입니다.
정의
프로미스는 지저분한 콜백함수의 코드의 해결책으로 자바스크립트 비동기 처리에 사용되는 객체입니다. 여기서 자바스크립트의 비동기 처리란 ‘특정 코드의 실행이 완료될 때까지 기다리지 않고 다음 코드를 먼저 수행하는 자바스크립트의 특성’을 의미합니다. 한 마디로 설명해 '실행은 되었지만 결과를 아직 반환하지 않은 객체' 라고 말할 수 있습니다.
프로미스의 기본 코드를 보며 설명 이어가겠습니다.
const condition = true; // true 면 resolve, false 면 reject
const promise = new Promise((resolve, reject) => {
if (condition) {
resolve('성공');
} else {
reject('실패');
}
});
// 다른 코드가 들어갈 수 있음
promise
.then((message) => {
console.log(message); // 성공(resolve) 한 경우 실행
})
.catch((error) => {
console.error(error); // 실패(reject) 한 경우 실행
})
.finally(() => { // 끝나고 무조건 실행
console.log('무조건');
});
new Promise 로 프로미스를 생성할 수 있으며, 안에 resolve 와 reject 를 매개변수로 갖는 콜백 함수를 넣습니다. 이렇게 만든 promise 변수에 then 과 catch 메서드를 붙일 수 있습니다. 프로미스 내부에서 resolve 가 호출되면 then 이 실행되고, reject 가 호출되면 catch 가 실행됩니다.
resolve 와 reject 에 넣어준 인수는 각각 then 과 catch 의 매개변수에서 받을 수 있습니다. 즉, resolve('성공') 이 호출되면 then 의 message 가 '성공' 이 됩니다. 만약 reject('실패') 가 호출되면 catch 의 error 가 '실패' 가 되는 것입니다. condition 변수를 false 로 바꿔보면 catch 에서 에러가 로깅됩니다.
프로미스를 쉽게 설명하자면, 실행은 바로 하되 결괏값은 나중에 받는 객체입니다. 결괏값은 실행이 완료된 후 then 이나 catch 메서드를 통해 받습니다. 위 예제에서는 new Promise 와 promise.then 사이에 다른 코드가 들어갈 수도 있습니다. new Promise 는 바로 실행되지만, 결괏값은 then 을 붙였을 때 받게 됩니다.
then 이나 catch 에서 다시 다른 then 이나 catch 를 붙일 수 있습니다. 이전 then 의 return 값을 다음 then 의 매개변수로 넘깁니다. 프로미스를 return 한 경우 프로미스가 수행된 후 다음 then 이나 catch 가 호출됩니다.
then 을 붙이면 결과를 반환할 수 있으며 실행이 완료되지 않았으면 완료된 후에 then 내부 함수가 실행됩니다.
필요한 이유
다른 비동기 함수인 setTimeout 이 있는데 프로미스가 필요한 이유는 함수를 분리할 수 있는 데에 있습니다.
setTimeout 의 기본 형태를 살펴보겠습니다.
빨간 글씨로 되어 있는 함수는 바깥으로 빼지 못하는 그런 함수입니다. 콜백함수를 이용해 아래와 같이 표현할 수 있지만, 결국엔 callback 함수는 그 자리에 있게 됩니다.
하지만 프로미스를 사용하게 된다면, 함수를 분리해 다음과 같이 표현할 수 있습니다.
또한, 프로미스를 꼭 알아두어야 하는 점이 현재 노드 생태계에서 콜백함수가 모두 프로미스로 전향되고 있는 추세입니다. 대부분의 함수들이 프로미스를 지원하는 쪽으로 바뀌고 있습니다.
사용방법
프로미스 → new Promise
promise
.then((message) => {
return new Promise((resolve, reject) => {
resolve(message);
});
})
.then((message2) => {
console.log(message2);
return new Promise((resolve, reject) => {
resolve(message2);
});
})
.then((message3) => {
console.log(message3);
})
.catch((error) => {
console.log(error);
});
처음 then 에서 message 를 resolve 하면 다음 then 에서 message2 로 받을 수 있습니다. 여기서 다시 message2 를 resolve 한 것을 다음 then 에서 message3 으로 받았습니다. 단, then 에서 new Promise 를 return 해야 다음 then 에서 받을 수 있습니다.
콜백 → 프로미스
이를 활용해 콜백을 프로미스로 바꿀 수 있습니다. 다음은 콜백을 쓰는 패턴 중 하나입니다.
function findAndSaveUser(Users) {
Users.findOne({}, (err, user) => { // 첫 번째 콜백
if (err) {
return console.error(err);
)
user.name = 'JH';
user.save((err) => { // 두 번째 콜백
if (err) {
return console.error(err);
}
Users.findOne({ gender: 'm' }, (err, user) => { // 세 번째 콜백
// 생략
});
});
});
}
콜백 함수가 세 번 중첩되어 있습니다. 콜백 함수나 나올 때마다 들여쓰기가 추가되면서 코드의 깊이가 깊어집니다. 각 콜백 함수마다 에러도 따로 처리해줘야 합니다. 이 코드는 다음과 같이 바꿀 수 있습니다.
function findAndSaveUser(Users) {
Users.findOne({})
.then((user) => {
user.name = 'JH';
return user.save();
})
.then((user) => {
return Users.findOne({ gender: 'm' });
})
.then((user) => {
// 생략
})
.catch(err => {
console.log(err);
});
}
코드의 깊이가 세 단계 이상 깊어지지 않습니다. 위 코드에서 then 메서드들은 순차적으로 실행됩니다. 콜백에서 매번 따로 처리해야 했던 에러도 마지막 catch 에서 한 번에 처리할 수 있습니다. 하지만 모든 콜백 함수를 위와 같이 바꿀 수 있는 것은 아닙니다. 메서드가 프로미스 방식을 지원해야 합니다.
예제의 코드는 findOne 과 save 메서드가 내부적으로 프로미스 객체를 갖고 있다고 가정했기에 가능합니다.(new Promise 가 함수 내부에 구현되어 있어야 합니다.) 지원하지 않는 경우 콜백 함수를 프로미스로 바꿀 수 있는 방법은 후에 쓰도록 하겠습니다.
const promise1 = Promise.resolve('성공1');
const promise2 = Promise.resolve('성공2');
Promise.all([promise1, promise2])
.then((result) => {
console.log(result); // ['성공1', '성공2'];
})
.catch((error) => {
console.error(error);
});
Promise.resolve
Promise.resolve 는 즉시 resolve 하는 프로미스를 만드는 방법입니다. 비슷한 것으로 즉시 reject 하는 Promise.reject 도 있습니다. 프로미스가 여러 개 있을 때 Promise.all 에 넣으면 모두 resolve 될 때까지 기다렸다가 then 으로 넘어갔습니다. reult 매개변수에 각각의 프로미스 결괏값이 배열로 들어 있습니다. Promise 중 하나라도 reject 가 되면 catch 로 넘어갑니다. 다만, 여러 프로미스 중 어떤 프로미스가 reject 되었는지는 알 수 없습니다.
정확히 어떤 프로미스에서 reject 되었는지 알기 위해서는 Promise.all 대신 Promise.allSettled 를 사용해야 합니다.
const prmoise1 = Promise.resolve('성공1');
const prmoise2 = Promise.resolve('성공2');
const prmoise3 = Promise.resolve('성공3');
Promise.allSettled([promise1, prmoise2, promise3])
.then((result) => {
console.log(result);
/* [
* { status: 'fulfuiled', value: '성공1' },
* { status: 'rejected', reason: '성공2' },
* { status: 'fulfuiled', value: '성공3' },
* ]
*/
})
.catch((error) => {
console.error(error);
});
Promise.allSettled 를 사용하면 결괏값이 좀 더 자세해져서 어떤 프로미스가 reject 되었는지 status 를 통해 알 수 있습니다. 실패 이유는 reason 에 들어 있습니다. 따라서 Promise.all 대신 Promise.allSettled 를 사용하는 것을 좀 더 권장합니다.
참고로 Node 16 버전부터는 reject 된 Promise 에 catch 를 달지 않으면 UnhandledPromiseRejection 에러가 발생합니다. 에러가 발생하면 다음 코드가 실행되지 않으니 반드시 프로미스에 catch 메서드를 붙이는 것을 권장합니다.
try {
Promise.reject('에러');
} catch (e) {
console.error(e); // UnhandledPromiseRejection: This error originated either by...
}
Promise.reject('에러').catch(() => {
// catch 메서드를 붙이면 에러가 발생하지 않음
}
resolve, reject
결국 promise 라는 것은 어떤 동작을 실행하는 것입니다. 예를 들어 "00 파일을 읽어와", "네이버에서 요청을 보냈다가 와" 처럼요. 하지만, 여기서 요청이 실패하는 경우도 있습니다.(네이버에서 악성코드로 인식해 요청을 거절할 수도 있습니다.) 그렇기 때문에 성공하면, resolve 를 실행해 then 으로 호출하고, 실패하면 reject 를 실행해 catch 를 호출합니다. 그래서 기능을 둘로 나눈 것이라고 할 수 있습니다.
async / await
노드 7.6 버전부터 지원되는 기능으로, ES2017 에서 추가되었습니다. 알아두면 정말 편리한 기능이며, 특히 노드처럼 비동기 위주로 프로그래밍을 해야 할 때 도움이 많이 됩니다.
프로미스가 콜백 지옥을 해결했다지만, 여전히 코드가 장황합니다. then 과 catch 가 계속 반복되기 때문입니다. async / await 문법은 프로미스를 사용한 코드를 한 번 더 깔끔하게 줄입니다.
앞에서 나온 프로미스 코드를 다시 한 번 보겠습니다.
function findAndSaveUser(Users) {
Users.findOne({})
.then((user) => {
user.name.save();
})
.then((user) => {
return Users.findOne({ gender: 'm' });
})
.then((user) => {
// 생략
})
.catch(err) => {
console.error(err);
});
}
콜백과 다르게 코드의 깊이가 깊어지진 않지만, 코드 길이는 여전히 깁니다. async / await 문법을 사용하면 다음과 같이 바꿀 수 있습니다. async function 이라는 것이 추가되었습니다.
async function findAndSaveUser(Users) {
let user = await Users.findOne({});
user.name = 'JH';
user = await user.save();
user = await Users.findOne({ gender: 'm' });
// 생략
}
코드가 이전보다 훨씬 짧아졌습니다. 함수 선언부를 일반 함수 대신 async function 으로 교체한 후, 프로미스 앞에 await 을 붙였습니다. 이제 함수는 해당 프로미스가 resolve 될 때까지 기다린 뒤 다음 로직으로 넘어갑니다. 예를 들면, await Users.findOner({}) 이 resolve 될 때까지 기다린 다음에 user 변수를 초기화하는 것입니다.
위 코드는 에러를 처리하는 부분(프로미스가 reject 된 경우) 이 없으므로 다음과 같은 추가 작업이 필요합니다.
async function findAndSaveUser(Users) {
try {
let user = await Users.findOne({});
user.name = 'JH';
user = await user.save();
user = await Users.findOne({ gender: 'm' });
// 생략
} catch(error) {
console.error(error);
}
}
try / catch 문으로 로직을 감쌌습니다. 프로미스의 catch 메서드처럼 try / catch 문의 catch 가 에러를 처리합니다.
화살표 함수
화살표 함수도 async 와 같이 사용할 수 있습니다.
const findAndSaveUser = async (Users) => {
try {
let user = await Users.finfOne({});
user.name = 'JH';
user = await.user.save();
user = await.Users.findOne({ gender: 'm' });
// 생략
} catch(error) {
console.error(error);
}
};
for await of
for 문과 async / await 을 같이 써서 프로미스를 순차적으로 실행할 수 있습니다. for 문과 함께 쓰는 것은 노드 10 버전부터 지원하는 ES2018 문법입니다.
const promise1 = Promise.resolve('성공1');
const promise2 = Promise.resolve('성공2');
(async () => {
for await (promise of [promise1, promise2]) {
console.log(promise);
}
})();
for await of 문을 사용해서 프로미스 배열을 순회하는 모습입니다. async 함수의 반환값은 항상 Promise 로 감싸집니다. 따라서 실행 후 then 을 붙이거나 또 다른 async 함수 안에서 await 을 붙여서 처리할 수 있습니다.
async function findAndSaveUser(Users) {
// 생략
}
findAndSaveUser().then(() => { /* 생략 */ });
// 또는
async function other() {
const result = await findAndSaveUser();
}
앞으로 중첩되는 콜백 함수가 있다면 프로미스를 거쳐 async / await 문법으로 바꾸는 연습을 해봐야 할 것 같습니다.