배열에 비동기 작업을 실시할 때 알아두면 좋은 이야기들

hanameee·2020년 7월 14일
137

1. 면접 질문에서 시작된 의문 - forEach는 순차처리가 왜 안되는가

최근 프론트엔드 개발 면접 질문 중, 아래와 같은 질문을 받았다.

Q. 지금 아래의 코드는 result가 1초 후 한꺼번에 10개가 출력되는데, 이걸 1초 간격으로 10번 출력되게 코드를 고쳐주세요.

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, Promise, forEach 등등... 분명히 다 아는 (알고 있다고 생각한) 문법이었지만, 결국 대답을 하지 못했다. 😥

일단 정답부터 말하면, 아래처럼 forEach 를 for 문이나 for...of 문으로 변경하면 원하는 대로 (=순차적으로) 동작한다.

for 문으로 변경하는 방법

// (1) test 앞에 async 키워드를 붙인다
async function test() {
    const promiseFunction = () =>
        new Promise((resolve) => setTimeout(() => resolve("result"), 1000));

    let arr = Array(10).fill(0);
  	// (2) forEach 대신 for 을 사용한다
    for (let i = 0; i < arr.length; i++) {
        const result = await promiseFunction();
        console.log(result);
    }
}

test();

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();

답은 알았지만, 의문이 생겼다. 왜 forfor...of는 되고, forEach는 순차적으로 동작하지 않는 것일까? 🤔

2. forEach 동작 원리 이해하기

이를 제대로 이해하기 위해서는 forEach의 동작 원리를 이해해야 한다. 링크에서 재구현한 forEach 코드는 아래와 같다. (참고 - MDN 의 forEach polyfill)

Array.prototype.forEach = function (callback) {
  for (let index = 0; index < this.length; index++) {
    callback(this[index], index, this);
  }
};

코드에서 볼 수 있듯이 forEach 는 배열 요소를 돌면서 callback을 실행할 뿐, 한 callback이 끝날때 까지 기다렸다가 다음 callback을 실행하는 것이 아니다.

이제 다시 처음 질문에서 나왔던 코드를 보자.

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();

forEach는 Array의 10개 요소들을 차례로 돌며 async 함수를 실행한다. 이 async 함수는 1000ms를 기다렸다가 resolve 된 "result" 를 콘솔에 찍는 함수이다.

앞서 동작 원리에서 보았듯, forEach는 자신이 실행하는 callback 함수가 비동기 작업을 하는지 안하는지는 아무런 관심이 없다. forEach에 의해 즉각적으로 실행된 10개의 callback 들은 다같이 1000ms를 기다리고, 1000ms가 되는 순간 순차적으로 event loop에 의해 call stack으로 옮겨져 하나씩 실행된다.

⚠️ 위 작동 흐름은 많은 내용이 생략된 개략적인 서술이다. 정확한 설명을 위해서는 task queue, micro task 등의 내용을 포함해야 하지만, 나는 해당 내용에 대한 확실한 이해도 없을 뿐더러, 해당 주제에 대한 설명은 이 글에서 전달하고자 했던 내용을 벗어나므로 생략하도록 한다. 언젠가(!) 별도의 포스팅으로 다뤄보도록 하겠다

따라서 forEach를 가지고는 우리가 원했던 작동 - 이전 callback이 끝난 후 다음 callback이 순차적으로 실행되는 - 결과를 얻을 수 없는 것이다.

3. 비동기 작업 순차처리 구현하기 (for, for...of)

그렇다면 forEach가 한 element에서 실행한 callback의 비동기 작업이 끝날때까지 기다리도록 할 수는 없을까?

아래처럼 forEach 자체를 async 함수로 만들고, 각 callback 을 await 하게 만들면 가능하다.

async function asyncForEach(array, callback) {
  for (let index = 0; index < array.length; index++) {
    const result = await callback(array[index], index, array);
    console.log(result)
  }
}

코드 출처

이는 결과적으로 앞서 1번에서 for 문으로 변경하는 방법과 동일한 방식임을 알 수 있다. 앞서 말했듯이, for 또는 for ... of 를 사용할 수 있다.

자! 이제 면접 질문에 대답할 수 있게 되었다. 😎

4. 순차처리와 병렬처리

면접 질문에서 시작된 의문을 해결하기 위해 여러 레퍼런스들을 찾아보면서, 배열의 각 요소들에 비동기 작업을 실시하는 것이 많은 사람들이 헷갈려 하는 주제임을 알 수 있었다.

배열 요소들에 비동기 작업을 실시할 때 기대하는 방향은 2가지로 나눠볼 수 있다.

1. 순차처리 (in sequence)

배열의 요소들에 대해 차례대로 비동기 작업을 수행하는 것으로, 실행 순서가 보장되어야 할 때 사용한다.

2. 병렬처리 (in parallel)

배열의 요소들에 대해 한꺼번에 여러 비동기 작업을 수행하는 것으로, 실행 순서가 중요하지 않을 때 사용한다.

우리는 앞서 forEach를 for 또는 for ... of 로 바꿔줌으로써 순차처리를 구현한 바 있다. 하지만 성능이 중요한 실무에서는, 순서가 중요하지 않다면 일반적으로 병렬처리를 해주는 것이 더 효율적일 것이다.

예를 들어, 파일들을 읽어온 후 어떤 작업을 해야 한다고 가정해보자. 배열 순서대로 파일을 순차적으로 읽는 것이 중요하다면 시간이 오래걸리더라도 순차처리를 해주어야 한다. 하지만, 순서 상관없이 파일들을 다 읽어오는 것만이 중요하다면? 그때는 병렬처리가 훨씬 좋은 방법이다.

