본문 바로가기

[자바스크립트] 비동기 작업을 순서대로 처리하기

 

 

 

들어가며

본격적으로 개발을 하면서, 다양한 API를 개발하곤 합니다. 한 번은, 검색 API를 개발하면서, 응답속도가 3초 이상이 걸리는 로직이 있었습니다. 간단한 로직이었음에도 왜 3초씩이나 걸리는지 제대로 알지 못했습니다. 지금 생각해보면, 비동기에 대한 이해가 없어서 생긴 문제였습니다. 지금부터라도 비동기에 대해 더 자세히 공부해야겠다고 생각했습니다. 아래 글은 이 글을 바탕으로 작성됐습니다.

 

 

 

 

 

 

 

 

 

 


 

 

 

 

 

 

 

 

 

 

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

 

 

얼핏 보면 문제가 없는 코드처럼 보입니다. 위의 물음에 대한 정답을 말하면, 아래처럼 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();

 

 

그럼 왜 for와 for...of는 순차처리가 되고, forEach는 순차처리가 되지 않을까요? 

 

 

 

 

 

 

 

 

 

 

 


 

 

 

 

 

 

 

 

 

 

 

 

 

forEach 동작 원리 이해하기

forEach가 순차처리가 안 되는 이유에 대해서 알려면, 먼저 forEach의 동작 원리를 이해해야 합니다. 링크에서 재구현한 forEach 코드는 아래와 같습니다.

 

 

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으로 옮겨져 하나씩 실행됩니다. 따라서 forEach를 가지고는 우리가 원했던 작동 - 이전 callback이 끝난 후 다음 callback이 순차적으로 실행되는 - 결과를 얻을 수 없는 것입니다. 

 

 

 

 

 

 

 

 

 

 


 

 

 

 

 

 

 

 

 

 

 

 

 

비동기 작업 순차처리 구현하기 (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를 사용할 수 있습니다. 그럼 이제 첫 번째 질문에 대답할 수 있게 되었습니다.

 

 

 

 

 

 

 

 

 

 


 

 

 

 

 

 

 

 

 

 

 

 

순차처리와 병렬 처리

지금까지 비동기를 살펴보면서, 순차처리와 병렬 처리를 어떻게 해야 하는지 정리해야겠다고 생각했습니다. 그럼 지금부터 각각의 처리를 어떻게 할 수 있는지를 알아보겠습니다. 

 

먼저 순차처리의 경우 배열의 요소들에 대해 차례대로 비동기 작업을 수행하는 것으로, 실행 순서가 보장되어야 할 때 사용됩니다. 그리고 병렬 처리는 배열의 요소들에 대해 한꺼번에 여러 비동기 작업을 수행하는 것으로, 실행 순서가 중요하지 않을 때 사용합니다. 우리는 앞서 forEach를 for 또는 for...of 로 바꿔줌으로써 순차처리를 구현한 바 있습니다. 하지만 성능이 중요한 실무에서는, 순서가 중요하지 않다면 일반적으로 병렬 처리를 해주는 것이 더 효율적일 것입니다.

 

예를 들어, 파일들을 읽어온 후 어떤 작업을 해야 한다고 가정해보면, 배열 순서대로 파일을 순차적으로 읽는 것이 중요하다면 시간이 오래 걸리더라도 순차처리를 해줘야 합니다. 하지만, 순서 상관없이 파일들을 다 읽어오는 것만이 중요하다면? 그때는 병렬 처리가 훨씬 좋은 방법입니다. 그렇다면 배열의 각 요소들에 비동기 작업을 병렬 처리하기 위해서는 어떤 방법을 택해야 할까요? 

 

 

 

 

 

 

 

 

 

 

 


 

 

 

 

 

 

 

 

 

 

 

 

비동기 작업 병렬 처리 구현하기 (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초 만에 완료됩니다. 

 

 

 

 

 

 

 

 

 

 

 


 

 

 

 

 

 

 

 

 

 

 

Promise.all이 forEach와 다른 점

여기까지 읽고 나면, 앞에서 나왔던 질문 (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을 사용하는 것이 좋습니다. 

 

 

 

 

 

 

 

 

 

 


 

 

 

 

 

 

 

 

마치며

비동기를 잘 다룰 수 있는 개발자가 되고 싶습니다. 비동기 처리를 잘해야만 더 효율적인 코드를 작성할 수 있다는 믿음으로 이 부분에 대해 더 열심히 공부하고 싶습니다.

 

 

 

 

 

 

 

 

 

 


 

 

 

 

 

 

 

 

 

 

 

출처

 

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

프론트엔드 인턴 면접에서 비동기 작업 관련 질문 대답 못한 뒤 외양간 뚝딱뚝딱 고치는 이야기.

velog.io