Frontend

[Frontend] Promis.race와 Promise.allSettled 구현하기

teddy bear 2026. 7. 9. 15:20
반응형

들어가며

Promise.race로 API 타임아웃을 구현하는 코드는 여러 번 써봤다. Promise.allSettled로 여러 요청을 동시에 보내고 실패한 것만 골라내는 코드도 익숙하다. 그런데 정작 "이 두 메서드가 내부적으로 어떻게 동작하길래 이런 결과가 나오는가"라는 질문을 받으면 말문이 막혔다.

사용법은 아는데 원리는 모르는 상태. 이런 지점을 가장 확실하게 메우는 방법은 직접 구현해보는 것이다. 이 글은 Polyfill이 왜 필요한지부터 시작해서, Promise.race와 Promise.allSettled를 직접 만들어보고, 그 과정에서 왜 특정 최신 스펙 API를 도입하지 못하고 있는 프로젝트들이 여전히 Polyfill에 의존하는지를 정리한 기록이다.


1. Polyfill이란 무엇이고, 왜 여전히 필요한가

1-1. Polyfill의 정의

Polyfill은 최신 스펙에는 정의되어 있지만, 특정 환경(오래된 브라우저, 특정 런타임 버전)에는 아직 구현되어 있지 않은 기능을 자바스크립트 코드로 직접 구현해서 채워 넣는 코드를 말한다. "메꾸다(fill)"라는 이름 그대로, 스펙과 실제 구현 사이의 틈을 코드로 메우는 것이다.

혼동하기 쉬운 개념으로 Transpile(Babel 등이 하는 문법 변환)이 있는데, 둘은 다루는 대상이 다르다.

Polyfill Transpile

대상 런타임 API (Promise, Array.prototype.flat 등) 문법 (화살표 함수, 클래스, 옵셔널 체이닝 등)
방식 없는 기능을 코드로 직접 구현해서 전역에 추가 새 문법을 옛 문법으로 변환
예시 Promise.allSettled가 없는 환경에 함수 추가 const f = () => {}를 var f = function() {}로 변환

옵셔널 체이닝(?.)처럼 문법 자체가 새로 생긴 건 Transpile로 해결하고, Array.prototype.at()처럼 기존 문법으로 존재하지 않던 메서드가 새로 생긴 건 Polyfill로 해결한다.

1-2. 요즘도 Polyfill이 필요한가

"요즘 브라우저는 다 최신 스펙 지원하지 않나?"라고 생각하기 쉽지만, 실무에서는 여전히 몇 가지 이유로 Polyfill을 마주치게 된다.

  • 레거시 브라우저 지원 요구사항: 특히 B2B 서비스에서 특정 대형 고객사가 구형 브라우저(오래된 사내 표준 환경) 사용을 강제하는 경우가 실제로 있다.
  • Node.js 버전 차이: 서버 런타임 버전이 낮으면 최신 API가 없을 수 있다.
  • React Native 등 특수 런타임 환경: 브라우저 표준 API가 완전히 구현되어 있지 않은 경우가 있다.

그리고 이런 실무적 필요와 별개로, Polyfill을 직접 구현해보는 건 해당 API의 동작 원리를 가장 확실하게 이해하는 방법이기도 하다. 이 글의 나머지는 이 관점에 집중한다.


2. Promise.race 직접 구현하기

2-1. 스펙 동작 다시 짚기

Promise.race(promises)는 배열로 받은 여러 Promise 중 가장 먼저 settle(성공 또는 실패로 확정)되는 것 하나의 결과를 그대로 반환한다. 성공이든 실패든 상관없이 "가장 먼저 끝난 것"이 기준이다.

const p1 = new Promise((resolve) => setTimeout(() => resolve('p1 완료'), 500));
const p2 = new Promise((resolve) => setTimeout(() => resolve('p2 완료'), 100));

Promise.race([p1, p2]).then(console.log); // 'p2 완료' (더 빨리 끝났으므로)

