많이들 배열의 요소들에 비동기 작업을 실시할 때
forEach, map, filter, reduce
를 사용하면 생각과는 다른 결과에 어려움을 겪어본 경험을 해보았을 것이다. 나 또한 forEach로 배열의 요소들에 비동기 작업을 하다가 순차처리가 안되는 문제를 접했을 때 내가 forEach
가 어떻게 내부적으로 어떻게 작동되는지 모르고 있다는 사실을 깨달았다. 이번 주차 주제인 ‘버전별 문법의 차이와 변환과정 및 치환방법‘에 대해 그 대상을 ES5(ECMAScript 2009)에 표준이 된 Array methods 중 특히 함수형 프로그래밍에 도움을 주는
forEach, map, filter, reduce
으로 잡고 살펴보겠다. 이들 method들이 내부적으로 어떻게 돌아가는지 그 특징을 살펴보고 이들 문법을 그것들이 존재하지 않았던 때로 돌아가서 이전 버전 문법으로 치환한다면 어떻게 만들 수 있을지 먼저 확인해보겠다. 이러한 작업이 해당 문법들이 어떤 필요성과 편리성에 의해 ES5에서 새로 추가되었는지에 대한 이유를 말해줄 수 있으리라 기대한다. 다음의 예시들에서 ES6(ECMAScript2015)에 새로 추가된 문법인
Promise
와 ES8(ECMAScript2017)에 추가된 async/await
를 사용하고 있다. 이는 앞서 말한 ES5 전 버전으로의 치환을 고민하겠다는 전제조건과 상충된다. 그러나 이번 글에서 버전별 차이를 다룰 문법의 대상은 forEach, map, filter, reduce로 한정 지을 것이다. 따라서 해당 문법이 비동기적 작업에서 어떤 특성을 보이는지를 가장 극명하게 보여줄 수 있다는 측면에서 Promise
와 async/await
를 예시에서 사용할 것이다. 다음 예제들은 [번역] 반복문안에서의 자바스크립트 async/await을 참고하여 다시 재구성해보았다.준비
다음과 같이 과일 바구니(
fruitBasket
) 안에 사과 10개, 바나나 3개, 키위 0개가 담겨있다고 가정한다.const fruitBasket = { apple: 10, banana: 3, kiwi: 0 }
그리고 과일 바구니 안에 있는 각 과일의 수를 알고 싶을 때 사용할 수 있는
getFruitLength
이라는 함수를 만든다. fruitBasket
가 원격 저장소 어딘가에 저장되어 있고, 이에 접근하기 위해서는 5초가 걸린다고 상상해보자. 해당 주제에 집중하기 위해 원격 저장소에서 응답이 오는 작업은 항상 성공한다고 가정한다.const fetchData = (req) => new Promise((resolve) => setTimeout(() => resolve(fruitBasket[req]), 5 * 60)) //비동기 작업이 무조건 성공한 경우만 가정하므로 바로 resolve(...)를 호출 const getFruitLength = async (fruit) => { return fetchData(fruit) } getFruitLength("apple")// 5초 뒤 10
원하는 결과물은
getFruitLengthSync
함수를 실행한 것과 같이 사과, 바나나, 키위 순으로 5초에 한번씩 각 과일의 개수를 순차적으로 가져오는 것이다.const getFruitLengthSync = async () => { console.log('Start') const result1 = await getFruitLength('apple') console.log(result1) const result2 = await getFruitLength('banana') console.log(result2) const result3 = await getFruitLength('kiwi') console.log(result3) console.log('End') }
Start //바로 실행 10 //5초 뒤 3 // 10초 뒤 0 // 15초 뒤 End //그 뒤 마지막으로 실행
위와 같은 결과물을 배열과 반복문을 사용해서 구현해보자.
fruitBasket
안에서 꺼내고 싶은 과일의 배열은 아래와 같다.const fruitsToGet = ["apple", "banana", "kiwi"]
반복문 안에서,
getFruitLength
을 이용해서 각 과일의 수를 얻고 콘솔창에도 출력해본다.const getFruitLengthWithLoop = async () => { console.log('Start') for (let index = 0; index < fruitsToGet.length; index++) { const fruit = fruitsToGet[index] const FruitLength = await getFruitLength(fruit) console.log(FruitLength) } console.log('End') }
await
을 사용하면, 우리는 await
중인 Promise
가 resolved
될 때까지 실행을 멈추는 것을 기대한다. 즉, 반복문 안에서 await
은 순서대로 진행된다는 것을 의미한다. 아래의 결과가 예상하는 결과일 것이다.Start //바로 실행 10 //5초 뒤 3 // 10초 뒤 0 // 15초 뒤 End //그 뒤 마지막으로 실행
이렇게 예상대로 동작하는 모습은
while
, for-of
(ES6 추가 문법)와 같은 대부분의 반복문에서 볼 수 있다. 하지만, forEach
, map
, filter
, reduce
처럼 callback을 요구하는 반복문에서는 예상대로 동작하지 않는다.forEach
const getFruitLengthWithForEach = () => { console.log('Start') fruitsToGet.forEach(async (fruit) => { const fruitLength = await getFruitLength(fruit) console.log(fruitLength) }) console.log('End') } getFruitLengthWithForEach()
forEach
는 반복문 안의 promise가 resolve되기 전에 console.log('End')
를 실행시킨다. 따라서 콘솔창에 실제로 찍힌 순서는 아래와 같다.Start End 10 3 0
왜냐하면
forEach
는 다음과 같이 구성되기 때문이다.(forEach polyfill을 단순화시켜 만든 코드이다.)Array.prototype.forEach = function (callback) { for (let index = 0; index < this.length; index++) { callback(this[index], index, this); } };
코드에서 볼 수 있듯이
forEach
는 배열 요소를 돌면서 배열의 순서에 따라 callback
을 실행한다. 이때 배열 요소의 개수가 중요한 관심사이지 한 callback
이 끝날 때가 관심사가 아니다. 따라서 await
이 있다는 이유만으로 forEach
가 앞의 callback
을 기다렸다가 다음 callback
을 실행하기를 기대할 수는 없다고 볼 수 있다.원하는 값을 얻으려면 앞서 예시로 든 반복문 또는
while
, for-of
를 사용해야한다.map
map
는 배열 내의 모든 요소 각각에 대하여 주어진 함수를 호출한 결과를 모아 새로운 배열을 반환하므로 위의 forEach
에서의 동작를 다음과 같이 비슷하게 바꿔볼 수 있다. const getFruitLengthWithMap = async () => { console.log('Start') const fruitsLengthResult = await fruitsToGet.map(async (fruit) => { const fruitLength = await getFruitLength(fruit) return fruitLength }) console.log(fruitsLengthResult) console.log('End') } getFruitLengthWithMap()
fruitsLengthResult
에 await으로 풀린 promise 결과값이 배열로 저장된다고 기대한다면 다음과 같은 결과를 예상해볼 수 있다. Start [ 10, 3, 0 ] End
그러나 실제 실행결과는 다음과 같다.
Start [ Promise { <pending> }, Promise { <pending> }, Promise { <pending> } ] End
마찬가지로
map
함수의 내부구조를 살펴보자. (map
polyfill을 단순화시켜 만든 코드이다.)Array.prototype.map = function (callBack) { const newArray = []; for (let index = 0; index < this.length; index++) { newArray.push(callBack(this[index], index, this)); } return newArray; };
forEach
와 마찬가지로 배열 요소의 개수가 중요한 관심사이지 한 callback
이 끝날 때가 관심사가 아니다. 따라서 getFruitLengthWithMap
예시를 기준으로 설명한다면 map
함수 내부에서 callback
의 결과값인 promise
가 풀리기까지 기다려 그 결과값을 newArray
에 담은 뒤에 다음 callback
을 실행하기를 기대할 순 없다.원하는 값을 얻으려면 다음과 같은 방법이 필요하다.
const getFruitLengthWithMap = async () => { console.log('Start') const promises = fruitsToGet.map(async (fruit) => { const fruitLength = await getFruitLength(fruit) return fruitLength }) const fruitLengthPromiseArr = await Promise.all(promises) console.log(fruitLengthPromiseArr) console.log('End') } getFruitLengthWithMap()
filter
만약 과일의 개수가 5개 이상인 과일만 가진 배열을 만들고 싶다고 가정해보자.
const getFruitLengthWithFilter = async () => { console.log('Start') const moreThan5 = await fruitsToGet.filter(async (fruit) => { const fruitLength = await getFruitLength(fruit) return fruitLength > 5 }) console.log(moreThan5) console.log('End') } getFruitLengthWithFilter()
5개 이상인 과일은 사과 뿐이기 때문에
getFruitLengthWithFilter
를 통해 다음과 같은 결과를 기대할 것이다.Start [ 'apple'] End
하지만
filter
안에서 await
은 동작자체를 하지 않는다. getFruitLengthWithFilter
함수를 사용하면 5개 이상이라는 조건으로 걸리지지 않은 배열을 그대로 다시 얻게 될 뿐이다.Start [ 'apple', 'banana', 'kiwi' ] End
filter의 polyfill을 살펴보자. (
filter
polyfill을 단순화시켜 만든 코드이다.)Array.prototype.filter = function (callBack) { const output = [] for (let index = 0; index < this.length; index++) { if (callBack(this[index])) { output.push(callBack(this[index], index, this)) } } return output }
filter
callback안에서 await
을 사용하면, callback은 항상 promise
를 반환한다. promise
는 object
이므로 항상 'truthy
'한 값이기 때문에 배열 안의 모든 요소들이 filter
를 통과할 수 있게 된다.원하는 값을 얻으려면 다음과 같은 방법이 필요하다.
const getFruitLengthWithFilter = async () => { console.log('Start') const promises = await fruitsToGet.map(getFruitLength) const fruitLengthPromiseArr = await Promise.all(promises) const moreThan5 = fruitsToGet.filter((fruit, idx) => { const fruitLength = fruitLengthPromiseArr[idx] return fruitLength > 5 }) console.log(moreThan5) console.log('End') } getFruitLengthWithFilter()
reduce
이번에는 과일 바구니 안에 들어있는 과일의 총 개수를 구하고 싶다고 가정해보자.
const getFruitLengthWithReduce = async () => { console.log('Start') const totalFruitLength = await fruitsToGet.reduce(async (acc, fruit) => { const fruitLength = await getFruitLength(fruit) return acc + fruitLength }, 0)//0은 callback의 최초 호출에서 첫 번째 인수에 제공하는 값으로 초기값을 제공하지 않으면 배열의 첫 번째 요소를 사용한다. console.log(totalFruitLength) console.log('End') } getFruitLengthWithReduce()
getFruitLengthWithReduce
함수를 실행하면 다음과 같은 결과를 얻을 수 있을 것이라 기대한다.Start 13 End
그러나 결과는 다음과 같이 엉뚱하다.
Start [object Promise]0 End
이런 결과가 나온 이유는 다음과 같다.
- 첫번째 반복에서
acc
은0
이다.fruitLength
은 10(getFruitLength('apple')에서 resolve된 값)
이다. 따라서 0 + 10을 하면 누적된 다음acc
값은 10이 된다.
- 두번째 반복에서
acc
은promise
이다. 왜냐하면 첫번째 반복에서의 accumulator(=sum
+fruitLength
)이promise
로 반환되었기 때문이다.fruitLength
은 두번째 값이 3이다. 그러나promise
는 연산을 할 수 없기때문에 자바스크립트는 promise를[obect Promise]
string으로 변환한다.[object Promise] + 0
은[object Promise]0
이 되버린다.
- 세번째 반복에서의
acc
또한promise
다.fruitLength
은0
이다.[object Promise] + 0
이므로[object Promise]0
가 결국 반환된다.
reduce
의 내부코드를 살펴보자.(reduce
polyfill을 단순화시켜 만든 코드이다.)Array.prototype.ourReduce = function (callback, initialValue) { let accumulator = initialValue === undefined ? undefined : initialValue for (let index = 0; index < this.length; index++) { if (accumulator !== undefined) { accumulator = callback.call( undefined, accumulator, this[index], index, this, ) } else { accumulator = this[index] } } return accumulator }
accumulator !== undefined
일 때 accumulator
에 할당되는 callback
의 결과값은 앞서의 forEach
, map
, filter
와 비슷하게 동작함을 알 수 있다. promise
가 await
을 통해 풀리기를 기다리기도 전에 다음 index
로 넘어간다.원하는 값을 얻으려면 다음과 같은 방법이 필요하다.
const getFruitLengthWithReduce = async ( ) => { console.log('Start') const promises = fruitsToGet.map(getFruitLength) const fruitLengthPromiseArr = await Promise.all(promises) const totalFruitLength = fruitLengthPromiseArr.reduce((sum, fruit) => sum + fruit) console.log(totalFruitLength) console.log('End') }
Share article