Promise.all과 Short-Circuit, Timeout

3/14/2025, 1:12:48 AM




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을 통해 병렬로 네트워크 요청을 시작 할 수 있습니다.

• 네트워크 요청을 보낼 때 개별 요청마다 Timeout을 설정하는 것이 중요합니다. • Promise.all로 묶여 있는 경우, 하나의 요청이 지연되면 전체 응답이 늦어질 수 있습니다.

const withTimeout = (promise, time) => {
  return Promise.race([
    promise,
    new Promise((_, rej) =>
      setTimeout(() => rej(new Error(`${time}ms TIMEOUT`)), time)
    ),
  ]);
};
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들 중 하나의 Promise가 reject된 경우 Promise.all은 catch로 코드 실행을 이동시킵니다.

하지만 Promise.all로 묶여있는 개별 Promise들이 모두 reject된다는 이야기는 아닙니다. 모두 reject되지 않고 개별적으로 resolve나 reject됩니다.

실행 컨텍스트가 catch로 이동되며, 개별 Promise는 백그라운드에서 작동하고 있고, 이 결과를 확인하지 않고 Promise.all이 종료됩니다.

이는 또한 Promise.all이 종료되는 것이 개별 Promise를 종료시킨다는 내용이 아닙니다. 성공한 Promise는 자신의 resolve 콜백을 실행하지만 이를 무시할 뿐입니다. 이는 중요합니다. DB의 transaction 처럼 동작하지 않는다는 점에서 알아두어야 한다고 생각합니다.

또한 개별 Timeout이 모든 api가 동일한 경우 어떤 Promise가 reject 되었는지는 http 요청에 의해 선착순으로 결정되며 이는 미리 알 수 없습니다.

// 아래의 두 Promise 중 어떤게 reject되어 catch로 받게 될지 모름
    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를 해둔다면 Promise.allShort-Circuit이 발생하는 것을 막을 수 있습니다.

Short-Circuit으로 동작하는 것을 막는 것이 좋은 패턴은 아니라고 생각합니다. 오히려 Short-Circuit은 옳은 방식이고, reject된 경우에 대한 핸들링이 더 좋다고 생각합니다.

그런데 이렇게 할 것이라면 굳이 각 API별로 catch하지 않고 Promise.allSettled를 사용하는 것이 낫습니다.

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

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

아래는 Shot-Circuit으로 동작하지 않고 Promise.allSettled를 통해 모든 Promise를 기다립니다. 따라서 Promise의 각 실패와 성공을 확인할 수 있고, 문제가 있는 요청을 한눈에 파악하기 용이합니다.

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을 회피해야하는 것은 아니지만 회피해야하는 경우 적절히 회피할 줄 알아야겠습니다.

Promise.all로 묶여있는 Promise 객체들에서 reject 발생시 Promise별로 catch 되어 있지 않다면 어떤 Promoise가 reject되었는지 판단하기 어려우므로 reject에 Promise를 특정할 수 있도록 로그를 남기는게 좋겠습니다.