Logo

자바스크립트의 배열 함수에 비동기 함수를 인자로 넘기면 안 되는 이유

자바스크립트의 배열은 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

왜 이런 뜻밖의 결과가 발생하는 걸까요? 🤔

디버깅 🪲

그럼 asyncawait 키워드를 사용해서 같이 디버깅을 좀 해볼까요?

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에 대한 자세한 설명은 별도 포스팅에서 자세히 다루고 있습니다.

asyncawait 문법은 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

반복문 안에서 각 emojisweet 변수 값도 한 번 찍어보겠습니다.

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() 등과 같이 콜백 함수를 인자로 받아 배열에 저장되어 있는 모든 원소로 상대로 호출해주는 다른 함수에서도 발생할 수 있어요. 신경을 쓰지 않고 코딩하면 실수하기 쉬운 부분이니 각별한 주의가 필요하겠습니다.