비동기 for문에서 빼내기

반복문에서 비동기를 사용하는 것은 일반적이나 for문이나 while문에서 비동기를 사용하면 성능이 떨어진다.
이는 비동기 함수가 실행되는 동안 다음 반복이 실행되지 않기 때문이다.

따라서 이를 해결하기 위한 방법을 알아보자.

Why not?

기존 블로그 코드에는 저장되어 있는 블로그 포스트 정보를 가져오는 함수가 있다.
블로그 포스트는 mdx 파일로 저장되어 있으며, 이를 읽어서 필요한 정보를 가져온다.
이때, mdx 파일을 읽는 것은 비동기로 처리되는데, 기존 코드는 이를 for문으로 처리하고 있다.

export const getPostsMeta: TGetPostsMeta = async (postType: TPost, page = 1) => {
  const viewedPosts: number[] = [];
  if (typeof page === "number") {
    viewedPosts.push((page - 1) * 6, page * 6);
  }
  const fileDirectory = path.join(rootDirectory, postType);
  const files = fs
    .readdirSync(fileDirectory)
    .filter(isMdx)
    .slice(...viewedPosts);

  let posts: IMetaData[] = [];

  for (const file of files) {
    const filePathWithType = path.join(postType, file);
    const post = await getPostBySlug(filePathWithType);

    if (!post) continue;

    posts.push(post.meta);
  }

  return posts.sort((a, b) => (a.date > b.date ? -1 : 1));
};

위 함수는 블로그 포스트의 메타 정보를 가져오는 함수이다.
중간에 있는 for (const file of files) 부분을 보자.

for (const file of files) {
  const filePathWithType = path.join(postType, file);
  const post = await getPostBySlug(filePathWithType);

  if (!post) continue;

  posts.push(post.meta);
}

이 부분은 파일을 하나씩 읽어서 메타 정보를 가져오는 부분이다.
파일의 정보는 스태틱 하지만 이를 동기식으로 가져오면 파일이 많아 질수록 성능이 크게 떨어질 것이다.
하지만 정작 사용하는 곳에서 for문을 사용하고 있기 때문에 이러면 함수를 비동기로 만든 의미가 없다.

또한, 이 프로젝트는 함수형 프로그래밍 패러다임을 많이 사용하려고 했기 때문에 for문 자체가 적합하지 않다.

이를 해결하기 위해 map을 고려할 수도 있겠다.

map?

map을 사용하여 코드를 변경해보자.

const posts = files.map(async (file) => {
  const filePathWithType = path.join(postType, file);
  const post = await getPostBySlug(filePathWithType);

  return post.meta;
});

map 메서드 안에 async 함수를 넣어서 반환 값을 받도록 했다.
중간에 const post = await getPostBySlug(filePathWithType); 를 했기 때문에 반환 값이 바로 map에 들어올까?

위 posts 변수를 출력해보자.

[
  Promise { <pending> },
  Promise { <pending> },
  Promise { <pending> },
  Promise { <pending> },
  Promise { <pending> },
  Promise { <pending> }
]

대실패다!
이유는 await의 대상이 Promise 객체가 아니라 Promise 배열이기 때문이다.
이를 간단하게 해결하는 방법은 Promise.all을 사용하는 것이다.

Promise.all

Promise.all은 Promise 배열을 받아서 모든 Promise가 완료될 때까지 기다렸다가 결과를 반환한다.
이미 우리는 Promise 배열을 가지고 있으므로 이를 Promise.all로 감싸주면 된다.

const posts = await Promise.all(
  files.map(async (file) => {
    const filePathWithType = path.join(postType, file);
    const post = await getPostBySlug(filePathWithType);

    return post.meta;
  })
);

위 함수의 결과는 아래와 같다.

[
  {
    title: 'title',
    date: '2021-01-01',
    tags: ['tag1', 'tag2'],
    description: 'description',
    slug: 'slug',
    type: 'post',
    thumbnail: 'thumbnail',
    readingTime: '1 min',
  },
  ...
]

드디어 원하는 결과를 얻었다.
이렇게 하면 비동기 함수를 for문에서 빼낼 수 있다.
for문과 달리 비동기 함수가 병렬로 실행되기 때문에 성능이 향상된다.
또한 함수형 프로그래밍 패러다임에 맞게 코드를 작성할 수 있게 되었다.

이제 한가지 작업을 더 해보자.

Promise.allSettled

비동기 작업은 대부분 실패의 가능성을 가지고 있다.
외부적인 요인에 의해서나 내부적인 요인에 의해서나 모든 비동기 작업은 실패할 수 있다.
특히 network를 통해 데이터를 가져오는 경우에는 더욱 그렇다.

Promise.all은 배열에 있는 Promise가 하나라도 실패하면 즉시 실패를 반환한다.
물론 이러한 동작이 필요한 경우도 있겠지만, 일부 Promise가 실패했을 때도 결과를 받고 싶을 수 있다.
이런 경우엔 Promise.allSettled를 사용하면 된다.
Promise.allSettled는 배열에 있는 모든 Promise가 완료될 때까지 기다렸다가 결과를 반환한다.
Promise.all과 다른 점은 실패한 Promise가 있어도 결과를 반환한다는 것이다.

그럼 Promise.allSettled를 사용해보자.

const posts = await Promise.allSettled(
  files.map(async (file) => {
    const filePathWithType = path.join(postType, file);
    const post = await getPostBySlug(filePathWithType);

    return post;
  })
);

Promise.allPromise.allSettled로 바꿨다.
바뀐 응답 값은 아래와 같다.

[
  {
    status: 'fulfilled',
    value: [object]
  },
  {
    status: 'rejected',
    reason: [object]
  },
  ...
]

이제 실패한 Promise도 결과를 받을 수 있게 되었다.
그러나 실패한 응답 값은 필요 없으므로 이를 제거해보자.

const posts = await Promise.allSettled<IBlogPost>(
  files.map(async (file) => {
    const filePathWithType = path.join(postType, file);
    const post = await getPostBySlug(filePathWithType);

    return post;
  })
);

const fulfilledPosts = posts.filter((post) => post.status === "fulfilled") as PromiseFulfilledResult<IBlogPost>[];

드디어 완성이다!

성능 향상과 함수형 프로그래밍 패러다임을 위해 비동기 함수를 for문에서 빼내는 방법을 알아보았다.

참고사항

Promise.all은 ES2015에 추가되었고,
Promise.allSettled는 ES2020에 추가되었다.

can i use promise all and allsettled

따라서 클라이언트에서 Promise.allSettled를 사용할 경우에는 브라우저 호환성을 고려해야 한다.

끝!

참조