자바스크립트의 배열 함수에 비동기 함수를 인자로 넘기면 안 되는 이유
자바스크립트의 배열은 forEach()
, filter()
,map()
, reduce
, every()
, some()
등과 같이 콜백 함수를 인자로 받아 배열에 저장되어 있는 모든 원소로 상대로 호출해주는 함수들을 제공합니다.
이 함수들을 잘 활용하면 소위 함수형 프로그래밍(Functional Programming) 스타일로 코딩을 할 수 있게 되죠.
그런데 혹시 이러한 자바스크립트의 배열에 제공하는 함수에 비동기 함수를 인자로 넘기면 낭패를 볼 수 있다는 것을 아시나요? 이번 포스팅에서는 자바스크립트 배열 함수를 통해서 비동기 함수를 호출할 때 조심해야 할 점에서 알아보겠습니다.
논란의 코드
간단한 실습을 위해서 먼저 비동기 함수를 하나 작성해볼까요?
아래 isSweet()
함수는 인자로 넘어온 이모자가 과일을 나타내면 참을 반환하고 아니면 거짓을 반환합니다.
좀 억지스럽지만 비동기 함수로 만들기 위해서 일부로 1초의 지연을 주었습니다.
async function isSweet(emoji) {
await new Promise((r) => setTimeout(r, 1_000)); // 일부로 1초의 지연을 줌
const fruits = ["🍎", "🍐", "🍊", "🍌", "🍉", "🍇", "🍓", "🍈"];
return fruits.includes(emoji);
}
이제 자바스크립트 배열의 filter()
함수를 사용하여 과일 이모지의 개수를 세볼까요?
const emojis = ["👨", "🍉", "👩", "🍓", "🧑"];
const count = emojis.filter(isSweet).length;
console.log("과일의 개수: ", count);
잉? emojis
배열에는 분명히 과일이 2개 밖에 안 들어있는데, 왜 5개수가 나올까요? 😳
과일의 개수: 5
왜 이런 뜻밖의 결과가 발생하는 걸까요? 🤔
디버깅 🪲
그럼 async
와 await
키워드를 사용해서 같이 디버깅을 좀 해볼까요?
async/await
키워드에 대한 자세한 설명은 관련 포스팅을 참고 바랍니다.
콜백 함수 앞에 async
를 붙여서 비동기로 선언해주고, await
키워드로 isSweet()
함수의 실행이 끝나기를 명시적으로 기다려보겠습니다.
const emojis = ["👨", "🍉", "👩", "🍓", "🧑"];
const count = emojis.filter(async (emoji) => await isSweet(emoji)).length;
console.log("과일의 개수: ", count);
아쉽게도 결과는 여전히 바뀌지 않을 거에요…
과일의 개수: 5
이 번에는 isSweet()
함수의 호출 결과를 sweet
변수에 저장한 다음에 emoji
와 함께 로그를 찍어볼께요.
const emojis = ["👨", "🍉", "👩", "🍓", "🧑"];
const count = emojis.filter(async (emoji) => {
const sweet = await isSweet(emoji);
console.log({ emoji, sweet });
return sweet;
}).length;
console.log("과일의 개수: ", count);
아니, 콜백 함수 내에서는 sweet
값이 예상대로 찍히고 있잖아요? 😮
과일의 개수: 5
{
emoji: "👨",
sweet: false,
}
{
emoji: "🍉",
sweet: true,
}
{
emoji: "👩",
sweet: false,
}
{
emoji: "🍓",
sweet: true,
}
{
emoji: "🧑",
sweet: false,
}
도대체 무슨 일이 일어나고 있는거죠? 😱
앗, 그런데 여기서 말이에요… 로그가 찍힌 순서를 한 번 유심히 살펴보세요. 과일의 개수가 먼저 찍이고, 그 아래 콜백 함수 내에서 출력하는 내용이 연달아 나오죠?
이 것을 통해 우리는 filter()
함수가 인자로 넘어온 콜백 함수의 실행이 완전히 종료될 때까지 기다리지 않는다는 것을 알 수 있습니다.
왜 그럴까요? 🤷♀️
자바스크립트의 배열 함수
자바스크립트에서 비동기 함수는 결국 프라미스(promise)를 반환한다는 사실을 알고 계시죠?
Promise에 대한 자세한 설명은 별도 포스팅에서 자세히 다루고 있습니다.
async
와 await
문법은 ES6(ES2015)에 추가되었고, 자바스크립트의 배열 함수는 아주 오래 전부터 사용되었습니다.
그래서 슬프게도, 자바스크립트의 배열 함수는 비동기 함수가 인자로 넘어왔을 때 대부분의 개발자가 예상하는 대로 작동하지 않습니다.
자바스크립트의 배열 함수는 비동기 함수가 프라미스를 반환한다는 사실만을 고려합니다. 그리고 모든 프라미스는 객체이기 때문에 불리언(boolean)으로 형 변환을 하면 참(true)이 됩니다.
즉, 다음과 같은 과정을 거쳐서 emojis
배열의 모든 원소가 필터링에서 무조건 살아 남는 것입니다.
emojis.filter(isSweet); // 비동기 함수를 인자로 넘어옴
emojis.filter((emoji) => Boolean(new Promise(/* ... */)));
emojis.filter((emoji) => true);
for…of
이 문제를 해결하는 가장 간단한 방법은 for...of
문법을 사용해서 배열을 상대로 루프를 도는 것입니다.
변수에 0
을 저장해두고 과일을 나타내는 이모지가 나올 때 마다 하나씩 더하는 거죠.
const emojis = ["👨", "🍉", "👩", "🍓", "🧑"];
let count = 0;
for (const emoji of emojis) {
const sweet = await isSweet(emoji);
if (sweet) count++;
}
console.log("과일의 개수: ", count);
이 코드를 실행해보면 의도했던 바와 같이 과일의 개수가 2가 나옵니다.
과일의 개수: 2
반복문 안에서 각 emoji
와 sweet
변수 값도 한 번 찍어보겠습니다.
const emojis = ["👨", "🍉", "👩", "🍓", "🧑"];
let count = 0;
for (const emoji of emojis) {
const sweet = await isSweet(emoji);
console.log({ emoji, sweet });
if (sweet) count++;
}
console.log("과일의 개수: ", count);
그러면 반복문 내에서 출력하는 내용이 먼저 찍이고, 마지막에 과일의 개수가 찍히는 것을 볼 수 있습니다. 코드가 원하는 순서대로 실행된다는 증거죠.
{
emoji: "👨",
sweet: false,
}
{
emoji: "🍉",
sweet: true,
}
{
emoji: "👩",
sweet: false,
}
{
emoji: "🍓",
sweet: true,
}
{
emoji: "🧑",
sweet: false,
}
과일의 개수: 2
하지만 전체 코드가 수행되는데 약 5초가 걸리는 것을 알 수 있습니다.
1초가 걸리는 isSweet()
함수가 순차적으로 5번 수행되기 때문에 당연한 결과죠.
너무 비효율적인 것 같은데, 좀 더 빨리 실행할 수 있는 방법은 없을까요? 🤔
Promise.all()
Promise.all()
함수를 사용하면 여러 개의 비동기 함수를 병렬로 실행할 수 있습니다.
함수의 연쇄 호출을 단계별로 설명해 드리면,
- 자바스크립트 배열의
map()
함수를 통해서 각 이모지를 인자로 넘겨서isSweet()
함수를 호출한 결과로 변환합니다. - 위 결과로 나온 프라미스 배열을
Promise.all()
함수의 인자로 넘겨서 병렬로 비동기 함수를 실행합니다. - 위 결과로 나온
true
또는false
를 담고 있는 배열을 상대로filter(Boolean)
함수를 호출합니다.
const emojis = ["👨", "🍉", "👩", "🍓", "🧑"];
const count = (await Promise.all(emojis.map(isSweet))).filter(Boolean).length;
console.log("과일의 개수: ", count);
이 코드를 실행해보면 역시 의도했던 결과가 출력됩니다.
과일의 개수: 2
전체 코드가 수행되는데는 1초 남짓이 시간이 걸릴 것입니다. 무려 500%의 성능 향상이죠? 🚀
for await…of
자바스크립트에 비교적 최근에 추가된 for await...of
문법을 사용해서 동일한 효과를 얻을 수 있습니다.
위에서 살펴본 for...of
와 달리 반복문 내에서 isSweet()
의 실행이 끝나기를 기다리지 않기 때문에 Promise.all()
함수를 사용한 것과 비슷한 성능을 기대할 수 있습니다.
const emojis = ["👨", "🍉", "👩", "🍓", "🧑"];
let count = 0;
for await (const sweet of emojis.map(isSweet)) {
if (sweet) count += 1;
}
console.log("과일의 개수: ", count);
과일의 개수: 2
for await...of
에 대해서는 추후 별도의 게시물에 자세히 다뤄보도록 하겠습니다.
마치면서
지금까지 자바스크립트의 배열 함수에 비동기 함수를 인자로 넘기면 안 되는 이유와 어떻게 다른 방법으로 안전하게 코드를 짤 수 있는지 살펴보았습니다.
이 문제는 비단 filter()
함수 뿐만 아니라 forEach()
, map()
, reduce
, every()
, some()
등과 같이 콜백 함수를 인자로 받아 배열에 저장되어 있는 모든 원소로 상대로 호출해주는 다른 함수에서도 발생할 수 있어요.
신경을 쓰지 않고 코딩하면 실수하기 쉬운 부분이니 각별한 주의가 필요하겠습니다.