forEach 안에서 await 했는데 다음 줄이 먼저 실행된 이유
문제 발생
목록을 순회하며 저장한 뒤 결과를 로그로 찍었는데, 항상 처리 이전 값이 찍혔습니다.
const results = [];
items.forEach(async (item) => {
const saved = await save(item);
results.push(saved);
});
console.log(results.length); // 0await를 분명히 썼는데도 그랬습니다.
원인 분석
await는 콜백 함수 안에서만 기다립니다. forEach는 그 콜백이 돌려준 프로미스를 보지 않습니다.
MDN이 그대로 적어둔 문장입니다 — forEach()는 동기 함수를 기대하며 프로미스를 기다리지 않습니다. 프로미스(또는 async 함수)를 forEach 콜백으로 쓸 때의 영향을 반드시 인지해야 합니다. 문서의 예제도 결과가 14가 아니라 0으로 나오는 것을 보여줍니다.
async 콜백은 호출되는 즉시 첫 await에서 반환되고, forEach는 그 반환값(프로미스)을 버린 뒤 다음 항목으로 넘어갑니다. 그래서 루프는 순식간에 끝나고 console.log가 먼저 실행됩니다. 실패한 프로미스는 아무도 잡지 않아 unhandled rejection이 됩니다.
같은 문서가 지적하는 또 하나의 제약도 함께 걸립니다 — 예외를 던지는 것 말고는 forEach() 루프를 멈추거나 빠져나갈 방법이 없습니다. 문서는 조기 종료가 필요하면 for, for...of, for...in 같은 반복문을 쓰라고 안내합니다.
해결 방안
- 순서가 중요하면
for...of입니다. 앞 작업이 끝나야 다음이 시작됩니다.
const results = [];
for (const item of items) {
results.push(await save(item));
}- 순서가 상관없고 빨라야 하면
Promise.all입니다. 다만 하나라도 실패하면 전체가 즉시 실패합니다.
const results = await Promise.all(items.map((item) => save(item)));- 부분 실패를 허용해야 하면
Promise.allSettled입니다. 성공한 것과 실패한 것을 함께 받습니다.
const settled = await Promise.allSettled(items.map((item) => save(item)));
const saved = settled.filter((r) => r.status === "fulfilled").map((r) => r.value);-
외부 API나 DB를 때린다면 동시 실행 수를 제한합니다.
Promise.all에 1,000개를 한 번에 넘기면 커넥션 풀이나 상대 서버의 rate limit이 먼저 무너집니다. 청크로 잘라for...of+Promise.all을 섞습니다. -
중간에 멈춰야 하는 루프에는 애초에
forEach를 쓰지 않습니다.break가 필요하다는 건 도구 선택이 틀렸다는 신호입니다.
댓글0
댓글을 남기려면 로그인이 필요해요. 로그인
아직 댓글이 없어요. 첫 의견을 편하게 남겨 보세요.