본문 바로가기
빌드노트 검색

빌드노트

await forEach를 썼는데 저장 완료가 먼저 표시된다

forEach는 async 콜백이 반환한 Promise를 기다리지 않는다. 직접 제어한 Promise로 완료 표시가 앞서는 조건을 재현하고, for...of와 Promise.all의 시작 순서·실패 전파·남은 작업의 동작을 비교한다.

여러 항목을 저장하면서 await items.forEach(async item => { await save(item); }) 다음 줄에 완료 표시를 넣으면, 저장이 끝나기 전에 그 표시가 나올 수 있다. forEach의 반환값은 undefined이며, 콜백이 돌려준 Promise를 모아 기다리지 않기 때문이다. 앞 항목의 저장이 끝나야 다음 항목을 시작한다면 for...of 안에서 기다린다. 서로 독립적인 저장을 함께 시작하려면 map으로 Promise 배열을 만들고 Promise.all을 기다린다.

MDN의 forEach 설명 (새 탭에서 열림)은 콜백 반환값을 버리고 Promise를 기다리지 않는다고 명시한다. 콜백 안의 await는 그 콜백의 다음 줄을 늦춘다. 바깥 함수까지 각 콜백의 완료를 기다리게 하지는 않는다.

아직 풀지 않은 Promise보다 완료 표시가 먼저 나왔다

시간을 길게 주는 대신, 직접 풀기 전에는 끝날 수 없는 Promise로 확인했다. 실제 저장 API는 호출하지 않았다. Node.js가 있는 빈 폴더에서 다음 코드를 foreach-demo.mjs로 저장하고 node foreach-demo.mjs를 실행한다. .mjs는 파일의 최상위에서 await를 쓰기 위한 모듈 확장자다. 아래 출력은 Windows의 Node.js 22.23.2에서 얻었다.

let release;
const gate = new Promise(resolve => { release = resolve; });

const returned = ["A", "B"].forEach(async id => {
  console.log(`start ${id}`);
  await gate;
  console.log(`done ${id}`);
});

console.log(`forEach return: ${returned}`);
await returned;
console.log("saved");
release();
start A
start B
forEach return: undefined
saved
done A
done B

saved를 출력할 때는 아직 release()를 호출하지 않았다. 따라서 두 콜백은 모두 await gate에서 멈춰 있다. 콜백을 전부 호출했다는 것과 콜백이 전부 끝났다는 것은 다른 시점이다.

위 코드는 반환값을 보려고 forEach 호출과 await를 두 줄로 나눴다. await ["A", "B"].forEach(...)로 붙여 써도 기다리는 값은 같다. MDN의 await 설명 (새 탭에서 열림)에 따르면 undefined처럼 Promise도 thenable도 아닌 값은 이미 이행된 Promise로 바뀌어 처리된다. 뒤 문장이 잠시 양보한 뒤 재개되기는 해도, 관계없는 gate를 기다리지는 않는다.

출력이 다르면 먼저 위 코드 그대로인지 확인한다. release()를 saved 앞으로 옮기면 비교 조건이 달라진다. 최상위 await 문법 오류가 나면 파일 확장자가 .mjs인지와 node --version을 확인한다. node 명령 자체를 찾지 못하면 Node.js 실행 환경부터 준비해야 한다.

다음 작업을 언제 시작할지에 따라 바꾼다

다음 두 함수는 저장할 항목 배열과 저장 함수 save를 인자로 받는다. save(item)은 실제 저장이 끝날 때 이행되고, 실패하면 거절되는 Promise를 반환해야 한다. console.log("saved")는 화면에서 완료 문구를 넣을 위치를 나타낸다.

앞 항목이 끝난 뒤 다음 항목을 시작해야 한다면 다음 형태를 쓴다. 실패한 항목 뒤의 저장도 시작하지 않으려는 경우에 해당한다.

async function saveInOrder(items, save) {
  for (const item of items) {
    await save(item);
  }
  console.log("saved");
}

항목들이 서로 독립적이고 함께 실행해도 된다면 다음 형태를 쓴다. 이 코드에서는 map이 배열 순서대로 save를 호출하고, Promise.all이 그 반환 Promise들을 기다린다. Promise.all 자체가 저장 함수를 호출하거나 CPU 작업을 병렬화하는 것은 아니다.

async function saveTogether(items, save) {
  const results = await Promise.all(items.map(item => save(item)));
  console.log("saved");
  return results;
}

MDN의 Promise.all 설명 (새 탭에서 열림)에 따르면 모든 입력 Promise가 이행돼야 묶음도 이행된다. 결과 배열의 순서는 완료 순서가 아니라 입력 순서다. 이 조건은 모든 작업의 성공 뒤에만 완료 문구를 표시하려는 경우에 맞는다.

별도 합성 실험에서는 A·B·C마다 대기용 Promise를 두고 시작·완료 순서를 기록했다. 제어용 기록을 제외한 관찰은 다음과 같다.

  • forEach: A·B·C 시작 → saved → B·C·A 완료. saved 시점의 완료 항목은 0개였다.
  • for...of: A 시작 → A 완료 → B 시작 → B 완료 → C 시작 → C 완료 → saved. A를 기다리는 동안 B의 대기를 미리 풀어도 B 저장 호출은 시작되지 않았다.
  • Promise.all: A·B·C 시작 → B·C·A 완료 → saved. 반환 결과는 ["A-ok", "B-ok", "C-ok"]였다.

