[번역] How to escape async/await hell

date
Sep 4, 2018
slug
avoiding-the-async-await-hell
status
Published
tags
Javascript
type
Post
comment
comment
summary
async/await은 우리를 콜백 지옥에서 탈출하게 해줬습니다만, 반대로 사람들은 async/await을 남용하기 시작했습니다. — async/await 지옥의 탄생
이 글은 Aditya Agarwal의 How to escape async/await hell를 번역한 글입니다. 원문: https://medium.freecodecamp.org/avoiding-the-async-await-hell-c77a0fb71c4c

notion image
async/await은 우리를 콜백 지옥에서 탈출하게 해줬습니다만, 반대로 사람들은 async/await을 남용하기 시작했습니다. — async/await 지옥의 탄생
이 글에서 저는 async/await 지옥이 무엇인지 설명하고, async/await 지옥에서 탈출하기 위한 몇 가지 노하우를 공유하고자 합니다.

async/await 지옥이란?

비동기적인 자바스크립트를 사용하는 동안, 사람들은 종종 함수 앞에 await을 붙여 여러 구문을 번갈아 작성합니다. 이때 하나의 명령문은 이전 명령문과 관련이 없기 때문에 성능적으로 문제가 발생합니다. — 당신은 이전 명령문 하나가 완료될 때까지 기다려야 합니다.

async/await 지옥의 예

당신이 피자와 음료를 주문하는 스크립트를 작성했다고 가정해봅시다. 아마, 그 스크립트는 아래와 같을 것입니다.
 
(async () => {
  const pizzaData = await getPizzaData(); // async call
  const drinkData = await getDrinkData(); // async call
  const chosenPizza = choosePizza(); // sync call
  const chosenDrink = chooseDrink(); // sync call
  await addPizzaToCart(chosenPizza); // async call
  await addDrinkToCart(chosenDrink); // async call
  orderItems(); // async call
})();
 
겉으로 보기에는 올바르고 잘 작동하는 것처럼 보입니다. 하지만 이러한 코드는 동시성을 벗어나기 때문에, 좋은 구현은 아닙니다. 이제 코드가 어떻게 실행되는지 살펴보고 문제를 해결해봅시다.

설명

우리는 우리의 코드를 async IIFE로 감쌌습니다. 이 코드는 아래와 같은 순서로 실행될 것입니다.
  1. 피자들의 목록을 가져옵니다.
  1. 음료들의 목록을 가져옵니다.
  1. 목록에서 하나의 피자를 고릅니다.
  1. 목록에서 하나의 음료를 고릅니다.
  1. 고른 피자를 하나의 카트(장바구니)에 담습니다.
  1. 고른 음료를 하나의 카트(장바구니)에 담습니다.

그래서 뭐가 문제죠?

앞에서 언급했던 것처럼, 위의 모든 명령문은 하나씩 실행됩니다. 여기에는 동시성이 없습니다.왜 음료수 목록을 가져오기 전에 피자 목록을 가져오길 기다려야 될까요? 우리는 그냥 두 개의 목록을 동시에 가져오도록 해야 합니다. 하지만 피자를 고를 땐 피자들의 목록을 미리 가져와야 됩니다. 음료도 마찬가지입니다.결과적으로, 우리는 피자와 관련된 작업과 음료에 관련된 작업 모두 동시에 끝낼 수 있습니다.그러나 피자에 관련된 개별 단계는 순차적으로 하나씩 진행돼야 합니다.

나쁜 구현의 또 다른 예

아래의 자바스크립트는 장바구니에 담겨있는 아이템을 가져와 주문하는 코드입니다.
 
async function orderItems() {
  const items = await getCartItems(); // async call
  const noOfItems = items.length;
  for (var i = 0; i < noOfItems; i++) {
    await sendRequest(items[i]); // async call
  }
}
 
이 경우 for 반복문은 다음 반복을 실행하기 전에 sendRequest() 함수가 완료될 때까지 기다려야 합니다. 그러나 실제로 기다릴 필요는 없습니다. 우리는 모든 반복 요청을 가능한 한 빨리 보내고 요청들이 완료될 때까지 기다릴 수 있습니다.
저는 이제 당신이 async/await 지옥은 무엇이고, 프로그램 성능에 얼마나 심각한 영향을 미치는지에 대해 감을 잡았기를 바랍니다. 이제, 저는 당신에게 질문 하나를 던지고 싶습니다.

만약 우리가 await 키워드를 빼먹는다면?

async 함수를 호출할 때 await을 빼먹어도 함수는 바로 실행됩니다. 즉, 함수를 실행하는 데에 await은 필수적이지 않습니다. async 함수는 promise를 반환할 것이고, 당신은 그 promise를 나중에 사용할 수 있습니다.
 
(async () => {
  const value = doSomeAsyncTask();
  console.log(value); // an unresolved promise
})();
 
