SOFTMOA TECHNOLOGY

JavaScript forEach에서 async await가 기다리지 않을 때 해결 방법

소프트모아가 정리한 기술 기록입니다.

Contents
작성일 2026. 10. 15.

배열을 forEach(async ...)로 처리했는데 완료 목록이 비어 있거나 저장이 끝나기 전에 다음 화면으로 넘어간다면, await 위치보다 반환값을 먼저 확인해야 합니다. forEach는 콜백이 돌려준 Promise를 모으지 않고 undefined를 반환합니다. 순서가 필요한 작업은 for...of, 서로 독립적인 작업의 전체 완료 대기는 Promise.all로 바꿉니다.[1][2]

콜백 안의 대기와 배열 전체의 대기는 다릅니다

콜백에 붙인 async가 무시되는 것은 아닙니다. 콜백 안의 await는 그 함수의 다음 줄을 미룹니다. 다만 forEach는 그 함수가 반환한 Promise를 기다리지 않습니다. 바깥에 await array.forEach(...)를 붙여도 작업 전체를 기다릴 대상이 생기지 않습니다.[1]

아래 코드는 외부 API 대신 직접 여는 대기 신호인 gate를 씁니다. 시간 지연이나 서버 속도에 기대지 않고, ID 101·102의 작업이 끝나기 전과 후를 비교하는 합성 예제입니다. Node.js v26.10.0에서 실행했으며 실제 파일·DB를 변경하지 않았습니다. repro.mjs로 저장하고 node --check repro.mjs, node repro.mjs 순으로 확인할 수 있습니다.

let release;
const gate = new Promise(resolve => { release = resolve; });
const completed = [];
const returned = [101, 102].forEach(async id => {
  await gate;
  completed.push(id);
});
console.log('반환값:', returned);
await returned;
console.log('대기 직후:', JSON.stringify(completed));
release();
await gate;
console.log('작업 종료:', JSON.stringify(completed));

실행 결과는 반환값: undefined, 대기 직후: [], 작업 종료: [101,102]였습니다. 마지막 배열은 이 예제에서 신호가 열린 뒤 콜백이 값을 넣었음을 뜻합니다. 바깥의 await returned가 두 작업을 기다렸다는 뜻이 아닙니다.

먼저 끝나야 할 작업이 있는지로 수정 방법을 고릅니다

다음은 별도의 fix.mjs로 실행할 수 있는 예제입니다. read는 ID를 문자열로 돌려주는 가짜 조회 함수입니다. 두 방식 모두 ["R101","R102"]를 얻지만 호출 방식은 다릅니다.

async function read(id) { return `R${id}`; }
const ids = [101, 102];
const sequential = [];
for (const id of ids) sequential.push(await read(id));
const concurrent = await Promise.all(ids.map(id => read(id)));
console.log('순차:', JSON.stringify(sequential));
console.log('동시:', JSON.stringify(concurrent));
  • 앞 작업의 결과나 처리 순서가 필요하면 반복문 안에서 하나씩 호출하고 기다립니다. 예외를 밖으로 전파하면 다음 ID의 호출은 시작하지 않습니다.
  • 독립된 조회를 함께 시작해도 되면 map으로 Promise 배열을 만들고 Promise.all로 묶습니다. 결과 배열은 실제 완료 순서가 아니라 입력 순서를 따릅니다.[2]

중괄호 콜백을 쓴다면 ids.map(id => { return read(id); })처럼 반환해야 합니다. return을 빠뜨리면 작업의 Promise 대신 undefined 배열을 넘길 수 있습니다. 바깥 함수도 이 완료 Promise를 반환하거나 기다려야 호출자가 완료 시점을 알 수 있습니다.

실패했다고 나머지 작업까지 취소되지는 않습니다

Promise.all은 입력 중 하나가 거부되면 전체 Promise도 거부합니다. 이미 시작한 다른 작업은 자동 취소하지 않습니다.[2] 추가 로컬 검사에서도 A의 실패를 받은 뒤 따로 신호를 열자 B의 후속 처리가 실행됐습니다. 따라서 저장·결제 같은 변경 요청을 모두 시작해 놓고 전체 예외만 잡는 방식은 원자적 처리나 롤백을 보장하지 않습니다.

건별 성공·실패를 전부 보고해야 한다면 Promise.allSettled를 선택합니다. 위 fix.mjs 뒤에 다음을 붙이면 101은 fulfilled, 102는 rejected이고, 결과는 각각 R101, 조회 실패로 출력됩니다. 두 항목 모두 결과가 정해질 때까지 기다린 뒤 상태에 따라 value와 reason을 구분합니다.[3]

const checked = await Promise.allSettled(ids.map(async id => {
  if (id === 102) throw new Error('조회 실패');
  return read(id);
}));
console.log(checked.map((r, i) => ({
  id: ids[i], status: r.status,
  result: r.status === 'fulfilled' ? r.value : r.reason.message
})));

빈 배열도 검사해야 합니다. 로컬 확인에서 await Promise.all([])와 await Promise.allSettled([])는 모두 빈 배열을 반환했습니다. 처리가 한 건도 없어도 집계 자체는 정상 완료할 수 있으므로, 업무상 최소 1건이 필요하면 별도로 입력 개수를 검사해야 합니다.

Promise.all에는 동시 실행 개수 제한 설정이 없습니다. 위 방식은 배열을 순회하면서 모든 조회를 호출하므로 대량 요청에는 별도 동시성 제한이 필요합니다. 이 예제는 완료 대기와 결과 분류를 검증한 것이며 실제 API 처리량, DB 반영, 재시도 안전성까지 확인한 코드는 아닙니다.

문법·동작 확인 자료

업무 시스템 개발 안내는 소프트모아 홈페이지에서 확인할 수 있습니다.

소프트모아는 해당 시스템을 구축합니다. 문의하기

Share this

FrontEnd

Have a questions?

견적 및 기술문의

mobile : 010-7931-4813

Contact Form