본문 바로가기

JavaScript

[번역] 반복문안에서의 자바스크립트 async/await

이 글은 원 글 작성자의 번역 허락을 받고 진행되었습니다. 의역, 오역 있을 수 있으니 댓글로 알려주신다면 감사드리겠습니다!

원글 보기 👉 https://zellwk.com/blog/async-await-in-loops/

시작하기 전에


이 글은 독자가 이미 async/await에 대해 기본적인 개념을 숙지하고 있다는 가정하에 진행되므로, async/await의 개념에 대해 숙지하고 글을 보는 것이 이해하는데 있어 도움이 될 것입니다.

 

예제 준비하기


먼저, 여러분이 과일바구니 안에 있는 과일의 수를 알고싶어한다고 가정해봅시다.

const fruitBasket = {
  apple: 27, 
  grape: 0,
  pear: 14 
}

그리고 과일 바구니 안에 있는 각 과일의 수를 알고싶다면, getNumFruit이라는 함수를 사용하도록 합니다.

const getNumFruit = fruit => {
  return fruitBasket[fruit]
}

const numApples = getNumFruit('apple')
console.log(numApples) // 27

자, 이제는 fruitBasket이 원격 저장소 어딘가에 저장되어 있고, 이에 접근하기 위해서는 1초가 걸린다고 가정해봅시다.

이 지연시간 1초는 아래와 같이 timeout을 사용해서 구현할 수 있습니다.

const sleep = ms => {
  return new Promise(resolve => setTimeout(resolve, ms))
}

const getNumFruit = fruit => {
  return sleep(1000).then(v => fruitBasket[fruit])
}

getNumFruit('apple')
  .then(num => console.log(num)) // 27

마지막으로, 여러분은 비동기 함수를 이용해서 각 과일의 수를 얻기 위해 await getNumFruit을 한다고 해봅시다.

const control = async _ => {
  console.log('Start')

  const numApples = await getNumFruit('apple')
  console.log(numApples)

  const numGrapes = await getNumFruit('grape')
  console.log(numGrapes)

  const numPears = await getNumFruit('pear')
  console.log(numPears)

  console.log('End')
}

아직 반복문은 사용하지 않았지만, 이렇게 해서 'await'을 반복해서 사용하는 모습을 볼 수 있습니다.

 

반복문안의 await


fruitBasket안에서 꺼내고 싶은 과일 배열을 아래와 같이 가지고 있다고 해봅시다.

const fruitsToGet = ['apple', 'grape', 'pear']

우리는 이 배열을 반복문을 통해서 접근할 수 있습니다.

const forLoop = async _ => {
  console.log('Start')

  for (let index = 0; index < fruitsToGet.length; index++) {
    // Get num of each fruit
  }

  console.log('End')
}

반복문 안에서, getNumFruit을 이용해서 각 과일의 수를 얻고 콘솔창에도 출력해봅시다.

getNumFruit은 프로미스를 반환하기 때문에 await을 이용해서 resolved 된 값을 기다릴 수 있습니다.

const forLoop = async _ => {
  console.log('Start')

  for (let index = 0; index < fruitsToGet.length; index++) {
    const fruit = fruitsToGet[index]
    const numFruit = await getNumFruit(fruit)
    console.log(numFruit)
  }

  console.log('End')
}

await을 사용하면, 여러분은 자바스크립트가 await중인 프라미스가 resolved될 때까지 실행을 멈추는 것을 기대합니다. 즉, 반복문 안에서 await은 순서대로 진행된다는 것을 의미합니다. 아래의 결과가 예상하는 결과일 것입니다.

'Start'
'Apple: 27'
'Grape: 0'
'Pear: 14'
'End'

이렇게 예상대로 동작하는 모습은 while, for-of와 같은 대부분의 반복문에서 볼 수 있습니다.
하지만, 예를들어 forEach, map, filter, reduce처럼 callback을 요구하는 반복문에서는 예상대로 동작하지 않습니다.
forEach, map, filter에서 await이 어떻게 동작하는지는 곧 알아보겠습니다!

 

forEach 반복문안의 await


위의 for 반복문에서 진행해본 것 처럼 같은 예제를 사용하도록 하겠습니다.
먼저, 과일 배열을 반복문을 사용해서 접근해봅시다.

const forEachLoop = _ => {
  console.log('Start')

  fruitsToGet.forEach(fruit => {
    // Send a promise for each fruit
  })

  console.log('End')
}

다음은, getNumFruit으로 과일의 수를 구해보도록 하겠습니다.(callback함수 안의 aysnc키워드를 주의하세요. 우리는 callback함수 안에서 await을 써야하기때문에 async키워드가 필요한 것입니다.)

const forEachLoop = _ => {
  console.log('Start')

  fruitsToGet.forEach(async fruit => {
    const numFruit = await getNumFruit(fruit)
    console.log(numFruit)
  })

  console.log('End')
}

아마 여러분은 아래와 같은 결과를 기대했을 것입니다.