2-2. 직접 구현해보기

핵심 아이디어는 단순하다. 새 Promise를 하나 만들고, 배열의 모든 Promise에 대해 각각 .then/.catch를 걸어둔다. 그중 누구든 가장 먼저 resolve나 reject를 호출하는 쪽이 새 Promise의 결과를 확정짓는다.

function myRace(promises) {
  return new Promise((resolve, reject) => {
    if (promises.length === 0) return; // 스펙상 빈 배열이면 영원히 pending 상태

    promises.forEach((p) => {
      // Promise가 아닌 값(숫자, 문자열 등)도 배열에 섞일 수 있으므로
      // Promise.resolve로 감싸서 항상 Promise로 다루도록 정규화한다
      Promise.resolve(p).then(resolve, reject);
    });
  });
}

여기서 중요한 지점은, 먼저 호출된 resolve나 reject만 실제로 효력을 갖는다는 Promise 자체의 스펙을 그대로 활용했다는 점이다. Promise executor 안에서 resolve나 reject를 여러 번 호출해도, 두 번째 이후 호출은 무시된다. 이 특성 덕분에 myRace는 "가장 먼저 도착한 결과만 채택하고 나머지는 자동으로 무시"되는 동작을 별도의 플래그 변수 없이도 자연스럽게 얻는다.

// 검증
const slow = new Promise((resolve) => setTimeout(() => resolve('느림'), 300));
const fast = new Promise((resolve) => setTimeout(() => resolve('빠름'), 50));

myRace([slow, fast]).then(console.log); // '빠름'

2-3. 실전 활용 — Promise.race로 타임아웃 구현하기

Promise.race가 실무에서 가장 많이 쓰이는 곳은 네트워크 요청에 타임아웃을 거는 상황이다. fetch API 자체에는 타임아웃 옵션이 없기 때문에(최근에는 AbortSignal.timeout()이 생겼지만, 이전에는 이 방식이 사실상 표준 해법이었다), "일정 시간 내에 응답이 없으면 실패로 간주"하는 로직을 직접 짜야 했다.

function fetchWithTimeout(url, timeoutMs) {
  const timeoutPromise = new Promise((_, reject) => {
    setTimeout(() => {
      reject(new Error(`요청 시간 초과: ${timeoutMs}ms`));
    }, timeoutMs);
  });

  return Promise.race([fetch(url), timeoutPromise]);
}

// 사용 예
fetchWithTimeout('/api/slow-endpoint', 3000)
  .then((response) => response.json())
  .catch((err) => {
    console.error(err.message); // 3초 안에 응답이 없으면 여기로 떨어짐
  });

원리는 이렇다. 실제 fetch Promise와, timeoutMs 이후 무조건 reject되는 "타이머 Promise"를 같이 race에 태운다. 응답이 타임아웃보다 빨리 오면 fetch 쪽이 이기고, 응답이 늦으면 타이머 쪽이 먼저 reject되면서 전체 결과가 실패로 확정된다.

여기서 실무에서 놓치기 쉬운 함정이 하나 있다. race에서 진 쪽 Promise가 실제로 취소되는 건 아니라는 점이다. 타임아웃으로 실패 처리를 했더라도, 원래의 fetch 요청은 백그라운드에서 계속 진행되다가 나중에 응답이 오면 그 결과를 그냥 버릴 뿐이다. 진짜로 요청 자체를 중단하려면 AbortController를 별도로 연결해야 한다.

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

  return fetch(url, { signal: controller.signal }).finally(() => {
    clearTimeout(timeoutId); // 응답이 먼저 왔다면 타이머를 정리해서 불필요한 abort 방지
  });
}

