JavaScript 비동기 루프 다시 생각하기

okorion·2025년 11월 14일

await, Promise.all, forEach, 동시성 패턴 총정리


1. 문제의 출발점: “루프 안에서 await 쓰면 되지 않나요?”

가장 흔한 패턴부터 보자.

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 호출이라면, 이 방식은 그냥 느리다.

  • 순차가 필요한 경우
    • 이전 결과에 따라 다음 요청을 보내야 할 때
    • API rate limit 때문에 의도적으로 천천히 보내야 할 때
  • 순차가 불필요한 경우
    • 각 id에 대한 호출이 서로 상관없고
    • “전체가 다 끝나면 결과를 모아서 쓰면 되는” 경우 → 병렬이 압도적으로 유리

2. 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을 걸지 않았기 때문에,
    resolve된 값이 아니라 Promise 객체들만 갖고 있는 상태.

원하는 건 “모든 요청을 병렬로 보내고, 다 끝난 뒤 결과 배열을 받는 것”이다.
정답 패턴은 다음처럼 써야 한다.

const results = await Promise.all(users.map(id => fetchUser(id)));

여기서 일어나는 일:

  • users.map(id => fetchUser(id))
    • 즉시 fetchUser모두 실행
    • 결과: [Promise, Promise, Promise]
  • Promise.all(…)
    • 모든 Promise가 resolve될 때까지 기다린 뒤
    • results실제 user 데이터 배열을 넘겨줌

“map 안에서 async/await” 자체는 문제가 아니고,
그 결과 Promise들을 어떻게 처리하느냐가 핵심이다.


3. 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이 reject
    • fetchUser(3) 결과는 정상적으로 끝나도 results에 접근할 수 없음
  • 나머지 Promise들은 백그라운드에서 계속 돌아가긴 하지만,
    호출자는 첫 번째 에러만 알 수 있다.

요약

  • 장점: 성공/실패 한 번에 판단, 간단함
  • 단점: 하나 실패하면 성공한 결과도 못 씀

4. 좀 더 안전한 배치 실행: Promise.allSettled와 개별 try/catch

4-1. Promise.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);
  }
});

특징:

  • 모든 Promise의 결과를 끝까지 모은다.
  • 각 항목이 { status: 'fulfilled' | 'rejected', value | reason } 구조로 들어온다.
  • “몇 개 실패했는지”를 포함해, 전체 상황을 보고 싶은 경우에 적합.

4-2. map 내부에서 try/catch – “fallback 값을 주고 싶을 때”

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 내부에서 실패를 잡아 fallback을 반환하기 때문에
    • Promise.all 자체는 reject되지 않는다.
    • results 배열 모든 요소가 “사용 가능한 값”을 갖게 된다.
  • Node.js에서 --unhandled-rejections=strict 같은 모드에서도
    Unhandled Promise Rejection을 방지할 수 있다.

5. 패턴별 선택 기준

5-1. 순차 실행: for...of + await

for (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);
  }
})();

언제 쓰나?

  • 요청 간 순서가 중요할 때
  • 이전 결과를 보고 다음 요청을 결정할 때
  • 레이트 리밋을 피하려고 일부러 천천히 보낼 때

특징:

  • 항상 concurrency = 1
  • 가장 이해하기 쉽고 디버깅이 간단
  • 독립적인 네트워크 요청에서는 필요 이상으로 느림

5-2. 완전 병렬 실행: Promise.all + map

const usersData = await Promise.all(
  users.map(id => fetchUser(id))
);

언제 쓰나?

  • 각 작업이 서로 독립이고
  • 순서가 중요하지 않거나, 나중에 정렬해도 되는 경우
  • 네트워크 호출처럼 IO-bound 작업에서 전체 시간을 줄이고 싶을 때

특징:

  • 이론상 동시성 = N (배열 길이)
  • 속도 최상
  • 하나라도 reject되면 전체 실패 → 필요시 allSettled/try-catch로 방어

5-3. 제어된 병렬 실행: throttling (p-limit 등)

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);

특징:

  • 한 번에 실행되는 호출 수를 명시적으로 제한
  • 속도와 안정성 사이의 균형
  • 라이브러리 의존성이 추가된다는 점은 감안해야 한다.

6. 절대 하면 안 되는 패턴: forEach + async

가장 유명한 함정:

users.forEach(async id => {
  const user = await fetchUser(id);
  console.log(user); // ❌ 호출자는 이걸 "기다리지" 않는다
});

문제:

  • forEach는 콜백의 반환값을 전혀 신경쓰지 않는다.
  • 루프는 즉시 끝나고, 내부 async 함수들은 백그라운드에서 돌아간다.
  • 상위 함수는 “다 끝났다”고 생각하고 다음 로직으로 넘어간다.
  • 에러 처리도 난잡해지고, “언제 끝나는지” 알 수가 없다.

결론:

  • forEach + async/await는 비동기 제어 흐름용으로 쓰지 말 것
  • 대신
    • 순차 실행: for...of + await
    • 병렬 실행: Promise.all + map

7. 동시성 전략 정리 표

목표 / 상황추천 패턴동시성(Concurrency)
순서 보장, 한 개씩 처리for...of + await1
최대 속도, 모두 병렬Promise.all + mapN (배열 길이)
실패해도 전체 결과 보고 싶다Promise.allSettled + mapN
일부 실패 시 fallback 값 사용Promise.all + map + try/catchN
API 제한, 서버 보호, 제어된 병렬p-limit, PromisePool 등N (직접 지정)
전역 알림/토스트 등과 섞이는 로직별도 error handling + 위 조합상황에 따라

8. 실무용 체크리스트

비동기 루프를 작성할 때, 아래를 먼저 결정해야 한다.

  1. 순서가 중요한가?
    • 예: “A 저장 후, 그 ID로 B를 호출”
      for...of + await 또는 명시적 체이닝
  2. 요청이 서로 독립적인가?
    • 그렇다면 병렬이 기본값이어야 한다.
      Promise.all + map / allSettled
  3. 부분 실패를 어떻게 다룰 것인가?
    • 일부만 실패해도 나머지를 쓰고 싶다 → Promise.allSettled
    • 실패시 default로 채우고 싶다 → map 내부 try/catch + fallback
  4. 외부 서비스/DB가 버틸 수 있는가?
    • 트래픽/쿼리 부담이 크면 → p-limit 등으로 동시성 제한
  5. 루프에 forEach를 쓰고 있지 않은가?
    • forEach 안의 async절대 기다리지 않는다 → 즉시 리팩터링 대상

profile
okorion's Tech Study Blog.

0개의 댓글