여러 개의 비동기 작업을 동시에 처리해야 할 때, Promise 조합 메서드를 쓰지 않고 반복문으로 하나씩 기다리는 개발자들이 많다. 다만 대부분은 Promise.all()만 알고 있거나, 하나의 Promise가 실패하면 전체가 실패하는 문제를 겪고도 그 원인을 정확히 모른 채 코드를 작성한다. 이번에는 세 가지 메서드의 동작 방식, 언제 써야 하는지, 그리고 실전에서 자주 마주치는 함정까지 완벽하게 정리해서 소개하겠다.

 

1단계. Promise 조합 메서드의 기초 개념

Promise.all(), Promise.allSettled(), Promise.race()는 모두 여러 Promise를 동시에 처리하지만, 성공/실패 판정 기준이 다르다. 간단히 말하면 Promise.all()은 "모두 성공해야 성공", allSettled()는 "결과가 뭐든 모두 완료되길 기다려", race()는 "가장 먼저 끝난 것의 결과를 반환"한다.

반복문으로 하나씩 await를 쓰면 첫 번째 Promise가 끝날 때까지 기다렸다가, 두 번째가 끝나길 기다리고, 이렇게 순차적으로 진행된다. 예를 들어 각각 1초 걸리는 작업 3개면 총 3초가 걸린다. 하지만 이 세 메서드를 쓰면 병렬로 동시 시작해서 가장 느린 것 기준으로(race 제외) 약 1초 만에 끝난다. 속도 차이는 엄청나다.

 

Promise.all() - 하나라도 실패하면 전체 실패

✓ 올바른 사용 사례: 모든 작업이 성공해야만 다음 단계로 진행할 때. 예를 들어 회원가입 후 이메일 인증, 휴대폰 인증, 약관 동의 기록을 동시에 처리할 때는 하나라도 실패하면 회원가입 자체가 무효다.

// ✗ 잘못된 코드: 순차 처리로 느린 속도
const result1 = await fetchUser();
const result2 = await fetchPosts();
const result3 = await fetchComments();
// 총 3초 이상 소요
// ✓ 올바른 코드: Promise.all()로 병렬 처리
const [user, posts, comments] = await Promise.all([
  fetchUser(),
  fetchPosts(),
  fetchComments()
]);
// 가장 느린 것 기준 약 1초 소요

하지만 하나의 Promise가 reject되면 전체 Promise.all()이 즉시 reject 상태로 변한다. 이게 문제다.

// ✗ 위험한 코드: 하나 실패하면 나머지는 무시됨
Promise.all([
  fetch('/api/user').then(r => r.json()),
  fetch('/api/posts').then(r => r.json()),
  fetch('/api/comments').then(r => r.json())
]).then(([user, posts, comments]) => {
  console.log('모두 성공');
}).catch(err => {
  console.log('하나라도 실패하면 여기로');
  // posts API는 성공했는데 comments API만 실패해도
  // 어떤 것이 실패했는지 알 수 없음
});

 

Promise.allSettled() - 실패해도 전체 완료 기다림

✓ 올바른 사용 사례: 각각의 성공/실패 여부를 개별적으로 처리해야 할 때. 예를 들어 여러 외부 API에서 데이터를 가져올 때 하나가 실패해도 나머지는 정상적으로 표시하고 싶다면 allSettled()를 써야 한다.

// ✓ 올바른 코드: Promise.allSettled()로 각각 결과 확인
const results = await Promise.allSettled([
  fetch('/api/user').then(r => r.json()),
  fetch('/api/posts').then(r => r.json()),
  fetch('/api/comments').then(r => r.json())
]);

results.forEach((result, index) => {
  if (result.status === 'fulfilled') {
    console.log(`API ${index} 성공:`, result.value);
  } else {
    console.log(`API ${index} 실패:`, result.reason);
  }
});

// 출력 예시:
// API 0 성공: {...user data}
// API 1 성공: [{...post data}]
// API 2 실패: Error: 404 Not Found

이렇게 하면 posts API가 실패해도 user, comments는 정상적으로 표시할 수 있다. 각 결과는 {status: 'fulfilled', value: ...} 또는 {status: 'rejected', reason: ...} 형태다.

