개발자
류준열

Array 메소드에 async await 넣으면 순서 보장 안된다.

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

// 첫 코드
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));

위 코드에서 순서가 보장되지 않는 이유는 다음과 같다.

  1. 위 제목처럼 Array 메소드는 async 콜백을 기다리지 않는다. (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을 주었는데 아래와 같이 1,2,4,5,6,7,8,9,10은 단숨에 찍히고 3은 3초후에 찍혔다.

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

이유는 다음과 같다.

  1. .map()[1,2,...10] 배열을 순회하며 10개의 async를 거의 동시에 순차적으로 호출한다.

  2. 호출된 10개의 async 함수는 sleep(500)을 만나 10개의 0.5초 타이머가 거의 동시에 시작된다.

  3. 약 0.5초가 지나면 9개(num===3 제외)의 sleep(500) Promise가 거의 동시에 완료된다.
    이어서 각 console.log(num)이 실행되어 1,2,4,5,6,7,8,9,10이 단숨에 출력된다.

  4. n===3인 경우 0.5초 대기 후 sleep(3000)을 추가로 만나 3초를 더 기다렸다가 console.log(num)을 실행한다.

  5. Promise.all의 역할은 10개의 Promise가 완료될때까지 기다리는 역할을 한다. 여기서는 가장 오래 걸리는 작업(n===3의 3.5초)이 끝날 때까지 기다린다.

이러한 원리로 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초 후)

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

쉽게말하면 n개의 각 async이 비동기로 실행되기 때문이다.

이 글을 참고했습니다.

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를 병렬로 처리하기 때문에 순서 보장에는 맞지 않는다.

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

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