'Start'
'27'
'0'
'14'
'End'

하지만 실제 결과는 다릅니다. 자바스크립트는 forEach 반복문 안의 promise가 resolve되기 전에 console.log('End')를 실행시킵니다. 따라서 콘솔창에 실제로 찍힌 순서는 아래와 같습니다.

'Start'
'End'
'27'
'0'
'14'

왜냐하면 자바스크립트의 forEach는 promise를 인지하지 못하기 때문입니다. 그 말은, async/await을 forEach안에서 쓸 수없다는 뜻입니다.

 

map 안의 await


만약 map안에서 await을 쓴다면, map은 언제나 promise 배열을 반환합니다. 왜냐하면, 동기 함수는 항상 promise를 반환하기 때문이죠.

const mapLoop = async _ => {
  console.log('Start')

  const numFruits = await fruitsToGet.map(async fruit => {
    const numFruit = await getNumFruit(fruit)
    return numFruit
  })

  console.log(numFruits)

  console.log('End')
}
>'Start'
'[Promise, Promise, Promise]'
'End'

async/await을 사용할 경우 map은 항상 promise들을 반환하기 때문에, 여러분은 promise로 이루어진 배열이 resolve되기를 기다려야 합니다. await Promise.all(arrayOfPromises) 처럼요.

const mapLoop = async _ => {
  console.log('Start')

  const promises = fruitsToGet.map(async fruit => {
    const numFruit = await getNumFruit(fruit)
    return numFruit
  })

  const numFruits = await Promise.all(promises)
  console.log(numFruits)

  console.log('End')
}

그러면 아래와 같은 결과를 얻을 수 있습니다.

'Start'
'[27, 0, 14]'
'End'

만약 반환되는 값에 추가적인 작업을 하고 싶다면, 아래와 같이 할 수 있습니다. 이렇게하면 resolve 될 값은 여러분이 callback함수 안에서  return하는 값이 됩니다.

const mapLoop = async _ => {
  // ...
  const promises = fruitsToGet.map(async fruit => {
    const numFruit = await getNumFruit(fruit)
    // Adds onn fruits before returning
    return numFruit + 100
  })
  // ...
}
'Start'
'[127, 100, 114]'
'End'

 

 

filter안의 await


filter를 사용할때, 여러분은 배열을 특정한 결과 값으로 정제하고 싶을 것입니다. 여러분이 과일의 수가 20개 이상인 과일만 가진 배열을 만들고 싶다고 가정해 봅시다.
만약, await 없이 filter를 쓰고 싶다면, 아마도 여러분은 아래와 같이 사용할 것입니다.

// Filter if there's no await
const filterLoop = _ => {
  console.log('Start')

  const moreThan20 = fruitsToGet.filter(fruit => {
    const numFruit = fruitBasket[fruit]
    return numFruit > 20
  })

  console.log(moreThan20)
  console.log('End')
}

그리고나서 사과의 수가 27개 이기 때문에 moreThan20의 값이 사과만 가지고 있을 것이라고 생각해볼 수 있겠죠.

'Start'
['apple']
'End'

하지만 filter안에서 await은 같은 방식으로 동작하지 않습니다. 사실은, 동작자체를 하지 않습니다. 그냥 정제되지 않은 배열을 그대로 다시 얻게될 뿐이에요.

const filterLoop = async _ => {
  console.log('Start')

  const moreThan20 = await fruitsToGet.filter(async fruit => {
    const numFruit = await getNumFruit(fruit)
    return numFruit > 20
  })

  console.log(moreThan20)
  console.log('End')
}
'Start'
['apple', 'grape', 'pear']
'End'

자, 무슨일이 일어난 것인지 알아봅시다.
filter callback안에서 await을 사용하면, callback은 항상 promise를 반환합니다. promise는 항상 'truthy'이기 때문에 배열 안의 모든 요소들이 filter를 통과할 수 있는 것이죠. 아래 처럼요.

// Everything passes the filter...
const filtered = array.filter(true)

자, 그렇다면 filter안에서 await을 제대로 쓸 수있는 방법을 3단계로 나누어 봅시다.

  1. promise배열을 반환 받기위해 map을 사용합시다.
  2. await을 이용해 반환받은 promise배열을 resolve 해줍니다.
  3. resolved된 값들을 filter을 이용해서 정제해줍니다.
const filterLoop = async _ => {
console.log('Start')

const promises = await fruitsToGet.map(fruit => getNumFruit(fruit))
const numFruits = await Promise.all(promises)

const moreThan20 = fruitsToGet.filter((fruit, index) => {
 const numFruit = numFruits[index]
 return numFruit > 20
})

console.log(moreThan20)
console.log('End')
}
Start
[ 'apple' ]
End

reduce안의 await


보통은 reduce를 반복문을 통해 누적합을 구하고 싶을 때 사용하는 경우가 많기 때문에, 이번에는 여러분이 과일 바구니 안에 들어있는 과일의 총 개수를 구하고 싶다고 가정해봅시다.

