목록의 항목을 하나씩 저장하려고 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를 기다리지 않는다고 따로 적어 둡니다.
실행해 보면 그대로 드러납니다. 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].](/images/ko/async-foreach-await-run-01.png)
순서대로 하나씩: 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의 한도 문서를 보고 정합니다. 이 예제의 숫자는 가짜 함수의 대기 시간일 뿐, 어떤 서비스의 한도나 속도를 잰 값이 아닙니다.
실패는 어디로 가나
두 번째 저장이 실패하도록 바꾸고, 네 가지 방식을 각각 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와 값 또는 실패 이유를 돌려줬습니다. 실패한 항목만 골라 다시 시도하거나 사용자에게 알릴 때 씁니다.

브라우저에서는 이 예제를 실행하지 않았습니다. 처리되지 않은 거부가 어떻게 보고되는지는 실행 환경마다 다르므로, 서버 코드는 쓰는 런타임의 설정을 확인하세요.
코드 리뷰에서 바로 고르는 법
코드에서 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는 그것을 건네주지 않습니다.
이 기록에 대화를 더해 주세요.
궁금한 점, 다른 접근, 직접 해 본 결과를 나눠 주세요.
로그인 상태 확인 중…
댓글을 불러오는 중…