다만 중요한 점은 컴파일러는 함수가 완전히 실행될 때까지 기다리고 싶다는 것을 알지 못한다는 것입니다. 때문에 컴파일러는 async 작업을 마치지 않고 프로그램을 종료합니다. 그래서 우리는 await 키워드가 필요한 것입니다.
promise에서 한 가지 재미있는 점은 한 라인에서 promise을 가져오고, 다음 라인에서 promise가 resolve 될 때까지 기다릴 수 있다는 것입니다. 이것은 async/await 지옥을 탈출하는 열쇠입니다.
 
(async () => {
  const promise = doSomeAsyncTask();
  const value = await promise;
  console.log(value); // the actual value
})();
 
보시다시피 doSomeAsyncTask()는 promise를 반환합니다. 이 반환 시점에서 doSomeAsyncTask()는 이미 실행을 시작했습니다. promise의 resolve 값을 얻기 위해 await 키워드를 사용하면, 자바스크립트가 다음 라인을 즉시 실행하지 않는 대신에 promise가 resolve 될 때까지 기다리고 다음 라인을 실행합니다.

어떻게 async/await 지옥에서 빠져나올까요?

async/await 지옥을 탈출하기 위해서는 아래의 단계를 따라야 합니다.

다른 명령문에 의존하는 명령문을 찾으세요.

첫 번째 예에서는 피자와 음료를 골랐습니다. 우리는 피자를 고르기 전에 피자 목록을 가지고 있어야 한다고 얘기했습니다. 또 피자를 장바구니에 담기 전에는 피자를 먼저 골라야 합니다. 따라서 우리는 이 세 단계가 서로 의존된다고 말할 수 있습니다. 즉, 이전 작업이 끝날 때까지 다음 작업을 진행할 수는 없습니다.
그러나 좀 더 광범위하게 살펴보면 피자를 고르는 것과 음료를 고르는 것은 서로 무관하기 때문에 우리는 피자와 음료를 동시에 고를 수 있음을 알 수 있습니다. 이것은 우리가 작성했던 것보다 더 나은 코드를 작성할 수 있는 한 가지 방법입니다.
이렇게 우리는 다른 명령문에 의존하는 명령문과 그렇지 않은 명령문을 찾았습니다.

의존적인 명령문들을 async 함수로 그룹화하세요.

우리가 살펴보았듯이, 피자를 고를 때 피자 목록을 가져와 하나를 고르고 나서, 장바구니에 담는 것처럼 이는 의존적인 명령문을 포함합니다. 우리는 이러한 명령문들을 async 함수로 묶어야 합니다. 이 방법은 두 개의 async 함수, selectPizza()와 selectDrink()를 실행할 수 있도록 합니다.

그룹화 한 async 함수들을 동시에 실행하세요.

그런 다음 async non blocking 함수를 동시에 실행하기 위해 이벤트 루프를 이용합니다. 이 작업을 수행하는 두 가지 일반적인 패턴은 promise들을 일찍 반환하고 Promise.all 메서드를 사용하는 것입니다.

예제들을 수정해봅시다.

위 세 단계를 통해서, 우리 예제에 적용해 보겠습니다.
 
async function selectPizza() {
  const pizzaData = await getPizzaData(); // async call
  const chosenPizza = choosePizza(); // sync call
  await addPizzaToCart(chosenPizza); // async call
}
async function selectDrink() {
  const drinkData = await getDrinkData(); // async call
  const chosenDrink = chooseDrink(); // sync call
  await addDrinkToCart(chosenDrink); // async call
}
(async () => {
  const pizzaPromise = selectPizza();
  const drinkPromise = selectDrink();
  await pizzaPromise;
  await drinkPromise;
  orderItems(); // async call
})();
// 저는 아래 방식을 더 선호합니다.
(async () => {
  Promise.all([selectPizza(), selectDrink()]).then(orderItems); // async call
})();
 
우리는 명령문을 두 가지 함수로 그룹화하고 selectPizza() 및 selectDrink() 함수를 동시에 실행했습니다.
함수 내에서 각 구문은 이전 구문의 실행에 의존됩니다.
두 번째 예제에서는 개수를 알 수 없는 promise들을 처리할 것입니다. 이 상황을 다루는 것은 매우 쉽습니다. 배열을 만들고 promise들을 push 하면 됩니다. 그러면 Promise.all()을 통해 모든 promise들이 동시에 resolve 될 때까지 기다릴 수 있습니다.
 
async function orderItems() {
  const items = await getCartItems(); // async call
  const noOfItems = items.length;
  const promises = [];
  for(var i = 0; i < noOfItems; i++) {
    const orderPromise = sendRequest(items[i]); // async call
    promises.push(orderPromise); // sync call
  }
  await Promise.all(promises); // async call
}
// 저는 아래 방식을 더 선호합니다.
async function orderItems() {
  const items = await getCartItems(); // async call
  const promises = items.map((item) => sendRequest(item));
  await Promise.all(promises); // async call
}
 

© chussum 2015 - 2024