forEach, map, filter, reduce의 polyfill과 비동기 작업 문제

Feb 03, 2024
forEach, map, filter, reduce의 polyfill과 비동기 작업 문제
 
많이들 배열의 요소들에 비동기 작업을 실시할 때 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로 한정 지을 것이다. 따라서 해당 문법이 비동기적 작업에서 어떤 특성을 보이는지를 가장 극명하게 보여줄 수 있다는 측면에서 Promiseasync/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중인 Promiseresolved될 때까지 실행을 멈추는 것을 기대한다. 즉, 반복문 안에서 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); } };
출처: https://codeburst.io/javascript-async-await-with-foreach-b6ba62bbf404
코드에서 볼 수 있듯이 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; };
출처: https://dev.to/umerjaved178/polyfills-for-foreach-map-filter-reduce-in-javascript-1h13
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 }
출처: https://dev.to/umerjaved178/polyfills-for-foreach-map-filter-reduce-in-javascript-1h13
filter callback안에서 await을 사용하면, callback은 항상 promise를 반환한다. promiseobject이므로 항상 '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
이런 결과가 나온 이유는 다음과 같다.
  1. 첫번째 반복에서 acc은 0 이다. fruitLength은 10(getFruitLength('apple')에서 resolve된 값) 이다. 따라서 0 + 10을 하면 누적된 다음 acc값은 10이 된다.
  1. 두번째 반복에서 accpromise 이다. 왜냐하면 첫번째 반복에서의 accumulator(= sum + fruitLength)이 promise로 반환되었기 때문이다. fruitLength은 두번째 값이 3이다. 그러나promise는 연산을 할 수 없기때문에 자바스크립트는 promise를 [obect Promise] string으로 변환한다. [object Promise] + 0은 [object Promise]0이 되버린다.
  1. 세번째 반복에서의 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 }
출처: https://dev.to/umerjaved178/polyfills-for-foreach-map-filter-reduce-in-javascript-1h13
 
accumulator !== undefined 일 때 accumulator 에 할당되는 callback 의 결과값은 앞서의 forEach, map, filter 와 비슷하게 동작함을 알 수 있다. promiseawait을 통해 풀리기를 기다리기도 전에 다음 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

veganee