async
/await
Javascript 하면 자연스럽게 callback hell 이 떠오르던 시절도 있었는데, Promise 가 정식으로 들어온 이후부터 그런 오명은 확실히 벗어난 것 같습니다. Node.js 와 함께 제공되는 모듈셋도 대부분 Promise 구현체가 있고, 최근 주목받는 대부분의 Javascript 모듈들은 전부 Promise 를 기본으로 생각하고 구현된 경우가 많습니다.
하지만 Promise 로 callback hell 은 벗어났지만, then
-then
-catch
로 이어지는 Promise Callback 들은 여전히 코드 들여쓰기를 지저분하게 합니다. 때로는 마치 blocking 타입의 언어처럼 순서대로 쭉쭉 쓰는게 편한 경우도 많으니까요. ES8 부터 도입된 async
/ await
는 바로 그걸 가능하게 합니다. non-blocking 이 기본인 Javascript 를 필요에 따라 blocking 처럼 보이게 만들 수 있죠.
// Function using promise
function getMessage(name) {
return new Promise((resolve, reject) => {
resolve(`Your name is ${name}`);
});
}
async function showMessage() {
// Using then
getMessage("Minsang Kim").then(message => {
console.log(`Message : ${message}`);
});
// Using async-await
const message = await getMessage("Minsang Kim");
console.log(`Message : ${message}`);
}
showMessage();
이름을 전달받아 메세지로 띄우는 간단한 예제를 만들어봤습니다. 단순히 들여쓰기를 1-depth 줄인 것뿐인데, 코드가 훨씬 깔끔하게 읽히고, 의미 전달이 명확합니다. 그냥 Promise 만 썼을 땐 메세지를 받아오면 이런 처리를 하라 라는 명령적인 느낌을 주지만, await
가 들어간 구문은, 메세지는 이거 라는 선언적인 느낌을 주지요. non-blocking 으로 움직이는 Javascript 코드의 제어흐름을 따라가지 않고도, 충분히 getMessage
라는 함수가 이름을 넘기면 메세지를 리턴한다는 사실을 직관적으로 알 수 있으니까요.
Array.map
에서제목을 보고 들어오셨다면 그럴 일은 없겠지만, 혹시나 이게 왜 포스트 주제가 되는지 이해가 안 되시는 분들을 위해 짤막한 코드 예제를 준비했습니다.
// Object array
const arr = [
{
id: 1,
name: "Minsang Kim",
age: 35
},
{
id: 2,
name: "Chenny Kim",
age: 2
}
];
// Async function using setTimeout
async function getIntroMessage(user) {
return new Promise((resolve, reject) => {
setTimeout(() => {
resolve(`${user.name} (${user.age}세)`);
}, 100);
});
}
async function getMessages() {
// WRONG :: Array.map using async-await
const messages = await arr.map(async user => {
return await getIntroMessage(user);
});
}
getMessages();
심플한 배열 arr
이 있고, 어거지로 비동기 처리를 하기 위해 setTimeout
을 이용하는 getIntroMessage
함수를 만들었습니다. arr
배열에서 map
을 돌면, 우리가 원하는대로 2개의 메세지가 들어있는 배열을 받을 수 있을까요?
# 결과값
[ Promise { <pending> }, Promise { <pending> } ]
땡! 그냥 실행되지 않은 Promise 배열 만 리턴해주고 마네요. 이유가 뭘까요? await
는 Promise 객체 를 실행하고 기다려주지만, Promise 배열 로는 그렇게 할 수 없기 때문입니다. 지금 우리의 코드는 arr.map
을 통해 Promise 배열 을 리턴하게 구현했으니까요.
그렇다면 Promise 배열 은 어떻게 처리해야 할까요? MDN의 Promise 항목을 볼게요.
여기엔 두가지 방법이 있습니다. Promise.all
은 배열 내의 모든 Promise 가 통과될 때까지 기다리는 Promise 를 반환하고, Promise.race
는 도중에 하나라도 실패하면 즉시 리턴되는 Promise 를 반환합니다. 설명이 좀 어렵게 쓰여있지만, 배열 내의 모든 Promise 를 중간에 거부(reject
or Exception
)가 발생하더라도 다 실행하려면 Promise.all
을, 거부 발생시 중도에 중단하려면 Promise.race
를 쓰면 됩니다.
@milkcoke님께서 이 부분의 오류를 지적해주셨습니다. (감사합니다!)
- reject 발생에도 다 실행할 때 => Promise.allSettled,
- 하나라도 reject 시 멈출 때 (모든 Promise 가 resolve 됐을 때를 보장하려면)=> Promise.all
- resolve / reject 여부 상관없이 가장 먼저 수행된 결과를 받아올 때 => Promise.race
우리는 처음부터 다 실행할 생각이었으니 이 경우는 Promise.all
을 쓰면 되겠네요. 다른 곳에 있던 async
-await
는 모두 필요없으니 지워버리고, Promise.all
에만 붙여주겠습니다.
async function showMessages() {
// RIGHT :: Array.map using async-await and Promise.all
const messages = await Promise.all(
arr.map(user => {
return getIntroMessage(user);
})
);
console.log(messages);
}
showMessages();
위 코드의 결과는 아래와 같습니다.
# 결과값
[ 'Minsang Kim (35세)', 'Chenny Kim (2세)' ]
참 쉽죠? 자, 이제 Array.reduce
로!
Array.reduce
에서 Array.reduce
는 Array.map
과는 조금 다른 문제가 있습니다. 위에서 사용한 arr
과 getIntroMessage
는 그대로 두고, 아래만 Array.reduce
를 쓰는 코드만 바꿔볼게요.
async function showMessages() {
// WRONG :: Array.reduce using async-await
const messageObject = await arr.reduce(async (result, user) => {
result[user.id] = await getIntroMessage(user);
return result;
}, {});
console.log(messageObject);
}
showMessages();
이렇게 실행하면, 결과는 아래와 같습니다.
# 결과값
{ '1': 'Minsang Kim (35세)' }
배열의 원소는 두개인데, 결과는 하나만 들어가 있네요. 아까는 Promise 객체 가 아니라 Promise 배열 이어서 await
가 제대로 동작하지 않았다고 설명드렸는데, 이건 왜일까요? 내용을 뜯어보기 위해 getIntroMessage
함수 콜의 앞뒤에 로그를 찍어보겠습니다.
async function showMessages() {
// WRONG :: Array.reduce using async-await
const messageObject = await arr.reduce(async (result, user) => {
console.log("before", user.id, result);
result[user.id] = await getIntroMessage(user);
console.log("after", user.id, result);
return result;
}, {});
console.log("messageObject", messageObject);
}
showMessages();
결과는 아래와 같습니다.
# 결과값
before 1 {}
before 2 Promise { <pending> }
after 1 { '1': 'Minsang Kim (35세)' }
after 2 Promise { { '1': 'Minsang Kim (35세)' }, '2': 'Chenny Kim (2세)' }
messageObject { '1': 'Minsang Kim (35세)' }
로그를 보면 확실히 알 수 있는데, await
는 실제로 함수가 결과를 줄 때까지 기다리는 것이 아닙니다. await
가 사용된 시점에서 이미 pending 상태의 Promise
를 리턴했죠. reduce
에 await
구문을 사용했으므로, 실제로 병합되는 과정에만 Promise
를 기다리고 있습니다. 하지만 이미 두번째 iteration 에서는 리턴받은 Promise 객체 에 값을 할당해버렸어요. (명백한 오류상황) 첫번째 iteration 의 결과값이 resolve
되어 도착하면 최종 결과가 그 값({ '1': 'Minsang Kim (35세)' }
이 될 수 밖에 없습니다.
이 문제를 해결하는데는 두가지 방법이 있습니다.
Promise.all
사용 첫번째 방법은 아까 Array.map
에서 사용했던 Promise.all
을 통해 Promise 를 사용하는 부분의 데이터를 따로 받아온 후 Array.reduce
를 사용하는 방법입니다.
async function showMessages() {
// RIGHT1 :: Using Promise.all
const messageObject = (await Promise.all(
arr.map(user => {
return getIntroMessage(user);
})
)).reduce((result, message, index) => {
const user = arr[index];
result[user.id] = message;
return result;
}, {});
console.log("messageObject", messageObject);
}
showMessages();
결과값이 어느 index
에서 매칭되는지 알고 있으니 사용할 수 있는 방법인데, 뭔가 깔끔한 느낌은 아니네요. 그래도 어쨌든 우리가 원하는 결과를 얻을 수 있습니다.
messageObject { '1': 'Minsang Kim (35세)', '2': 'Chenny Kim (2세)' }
Promise.resolve
사용 Promise.resolve
는 넘겨준 값을 그대로 resolve
해주는 Promise 를 리턴합니다. 이런 용도로 사용하는 Promise 의 prototype function 으로, 아래의 두 줄은 같은 동작을 하는 Promise 객체입니다.
const promise1 = Promise.resolve('abc');
const promise2 = new Promise((resolve, reject) => resolve('abc'));
이걸 어떻게 쓰는지는 코드를 통해 설명할게요.
async function showMessages() {
// RIGHT :: Using Promise.resolve
const messageObject = await arr.reduce(async (promise, user) => {
// 누산값 받아오기 (2)
let result = await promise.then();
// 누산값 변경 (3)
result[user.id] = await getIntroMessage(user);
// 다음 Promise 리턴
return Promise.resolve(result);
}, Promise.resolve({})); // 초기값 (1)
console.log("messageObject", messageObject);
}
showMessages();
뭔가 많이 복잡해보이지만, 실질적으로는 result
가 Promise.resolve
로 대체된 것이 다입니다. Array.reduce
의 누산되는 결과값을 그냥 값 으로 쓰는게 아니라 Promise.resolve(값)
으로 쓰는거죠.
이것을 순서대로 풀면, (코드의 주석 참조)
초기값을 Promise.resolve({})
로 준다.
iteration 안에서는 전달받은 Promise 를 누산값으로 찾아와야하니, await promise.then()
으로 받아온다.
누산값을 변경한다.
다음 iteration 에서는 다시 Promise 로 넘기기 위해 Promise.resolve(result)
를 리턴한다.
이 방법을 통해, 아래와 같은 결과를 얻을 수 있습니다.
messageObject { '1': 'Minsang Kim (35세)', '2': 'Chenny Kim (2세)' }
아래 jeongtae님이 댓글로 말씀해주신 바와 같이, async-await의 동작방식상 async 함수의 리턴값은 명시적으로 Promise.resolve
로 래핑하지 않아도 Promise로 자동 래핑됩니다. 따라서 아래와 같이 간략한 방식으로 쓰는 것을 추천드립니다.
const messageObject = await arr.reduce(async (promise, user) => {
// 누산값 받아오기 (2)
let result = await promise;
// 누산값 변경 (3)
result[user.id] = await getIntroMessage(user);
// 다음 Promise 리턴
return result;
}, {}); // 초기값 (1)
아예 본문의 예제 부분을 치환할까 했지만, 명시적으로 Promise.resolve
래퍼를 쓰는 방식이 동작방식을 이해하는데 도움이 되실 것 같아 별도로 첨부할게요.
코드가 다르다는 것 외에, 두 방식은 실제로 조금 다르게 동작합니다. Promise.all
은 배열 안의 모든 Promise 를 한번에 실행하여 결과를 취합하고, Promise.resolve
를 사용한 Array.reduce
는 각 iteration 을 순차적으로 실행하게 됩니다. 따라서 이 부분이 둘 중 "어떤 방식을 사용할 것인가" 를 결정하는데 중요한 요소입니다. 순서가 중요하다면 2. 의 방식을, 순서가 중요하지 않은 경우 1. 의 방식을 사용하는 것이 비동기 작업의 퍼포먼스 측면에서 더 좋은 결과를 얻게 됩니다.
Typescript 로 개발중 이 이슈에 부딪혀 이런저런 고민을 해보고, 구글링을 통해 방법을 찾아봤는데 깔끔히 잘 정리된 글이 없어 포스팅하게 되었습니다. 많은 분들에게 도움이 되었으면 합니다.
정리 감사합니다.
❎ Promise 를 중간에 거부(reject or Exception)가 발생하더라도 다 실행하려면Promise.all
을, 거부 발생시 중도에 중단하려면 Promise.race
를 쓰면 됩니다.
✅ reject 발생에도 다 실행할 때 => Promise.allSettled
,
하나라도 reject 시 멈출 때 (모든 Promise 가 resolve 됐을 때를 보장하려면)=> Promise.all
resolve / reject 여부 상관없이 가장 먼저 수행된 결과를 받아올 때 => Promise.race
많은 도움이 되었습니다!
작성해주신 글은 구현 원리를 이해하기에 정말 좋았습니다. 제공해주신 마지막 reduce 예제는 조금 더 단순화할 수 있을 것 같아서 댓글을 남겨봅니다.
async
함수이므로, 콜백에서 뭘 리턴하더라도 리턴 타입은 자동으로 Promise가 됩니다. 따라서 콜백 내에서 Promise.resolve(result)
를 리턴하는 대신 그냥 return result;
하여도 될 것 같습니다.Promise.resolve({})
대신에 그냥 바로 빈 객체({}
)를 바로 넘겨도 될 것 같습니다. Promise가 아닌 원시값이나 객체에 대해 await하여도 Promise.resolve한 것과 동일하게 작동하기 때문입니다.promise.then()
을 await하는 대신, promise를 바로 await하여도 됩니다.단순화한 결과는 다음과 같습니다.
const messageObject = await arr.reduce(async (promise, user) => {
// 누산값 받아오기 (2)
let result = await promise;
// 누산값 변경 (3)
result[user.id] = await getIntroMessage(user);
// 다음 Promise 리턴
return result;
}, {}); // 초기값 (1)
큰 도움 되었습니다 :) 좋은 글 감사해요!