2026.06.07·6

JavaScript 비동기 흐름 다시 보기 (05) - Promise.all, race, any, allSettled는 실패를 다르게 다룹니다

여러 Promise를 함께 실행할 때 Promise.all, race, any, allSettled가 성공과 실패를 어떻게 다르게 결정하는지 정리합니다.

JavaScript 비동기 흐름 다시 보기 (05) - Promise.all, race, any, allSettled는 실패를 다르게 다룹니다 대표 이미지

여러 Promise를 한 번에 다룬다고 해서 실패 처리 방식까지 같아지지는 않습니다. 무엇을 전체 성공으로 볼지, 어떤 실패를 전체 실패로 볼지가 메서드마다 다릅니다.

들어가며

앞선 글에서는 try/catch가 잡는 에러와 놓치는 에러를 정리했습니다. 이번에는 여러 Promise를 함께 다룰 때의 실패 처리를 보겠습니다.

실무에서는 비동기 작업 하나만 기다리는 경우보다 여러 작업을 함께 기다리는 경우가 많습니다.

const profile = await fetchProfile();
const posts = await fetchPosts();
const notices = await fetchNotices();

이 코드는 읽기 쉽지만 세 요청을 순서대로 기다립니다. 프로필 요청이 끝나야 게시글 요청을 시작하고, 게시글 요청이 끝나야 알림 요청을 시작합니다.

서로 의존하지 않는 요청이라면 동시에 시작하는 편이 자연스럽습니다.

const [profile, posts, notices] = await Promise.all([
  fetchProfile(),
  fetchPosts(),
  fetchNotices(),
]);

여기까지는 익숙합니다. 하지만 질문은 여기서 시작됩니다.

프로필은 성공했는데 게시글 요청이 실패하면 어떻게 해야 할까요. 가장 빠른 응답만 쓰고 싶을 때 실패가 먼저 오면 어떻게 될까요. 여러 후보 중 하나만 성공해도 충분하다면 모든 실패를 같은 방식으로 다뤄야 할까요.

이번 글의 기준은 하나입니다.

Keypoint

여러 Promise를 묶는 메서드의 차이는 "몇 개를 실행하느냐"보다 어떤 상태를 전체 결과로 인정하느냐 에서 드러납니다.

먼저 settled라는 기준을 잡습니다

Promise에는 pending, fulfilled, rejected 상태가 있습니다. 이 중 fulfilledrejected처럼 결과가 결정된 상태를 묶어 settled라고 부릅니다.

즉 settled는 별도의 네 번째 상태가 아닙니다. pending이 끝났다는 뜻입니다.

const success = Promise.resolve("ok");
const failure = Promise.reject(new Error("fail"));
 
// 예시에서는 rejected Promise를 바로 처리해 둡니다.
failure.catch(() => {});

여러 Promise를 함께 다루는 메서드를 읽을 때는 이 표현이 중요합니다. 어떤 메서드는 모든 Promise가 settled될 때까지 기다리고, 어떤 메서드는 하나라도 settled되면 전체 결과를 결정합니다.

이 차이가 곧 실패 처리 방식의 차이입니다.

Promise.all은 하나라도 실패하면 전체가 실패합니다

Promise.all은 모든 Promise가 fulfilled 상태가 되어야 fulfilled가 됩니다. 반대로 하나라도 rejected가 되면 전체 Promise도 rejected가 됩니다.

대시보드처럼 여러 데이터가 모두 있어야 한 화면을 제대로 그릴 수 있는 경우를 생각해 보겠습니다.

type DashboardState =
  | { status: "pending" }
  | { status: "fulfilled"; data: DashboardData }
  | { status: "rejected"; message: string };
 
let state: DashboardState = { status: "pending" };
 
async function loadDashboard() {
  state = { status: "pending" };
 
  try {
    const [profile, posts, notices] = await Promise.all([
      fetchProfile(),
      fetchPosts(),
      fetchNotices(),
    ]);
 
    state = {
      status: "fulfilled",
      data: { profile, posts, notices },
    };
  } catch {
    state = {
      status: "rejected",
      message: "대시보드를 불러오지 못했습니다.",
    };
  }
}

이 코드에서는 세 요청 중 하나라도 실패하면 전체 대시보드를 실패 상태로 봅니다. 일부 데이터만 보여 주는 대신, 화면 전체를 에러 상태로 바꾸는 판단입니다.

이 판단이 맞는 경우가 있습니다. 프로필, 권한, 설정이 모두 있어야 화면을 안전하게 보여 줄 수 있다면 하나라도 실패했을 때 전체 실패로 처리하는 편이 단순하고 명확합니다.