// 실무 예제: 여러 이미지 업로드 후 성공한 것만 저장
const imageUploads = await Promise.allSettled(
  selectedFiles.map(file => uploadToS3(file))
);

const uploadedUrls = imageUploads
  .filter(r => r.status === 'fulfilled')
  .map(r => r.value);

const failedFiles = imageUploads
  .filter(r => r.status === 'rejected')
  .map((r, idx) => ({ file: selectedFiles[idx], error: r.reason }));

if (failedFiles.length > 0) {
  alert(`${failedFiles.length}개 파일 업로드 실패`);
}

// 성공한 URL만 데이터베이스에 저장
await saveImageUrls(uploadedUrls);

 

Promise.race() - 가장 먼저 끝난 것만 반환

✓ 올바른 사용 사례: 여러 소스 중 가장 빠른 응답이 필요할 때. 예를 들어 캐시에서 먼저 조회하고, 동시에 서버에서도 조회해서 가장 빨리 도착하는 데이터를 쓰고 싶을 때다.

// ✓ 올바른 코드: Promise.race()로 타임아웃 구현
function fetchWithTimeout(url, timeoutMs) {
  return Promise.race([
    fetch(url).then(r => r.json()),
    new Promise((_, reject) =>
      setTimeout(() => reject(new Error('타임아웃')), timeoutMs)
    )
  ]);
}

const user = await fetchWithTimeout('/api/user', 5000);
// 5초 안에 응답이 오면 그걸 쓰고,
// 5초를 넘으면 타임아웃 에러 발생

race()는 하나의 Promise가 settle(fulfilled 또는 rejected)되는 순간 즉시 반환된다. 따라서 가장 빠른 응답 또는 가장 먼저 발생한 에러를 받는다.

// 실무 예제: 캐시와 네트워크 중 가장 빠른 것 쓰기
const cachedData = localStorage.getItem('user_cache');

const data = await Promise.race([
  // 캐시가 있으면 즉시 반환
  cachedData ? Promise.resolve(JSON.parse(cachedData)) : new Promise(() => {}),
  // 네트워크 요청
  fetch('/api/user').then(r => r.json())
]);

console.log(data); // 캐시 또는 네트워크 중 더 빠른 것

 

2단계. 세 가지 메서드 비교표
메서드 성공 조건 실패 조건 반환 값 사용 사례
Promise.all() 모든 Promise가 fulfilled 하나라도 rejected 성공한 값들의 배열 모든 작업이 필수인 경우(회원가입, 주문 결제)
Promise.allSettled() 모든 Promise가 완료(fulfilled or rejected) 없음. 항상 성공 상태로 완료 {status, value/reason} 배열 각각 성공/실패를 따로 처리(데이터 수집)
Promise.race() 첫 번째 Promise가 fulfilled 첫 번째 Promise가 rejected 가장 먼저 완료된 Promise의 값 가장 빠른 응답만 필요(타임아웃, 캐시 레이스)

 

3단계. 흔한 실수와 해결법
실수 1: Promise.all()에서 에러 원인을 모르는 경우

✗ 잘못된 코드: 어떤 Promise가 실패했는지 알 수 없음

try {
  const [user, posts, comments] = await Promise.all([
    fetchUser(),
    fetchPosts(),
    fetchComments()
  ]);
} catch (err) {
  console.log('뭔가 실패했음');
  // 세 개 중 뭐가 실패했는지 모름
}

✓ 올바른 코드: allSettled()로 각각 확인하거나 레이블 붙이기

const results = await Promise.allSettled([
  fetchUser().then(user => ({ type: 'user', data: user })),
  fetchPosts().then(posts => ({ type: 'posts', data: posts })),
  fetchComments().then(comments => ({ type: 'comments', data: comments }))
]);

const failedRequests = results
  .map((r, idx) => r.status === 'rejected' ? results[idx] : null)
  .filter(Boolean);

if (failedRequests.length > 0) {
  failedRequests.forEach(r => {
    console.log(`${r.data.type} 조회 실패:`, r.reason);
  });
}
실수 2: Promise.race()로 타임아웃 구현할 때 반복 실행