race 기반 구현은 이해하기 쉽지만 "진 쪽의 뒷정리"까지는 책임지지 않는다는 걸 직접 구현해보며 명확히 알게 됐다. 실제 프로젝트에서는 두 방식을 상황에 맞게 섞어 썼다 — 단순히 UI에 에러 메시지만 띄우면 되는 경우엔 race 방식으로 충분했고, 요청 자체를 확실히 중단해야 하는 경우(예: 사용자가 검색어를 빠르게 여러 번 바꿀 때 이전 요청을 취소해야 하는 경우)엔 AbortController를 반드시 함께 썼다.


3. Promise.allSettled 직접 구현하기

3-1. Promise.all과의 차이부터

Promise.all은 배열 안의 Promise 중 하나라도 reject되면 즉시 전체가 reject된다. 반면 Promise.allSettled는 성공/실패 여부와 무관하게 모든 Promise가 끝날 때까지 기다린 뒤, 각각의 결과를 상태와 함께 배열로 반환한다.

const results = await Promise.allSettled([
  Promise.resolve('성공1'),
  Promise.reject('실패1'),
  Promise.resolve('성공2'),
]);

console.log(results);
// [
//   { status: 'fulfilled', value: '성공1' },
//   { status: 'rejected', reason: '실패1' },
//   { status: 'fulfilled', value: '성공2' },
// ]

여러 개의 독립적인 API 요청을 동시에 보낼 때, "하나 실패했다고 나머지 결과까지 버릴 필요는 없는" 상황(예: 대시보드에서 여러 위젯 데이터를 각각 불러오는 경우)에 정확히 들어맞는 메서드다.

3-2. 직접 구현해보기

Promise.race와 다른 점은, 모든 Promise가 끝나기를 기다려야 한다는 것과, 실패도 결과값으로 변환해서 담아야 한다는 것이다. 이걸 위해 "완료된 개수"를 세는 카운터가 필요하다.

function myAllSettled(promises) {
  return new Promise((resolve) => {
    const results = new Array(promises.length);
    let completedCount = 0;

    if (promises.length === 0) {
      resolve(results);
      return;
    }

    promises.forEach((p, index) => {
      Promise.resolve(p).then(
        (value) => {
          results[index] = { status: 'fulfilled', value };
          completedCount++;
          if (completedCount === promises.length) resolve(results);
        },
        (reason) => {
          results[index] = { status: 'rejected', reason };
          completedCount++;
          if (completedCount === promises.length) resolve(results);
        }
      );
    });
  });
}

몇 가지 설계 포인트를 짚어보면:

  • reject를 아예 쓰지 않는다. allSettled는 정의상 절대 reject되지 않는 Promise를 반환한다. 개별 Promise의 실패는 results 배열 안의 { status: 'rejected', ... } 형태로 "성공적으로 담기는" 것이지, 전체를 실패시키는 요소가 아니다.
  • results 배열을 미리 길이만큼 할당해두고 index로 채운다. 순서를 보장하기 위함이다. 각 Promise가 끝나는 시점은 제각각이라, forEach 콜백이 호출되는 순서와 실제 완료 순서가 다를 수 있다. push 대신 index로 직접 위치를 지정해야 원본 배열의 순서가 결과에 그대로 유지된다.
  • completedCount로 "전부 끝났는가"를 판단한다. Promise.race처럼 "누가 먼저"가 아니라 "전부 다 끝났는가"가 조건이기 때문에, 성공/실패 콜백 양쪽 모두에서 카운터를 증가시키고 마지막에 도달했을 때만 resolve를 호출한다.

3-3. 검증

const slow = new Promise((resolve) => setTimeout(() => resolve('느리지만 성공'), 300));
const fast = new Promise((_, reject) => setTimeout(() => reject('빠르게 실패'), 50));
const instant = Promise.resolve('즉시 성공');

myAllSettled([slow, fast, instant]).then((results) => {
  console.log(results);
  // [
  //   { status: 'fulfilled', value: '느리지만 성공' },
  //   { status: 'rejected', reason: '빠르게 실패' },
  //   { status: 'fulfilled', value: '즉시 성공' },
  // ]
});

