개발자
류준열

Array 메소드는 비동기를 기다려주지 않는다.

회사에서 리포트 발행 기능을 만드는데, 리포트 페이지가 뒤죽박죽으로 발행되었다.

const processPages = async () => {
  const promises = (previewPDFs:HTMLElement[])
	  .map(async (page) => await makePdf(page));

  await Promise.all(promises);
};

processPages()
  .than(result=>pdf.save(result))
  .catch((err) => message.error(err));

위 코드에서 previewPDFs의 인덱스 순서대로 makePDF(page)가 실행되는것이 보장되지 않는 이유는 다음과 같다.

  1. 위 제목처럼 Array 메소드는 비동기를 기다려주지 않기 때문 (MDN forEach)
  2. Promise.all은 병렬로 처리되기 때문 (MDN Promise)

배열 [1,2,3,4,5,6,7,8,9,10] 으로 예시코드를 작성해보았다.

map으로 비동기 처리를 한 코드

const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));

(async () => {
  const promises = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10].map(async (num) => {
    await sleep(500); // 각 인덱스 기본 0.5초 대기
    if (num === 3) {
      await sleep(3000); // num이 3일 때 추가로 3초 대기
    }
    console.log(num); 
  });

  await Promise.all(promises);
})();

3을 제외한 각 요소마다 0.5초 sleep을 주었다.

하지만 실제로는 각 인덱스마다 0.5초를 기다리지 않고 1,2,4,5,6,7,8,9,10이 단숨에 찍힌다.

그리고 Promise.all 내부에서 num이 3일때 wait(3000)이 Pending인 동안에 다른 Promise들이 병렬적으로 실행된다.

// 찍힌 콘솔
// 1
// 2
// 4
// 5
// 6
// 7
// 8
// 9
// 10
// 3 // 3초 후 

이러한 원리로 pdf출력때는 오래 걸리는 3페이지를 기다리지 않고 4페이지가 먼저 출력되었던 것이다.

Promise.all 제거 및 for문으로 변경

변경한 코드는 다음과 같다.

const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));

(async () => {
  
  for (const num of [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]) {
    await sleep(500); // 각 인덱스 기본 0.5초 대기
    if (num === 3) {
      await sleep(3000); // num이 3일 때 추가로 3초 대기
    }
    console.log(num); // 작업 완료 후 출력
  }
})();

for문으로 변경했을때는 순서가 보장되기 때문에 다음과 같이 콘솔이 찍힌다.

// 1 (0.5초 후)
// 2 (0.5초 후)
// 3 (3.5초 후)
// 4 (0.5초 후)
// 5 (0.5초 후)
// 6 (0.5초 후)
// 7 (0.5초 후)
// 8 (0.5초 후)
// 9 (0.5초 후)
// 10 (0.5초 후)

왜 배열 메소드는 비동기를 기다려주지 않을까?

이 글을 참고했습니다.

forEach의 polyfill을 보면 다음과 같다.

if (window.NodeList && !NodeList.prototype.forEach) {
  NodeList.prototype.forEach = function (callback, thisArg) {
    thisArg = thisArg || window;
    for (var i = 0; i < this.length; i++) {
      callback.call(thisArg, this[i], i, this);
    }
  };
}

forEach에 비동기 callback을 넣으면 다음과 같다.

if (window.NodeList && !NodeList.prototype.forEach) {
  NodeList.prototype.forEach = function (callback, thisArg) {
    thisArg = thisArg || window;
    for (var i = 0; i < this.length; i++) {
      async (num) => {
        if (num === 3) {
          await wait(3000); // num이 3일 때 3초 대기
        }
        return await makePdf(num); // 나머지 숫자는 병렬로 실행
    })(thisArg, this[i], i, this)}};
};

반복문안에서 비동기 함수를 실행만 시키니까 각 인덱스마다 실행되는 콜백을 기다리지 않는 것이다.

각 인덱스마다 실행되는 콜백을 기다리려면 다음과 같이 되어야 한다.

if (window.NodeList && !NodeList.prototype.forEach) {
  NodeList.prototype.forEach = async function (callback, thisArg) {
    thisArg = thisArg || window;
    for (var i = 0; i < this.length; i++) {
      await callback.call(thisArg, this[i], i, this);
    }
  };
}

마무리

검색하며 블로그들을 보니까 배열 메소드는 비동기를 기다려주지 않기 때문에 Promise.all을 사용하라는 말이 많았다.

하지만 Promise.all는 전달된 모든 Promise를 병렬로 처리한다.
즉, 개별적으로 동시에 실행되어 먼저 완료되는 Promise가 먼저 Resolve 되기 때문에 실행 순서와 결과 순서가 맞지 않을 수 있다.

예를 들어서 num===3에서 wait(3000)이 실행되어 Pending 상태에 놓이더라도 다른 Promise들은 계속 진행되는 것이다.

그렇기 때문에 순서도 보장하려면 for문을 이용하는 것이 좋다. (for await ...of도 있다.)