Promise.all과 Short-Circuit, Timeout

수정일: 2025. 4. 30.

숏서킷을 알아야하는 이유

Promise.all은 Short-Circuit으로 작동합니다

Short-Circuit

Short-Circuit은 합선이라는 의미를 가지고 있습니다.
개발에선 하나라도 실패하면 다른 작업을 기다리지 않고 즉시 종료시키는 것을 의미합니다.

1 && false && 2 // false => 2가 반환되지 않은 이유는 false에 의해 Short-Circuit되었기 때문

Promise.all과 Short-Circuit

자바스크립트의 Promise.all은 이 Short-Circuit 방식을 따릅니다.

Promise.method들은 아래와 같이 표로 정리할 수 있습니다:

이름 방식 비고
Promise.allSettled Short-Circuit이 아님 모든 Promise의 결과를 기다림, Short-Circuit이 없음
Promise.all reject에 의한 Short-Circuit 하나라도 rejected되면 Short-Circuit
Promise.race resolve에 의한 Short-Circuit 가장 먼저 fulfilled된 값 반환
Promise.any fulfilled에 의한 Short-Circuit 하나라도 fulfilled이면 즉시 반환, 모두 실패 시 AggregateError

개별 Promise의 Timeout을 설정하지 않으면 전체 요청이 오래 걸릴 수 있다

어떤 API 요청들이 서로 의존하지 않는 경우, Promise.all을 통해 병렬로 네트워크 요청을 시작 할 수 있습니다.

const withTimeout = (promise, time) => {
  return Promise.race([
    promise,
    new Promise((_, rej) =>
      setTimeout(() => rej(new Error(`${time}ms TIMEOUT`)), time)
    ),
  ]);
};

const wait = (ms) => new Promise((res) => setTimeout(() => res(`wait 완료`), ms));

async function main() {
  console.log("Start");
  try {
    await Promise.all([
      withTimeout(wait(3000), 1500), // 1.5초 내에 완료되지 않으면 Timeout
      withTimeout(wait(2000), 2500), // 정상 완료됨
    ]);
  } catch (error) {
    console.log("🚀 ~ main ~ error:", error);
  }
}
main();

위 코드는 Short-Circuit으로 동작합니다.
여러 Promise들 중 하나라도 reject되면 Promise.allcatch로 흐름이 이동합니다.

하지만 Promise.all로 묶여있는 모든 Promise가 reject된다는 뜻은 아닙니다.
각 Promise는 개별적으로 resolve/reject되며, Promise.all은 가장 먼저 reject된 것을 기준으로 종료됩니다.

이 말은 Promise.all종료된다고 해서 내부의 개별 Promise가 중단되는 것은 아니라는 것입니다.
성공한 Promise는 자신의 resolve 콜백을 실행하지만, 결과는 무시됩니다.
이는 DB의 transaction처럼 완전히 rollback되는 구조는 아니라는 점에서 중요합니다.

또한 개별 Timeout이 동일한 경우, 어떤 Promise가 reject되는지는 HTTP 요청 선착순에 따라 결정되며,
미리 알 수 없습니다.

// 아래의 두 Promise 중 어떤 게 reject될지 모름
await Promise.all([
  withTimeout(wait(3000), 2500), 
  withTimeout(wait(3000), 2500), 
]);

이름을 명시해서 log에 남기기

제가 생각하는 대응은
각 Promise가 reject될 때 추적하기 용이하도록 Error handling이 되어 있어야 한다는 점입니다.

요청 URL이나 식별 가능한 이름을 추가하면 로그 추적이 수월해집니다.

const timeout = (time) => {
  return new Promise((_, rej) =>
    setTimeout(
      () => rej(new Error(`[timeout function]: ${time}ms, TIME OUT ERROR`)),
      time
    )
  );
};

API별로 catch하고 기본 값을 반환하기

Promise.allShort-Circuit을 일으키더라도,
Promise별로 catch를 해주면 Short-Circuit을 막을 수 있습니다.

Short-Circuit을 막는 것이 좋은 패턴은 아닐 수 있습니다.
중요한 것은 reject에 대한 핸들링입니다.

이렇게 할 바엔 차라리 Promise.allSettled를 쓰는 것이 낫습니다:

await Promise.all([
  withTimeout(wait(3000), 2500).catch(e => undefined), 
  withTimeout(wait(3000), 2500).catch(e => undefined), 
]);

Promise.allSettled를 사용하여 대응하기

Short-Circuit 없이 모든 Promise를 기다리려면 Promise.allSettled를 사용하면 됩니다.

async function main() {
  console.log("test start:");
  const results = await Promise.allSettled([
    wait(1000),
    timeout(2000),
  ]);
  console.log("🚀 ~ main ~ results:", results);
}
main();

예시 결과:

🚀 ~ main ~ results: [
  { status: 'fulfilled', value: 'wait 완료' },
  { status: 'rejected', reason: Error: 2000ms, TIME OUT ERROR }
]

요약

Short-Circuit을 회피해야 하는 것은 아니지만,
회피가 필요한 경우 적절히 제어할 수 있어야 합니다.