fast가 가장 먼저 끝나고 slow가 가장 늦게 끝나지만, 결과 배열의 순서는 입력 배열 순서(slow, fast, instant)를 그대로 유지한다. 이 부분을 놓치고 push로 구현하면 실행 순서에 따라 결과 배열 순서가 뒤죽박죽되는 버그가 생긴다 — 실제로 처음 구현했을 때 이 실수를 했고, 테스트 케이스에서 순서가 뒤바뀐 걸 보고서야 인덱스 기반으로 다시 고쳤다.

3-4. 실전 활용 — 대시보드 위젯 데이터 동시 로딩

실무에서 allSettled가 빛을 발했던 상황은, 여러 개의 독립적인 위젯으로 구성된 대시보드 페이지였다. 기존에는 Promise.all을 쓰고 있었는데, 위젯 하나(예: 외부 결제 API 연동 위젯)에서 에러가 나면 나머지 멀쩡한 위젯들까지 전부 빈 화면으로 떨어지는 문제가 있었다.

// 문제가 있던 기존 코드
async function loadDashboard() {
  try {
    const [sales, users, payments, traffic] = await Promise.all([
      fetchSalesData(),
      fetchUserData(),
      fetchPaymentData(), // 이게 실패하면
      fetchTrafficData(),
    ]);
    renderDashboard({ sales, users, payments, traffic });
  } catch (err) {
    renderErrorPage(); // 전체 대시보드가 에러 페이지로 대체됨
  }
}
// allSettled로 개선한 코드
async function loadDashboard() {
  const results = await Promise.allSettled([
    fetchSalesData(),
    fetchUserData(),
    fetchPaymentData(),
    fetchTrafficData(),
  ]);

  const [sales, users, payments, traffic] = results.map((r) =>
    r.status === 'fulfilled' ? r.value : null
  );

  renderDashboard({ sales, users, payments, traffic }); // null인 위젯만 에러 상태로 표시
}

성공한 위젯은 정상적으로 그리고, 실패한 위젯만 "데이터를 불러올 수 없습니다"라는 개별 에러 UI로 대체하도록 바꿨다. 사용자 입장에서는 결제 위젯 하나가 일시적으로 안 열려도 나머지 대시보드는 정상적으로 볼 수 있게 된 것이다. 이 변경 이후로 "대시보드가 통째로 안 보인다"는 문의가 눈에 띄게 줄었다.


4. 두 메서드를 나란히 비교하면

Promise.race Promise.allSettled

기다리는 조건 가장 먼저 끝나는 것 하나 전부 다 끝날 때까지
실패 처리 가장 먼저 reject되면 전체가 reject 실패도 결과값으로 담아서 절대 reject 안 함
반환값 이긴 Promise의 결과 그대로 {status, value/reason} 객체 배열
대표 활용 타임아웃 구현, 여러 소스 중 가장 빠른 응답 채택 독립적인 여러 요청을 부분 실패 허용하며 처리

마치며

Promise.race와 Promise.allSettled를 직접 만들어보면서 새삼 느낀 건, 두 메서드 모두 새로운 개념을 발명한 게 아니라 Promise executor의 기본 규칙(resolve/reject는 처음 호출만 유효하다는 것) 위에서 "언제 resolve를 호출할 것인가"의 조건만 다르게 설계했다는 점이었다. race는 "누구든 먼저", allSettled는 "모두가 끝나면"이라는 조건 차이가 전부다.

API 사용법만 외우고 있을 때는 이 둘이 그냥 "다른 기능"으로 느껴졌는데, 직접 구현해보고 나니 같은 뼈대 위에 다른 조건을 얹은 변주라는 게 명확하게 보였다. 앞으로 새로운 Promise 조합 메서드(Promise.any 등)를 마주쳐도, "이건 언제 resolve/reject를 호출하도록 설계됐을까"라는 같은 질문으로 접근하면 구조를 훨씬 빠르게 파악할 수 있을 것 같다.


참고하면 좋을 자료