다만 중요한 한계가 있습니다.

Promise.all이 rejected가 된다고 해서 이미 시작한 다른 작업이 자동으로 취소되지는 않습니다. 결과 Promise가 실패로 결정될 뿐, 네트워크 요청이나 타이머 같은 작업은 별도 취소 로직이 없으면 계속 진행될 수 있습니다.

Promise.all은 실패 정책을 정하는 도구이지, 실행 중인 작업을 자동으로 멈추는 도구는 아닙니다.

Promise.allSettled는 실패까지 결과로 모읍니다

Promise.allSettled는 모든 Promise가 settled될 때까지 기다립니다. 각 Promise가 성공했는지 실패했는지를 배열에 담아 돌려줍니다.

여기서 핵심은 실패를 전체 실패로 바로 바꾸지 않는다는 점입니다.

async function loadHomeSections() {
  const [profileResult, postsResult, noticesResult] = await Promise.allSettled([
    fetchProfile(),
    fetchPosts(),
    fetchNotices(),
  ]);
 
  if (profileResult.status === "fulfilled") {
    renderProfile(profileResult.value);
  } else {
    renderProfileFallback();
  }
 
  if (postsResult.status === "fulfilled") {
    renderPosts(postsResult.value);
  } else {
    renderPostsFallback();
  }
 
  if (noticesResult.status === "fulfilled") {
    renderNotices(noticesResult.value);
  } else {
    renderNoticesFallback();
  }
}

이 코드는 홈 화면의 여러 섹션을 독립적으로 다룹니다. 프로필 요청이 실패해도 게시글 섹션은 보여 줄 수 있고, 알림 요청이 실패해도 다른 섹션은 유지할 수 있습니다.

Promise.allSettled가 잘 맞는 상황은 전체 화면보다 부분 결과가 중요한 경우입니다. 검색 결과 일부, 추천 영역, 사이드바 알림처럼 한 영역의 실패가 다른 영역까지 무너뜨릴 필요가 없을 때 유용합니다.

반대로 모든 데이터가 함께 있어야 의미가 생기는 화면에서는 allSettled가 오히려 처리를 복잡하게 만들 수 있습니다. 각 결과의 status를 모두 확인하고, 부분 실패 UI를 직접 설계해야 하기 때문입니다.

Promise.race는 가장 먼저 끝난 결과를 따릅니다

Promise.race는 가장 먼저 settled된 Promise의 결과를 전체 결과로 사용합니다. 가장 먼저 fulfilled되면 fulfilled가 되고, 가장 먼저 rejected되면 rejected가 됩니다.

그래서 race는 "가장 빠른 성공"이 아닙니다. 가장 먼저 끝난 결과입니다.

대표적인 예시는 timeout입니다.

function timeout(ms: number) {
  return new Promise<never>((_, reject) => {
    setTimeout(() => {
      reject(new Error("요청 시간이 초과되었습니다."));
    }, ms);
  });
}
 
async function loadProfileWithTimeout() {
  try {
    const profile = await Promise.race([
      fetchProfile(),
      timeout(3000),
    ]);
 
    renderProfile(profile);
  } catch (error) {
    renderError(error);
  }
}

fetchProfile()이 3초 안에 fulfilled되면 프로필을 보여 줍니다. 하지만 timeout Promise가 먼저 rejected되면 전체 race도 rejected가 되고, 에러 UI로 넘어갑니다.

이 구조에서 timeout은 "요청을 실제로 중단한다"기보다 "이 화면에서는 더 기다리지 않겠다"는 판단에 가깝습니다. 실제 fetch 요청까지 중단하려면 AbortController 같은 별도 취소 흐름이 필요합니다.

race는 첫 결과가 성공이든 실패든 그대로 받아들입니다. 그래서 실패가 먼저 올 수 있는 후보를 넣을 때는 그 실패가 전체 실패로 이어져도 괜찮은지 먼저 확인해야 합니다.

Promise.any는 첫 번째 성공을 기다립니다

Promise.any는 여러 Promise 중 하나라도 fulfilled가 되면 그 값을 전체 성공으로 사용합니다. 중간에 rejected가 발생해도 바로 전체 실패로 보지 않습니다.

여러 후보 중 하나만 성공해도 충분한 경우에 어울립니다.

async function loadAvatarUrl() {
  try {
    const imageUrl = await Promise.any([
      fetchAvatarFromPrimaryCdn(),
      fetchAvatarFromBackupCdn(),
      fetchAvatarFromCache(),
    ]);
 
    return imageUrl;
  } catch (error) {
    return "/images/default-avatar.png";
  }
}

