본문으로 건너뛰기
개발 머꼬
개발 노트JavaScript
hohyeon.dev26

forEach 안에서 await 했는데 다음 줄이 먼저 실행된 이유

  • #Common Pitfall
  • #Engineering Note
  • #JavaScript

문제 발생

목록을 순회하며 저장한 뒤 결과를 로그로 찍었는데, 항상 처리 이전 값이 찍혔습니다.

const results = [];

items.forEach(async (item) => {
  const saved = await save(item);
  results.push(saved);
});

console.log(results.length); // 0

await를 분명히 썼는데도 그랬습니다.

원인 분석

await콜백 함수 안에서만 기다립니다. forEach는 그 콜백이 돌려준 프로미스를 보지 않습니다.

MDN이 그대로 적어둔 문장입니다 — forEach()는 동기 함수를 기대하며 프로미스를 기다리지 않습니다. 프로미스(또는 async 함수)를 forEach 콜백으로 쓸 때의 영향을 반드시 인지해야 합니다. 문서의 예제도 결과가 14가 아니라 0으로 나오는 것을 보여줍니다.

async 콜백은 호출되는 즉시 첫 await에서 반환되고, forEach는 그 반환값(프로미스)을 버린 뒤 다음 항목으로 넘어갑니다. 그래서 루프는 순식간에 끝나고 console.log가 먼저 실행됩니다. 실패한 프로미스는 아무도 잡지 않아 unhandled rejection이 됩니다.

같은 문서가 지적하는 또 하나의 제약도 함께 걸립니다 — 예외를 던지는 것 말고는 forEach() 루프를 멈추거나 빠져나갈 방법이 없습니다. 문서는 조기 종료가 필요하면 for, for...of, for...in 같은 반복문을 쓰라고 안내합니다.

해결 방안

  1. 순서가 중요하면 for...of입니다. 앞 작업이 끝나야 다음이 시작됩니다.
const results = [];
for (const item of items) {
  results.push(await save(item));
}
  1. 순서가 상관없고 빨라야 하면 Promise.all입니다. 다만 하나라도 실패하면 전체가 즉시 실패합니다.
const results = await Promise.all(items.map((item) => save(item)));
  1. 부분 실패를 허용해야 하면 Promise.allSettled입니다. 성공한 것과 실패한 것을 함께 받습니다.
const settled = await Promise.allSettled(items.map((item) => save(item)));
const saved = settled.filter((r) => r.status === "fulfilled").map((r) => r.value);
  1. 외부 API나 DB를 때린다면 동시 실행 수를 제한합니다. Promise.all에 1,000개를 한 번에 넘기면 커넥션 풀이나 상대 서버의 rate limit이 먼저 무너집니다. 청크로 잘라 for...of + Promise.all을 섞습니다.

  2. 중간에 멈춰야 하는 루프에는 애초에 forEach를 쓰지 않습니다. break가 필요하다는 건 도구 선택이 틀렸다는 신호입니다.

공식 문서

마지막 수정

좋아요북마크

댓글0

아직 댓글이 없어요. 첫 의견을 편하게 남겨 보세요.