빌드노트
Promise.any가 실패했는데 원래 오류 메시지가 보이지 않는다
Promise.any의 전체 실패 원인은 AggregateError.errors에 남는다. B가 먼저 거절되는 타이머로 입력 순서와 실패 순서를 비교하고, 작업 이름별 원인 처리, 성공값 하나의 소비, 빈 입력의 구분을 설명한다.
대체 작업을 Promise.any로 묶었는데 catch의 error.message에 All promises were rejected만 남을 수 있다. 이 메시지는 개별 작업의 실패 이유가 아니라 전체 실패를 알리는 바깥 오류의 메시지다. 각 작업의 원인은 AggregateError.errors에서 읽고, 그 목록을 Promise.any에 넣은 순서대로 작업 이름과 연결해야 한다.
기존 코드에서는 Promise.any를 기다리는 try와 catch부터 확인한다. error.message 하나만 기록하고 있다면 errors도 읽도록 바꾼다. 이때 작업 이름은 Promise를 모을 때 함께 보관한다. 실패 로그가 찍힌 순서로 이름을 붙이거나 errors[0]을 가장 먼저 실패한 작업으로 해석하면 원인을 잘못 연결할 수 있다.
B가 먼저 실패해도 errors[0]은 A의 오류다
네트워크 요청 없이 두 타이머로 순서를 비교했다. 입력은 A·B 순서지만 A는 80ms, B는 10ms 뒤 거절되게 했다. timeout과 unavailable은 직접 넣은 합성 오류 메시지이며 실제 서비스 장애 기록이 아니다.
추가 패키지 없이 Node.js에서 실행할 수 있다. node --version으로 실행 환경을 확인하고, 다음 내용을 빈 폴더의 any-errors.mjs에 저장한 뒤 그 폴더에서 node any-errors.mjs를 실행한다. .mjs를 사용하므로 파일 최상위에서 await를 쓸 수 있다. 직접 확인한 환경은 Windows의 Node.js v22.23.2였다. 아래 코드는 거절 로그의 순서와 오류 목록에 붙인 작업 이름을 비교한다.
function fail(name, ms, message) {
return new Promise((_, reject) => {
setTimeout(() => {
console.log('reject', name);
reject(new Error(message));
}, ms);
});
}
const attempts = [
{ name: 'A', promise: fail('A', 80, 'timeout') },
{ name: 'B', promise: fail('B', 10, 'unavailable') },
];
try {
const value = await Promise.any(attempts.map(attempt => attempt.promise));
console.log('success', value);
} catch (error) {
if (!(error instanceof AggregateError)) throw error;
console.log(error.name, error.message);
error.errors.forEach((reason, index) => {
const message = reason instanceof Error ? reason.message : String(reason);
console.log(`${attempts[index].name}: ${message}`);
});
}
실제 출력은 다음과 같았다.
reject B
reject A
AggregateError All promises were rejected
A: timeout
B: unavailable
B가 먼저 거절됐지만 catch는 A까지 거절된 뒤 실행됐다. errors[0]에는 A의 timeout, errors[1]에는 B의 unavailable이 들어 있었다. errors는 실패가 발생한 시간순 목록이 아니다. 먼저 실패한 작업을 알아야 한다면 각 작업이 실패한 시점에 별도로 기록해야 한다.
ECMAScript의 PerformPromiseAny 절차 (새 탭에서 열림)는 각 입력의 인덱스를 보관하고, 그 입력이 거절되면 해당 인덱스에 이유를 저장한다. 그래서 배열을 넘긴 이 예제에서는 errors[index]와 attempts[index]를 대응시킬 수 있다. 다른 iterable을 쓴다면 그 iterable이 값을 내놓은 순서가 기준이다. Promise.any에 넘긴 뒤에는 대응에 쓸 attempts 배열을 정렬하거나 항목을 삭제하지 않는다.
첫 코드의 attempts 배열에서 두 항목의 위치만 바꿔 B·A 순서로도 실행했다. 거절 로그는 여전히 B → A였지만, 원인 출력은 B: unavailable 다음 A: timeout으로 바뀌었다. 입력 순서를 바꾼 만큼 오류 목록도 바뀌었다.
AggregateError의 명세 (새 탭에서 열림)에서 message와 errors는 별도 속성이다. 바깥 message에 개별 메시지가 모두 합쳐질 것으로 기대하지 않는다. 위 실행의 All promises were rejected 문구도 환경에 따라 달라질 수 있으므로 이 문자열로 분기하지 않는다.
목록의 원소는 거절 이유로 전달한 값이다. 항상 Error라고 가정할 수 없어 위 코드는 Error이면 message를 읽고, 다른 값이면 문자열로 표시한다. 별도 대조에서 A를 null, B를 문자열 unavailable로 거절시키자 두 이유도 그대로 보존됐다. 이 분기는 같은 Node 실행 환경에서 만든 오류를 대상으로 확인했다.
하나가 이행되면 그 값을 사용한다
여기서 성공은 Promise의 이행(fulfill)을 뜻한다. Promise.any의 명세 (새 탭에서 열림)에 따르면 다른 입력이 먼저 거절돼도 처음 이행된 입력의 값으로 결과가 이행된다. 가장 먼저 끝난 작업이 실패했다고 즉시 전체 실패로 처리하는 메서드가 아니다.
같은 파일에서 attempts 선언만 다음으로 교체하면, B가 먼저 실패한 뒤 A의 성공값을 받는지 확인할 수 있다. fail 함수와 try·catch는 그대로 둔다.
const attempts = [
{ name: 'A', promise: new Promise(resolve => {
setTimeout(() => resolve('A data'), 80);
}) },
{ name: 'B', promise: fail('B', 10, 'unavailable') },
];
같은 node any-errors.mjs 명령의 실제 출력이다.
reject B
success A data
이번에는 catch의 오류 목록 출력이 없었다. 먼저 발생한 B의 실패는 전체 실패가 되지 않았고, await 다음의 value에는 배열 대신 A가 이행한 값 하나가 들어왔다. 이 값을 후속 처리에 사용한다. 성공 경로에서 실패한 다른 입력들의 목록까지 함께 받을 수 있는 것은 아니다.
후보가 없으면 오류 목록도 비어 있다
Promise.any([])는 사용할 성공값이 없어서 거절된다. MDN의 반환값 설명 (새 탭에서 열림)도 빈 입력의 거절과 전체 입력 거절을 구분해 설명한다. 같은 파일 끝에 다음을 붙이면 빈 입력의 오류 이름과 원인 개수를 확인할 수 있다.
try {
await Promise.any([]);
} catch (error) {
if (!(error instanceof AggregateError)) throw error;
console.log(error.name, error.errors.length);
}
앞의 출력에 이어 AggregateError 0이 나왔다. 이는 대체 작업을 실제로 호출했다가 모두 실패했다는 증거가 아니다. 후보 자체가 없었으므로, 자기 코드에서 이런 결과가 나오면 후보를 만드는 필터나 설정부터 확인한다. 빈 목록에서 errors[0].message를 읽으면 원래 문제 대신 새로운 오류가 생길 수 있다.
예제의 catch는 AggregateError가 아닌 오류를 다시 던진다. 입력 iterable을 읽는 과정의 오류까지 무조건 전체 작업 실패로 다루지 않기 위해서다. 별도 대조에서 iterable의 초기화가 던진 오류는 AggregateError로 바뀌지 않고 원래 오류로 전달됐다.
실행 환경에서 Promise.any is not a function이 나오면 먼저 해당 API 지원을 확인한다. 지원되는 환경에서는 바깥 메시지의 정확한 문구보다 errors의 개수와 입력 작업의 대응을 본다. 타이머 지연이 달라져도 비교할 대상은 거절 로그의 순서와 원인 목록의 순서다.
직접 실행한 범위는 기본 Promise, 합성 오류와 짧은 타이머다. 성공이 먼저 나오는 추가 대조에서도 남은 입력의 종료까지 기다렸다. 브라우저별 콘솔 표시와 오류 문구, 실제 네트워크 요청의 성공 판정·재시도·취소는 검증하지 않았다.