화면 하나를 그리려고 API 세 개를 Promise.all로 동시에 불렀습니다. 그중 하나가 500을 돌려주자 catch로 넘어가 오류 메시지를 띄웠습니다. 그런데 서버 로그를 보면 나머지 두 요청도 끝까지 처리되어 있습니다. 실패했는데 왜 나머지는 멈추지 않았을까요? 이 글을 읽고 나면 Promise.all이 실패할 때 실제로 무엇이 멈추고 무엇이 계속되는지 로그로 확인할 수 있고, 상황에 따라 Promise.allSettled와 AbortController 중 무엇을 붙일지 고를 수 있습니다.
예제는 2026-10-08 macOS 27.0.1에서 Node.js v24.21.0으로 실행했습니다. Node.js의 fetch는 함께 들어 있는 undici 7.29.1이 처리합니다. 서버는 같은 컴퓨터(127.0.0.1)에 띄운 작은 Node.js 가짜 API입니다. /a는 100 ms 뒤 500을, /b는 300 ms, /c는 600 ms 뒤 200을 돌려주고, 요청을 끝낼 때마다 로그를 남깁니다. 경로와 시간은 모두 설명을 위해 만든 것입니다.
Promise.all은 작업이 아니라 기다리는 방법이다
먼저 요청이 언제 출발하는지 봐야 합니다. ['/a', '/b', '/c'].map((p) => get(p))를 실행하는 순간 fetch가 세 번 불리고, 세 요청은 이미 네트워크로 나갑니다. Promise.all은 그 뒤에 만들어진 세 개의 Promise를 받아 "모두 성공하면 결과 배열로, 하나라도 실패하면 그 첫 번째 실패 이유로" 끝나는 새 Promise를 돌려줄 뿐입니다. 요청을 출발시키지도, 멈추게 하지도 않습니다.
JavaScript의 Promise에는 취소라는 동작이 없습니다. Promise는 이미 진행 중인 일의 결과를 나중에 받기 위한 자리일 뿐이라서, 그 결과를 기다리는 쪽이 포기해도 일 자체에는 아무 신호가 가지 않습니다. Promise.all이 정해 둔 것은 입력 중 하나가 거부되면 자신도 바로 거부된다는 것뿐이고, 나머지 입력을 어떻게 한다는 규칙은 없습니다.
개발 노트
Promise.all은 기다리는 규칙일 뿐이다
첫 번째 경우 · Node.js v24.21.0 실행
1 세 요청이 동시에 출발
Promise.all(['/a','/b','/c'].map(get))- map()이 fetch를 부르는 순간 요청이 나감
- Promise.all은 그 결과를 모을 뿐
2 /a 실패 → 바로 거부
183 ms rejected: /a HTTP 500- 첫 번째 거부 사유로 catch에 도착
- 나머지 결과는 기다리지 않음
3 /b·/c는 끝까지 실행
372 ms /b done · 669 ms /c done- 취소 신호가 없으니 응답까지 받음
- 서버도 세 요청을 모두 처리
시간은 2026-10-08 client.mjs 실행 출력 그대로입니다. /a·/b·/c는 127.0.0.1의 가짜 API로, 각각 100·300·600 ms 뒤에 답합니다.
실행해 보면: catch가 끝난 뒤에도 요청은 돈다
클라이언트의 get()은 fetch로 요청을 보내고, 응답이 ok가 아니면 오류를 던집니다. fetch는 404나 500을 받아도 그 자체로는 실패하지 않기 때문에 이렇게 직접 던져야 Promise.all이 실패를 알 수 있습니다. 성공하면 done (still ran)을, 실패하면 stopped와 오류 이름을 찍습니다.
첫 번째 경우는 흔히 쓰는 try { await Promise.all(...) } catch 그대로입니다. 183 ms에 /a가 500을 받아 실패했고, 같은 시각 Promise.all rejected: /a HTTP 500이 찍혔습니다. 여기까지는 기대한 대로입니다. 그런데 그 뒤로 372 ms에 /b, 669 ms에 /c가 응답을 끝까지 받았습니다. 서버 로그에도 세 요청이 모두 finished로 남았습니다. 첫 요청이 100 ms보다 늦은 183 ms에 끝난 것은 처음 연결을 맺는 시간이 더해졌기 때문으로 보이며, 두 번째 경우부터는 104 ms 안팎이었습니다.

