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 에서

제목을 보고 들어오셨다면 그럴 일은 없겠지만, 혹시나 이게 왜 포스트 주제가 되는지 이해가 안 되시는 분들을 위해 짤막한 코드 예제를 준비했습니다.

The Wrong Way

// 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 배열 만 리턴해주고 마네요. 이유가 뭘까요? awaitPromise 객체 를 실행하고 기다려주지만, Promise 배열 로는 그렇게 할 수 없기 때문입니다. 지금 우리의 코드는 arr.map 을 통해 Promise 배열 을 리턴하게 구현했으니까요.

The Right Way

그렇다면 Promise 배열 은 어떻게 처리해야 할까요? MDN의 Promise 항목을 볼게요.

스크린샷 2019-09-05 오후 11.50.21.png

여기엔 두가지 방법이 있습니다. Promise.all 은 배열 내의 모든 Promise 가 통과될 때까지 기다리는 Promise 를 반환하고, Promise.race 는 도중에 하나라도 실패하면 즉시 리턴되는 Promise 를 반환합니다. 설명이 좀 어렵게 쓰여있지만, 배열 내의 모든 Promise 를 중간에 거부(reject or Exception)가 발생하더라도 다 실행하려면 Promise.all 을, 거부 발생시 중도에 중단하려면 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.reduceArray.map 과는 조금 다른 문제가 있습니다. 위에서 사용한 arrgetIntroMessage 는 그대로 두고, 아래만 Array.reduce 를 쓰는 코드만 바꿔볼게요.

The Wrong Way

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 를 리턴했죠. reduceawait 구문을 사용했으므로, 실제로 병합되는 과정에만 Promise 를 기다리고 있습니다. 하지만 이미 두번째 iteration 에서는 리턴받은 Promise 객체 에 값을 할당해버렸어요. (명백한 오류상황) 첫번째 iteration 의 결과값이 resolve 되어 도착하면 최종 결과가 그 값({ '1': 'Minsang Kim (35세)' }이 될 수 밖에 없습니다.

The Right Way

이 문제를 해결하는데는 두가지 방법이 있습니다.

1. 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세)' }

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();

뭔가 많이 복잡해보이지만, 실질적으로는 resultPromise.resolve 로 대체된 것이 다입니다. Array.reduce 의 누산되는 결과값을 그냥 으로 쓰는게 아니라 Promise.resolve(값) 으로 쓰는거죠.

이것을 순서대로 풀면, (코드의 주석 참조)

  1. 초기값을 Promise.resolve({}) 로 준다.

  2. iteration 안에서는 전달받은 Promise 를 누산값으로 찾아와야하니, await promise.then() 으로 받아온다.

  3. 누산값을 변경한다.

  4. 다음 iteration 에서는 다시 Promise 로 넘기기 위해 Promise.resolve(result) 를 리턴한다.

    이 방법을 통해, 아래와 같은 결과를 얻을 수 있습니다.

    messageObject { '1': 'Minsang Kim (35세)', '2': 'Chenny Kim (2세)' }

두 방식의 차이

코드가 다르다는 것 외에, 두 방식은 실제로 조금 다르게 동작합니다. Promise.all 은 배열 안의 모든 Promise 를 한번에 실행하여 결과를 취합하고, Promise.resolve 를 사용한 Array.reduce 는 각 iteration 을 순차적으로 실행하게 됩니다. 따라서 이 부분이 둘 중 *"어떤 방식을 사용할 것인가"* 를 결정하는데 중요한 요소입니다. 순서가 중요하다면 2. 의 방식을, 순서가 중요하지 않은 경우 1. 의 방식을 사용하는 것이 비동기 작업의 퍼포먼스 측면에서 더 좋은 결과를 얻게 됩니다.

Typescript 로 개발중 이 이슈에 부딪혀 이런저런 고민을 해보고, 구글링을 통해 방법을 찾아봤는데 깔끔히 잘 정리된 글이 없어 포스팅하게 되었습니다. 많은 분들에게 도움이 되었으면 합니다.