장난감 연구소
[JS] forEach 함수는 async 함수를 기다려주지 않는다 본문
최근 Mongoose를 사용하는 프로젝트를 진행하면서 forEach 함수는 비동기(async) 함수를 기다려주지 않는다는 걸 알게 되었다.
forEach 함수의 콜백이 비동기 함수일 때 경험한 문제
문제가 생긴 코드는 아래와 같다. 이 코드는 wantedGenres 배열의 각각의 값에 대해 MongoDB에서 일치하는 오브젝트를 찾아 genres 배열에 넣고자 하는 코드이다. 예상하기에는, forEach의 콜백 함수에서 genres에 모두 삽입이 완료된 후 결과가 출력될 것으로 보였다. 그러나 실제 실행 결과에서는, 최종 결과가 먼저 출력된 후 삽입이 일어났다.
let genres = [];
wantedGenres.forEach(async (value, index) => {
const g = await Genre.findOne({ name: value }); // MongoDB에서 오브젝트 조회
genres.push(g);
console.log(`Push ${index}: ${genres}`);
});
console.log(`Done: ` + genres);
// wantedGenres = ['로맨스', '판타지']
// 예상 결과
// Push 0: [{...name: '로맨스'...}]
// Push 1: [{...name: '로맨스'...}, {...name: '판타지'...}]
// Done: [{...name: '로맨스'...}, {...name: '판타지'...}]
// 실행 결과
// Done:
// Push 0: [{...name: '로맨스'...}]
// Push 1: [{...name: '로맨스'...}, {...name: '판타지'...}]
이와 같은 문제는 forEach 함수는 콜백 함수로 비동기 함수를 받더라도 실행을 기다려주지 않기 때문이다. 콜백 함수가 실행되는 도중에 마지막 console.log()가 실행되었기에 이런 결과가 나타난 것이다.
forEach expects a synchronous function forEach does not wait for promises. Kindly make sure you are aware of the implications while using promises(or async functions) as forEach callback.
#MDN Web Docs
찾아본 결과 MDN Web Docs에서도 forEach()는 Promises를 기다리지 않는다고 명시되어 있다.
해결 방안
이를 간단하게 대체할 방법을 찾던 도중 이에 대해 잘 다룬 글이 이미 존재하였다. 자세한 정리를 아래 글로 이동해 확인할 수 있다.
배열에 비동기 작업을 실시할 때 알아두면 좋은 이야기들 (velog.io)
대안 1: for...of 문
배열의 각 항목에 대해 비동기 작업를 반복 실행하도록 할 때는 for...of 문이 대안이 될 수 있다. for...of 문을 아래와 같이 사용할 경우 비동기 작업을 기다리면서 순차적으로 실행할 수 있다. 그런데 이 방법은 forEach()를 사용했을 때와 달리 병렬로 처리되지 않으므로 소요 시간이 길어질 수 있음에 주의해야 한다.
genres = [];
for (const value of wantedGenres) {
const g = await Genre.findOne({ name: value });
genres.push(g);
}
참고로 아래와 같이 entries()를 활용하면 for...of 문에서도 값과 함께 인덱스에도 접근할 수 있다.
genres = [];
// entries()는 인덱스/키 쌍의 새로운 배열을 반환한다
for (const [index, value] of wantedGenres.entries()) {
const g = await Genre.findOne({ name: value });
genres.push(g);
}
대안 2: Promise.all()
비동기 작업의 소요 시간이 크면, 순차적으로 반복하는 대신 병렬로 처리하는 걸 고려할 수 있다. 각 비동기 함수를 Promise 배열로 만들고, Promise.all()을 통해 실행하면 비동기 함수들을 동시에 실행할 수 있다. 아래 코드는 map()을 통해 각 Promise를 배열로 저장하고 Promise.all()을 통해 실행하게 된다.
let genres = [];
const promises = wantedGenres.map(async (value, index) => {
const g = await Genre.findOne({ name: value });
genres.push(g);
});
await Promise.all(promises);
아래 글에서 for...of 문과 Promise.all()의 직렬 처리, 병렬 처리를 확인할 수 있다.
[JS] Promise.all()은 병렬일까 직렬일까🤔 (tistory.com)
결론
forEach 함수의 콜백이 비동기 함수일 때는 예상과 다른 결과가 나타나는 문제가 있었다. 이를 해결하려면 for...of 문이나 Promise.all()을 대안으로 사용해 비동기 작업을 기다릴 필요가 있었다.
그런데 놀란 것은 이와 거의 똑같은 코드가 정상적인 순서로 작동하는 경우도 있었다는 것이다. 간헐적으로 발생하여 해결이 어려운 버그를 만들지 않으려면 동기, 비동기에 관해서는 깊게 고려할 필요가 있어 보인다.
참고 자료
javascript - How can i fix an async forEach push? - Stack Overflow
배열에 비동기 작업을 실시할 때 알아두면 좋은 이야기들 (velog.io)
Array.prototype.forEach() - JavaScript | MDN (mozilla.org)