이 코드는 기본 CDN이 실패해도 백업 CDN이나 캐시가 성공하면 그 값을 사용합니다. 사용자 입장에서는 "어느 출처가 성공했는지"보다 "아바타를 볼 수 있는지"가 더 중요할 수 있습니다.

하지만 모든 후보가 rejected되면 Promise.any도 rejected가 됩니다. 이때 실패 이유는 AggregateError로 전달됩니다. 여러 실패를 하나로 묶은 에러입니다.

async function loadAvatarUrl() {
  try {
    return await Promise.any([
      fetchAvatarFromPrimaryCdn(),
      fetchAvatarFromBackupCdn(),
      fetchAvatarFromCache(),
    ]);
  } catch (error) {
    if (error instanceof AggregateError) {
      console.error("모든 아바타 요청이 실패했습니다.", error.errors);
    }
 
    return "/images/default-avatar.png";
  }
}

Promise.any는 실패를 무시하는 메서드가 아닙니다. 첫 성공이 나오기 전까지 개별 실패를 전체 실패로 확정하지 않을 뿐입니다. 모든 후보가 실패하면 그때 전체 실패가 됩니다.

어떤 메서드를 고를지는 UI 실패 정책으로 정합니다

네 메서드는 모두 여러 Promise를 다루지만, 실패를 해석하는 방식이 다릅니다.

Promise.all은 전부 성공해야 합니다. 하나라도 실패하면 전체를 실패로 봅니다. 화면이나 작업이 여러 결과를 하나의 묶음으로 필요로 할 때 적합합니다.

Promise.allSettled는 전부 끝날 때까지 기다립니다. 실패도 결과 배열에 담습니다. 부분 성공과 부분 실패를 나눠 보여 줄 때 적합합니다.

Promise.race는 가장 먼저 끝난 결과를 따릅니다. 성공이 먼저 오면 성공, 실패가 먼저 오면 실패입니다. timeout이나 첫 응답 기준 제어처럼 "먼저 결정된 결과"가 중요한 경우에 맞습니다.

Promise.any는 첫 성공을 기다립니다. 실패가 먼저 와도 다른 성공 후보가 남아 있다면 계속 기다립니다. 여러 대체 경로 중 하나만 성공해도 되는 경우에 맞습니다.

이 차이는 단순 API 선택이 아니라 UI 상태 설계로 이어집니다.

type AsyncState<T> =
  | { status: "pending" }
  | { status: "fulfilled"; data: T }
  | { status: "partial"; data: Partial<T> }
  | { status: "rejected"; message: string };

여러 요청을 묶을 때는 상태가 단순히 pending, fulfilled, rejected 세 가지로만 끝나지 않을 수 있습니다. allSettled를 쓰면 partial 상태가 필요할 수 있고, any를 쓰면 "성공 후보 중 하나만 확보한 상태"를 성공으로 볼 수 있습니다.

그래서 먼저 정해야 하는 것은 메서드 이름이 아닙니다.

  • 모든 결과가 있어야 화면을 보여 줄 수 있는가
  • 일부 실패를 허용하고 나머지를 보여 줄 것인가
  • 가장 빠른 결과가 실패여도 그 실패를 받아들일 것인가
  • 여러 후보 중 하나만 성공해도 충분한가

이 질문에 대한 답이 정해지면 어떤 Promise 메서드를 써야 하는지도 자연스럽게 좁혀집니다.

마치며

Promise.all, Promise.allSettled, Promise.race, Promise.any는 모두 여러 비동기 작업을 묶습니다. 하지만 실패를 다루는 기준은 서로 다릅니다.

all은 하나의 실패를 전체 실패로 봅니다. allSettled는 실패를 결과로 남기고 전체 흐름을 끝까지 기다립니다. race는 가장 먼저 끝난 성공 또는 실패를 그대로 따릅니다. any는 첫 성공을 기다리고, 모든 후보가 실패했을 때만 실패합니다.

결국 중요한 질문은 이것입니다.

Keypoint

여러 Promise를 묶을 때는 "어떻게 동시에 실행할까"보다 먼저 어떤 실패를 사용자에게 어떤 상태로 보여 줄 것인가 를 정해야 합니다.

다음 글에서는 fetch로 넘어가 보겠습니다. fetch는 네트워크 실패와 HTTP 실패를 다르게 다루기 때문에, 어디에서 throw해야 UI의 rejected 상태로 이어지는지 따로 정리할 필요가 있습니다.