JAVASCRIPT / FIELD NOTES

forEach 안의 await는 왜 반복문을 멈추지 못할까

forEach는 async 콜백이 돌려준 Promise를 받아 두지 않아서, 저장이 끝나기 전에 다음 줄이 실행되고 오류는 try...catch에 닿지 않습니다. Node.js v24.21.0 실행 결과로 for...of, map과 Promise.all, 묶음 처리, Promise.allSettled 중 무엇을 고를지 정리합니다.

목록의 항목을 하나씩 저장하려고 ids.forEach(async (id) => { await save(id); })를 썼다고 해 보겠습니다. 그런데 바로 아래의 완료 로그가 저장보다 먼저 찍히고, 저장 개수를 세던 변수는 0이고, try로 감쌌는데 오류는 잡히지 않습니다. 이 글을 읽고 나면 forEach가 무엇을 기다리지 않는지 설명할 수 있고, 순서대로 처리할 때와 한꺼번에 처리할 때 각각 어떤 코드를 쓸지, 실패한 항목을 어떻게 잡을지 고를 수 있습니다.

예제는 모두 2026-10-03에 macOS 27.0.1의 Node.js v24.21.0(V8 13.6)에서 ES 모듈 파일로 실행했습니다. save()는 100ms(오류 예제는 50ms)를 기다렸다가 끝나는 가짜 저장 함수이고, 실제 데이터베이스나 API는 호출하지 않았습니다. 밀리초 값은 실행할 때마다 1~2ms씩 달라집니다.

forEach는 콜백이 돌려준 Promise를 버린다

async 함수는 호출되는 즉시 Promise를 돌려줍니다. 함수 안의 await는 그 함수 자신의 나머지 부분만 멈추게 할 뿐, 그 함수를 부른 쪽은 멈추지 않습니다. 그래서 forEach의 콜백을 async로 만들면 forEach는 콜백을 세 번 부르고, 부를 때마다 Promise를 하나씩 돌려받습니다.

문제는 forEach가 그 Promise를 어디에도 담아 두지 않는다는 점입니다. ECMAScript 명세의 Array.prototype.forEach 단계를 보면 원소마다 콜백을 호출하기만 하고, 반복이 끝나면 undefined를 돌려줍니다. 콜백의 반환값을 받는 단계가 없습니다. 같은 자리에서 map은 반환값을 새 배열에 넣습니다. MDN의 forEach 문서도 forEach가 동기 함수를 기대하며 Promise를 기다리지 않는다고 따로 적어 둡니다.

세 단계. 1 forEach가 원소마다 async 콜백을 호출하고 콜백은 즉시 Promise를 돌려준다. 2 명세상 forEach는 호출만 하고 반환값을 버린 뒤 undefined를 돌려준다. 3 그래서 다음 줄이 약 1ms에 먼저 실행되고 저장은 약 100ms에 끝나며 직후 saved는 0이다. 기다리려면 map으로 Promise 배열을 만들어 Promise.all에 넘긴다.
forEach는 async 콜백이 돌려준 Promise를 받아 두지 않으므로 기다릴 대상이 없습니다.

실행해 보면 그대로 드러납니다. forEach는 곧바로 undefined를 돌려줬고 다음 줄은 약 1ms 뒤에 실행됐으며, 세 번의 저장은 약 100ms 뒤에야 끝났습니다. 저장 개수를 세는 변수는 forEach 직후 0이었고, 150ms를 더 기다린 뒤에야 3이 됐습니다. 응답을 보내거나 함수를 끝내는 코드가 forEach 바로 뒤에 있다면, 그 코드는 저장이 끝나기 전에 실행된다는 뜻입니다.

Node.js v24.21.0 실행 결과. forEach는 약 1ms에 undefined를 돌려주고 다음 줄이 실행된 뒤 약 100ms에 저장 1, 2, 3이 끝났다. 개수를 세면 직후 0, 150ms 뒤 3. for...of는 약 100ms 간격으로 1, 2, 3을 저장하고 약 300ms에 다음 줄. map과 Promise.all은 약 100ms에 셋 모두 끝나고 [1,2,3].
세 가지 반복 방식의 실제 출력입니다. forEach만 저장이 끝나기 전에 다음 줄로 넘어갔습니다.

순서대로 하나씩: for...of 안에서 await

앞의 저장이 끝난 뒤에 다음 저장을 해야 한다면 for...of 안에서 await합니다. 이때 await는 반복문을 감싼 async 함수(또는 ES 모듈의 최상위 코드)를 멈추게 하므로, 다음 반복은 앞의 Promise가 끝난 뒤에 시작됩니다.

실행 결과 저장은 약 100ms 간격으로 1, 2, 3 순서대로 끝났고, 다음 줄은 약 300ms에 실행됐습니다. 항목 수만큼 시간이 늘어나는 대신 순서가 보장됩니다. 앞 요청의 결과를 다음 요청에 써야 하거나, 상대 서버가 한 번에 하나만 받는다면 이 방식이 맞습니다.

한꺼번에: map으로 모아서 Promise.all

저장끼리 순서가 상관없다면 map으로 Promise 배열을 만들고 Promise.all로 기다립니다. forEach와 달리 map은 콜백이 돌려준 Promise를 배열에 담아 주므로, 기다릴 대상이 생깁니다. 실행 결과 세 저장이 동시에 시작해 약 100ms에 모두 끝났고, Promise.all은 입력 순서대로 [1,2,3]을 돌려줬습니다.