이 비교로 확인한 것은 시작과 완료의 순서다. 동시 실행이 얼마나 빠른지는 측정하지 않았다. Promise.all 형태는 항목 수만큼 저장을 먼저 호출하므로, 호출 수 제한이 있는 API라면 이 방식 그대로 적용할 수 있는지도 따로 판단해야 한다.

실패를 전달하는 것과 남은 작업을 멈추는 것은 다르다

두 함수 모두 실패를 내부에서 삼키지 않는다. 호출하는 비동기 함수 안에서도 선택한 함수를 await하고 오류 표시를 처리한다. 다음의 items와 save는 자기 코드의 항목 배열과 저장 함수이며, 순차 저장을 선택했다면 함수 이름을 saveInOrder로 바꾼다.

try {
  await saveTogether(items, save);
} catch (error) {
  console.error("save failed:", error.message);
}

B가 실패하도록 바꾼 실험에서는 두 수정안 모두 B-fail을 바깥 catch로 전달했고 saved를 출력하지 않았다. 하지만 남은 작업의 상태가 달랐다.

  • for...of에서는 A 완료 뒤 B가 시작했고, B 거절 뒤 catch B-fail이 나왔다. C는 시작하지 않았다.
  • Promise.all에서는 A·B·C가 이미 시작한 상태에서 B를 거절시켰다. A·C가 아직 대기 중일 때 catch B-fail이 나왔고, 이후 대기를 풀자 C·A가 차례로 완료됐다.

Promise.all의 거절은 이미 시작한 나머지 작업을 취소하지 않는다. 같은 문서의 실패·취소 설명 (새 탭에서 열림)과 위 관찰이 일치한다. 따라서 catch가 실행됐다고 모든 저장이 멈췄거나 성공한 저장까지 되돌아갔다고 표시하면 안 된다. 실제 쓰기의 취소와 되돌리기는 저장 API가 제공하는 별도 기능이 필요하다.

원래의 forEach 형태도 B를 거절시켜 비교했다. saved는 이미 출력됐고 바깥 catch는 실행되지 않았다. 이때는 콜백이 반환한 Promise를 실험에서 따로 보관하고 오류 관찰용 핸들러를 붙였다. 바깥에서 기다린 undefined와 그 거절이 연결돼 있지 않다는 점을 확인한 것이다. async 함수 설명 (새 탭에서 열림)처럼 async 함수 안에서 처리하지 않은 예외는 그 함수가 반환하는 Promise의 거절로 전달된다. 그 Promise를 버린 forEach 바깥의 try/catch만으로는 해당 실패를 받지 못한다.

각 저장의 오류를 catch로 로그만 남기고 정상 반환하면, Promise.all에는 그 항목이 성공한 것으로 전달될 수 있다. 전체 성공을 뜻하는 완료 표시가 필요하면 실패를 다시 던져 전달한다. 일부 실패를 허용하고 모든 항목의 최종 결과를 보여 줘야 한다면 Promise.allSettled를 기다린 뒤 각 결과를 판정하는 쪽을 검토한다. Promise.all 문서는 이 메서드가 거절 여부와 관계없이 모든 입력의 종료를 기다린다고 구분한다.

수정 뒤에도 먼저 표시되면 저장 함수의 반환값을 본다

자기 코드에서 먼저 찾을 곳은 완료 문구를 넣는 줄과 그 앞에서 기다리는 값이다. forEach라면 위 두 방식 중 필요한 시작 순서에 맞춰 바꾼다. map으로 바꿨다면 콜백이 save(item)의 Promise를 실제로 반환하는지도 확인한다. item => { save(item); }처럼 중괄호 안에서 return을 빠뜨리면 저장 Promise 대신 undefined 배열을 만든다.

그런데 save 자체가 요청만 시작하고 바로 반환한다면 반복문을 고쳐도 저장 완료까지 기다릴 수 없다. 저장 시작, save의 Promise가 끝난 시점, 완료 표시 직전의 기록을 항목 ID와 함께 남겨 비교한다. 완료 표시가 모든 save의 종료 뒤인데도 실제 데이터가 아직 저장되지 않았다면, 다음에 확인할 것은 save가 무엇을 완료로 약속하는지와 API의 응답 의미다.

이번 검증은 Node.js 22.23.2에서 직접 제어한 Promise와 콘솔 표시를 사용했다. 브라우저의 실제 화면 갱신, 실제 저장 API의 커밋·취소·재시도는 검증하지 않았다. 오류를 삼키는 개별 catch와 Promise.allSettled의 비교는 문서 설명이며 별도 실행하지 않았다. 모든 저장이 성공해야 하는 화면에서는 완료 표시를 성공 경로의 await 뒤에 두고, 실패한 묶음의 상태를 별도로 표시한다.

← 전체 글 보기