상품 3개를 저장하고 나서 '모두 저장됨'을 표시했다. 그런데 화면에는 완료 문구가 먼저 뜨고, 저장 결과가 뒤늦게 도착한다. 코드에는 분명 await이 있는데 말이다. 범인은 await의 철자가 아니라 그 Promise를 누가 기다리는가다.
아래의 saveItem()은 실제 서버 대신 짧은 지연을 둔 가상 저장 함수다. 같은 상품 ID 1, 2, 3으로 forEach, for...of, Promise.all을 비교해 보자.
forEach(async ...) 뒤의 완료 로그가 먼저 찍히는 이유
forEach는 콜백을 각 원소에 호출한 뒤 자신은 undefined를 반환한다. async 콜백이 돌려주는 Promise들을 모아서 기다려 주지 않는다. 콜백 안의 await은 그 콜백 안에서만 다음 줄을 기다린다.
도식의 시간축에서 forEach 호출은 세 비동기 작업이 끝나기 전에 종료된다. 로그 순서를 확인할 수 있는 최소 예제로 이 차이를 재현해 보자.
const ids = [1, 2, 3];
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
async function saveItem(id) {
await sleep((4 - id) * 10);
console.log(`saved:${id}`);
return id;
}
ids.forEach(async (id) => {
await saveItem(id);
});
console.log('all done?');이 독립 예제에서는 all done?가 먼저 찍히고, 저장 완료는 saved:3 → saved:2 → saved:1 순으로 찍혔다. 지연 시간을 의도적으로 다르게 줘 순서 차이를 드러낸 것이다. 실제 네트워크라면 완료 순서는 일정하지 않다. await ids.forEach(...)로 바깥에 await을 하나 더 붙여도, 기다릴 Promise가 아니라 undefined를 받으므로 해결되지 않는다. 완료 문구가 너무 성급하게 출근한 셈이다.
저장 순서가 중요하면 for...of는 무엇을 기다릴까?
앞의 saveItem()을 그대로 쓰고 반복문만 바꿔 보자.
for (const id of ids) {
await saveItem(id);
}
console.log('all done');이번에는 saved:1 → saved:2 → saved:3 → all done이다. 각 await이 끝난 뒤에야 다음 ID를 처리한다. 앞선 저장이 성공해야 다음 저장을 할 수 있거나, 호출 순서 자체가 중요한 작업에 맞다. 단, 세 요청을 차례로 기다리므로 서로 독립적인 작업이라면 기다리는 시간이 늘어난다.
반복 도중 실패하면 다음 ID는?
for...of 안의 await saveItem(id)이 거부되면, 별도로 처리하지 않는 한 반복을 빠져나와 바깥 catch로 간다. '세 개 중 하나만 실패해도 멈춘다'는 정책을 의도했다면 자연스럽다. 나머지도 계속 시도해야 한다면 항목별 try/catch와 실패 기록이 필요하다. 실패를 조용히 무시한 채 all done을 표시하는 것은 완료 표시의 뜻을 흐린다.
독립적인 작업을 함께 기다리려면 Promise.all은 어떻게 쓸까?
저장 순서가 중요하지 않고 모든 결과가 필요하면 Promise 배열을 만든 뒤 한 번에 기다릴 수 있다.
const results = await Promise.all(ids.map((id) => saveItem(id)));
console.log('all done');
console.log(results); // [1, 2, 3]세 saveItem() 호출이 먼저 시작되고, 모두 이행한 뒤에 all done이 찍힌다. 작업 자체의 완료 순서는 3, 2, 1처럼 달라질 수 있다. 각 결과를 반환받는 Promise.all 배열은 입력 ID의 순서를 유지하지만, 실제 실행이 순서대로 끝난다는 뜻은 아니다.
한 작업이 실패하면 Promise.all의 결과는 거부된다. 그렇다고 이미 시작한 나머지 저장이 자동으로 취소되는 것은 아니다. 세 요청 중 하나의 실패로 전체를 다시 시도한다면, 이미 성공한 요청이 중복 실행될 수 있다. 실제 저장 API라면 재시도·중복 처리 정책까지 함께 정해야 한다. 동시에 수천 건을 보내는 경우에는 Promise.all 한 번에 모두 몰아넣기보다 허용할 동시 요청 수를 제한하는 편이 안전하다.
어떤 반복 방식을 고르면 될까?
| 원하는 동작 | 선택 | 완료 판단 |
|---|---|---|
| 각 작업을 시작만 하고 기다리지 않음 | forEach 콜백 | 반복문 종료는 작업 완료가 아님 |
| 앞 작업이 끝난 뒤 다음 작업 시작 | for...of + await | 마지막 await 뒤에 완료 |
| 독립 작업을 함께 시작하고 모두 기다림 | Promise.all(ids.map(...)) | 모두 이행한 뒤 완료 |
실제 요청을 보내기 전에는 '순서가 필요한가?', '하나가 실패하면 나머지는 어떻게 할까?', '중복 시도해도 안전한가?'를 먼저 정하자. 문법을 바꾸는 작업보다 완료의 뜻을 정하는 작업이 더 중요하다.
핵심 요약
forEach(async ...)의 await은 각 콜백 안에서만 기다린다. forEach 바깥은 콜백의 Promise를 기다리지 않아 완료 문구가 먼저 나올 수 있다. 순차 처리는 for...of로 각 작업을 기다리고, 독립 작업을 함께 시작하되 전부 끝난 뒤 진행하려면 Promise.all을 쓴다. 실패와 중복 처리까지 정해야 '모두 완료'라는 말이 실제 상태와 맞는다.
작성자
기초 개념을 구현과 검증, 실제 운영 판단까지 연결해 기록합니다.

