[JS] 배열의 비동기 처리

Jiwon Youn·2022년 5월 27일
0

forEach는 순차 처리가 안된다.

function test() {
    const promiseFunction = () =>
        new Promise((resolve) => setTimeout(() => resolve("result"), 1000));
  
    Array(10)
        .fill(0)
        .forEach(async () => {
            const result = await promiseFunction();
            console.log(result);
        });
}

test();

위 코드는 분명 Promise async/await를 걸어 비동기로 작업해 result 가 1초마다 한 번씩 출력될 것 같지만, 1초 후 한꺼번에 10개가 출력된다.

정답부터 말하자면 아래처럼 forEachfor문 또는 for...of문으로 변경하면 순차적으로 동작한다.

for ... of

function test() {
    const promiseFunction = () =>
        new Promise((resolve) => setTimeout(() => resolve("result"), 1000));

    let array = Array(10).fill(0);
  	// (1) test를 async로 감싸는 대신, for문을 async 즉시실행함수로 감싸도 된다
    (async () => {
      	// (2) forEach 대신 for ... of를 사용한다
        for (let element of array) {
            const result = await promiseFunction();
            console.log(result);
        }
    })();
}

test();

이유?

forEach는 배열 요소를 돌면서 callback을 실행할 뿐, 한 callback이 끝날 때까지 기다렸다가 다음 callback을 실행하는 것이 아니다. forEach는 10개의 요소를 차례로 돌며 async 함수를 실행한다. 실행된 async 함수 10개가 한꺼번에 1초를 기다렸다가 resolve 된 result 를 콘솔에 출력하는 것이다.

즉, forEach는 자신이 실행하는 callback 함수가 비동기 작업을 하는지 아닌지는 관심 없는 것이다.


순차 처리와 병렬 처리

배열의 요소들에 대해 실행 순서가 보장되어야 할 경우 순차 처리 (위에서 언급한 for...of문 등)을 사용한다.

실행 순서가 중요하지 않으며 성능이 중요한 경우에는 병렬 처리를 해주는 것이 더 효율적일 것이다.

예를 들어, 복수의 파일을 다운로드할 때 굳이 순서가 중요하지 않다면 복수의 URL에 요청을 보내 병렬으로 처리하는 것이 효율적이다.

병렬 처리에서의 Promise.all

const target_url = ["ur11", "ur12", "url3"];

// 다운로드에 약 1초가 걸리는 비동기 함수라고 가정
function async_download(url) {
    return new Promise((resolve, reject) => {
        setTimeout(() => {
            console.log(url);
            resolve();
        }, 1000);
    });
}

async function parallel(array) {
    const promises = array.map((url) => async_download(url));
    await Promise.all(promises);
    console.log("all done :)");
}

parallel(target_url);
  1. map은 array의 각 요소를 돌면서 async_download 함수를 병렬적으로 실행한 결과 (promise)를 새로운 promises 배열에 담는다.
  2. Promise.all 은 pending 상태인 promises들이 모두 resolve 될 때까지 기다린다.
  3. Promise.all 마저 resolve 되면 마지막 작업이었던 “all done” 이 출력된다.
🤔 그럼 앞서 소개된 forEach 사용 코드도 병렬 처리 아닌가? 왜 굳이 Promise.all을 사용해야 하지?

forEach를 사용한 아래 코드를 보면 확실한 차이를 알 수 있다.

const target_url = ["ur11", "ur12", "url3"];

function async_download(url) {
    return new Promise((resolve, reject) => {
        setTimeout(() => {
            console.log(url);
            resolve();
        }, 1000);
    });
}

async function parallel(array) {
    array.forEach(async (url) => {
        await async_download(url);
    });
  	// all done은 언제 찍힐까?
    await console.log("all done :)");
}

parallel(target_url);

Promise.all에서 그랬던 것처럼, 병렬 처리가 완료된 후 ”all done”이 출력되길 의도했으나,
실상은 “all done”이 가장 먼저 출력되고 있다.

모든 비동기 작업이 끝나고 수행되길 원했던 함수가 맨 처음 실행되고 있다. 왜일까?

forEach는 배열을 돌며 callback을 호출하기만 하면 맡은 임무를 다 한 것으로 생각하고 종료된다. 사실 호출한 callback들은 pending 상태로 resolve 되지 않았지만, forEach 입장에서는 할 일을 다 한 것이다.

forEach는 callback만 실행하고 끝나버리기에 비동기 작업의 처리 상태를 추적하지 못하고, 따라서 이후의 흐름을 제어하기도 어렵다.

하지만 mappromise.all을 사용하면 callback들이 return하는 promise들을 새로운 배열에 담아두었다가 모든 promise가 resolved 되는 타이밍을 감지할 수 있다.

따라서 배열의 요소들에 비동기 작업을 실시한 후 (순차든, 병렬이든) 어떤 작업을 해야 한다면 forEach가 아닌 map과 Promise.all을 사용하는 것이 좋다.


정리

  • forEach를 비롯한 배열의 요소에 callback을 실행하는 방식인 ES5 array methods (map, filter, reduce...) 를 사용할 땐, callback이 async 하더라도 전체 method는 async 하지 않음을 유의해야 한다.
  • 일반적으로 순차 처리는 for 또는 for...of문을 통해,
    병렬 처리는 map + Promise.all 을 통해 구현할 수 있다.
  • 순차 처리가 꼭 필요한 상황이 아니라면 성능상 유리한 병렬 처리를 고려하자.

참고 출처 : hanameee님의 배열 비동기 글

0개의 댓글