// Reduce if there's no await
const reduceLoop = _ => {
  console.log('Start')

  const sum = fruitsToGet.reduce((sum, fruit) => {
    const numFruit = fruitBasket[fruit]
    return sum + numFruit
  }, 0)

  console.log(sum)
  console.log('End')
}

이렇게하면 총 41개의 과일 수를 구할 수 있을 것입니다. (27 + 0 + 14 = 41).

'Start'
'41'
'End'

awaitreduce와 함께 쓴다면 어떻게 될까요? 결과는 엉망이 됩니다.

// Reduce if we await getNumFruit
const reduceLoop = async _ => {
  console.log('Start')

  const sum = await fruitsToGet.reduce(async (sum, fruit) => {
    const numFruit = await getNumFruit(fruit)
    return sum + numFruit
  }, 0)

  console.log(sum)
  console.log('End')
}
'Start'
'[object Promise]14'
'End'

네? [object Promise]14요?


자, 어떻게 된 일인지 알아보도록 합시다.

  1. 첫번째 반복에서 sum0입니다. numFruit은 27(getNumFruit('apple')에서 resolve된 값)입니다. 따라서0 + 27`을 하면 27입니다.
  2. 두번째 반복에서 sum은 promise입니다.(왜일까요? 당연히 동기 함수는 항상 promise를 반환하니까요! 첫번째 반복에서의 accumulator(=sum + numFruit)이 promise로 반환되었기 때문입니다.) numFruit은 0입니다. promise는 연산을 할 수 없습니다. 그렇기 때문에 자바스크립트는 promise를 [obect Promise] string으로 변환합니다. 그렇기 때문에 [object Promise] + 0[object Promise]0이 되는 것이죠.
  3. 세번째 반복에서의 sum 또한 promise입니다. numFruit14입니다. [object Promise] + 14는 `[object Promise]14가 됩니다.

미스터리 해결!

그렇다면 어떻게 해야할까요?

reduce callback 안에서 await을 사용할 수는 있습니다. 하지만 accumulatorawait해주어야 한다는걸 잊지마세요!

const reduceLoop = async _ => {
  console.log('Start')

  const sum = await fruitsToGet.reduce(async (promisedSum, fruit) => {
    const sum = await promisedSum
    const numFruit = await getNumFruit(fruit)
    return sum + numFruit
  }, 0)

  console.log(sum)
  console.log('End')
}
'Start'
'41'
'End'

원하는대로 값이 잘 나왔나요? 하지만 위의 gif파일에서도 보면 알 수 있듯이, 모든 것을 await하는데 꽤 많은 시간이 소요되는 군요.
이는 reduceLooppromisedSum이 각 반복에서 완료 될때까지 기다려야하기 때문에 발생하는 것입니다.

이러한 reduce 반복문의 속도를 빠르게하는 방법이 있습니다. 만약 await getNumFruits()await promisedSum의 전에 실행시킨다면, reduceLoop은 코드를 처리하는데 긴 시간이 필요하지 않습니다.

const reduceLoop = async _ => {
  console.log('Start')

  const sum = await fruitsToGet.reduce(async (promisedSum, fruit) => {
    // Heavy-lifting comes first. 
    // This triggers all three `getNumFruit` promises before waiting for the next interation of the loop. 
    const numFruit = await getNumFruit(fruit)
    const sum = await promisedSum
    return sum + numFruit
  }, 0)

  console.log(sum)
  console.log('End')
}

reduce가 모든 세 getNumFruit 프라미스를 다음 반복을 기다리기 전에 실행시키기 때문에 속도가 빨라진 것입니다. 하지만, 이 방법은 await을 사용할때 순서에 주의를 기울여야 하기 때문에 조금 혼란스럽기도 합니다.

가장 간단하고 효율적인 방법은 이렇게 사용하는 것입니다.

  1. 먼저 promise 배열을 반환 받기 위해 map을 사용하세요.
  2. 반환 받은 promise배열을 await Promise.all()을 사용해서 resolve 된 값으로 반환받으세요.
  3. resolved된 값에 reduce를 사용하세요.
const reduceLoop = async _ => {
  console.log('Start')

  const promises = fruitsToGet.map(getNumFruit)
  const numFruits = await Promise.all(promises)
  const sum = numFruits.reduce((sum, fruit) => sum + fruit)

  console.log(sum)
  console.log('End')
}

이렇게하면 가독성도 좋고, 간단하게 해결 할 수 있습니다. 그리고 모든 숫자를 계산하는데도 오래걸리지 않죠.

결론


  1. await을 반복해서 실행시키고 싶다면, for-loop이나 callback이 없는 반복문을 사용하세요.
  2. forEach에서는 await을 절대 사용하지 마세요! 대신에 for-loop이나 callback이 없는 반복문을 사용하세요.
  3. filterreduce안에서는 await을 사용하지 마세요. 항상 map으로 promise배열을 반환해주어서  await으로 resolve해준 뒤, 그 값으로  filterreduce를 사용하세요.