그렇다면 배열의 각 요소들에 비동기 작업을 병렬처리 하기 위해서는 어떤 방법을 택해야 할까?

5. 비동기 작업 병렬처리 구현하기 (Promise.all)

비동기 작업의 병렬 처리를 위해서는 Promise.all 을 사용할 수 있다.

예를 들어, 복수의 URL에 요청을 보내고, 모든 다운로드가 완료된 후에 특정 처리를 해야 한다고 가정해보자. 다운로드 완료 순서는 중요하지 않기에 병렬으로 처리하는 것이 효율적일 것이다.

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" 이 출력된다.

async_download는 1초가 걸리는 작업이기에 3개의 async_download를 순차처리하면 총 3초 이상이 걸려야 하지만, 병렬처리를 했기 때문에 전체 작업은 약 1초만에 완료된다.

6. Promise.all 이 forEach와 다른 점

여기까지 읽고 나면, "앞서 1번에서 소개된 면접질문 (forEach 사용 코드) 역시 10개의 코드가 1초만에 모두 resolve 되니까, 이것도 병렬처리 아닌가? 왜 궂이 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 :)" 이 가장 먼저 출력되고 있다.

이는 아래와 같이 then 을 사용해서 코드를 바꿔도 마찬가지이다.

async function parallel(array) {
    array.forEach(async (url) => {
        await async_download(url);
    });
}

parallel(target_url).then(console.log("all done :)"));

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

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

배열의 비동기 작업에 forEach를 사용하면 순차처리이든, 병렬처리이든 올바르게 작동하기 힘든 이유가 여기에 있다. forEach는 콜백만 실행하고 끝나버리기에 비동기 작업의 처리 상태를 추적하지 못하고, 따라서 이후의 흐름을 제어하기도 어렵다.

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

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

7. 정리

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

8. 참고한 레퍼런스들

[Async function] async/await 비동기 처리

Using async/await with a forEach loop

비동기 함수 - 프라미스에 친숙해질 수 있게 해주는 함수

How to make your JavaScript functions sleep

JavaScript: async/await with forEach()

map, reduce 함수에서 async/await 쓰기

글의 순서가 매끄럽지 않고 중복된 설명이 많은 부족한 글이지만, 제가 헷갈렸던 forEach와 async/await 에 대한 설명이 어느 정도 전달되었기를 바랍니다.

저는 자바스크립트 마스-따와 거리가 먼 학생이며, 이에 잘못 설명된 부분이 있을 수 있습니다. 댓글로 의문점을 남겨주시면 열심히 궁리해보도록 하겠습니다. 🙏

profile
hopefully, frontend developer

33개의 댓글

comment-user-thumbnail
2020년 7월 17일

좋은 글 감사합니다ㅎㅎ

1개의 답글
comment-user-thumbnail
2020년 8월 6일

forEach에 비동기 처리가 될 줄 알았는데 아니었군요. 덕분에 forEach를 깊이 이해하게 되었습니다!

1개의 답글
comment-user-thumbnail
2020년 8월 17일

저도 어제 딱 이부분으로 고생했는데 정리잘하셨네요!

1개의 답글
comment-user-thumbnail
2020년 8월 17일

좋은 글 잘 봤습니다!
3번째 방법 (asyncForEach)을 모든 배열에서 메소드로 편하게 사용하려면 아래와 같이 배열의 프로토타입을 직접 수정하는 방법이 좋을 것 같네요
Array.prototype.asyncForEach = async function (callback) {
for (let index = 0; index < this.length; index++) {
await callback(this[index], index, this);
}
};

1개의 답글
comment-user-thumbnail
2020년 8월 19일

잘 보고 갑니당!

1개의 답글
comment-user-thumbnail
2020년 8월 25일

잘봤습니다😊

1개의 답글
comment-user-thumbnail
2020년 8월 25일

머릿속에 애매하게 떠돌던 게 한층 정리된 느낌이네요, 감사합니다 ㅎㅎ

1개의 답글
comment-user-thumbnail
2020년 9월 18일

너무 좋은 글이네요 감사합니다!

1개의 답글
comment-user-thumbnail
2020년 11월 8일

베니 글 잘 읽었어요! 유익한 글 감사해용 :) 확인해봤는데 정말 신기하네요! 굿굿... 시간날 때 더 찾아봐야겠어요!

1개의 답글
comment-user-thumbnail
2021년 1월 19일

좋은글 잘 읽었습니다. 결국, block, nonblock 함수에대한 차이일듯한데요, 이 부분도 함께 찾아보셔도 좋을듯해요 ^^

1개의 답글
comment-user-thumbnail
2021년 3월 4일

몰랐던 사실을 배우고 갑니다! 감사합니다

1개의 답글
comment-user-thumbnail
2021년 3월 15일

forEach로하다 안되면 바꾸기만 했는데 원리가 이런거였군요.
덕분에 한층 공부한 느낌입니다. 감사합니다 !!

1개의 답글
comment-user-thumbnail
2021년 3월 23일

덕분에 많은걸 알아가네요!! 감사합니다

1개의 답글
comment-user-thumbnail
2021년 5월 14일

좋은 정보 감사합니다.

올려두신 Github 코드를 보면서 많이 배웠습니다. (+ Follow도 했습니다! 👍)

1개의 답글
comment-user-thumbnail
2021년 11월 1일

감사합니다! 혹시 작성하신 코드와 설명을 따로 정리해서 포스팅하는데 사용해도될까요?

1개의 답글
comment-user-thumbnail
2022년 1월 26일

왜 promise.all을 써야하는지 이해가 잘 안됐는데, 이 글을 통해 많은 걸 배울 수 있었습니다!! 혹시 괜찮다면 이 글의 코드와 설명을 정리해서 포스팅하는데 사용해도될까요?

1개의 답글