✗ 위험한 코드: 타임아웃이 발생해도 백그라운드 요청은 계속 실행됨

try {
  const user = await Promise.race([
    fetch('/api/user').then(r => r.json()),
    new Promise((_, reject) =>
      setTimeout(() => reject(new Error('타임아웃')), 5000)
    )
  ]);
} catch (err) {
  console.log('요청 타임아웃');
  // 하지만 fetch는 여전히 백그라운드에서 실행 중
  // 5초 후 응답이 오면 메모리 낭비
}

✓ 올바른 코드: AbortController로 요청 명시적 취소

function fetchWithTimeout(url, timeoutMs) {
  const controller = new AbortController();
  const timeoutId = setTimeout(() => controller.abort(), timeoutMs);

  return fetch(url, { signal: controller.signal })
    .then(r => r.json())
    .finally(() => clearTimeout(timeoutId));
}

try {
  const user = await fetchWithTimeout('/api/user', 5000);
} catch (err) {
  if (err.name === 'AbortError') {
    console.log('요청 타임아웃');
  }
  // fetch 요청이 명시적으로 취소됨
}
실수 3: allSettled()에서 fulfilled와 rejected 구분 실수

✗ 잘못된 코드: status를 확인하지 않고 value에 접근

const results = await Promise.allSettled([...]);

results.forEach(r => {
  console.log(r.value); // rejected이면 undefined
});

✓ 올바른 코드: status 먼저 확인

const results = await Promise.allSettled([...]);

const successData = results
  .filter(r => r.status === 'fulfilled')
  .map(r => r.value);

const errors = results
  .filter(r => r.status === 'rejected')
  .map(r => r.reason);

 

4단계. 실전 패턴 - 조합 활용
패턴: 필수 요청은 Promise.all(), 선택 요청은 allSettled()
// 사용자 정보는 필수, 추천 상품은 선택사항
const [user, recommendedProducts] = await Promise.all([
  // 필수: 실패하면 전체 실패
  fetchUser(),
  // 선택: 실패해도 무시
  Promise.allSettled([
    fetchRecommendedProducts('category1'),
    fetchRecommendedProducts('category2'),
    fetchRecommendedProducts('category3')
  ]).then(results => 
    results
      .filter(r => r.status === 'fulfilled')
      .flatMap(r => r.value)
  )
]);

console.log(user); // 반드시 존재
console.log(recommendedProducts); // 없을 수도 있음
패턴: 캐시와 네트워크 동시 요청
async function getDataWithCache(key, fetchFn) {
  const cached = localStorage.getItem(key);
  
  // 캐시가 있으면 즉시 반환하되, 백그라운드에서 갱신
  if (cached) {
    // 캐시된 데이터를 즉시 반환
    const result = Promise.resolve(JSON.parse(cached));
    // 백그라운드에서 새 데이터 가져오기 (기다리지 않음)
    fetchFn().then(fresh => {
      localStorage.setItem(key, JSON.stringify(fresh));
    }).catch(err => console.log('백그라운드 갱신 실패'));
    return result;
  }
  
  // 캐시가 없으면 네트워크에서 가져오기
  const fresh = await fetchFn();
  localStorage.setItem(key, JSON.stringify(fresh));
  return fresh;
}

const data = await getDataWithCache('user', () => fetchUser());

 

마무리 - 핵심 정리

Promise 조합 메서드는 비동기 작업의 속도를 극적으로 개선한다. Promise.all()은 모두 성공해야 할 때, allSettled()는 각각 결과를 봐야 할 때, race()는 가장 빠른 것만 필요할 때 쓴다. 반복문으로 순차 처리하는 습관을 버리고 이 세 메서드 중 하나를 골라 쓰는 것만으로도 네트워크 대기 시간을 1/n으로 줄일 수 있다. 이 글의 "흔한 실수" 섹션을 참고해 AbortController와 allSettled()의 status 확인 패턴을 습관화하면, 안정적이면서도 빠른 비동기 코드를 작성할 수 있을 것이다.