모든 요청을 한 번에 보내면 상대 서비스의 요청 한도나 연결 수에 걸릴 수 있습니다. 그럴 때는 배열을 일정한 크기로 잘라 묶음마다 Promise.all을 기다립니다. 다섯 항목을 두 개씩 처리한 예제는 약 100ms, 200ms, 300ms에 묶음별로 끝났습니다. 묶음 크기는 쓰는 API의 한도 문서를 보고 정합니다. 이 예제의 숫자는 가짜 함수의 대기 시간일 뿐, 어떤 서비스의 한도나 속도를 잰 값이 아닙니다.

네 칸. forEach와 async는 다음 줄이 약 1ms에 실행되고 저장은 약 100ms에 끝나 기다리지 않는다. for...of와 await는 1, 2, 3 순서로 저장하고 다음 줄이 약 300ms. map과 Promise.all은 세 개를 동시에 저장하고 다음 줄이 약 100ms이며 결과는 [1,2,3]. 두 개씩 묶은 Promise.all은 다섯 개를 2, 2, 1로 처리하고 다음 줄이 약 300ms.
순서가 필요하면 for...of, 동시에 해도 되면 map과 Promise.all, 한도가 있으면 묶음으로 나눕니다.

실패는 어디로 가나

두 번째 저장이 실패하도록 바꾸고, 네 가지 방식을 각각 try...catch로 감싸 실행했습니다.

  • forEach: try 블록은 아무것도 잡지 못한 채 끝났습니다. 콜백이 돌려준 Promise는 거부됐지만, 그 Promise를 받은 코드가 없기 때문입니다. Node.js는 v15부터 처리되지 않은 거부의 기본 모드가 throw여서, 프로세스는 Error: save 2 failed를 출력하고 종료 코드 1로 끝났습니다. 세 번째 저장의 로그는 나오지 않았습니다.
  • for...of: catch가 오류를 잡았고, 실패한 곳에서 반복이 멈춰 세 번째 저장은 시작하지 않았습니다.
  • Promise.all: 첫 번째 거부 이유로 곧바로 거부되어 catch가 잡았습니다. 하지만 나머지 작업이 취소되지는 않아, saved 3이 catch 다음에 출력됐습니다. 거부된 뒤에도 이미 시작한 작업은 끝까지 진행된다는 점을 기억해야 합니다.
  • Promise.allSettled: 거부하지 않고, 항목마다 status와 값 또는 실패 이유를 돌려줬습니다. 실패한 항목만 골라 다시 시도하거나 사용자에게 알릴 때 씁니다.
Node.js v24.21.0 실행 결과. forEach는 try block ended without catching을 출력하고 saved 1 뒤 종료 코드 1, 오류 Error: save 2 failed. for...of는 saved 1, caught: save 2 failed, 종료 코드 0. Promise.all은 saved 1, caught: save 2 failed, saved 3, 종료 코드 0. allSettled는 fulfilled 1, rejected save 2 failed, fulfilled 3.
두 번째 저장이 실패할 때의 실제 출력입니다. forEach 안의 실패만 try...catch에 닿지 않고 프로세스를 끝냈습니다.

브라우저에서는 이 예제를 실행하지 않았습니다. 처리되지 않은 거부가 어떻게 보고되는지는 실행 환경마다 다르므로, 서버 코드는 쓰는 런타임의 설정을 확인하세요.

네 칸. forEach는 catch가 잡지 못하고 처리되지 않은 거부로 Error: save 2 failed를 출력하며 종료 코드 1. for...of는 catch가 잡고 실패 지점에서 멈춰 3번은 시작하지 않는다. Promise.all은 첫 거부를 catch가 잡지만 3번은 취소되지 않고 저장된다. Promise.allSettled는 거부하지 않고 fulfilled, rejected, fulfilled 상태를 돌려준다.
forEach 안의 실패는 try...catch에 닿지 않습니다. 실패를 다뤄야 하면 Promise를 돌려받는 방식으로 바꿉니다.

코드 리뷰에서 바로 고르는 법

코드에서 forEach(async가 보이면 먼저 이렇게 묻습니다. 이 반복 뒤의 코드가 작업 완료를 전제하는가, 그리고 실패를 알아야 하는가. 둘 중 하나라도 그렇다면 forEach를 바꿉니다.

  • 순서가 중요하거나 하나씩 보내야 하면 for (const id of ids) await save(id);
  • 동시에 해도 되면 await Promise.all(ids.map(save));, 요청 한도가 있으면 묶음으로 나눈 Promise.all
  • 일부가 실패해도 나머지 결과가 필요하면 Promise.allSettled

정말로 결과를 기다릴 필요가 없는 작업이라도, 콜백 안에서 오류를 직접 처리하지 않으면 Node.js 프로세스가 처리되지 않은 거부로 끝날 수 있습니다. 기억할 한 가지는 이것입니다. 기다리고 싶다면 먼저 Promise를 손에 쥐어야 하고, forEach는 그것을 건네주지 않습니다.

END OF NOTE목록으로
COMMENTS BOX

이 기록에 대화를 더해 주세요.

궁금한 점, 다른 접근, 직접 해 본 결과를 나눠 주세요.

최신순

로그인 상태 확인 중…

댓글을 불러오는 중…