이게 실제로 문제가 되는 때는 이런 경우입니다. 실패 화면을 띄운 뒤에 늦게 도착한 /b의 결과가 상태를 덮어써 화면이 다시 바뀌거나, 사용자가 다른 페이지로 넘어간 뒤에 응답이 와서 이미 사라진 컴포넌트를 건드리는 경우입니다. 그리고 /b가 조회가 아니라 저장 요청이었다면, 사용자는 "실패했다"는 메시지를 봤는데 데이터는 일부 저장된 상태가 됩니다.
고르는 기준: 실패 하나가 무엇을 뜻하는가
두 번째 경우는 Promise.allSettled입니다. 이 함수는 실패가 있어도 모든 입력이 끝날 때까지 기다린 뒤, 각 결과를 { status: 'fulfilled', value } 또는 { status: 'rejected', reason } 모양으로 돌려줍니다. 실행에서는 104 ms에 /a가 실패했지만 결과는 /c까지 끝난 607 ms에 rejected, fulfilled, fulfilled로 한 번에 나왔습니다. 대시보드처럼 일부 카드만 실패해도 나머지를 보여 줄 수 있는 화면이라면 이쪽이 맞습니다.
세 번째 경우는 하나라도 실패하면 나머지가 의미 없는 상황입니다. 세 요청에 같은 AbortController의 signal을 넘기고, 어느 하나가 실패하면 abort()를 부르게 했습니다.
const ac = new AbortController();
await Promise.all(paths.map((p) =>
get(p, ac.signal).catch((e) => { ac.abort(); throw e; })));
103 ms에 /a가 실패하자 112 ms에 /b와 /c의 fetch가 AbortError로 멈췄습니다. abort()에 이유를 넘기지 않으면 이름이 AbortError인 DOMException이 이유가 됩니다. 멈춘 두 요청도 각자의 catch에서 abort()를 다시 부르지만, DOM 표준은 이미 중단된 신호에 대한 중단을 아무 일 없이 끝내도록 정해 두었습니다. 따로 확인한 실행에서도 abort()를 두 번 불렀을 때 abort 이벤트는 한 번만 발생했습니다. 또 Promise.all이 이미 실패한 뒤에 거부된 /b·/c 때문에 처리되지 않은 거부 경고가 나오지는 않았습니다.
개발 노트
실패 하나에 무엇을 할지 먼저 정한다
같은 세 요청, 세 가지 묶는 방법
Promise.all
183 ms rejected · /b·/c still run- 첫 실패에서 바로 catch
- 다른 요청은 몰래 계속 돎
Promise.allSettled
607 ms rejected, fulfilled, fulfilled- 모두 끝날 때까지 기다림
- 성공·실패를 하나씩 확인
all + AbortController
112 ms /b·/c AbortError- 첫 실패에서 abort()
- 나머지 fetch도 AbortError로 멈춤
값은 2026-10-08 같은 실행의 출력입니다. 일부만 실패해도 쓸 수 있으면 allSettled, 하나라도 실패하면 의미가 없으면 abort를 붙인 Promise.all이 맞습니다.

abort()도 서버가 한 일을 되돌리지는 않는다
세 번째 경우의 서버 로그에는 server saw client close ... (work stopped)가 찍혔습니다. 하지만 이것은 실습 서버를 그렇게 짰기 때문입니다. 이 서버는 응답의 close 이벤트를 듣다가, 응답을 보내기 전에 연결이 닫히면 예약해 둔 작업을 clearTimeout으로 취소합니다. abort()가 하는 일은 클라이언트가 더 기다리지 않게 하고 연결을 닫는 것까지입니다. 서버가 그 신호를 듣지 않거나, 이미 데이터베이스에 쓰기를 마쳤거나, 다른 서비스에 결제를 요청한 뒤라면 그 일은 그대로 남습니다.
그래서 저장이나 결제처럼 되돌릴 수 없는 요청을 여러 개 묶을 때는 abort()를 취소 버튼으로 생각하면 안 됩니다. 한 번에 처리하는 API 하나로 합치거나, 실패했을 때 무엇을 되돌릴지 서버 쪽에서 따로 설계해야 합니다. abort()는 조회처럼 결과가 더 필요 없는 요청의 낭비를 줄이는 도구로 쓰는 것이 맞습니다.
개발 노트
abort()가 멈추는 것과 못 멈추는 것
클라이언트에서 서버까지
1 클라이언트: 기다림을 멈춤
ac.abort() → AbortError- fetch가 AbortError로 거부됨
- 응답 처리 코드가 실행되지 않음
2 연결: 닫힘
res.on('close') → clearTimeout- 서버는 연결이 끊긴 것을 알 수 있음
- 듣고 멈출지는 서버 코드가 정함
3 이미 끝난 일: 그대로
abort ≠ rollback- 저장·결제가 끝났다면 되돌리지 않음
- 되돌리기는 따로 설계해야 함
실습 서버는 연결 종료를 들으면 작업을 멈추도록 직접 짠 것입니다. 모든 서버가 이렇게 동작한다는 뜻은 아닙니다.
정리하면
Promise.all이 실패했다는 것은 "더 기다리지 않겠다"는 뜻일 뿐, 이미 출발한 요청을 멈췄다는 뜻이 아닙니다. 코드에서 Promise.all을 볼 때 이렇게 한 번 물어보면 됩니다. 하나가 실패하면 나머지 결과를 그래도 쓸 것인가? 쓸 것이라면 Promise.allSettled로 바꾸고, 쓰지 않을 것이라면 같은 AbortController의 signal을 넘겨 첫 실패에서 abort()를 부르세요. 그리고 저장처럼 되돌릴 수 없는 요청이 섞여 있다면, 어느 쪽이든 서버가 이미 한 일은 따로 챙겨야 합니다.
이 기록에 대화를 더해 주세요.
궁금한 점, 다른 접근, 직접 해 본 결과를 나눠 주세요.
로그인 상태 확인 중…
댓글을 불러오는 중…