JavaScript forEach에 await 안 먹히는 이유와 비동기 해결법 (for...of)

forEach는 async 콜백이 돌려준 Promise를 모아 기다리지 않습니다. forEach 앞에 await를 붙여도 각 작업의 완료를 기다리는 연결은 생기지 않습니다. 앞 항목이 끝난 뒤 다음 항목을 시작해야 한다면, for...of 안에서 작업의 Promise를 await하는 형태로 바꿀 수 있습니다.

AI가 만든 JavaScript 코드에서 ‘전체 완료’가 너무 일찍 찍힌다면, await가 어디에 붙었는지보다 어떤 값을 기다리는지부터 보세요.

전체 완료가 찍힌 순간, A·B는 아직 대기 중이었습니다

A·B의 작업을 같은 Promise에서 멈춰 두고 실행 순서를 기록했습니다. forEach 앞에 await를 쓴 경우, 대기를 풀기 전 첫 출력은 ["전체 완료?"]였습니다. A·B가 끝나지 않았는데도 바깥 코드는 그다음 줄까지 온 것입니다.

대기를 풀자 기록은 ["전체 완료?","A 완료","B 완료"]가 됐습니다. 반면 아래의 순차 처리 예제는 A 시작 → A 완료 → B 시작 → B 완료 → 전체 완료 순서였습니다.

async를 썼다는 사실만으로는 부족합니다. 바깥 코드가 각 작업의 완료를 기다리도록 연결돼 있어야 합니다.

아직 대기 중인 작업 상자 앞에 먼저 찍힌 완료 도장
완료 표시와 개별 작업의 완료는 다른 시점일 수 있습니다. 설명용 생성 일러스트입니다.

await forEach가 각 콜백을 기다리지 않는 이유

MDN의 forEach 설명은 콜백의 반환값을 버리며, forEach 자체는 undefined를 반환한다고 명시합니다. async 콜백은 Promise를 돌려주지만, forEach가 그 Promise들을 모아 반환하는 것은 아닙니다.

다음 코드는 타이머나 네트워크 대신 ready라는 대기용 Promise를 만듭니다. release()를 호출하기 전까지 콜백 안의 await ready 다음 줄은 실행되지 않습니다. 그런데 바깥의 await forEach는 이미 다음 줄로 넘어옵니다.

(async () => {
  const log = [];
  let release;
  const ready = new Promise(resolve => {
    release = resolve;
  });

  await ['A', 'B'].forEach(async item => {
    await ready;
    log.push(item + ' 완료');
  });

  log.push('전체 완료?');
  console.log(JSON.stringify(log));
  release();
  await ready;
  console.log(JSON.stringify(log));
})();

첫 번째 console.log가 실행될 때 ready는 아직 대기 중입니다. 그 뒤 release()로 대기를 풀고, 두 번째 console.log에서 A·B의 완료 기록을 확인합니다. forEach가 작업을 취소한 것도, async 콜백 안의 await가 고장 난 것도 아닙니다. 기다리는 범위가 달랐던 것입니다.

await가 아무 일도 하지 않는다는 뜻은 아닙니다. 여기서 await에 전달된 값은 forEach의 반환값인 undefined입니다. 콜백 각각의 Promise를 기다리는 연결은 그 값 안에 없습니다. MDN의 async function 설명에서 다루는 await의 중단 범위도 이 구분과 함께 읽어야 합니다.

먼저 끝으로 이어진 흐름 아래에 따로 대기하는 A와 B 작업
바깥 흐름과 콜백이 기다리는 Promise를 나눠 표현한 생성 그림입니다. 실제 실행 화면은 아닙니다.

한 항목씩 끝내려면 for...of 안에서 기다리세요

다음 예제는 A의 작업을 끝낸 뒤 B를 시작합니다. processItem이 돌려준 Promise를 반복문 안에서 await하기 때문에, 다음 반복으로 넘어가기 전에 해당 작업을 기다립니다.

(async () => {
  const log = [];
  async function processItem(item) {
    log.push(item + ' 시작');
    await Promise.resolve();
    log.push(item + ' 완료');
  }

  for (const item of ['A', 'B']) {
    await processItem(item);
  }
  log.push('전체 완료');
  console.log(JSON.stringify(log));
})();

이 예제의 기록 순서는 A 시작, A 완료, B 시작, B 완료, 전체 완료입니다. for...of가 값을 하나씩 순회한다는 설명만이 아니라, 그 안에서 실제 작업의 Promise를 await한다는 조건까지 함께 봐야 합니다.

processItem이 내부 작업을 시작만 하고 곧바로 반환한다면, 그 함수를 await해도 숨겨진 작업까지 기다려 주지는 않습니다. 함수가 ‘실제 완료’를 나타내는 Promise를 반환하는지 확인해야 합니다.

A 다음 B를 거쳐 마지막 완료 표시로 이어지는 하나의 길
앞 작업을 기다린 뒤 다음 단계로 가는 순차 흐름의 개념도입니다. 생성 일러스트입니다.

이 방식은 ‘앞 항목이 끝난 뒤 다음 항목을 시작한다’는 요구에 맞춘 예입니다. 모든 작업을 순차로 바꾸라는 뜻도, 처리 속도가 빨라진다는 뜻도 아닙니다. 서로 독립적인 작업을 겹쳐 실행할지, 실패를 어떻게 처리할지는 별도로 정해야 합니다.

시험은 2026년 10월 31일 Windows x64의 Node.js 26.5.0(V8 14.6.202.34-node.24)에서 본문과 같은 코드로 했습니다. 합성 문자열 배열과 기본 Promise만 사용했으며 실제 파일 저장·DB·외부 요청은 시험하지 않았습니다.

다른 종류의 ‘완료’ 문제까지 확인하려면 AI의 완료 보고와 실제 코드 검수를 구분한 글로 이어갈 수 있습니다.

출처 및 확인일

문서·실행 확인: 2026년 10월 31일 KST. 이미지는 실제 실행 화면이 아닌 설명용 생성 일러스트입니다.