await, Promise.all, forEach, 동시성 패턴 총정리
가장 흔한 패턴부터 보자.
const users = [1, 2, 3];
for (const id of users) {
const user = await fetchUser(id);
console.log(user);
}
겉으로 보기엔 멀쩡하다.
하지만 실제 동작은 다음과 같다.
fetchUser(1) 완료까지 기다린 뒤fetchUser(2) 시작fetchUser(3) …즉, 완전히 순차 실행이다.
요청들이 서로 독립적인 API 호출이라면, 이 방식은 그냥 느리다.
map + async의 함정: “왜 Promise 배열이 나오지?”많이 하는 실수:
const users = [1, 2, 3];
const results = users.map(async id => {
const user = await fetchUser(id);
return user;
});
console.log(results);
// [Promise, Promise, Promise] – 실제 유저 데이터가 아님
여기서 중요한 점:
async 함수는 항상 Promise를 반환한다.map은 그 Promise들을 모아서 Promise 배열을 만든다.await을 걸지 않았기 때문에,원하는 건 “모든 요청을 병렬로 보내고, 다 끝난 뒤 결과 배열을 받는 것”이다.
정답 패턴은 다음처럼 써야 한다.
const results = await Promise.all(users.map(id => fetchUser(id)));
여기서 일어나는 일:
users.map(id => fetchUser(id))fetchUser를 모두 실행[Promise, Promise, Promise]Promise.all(…)results에 실제 user 데이터 배열을 넘겨줌→ “map 안에서 async/await” 자체는 문제가 아니고,
그 결과 Promise들을 어떻게 처리하느냐가 핵심이다.
Promise.all의 fail-fast 특성const results = await Promise.all(
users.map(id => fetchUser(id)) // fetchUser(2)에서 에러가 날 수 있음
);
여기서 알아둘 점:
Promise.all은 “하나라도 reject되면 전체가 reject” 되는 구조다.fetchUser(1) 성공fetchUser(2)에서 404/네트워크 에러 발생 → 전체 Promise.all이 rejectfetchUser(3) 결과는 정상적으로 끝나도 results에 접근할 수 없음요약
Promise.allSettled와 개별 try/catchPromise.allSettled – “성공/실패를 모두 보고 싶을 때”const results = await Promise.allSettled(
users.map(id => fetchUser(id))
);
results.forEach(result => {
if (result.status === 'fulfilled') {
console.log('✅ User:', result.value);
} else {
console.warn('❌ Error:', result.reason);
}
});
특징:
{ status: 'fulfilled' | 'rejected', value | reason } 구조로 들어온다.const results = await Promise.all(
users.map(async id => {
try {
return await fetchUser(id);
} catch (err) {
console.error(`Failed to fetch user ${id}`, err);
return { id, name: 'Unknown User' }; // 실패 시 대체 값
}
})
);
특징:
Promise.all은 여전히 “올-성공” 기준이지만,Promise.all 자체는 reject되지 않는다.results 배열 모든 요소가 “사용 가능한 값”을 갖게 된다.--unhandled-rejections=strict 같은 모드에서도for...of + awaitfor (const id of users) {
const user = await fetchUser(id);
console.log(user);
}
혹은 async 컨텍스트 밖이라면:
(async () => {
for (const id of users) {
const user = await fetchUser(id);
console.log(user);
}
})();
언제 쓰나?
특징:
Promise.all + mapconst usersData = await Promise.all(
users.map(id => fetchUser(id))
);
언제 쓰나?
특징:
allSettled/try-catch로 방어API에 부하를 주고 싶지 않을 때,
혹은 외부 서비스에서 “초당 N건” 같은 제한을 걸었을 때:
import pLimit from 'p-limit';
const limit = pLimit(2); // 동시에 2개씩만 실행
const limitedFetches = users.map(id =>
limit(() => fetchUser(id))
);
const results = await Promise.all(limitedFetches);
특징:
forEach + async가장 유명한 함정:
users.forEach(async id => {
const user = await fetchUser(id);
console.log(user); // ❌ 호출자는 이걸 "기다리지" 않는다
});
문제:
forEach는 콜백의 반환값을 전혀 신경쓰지 않는다.결론:
forEach + async/await는 비동기 제어 흐름용으로 쓰지 말 것for...of + awaitPromise.all + map| 목표 / 상황 | 추천 패턴 | 동시성(Concurrency) |
|---|---|---|
| 순서 보장, 한 개씩 처리 | for...of + await | 1 |
| 최대 속도, 모두 병렬 | Promise.all + map | N (배열 길이) |
| 실패해도 전체 결과 보고 싶다 | Promise.allSettled + map | N |
| 일부 실패 시 fallback 값 사용 | Promise.all + map + try/catch | N |
| API 제한, 서버 보호, 제어된 병렬 | p-limit, PromisePool 등 | N (직접 지정) |
| 전역 알림/토스트 등과 섞이는 로직 | 별도 error handling + 위 조합 | 상황에 따라 |
비동기 루프를 작성할 때, 아래를 먼저 결정해야 한다.
for...of + await 또는 명시적 체이닝Promise.all + map / allSettledPromise.allSettledtry/catch + fallbackp-limit 등으로 동시성 제한forEach를 쓰고 있지 않은가?forEach 안의 async는 절대 기다리지 않는다 → 즉시 리팩터링 대상