DEV BOX

'26/10/09 UPDATE

WEB / JAVASCRIPT / FIELD NOTES

Promise.all이 실패했는데 나머지 요청은 왜 계속 돌까

API 세 개를 Promise.all로 불렀는데 하나가 500을 돌려주자 catch로 넘어갔습니다. 그런데 서버 로그에는 나머지 두 요청도 끝까지 처리되어 있습니다. 127.0.0.1의 가짜 API와 Node.js v24.21.0으로 Promise.all, Promise.allSettled, AbortController를 붙인 Promise.all을 실제로 실행해 무엇이 멈추고 무엇이 계속되는지 확인하고, abort()가 서버가 이미 한 일은 되돌리지 않는다는 점까지 정리합니다.

화면 하나를 그리려고 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. 1 세 요청이 동시에 출발

    Promise.all(['/a','/b','/c'].map(get))

    • map()이 fetch를 부르는 순간 요청이 나감
    • Promise.all은 그 결과를 모을 뿐
  2. 2 /a 실패 → 바로 거부

    183 ms rejected: /a HTTP 500

    • 첫 번째 거부 사유로 catch에 도착
    • 나머지 결과는 기다리지 않음
  3. 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 뒤에 답합니다.

Promise.all이 실패했다는 것은 '더 기다리지 않겠다'는 뜻입니다. 이미 시작된 요청을 멈추는 일은 하지 않습니다.

실행해 보면: 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 안팎이었습니다.

실행 결과. # 1 Promise.all: 183 ms에 /a가 Error로 멈추고 Promise.all이 /a HTTP 500으로 실패. 372 ms에 /b, 669 ms에 /c가 done (still ran). server.log: GET /a?case=1은 500, /b와 /c는 200으로 모두 처리.
첫 번째 경우의 클라이언트 출력과 서버 로그입니다. 클라이언트 시간은 이 경우를 시작한 때부터, 서버 시간은 서버를 띄운 때부터 잰 값이라 두 숫자를 직접 비교하지 않습니다.

이게 실제로 문제가 되는 때는 이런 경우입니다. 실패 화면을 띄운 뒤에 늦게 도착한 /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이 맞습니다.

세 방법은 같은 요청을 보내지만 실패 하나를 다루는 방식이 다릅니다.
실행 결과. # 2 Promise.allSettled: 104 ms에 /a가 Error, 306 ms에 /b, 607 ms에 /c가 끝나고 607 ms에 allSettled 결과 rejected, fulfilled, fulfilled. # 3 Promise.all + AbortController: 103 ms에 /a가 Error, 112 ms에 /b와 /c가 AbortError로 멈추고 Promise.all이 /a HTTP 500으로 실패. server.log: GET /b?case=3과 /c?case=3에서 client close를 보고 작업을 멈춤.
같은 실행의 두 번째와 세 번째 경우입니다. 서버 로그는 세 번째 경우의 연결 종료 두 줄만 골라 옮겼고, 값은 바꾸지 않았습니다.

abort()도 서버가 한 일을 되돌리지는 않는다

세 번째 경우의 서버 로그에는 server saw client close ... (work stopped)가 찍혔습니다. 하지만 이것은 실습 서버를 그렇게 짰기 때문입니다. 이 서버는 응답의 close 이벤트를 듣다가, 응답을 보내기 전에 연결이 닫히면 예약해 둔 작업을 clearTimeout으로 취소합니다. abort()가 하는 일은 클라이언트가 더 기다리지 않게 하고 연결을 닫는 것까지입니다. 서버가 그 신호를 듣지 않거나, 이미 데이터베이스에 쓰기를 마쳤거나, 다른 서비스에 결제를 요청한 뒤라면 그 일은 그대로 남습니다.

그래서 저장이나 결제처럼 되돌릴 수 없는 요청을 여러 개 묶을 때는 abort()를 취소 버튼으로 생각하면 안 됩니다. 한 번에 처리하는 API 하나로 합치거나, 실패했을 때 무엇을 되돌릴지 서버 쪽에서 따로 설계해야 합니다. abort()는 조회처럼 결과가 더 필요 없는 요청의 낭비를 줄이는 도구로 쓰는 것이 맞습니다.

개발 노트

abort()가 멈추는 것과 못 멈추는 것

클라이언트에서 서버까지

  1. 1 클라이언트: 기다림을 멈춤

    ac.abort() → AbortError

    • fetch가 AbortError로 거부됨
    • 응답 처리 코드가 실행되지 않음
  2. 2 연결: 닫힘

    res.on('close') → clearTimeout

    • 서버는 연결이 끊긴 것을 알 수 있음
    • 듣고 멈출지는 서버 코드가 정함
  3. 3 이미 끝난 일: 그대로

    abort ≠ rollback

    • 저장·결제가 끝났다면 되돌리지 않음
    • 되돌리기는 따로 설계해야 함

실습 서버는 연결 종료를 들으면 작업을 멈추도록 직접 짠 것입니다. 모든 서버가 이렇게 동작한다는 뜻은 아닙니다.

abort()는 클라이언트가 결과를 기다리지 않게 만들고 연결을 닫습니다. 서버가 이미 한 일까지 취소하지는 않습니다.

정리하면

Promise.all이 실패했다는 것은 "더 기다리지 않겠다"는 뜻일 뿐, 이미 출발한 요청을 멈췄다는 뜻이 아닙니다. 코드에서 Promise.all을 볼 때 이렇게 한 번 물어보면 됩니다. 하나가 실패하면 나머지 결과를 그래도 쓸 것인가? 쓸 것이라면 Promise.allSettled로 바꾸고, 쓰지 않을 것이라면 같은 AbortController의 signal을 넘겨 첫 실패에서 abort()를 부르세요. 그리고 저장처럼 되돌릴 수 없는 요청이 섞여 있다면, 어느 쪽이든 서버가 이미 한 일은 따로 챙겨야 합니다.

끝 · END OF NOTE목록으로
COMMENTS BOX

이 기록에 대화를 더해 주세요.

궁금한 점, 다른 접근, 직접 해 본 결과를 나눠 주세요.

최신순

로그인 상태 확인 중…

댓글을 불러오는 중…