본문으로 건너뛰기
Tech Blog

Promise.all 의 진짜 비용

글 복사 완료!

Promise.all 의 효과는 JS 한 줄 안쪽에서만 보장돼요.

·11분·

두 API 응답을 합쳐 화면 하나를 그려야 했던 적이 있어요. 처음엔 await 두 줄로 나눠 받았는데, "두 번째 응답이 첫 번째를 기다릴 이유가 없는데" 싶어서 Promise.all 로 묶었거든요. 체감 속도가 두 배 가까이 빨라졌어요. 거기까지는 좋았는데, 갯수를 30개로 늘렸더니 Network 탭의 막대들이 일곱 번째부터 줄을 서기 시작하더라고요. JS 코드는 동시에 보낸다고 했는데 wire 위에서는 그게 아니었던 거예요.

Promise.all 옆에 셋이 더 있어요

MDN 의 Promise 정적 메서드는 네 개예요. all, allSettled, race, any. 셋 다 비슷하게 생겼지만 실패를 다루는 방식이 전부 달라서, 어느 걸 쓸지 잘못 고르면 같은 코드가 정반대로 동작해요.

가장 익숙한 Promise.all 부터 짚을게요. 입력 promise 가 모두 fulfill 되면 결과도 fulfill, 하나라도 reject 되면 즉시 reject 거든요. fail-fast 라고 부르는 동작이에요. 결과 배열은 입력 순서로 정렬돼서 완료 순서랑은 달라요. 그래서 인덱스로 매핑하기가 편하죠.

여기서 헷갈리는 점이 있어요. 모두 reject 됐을 때 AggregateError 가 나오지 않냐고 묻는 분들이 있는데, all 은 그렇지 않아요. 가장 먼저 reject 한 reason 그대로 reject 해요. AggregateError 는 곧 나올 any 의 특기예요.

"In comparison, the promise returned by Promise.allSettled() will wait for all input promises to complete, regardless of whether or not one rejects." - MDN

all 이 한 명이 넘어지면 줄 전체를 멈춘다면, allSettled 는 끝까지 기다려요. 부분 실패를 받아들일 수 있을 때의 도구죠.

Promise.allSettled 는 절대 reject 하지 않아요. 결과는 항상 객체 배열로 들어와요. 각 원소가 { status: "fulfilled", value } 또는 { status: "rejected", reason } 모양이거든요. 대시보드처럼 6개 위젯 중 하나가 망가져도 나머지 5개는 그려야 하는 자리에서 어울려요. all 로 묶으면 하나 망가질 때 전체가 빈 화면이 되니까요.

raceany 는 첫 결과만 필요한 자리예요. 둘이 비슷해 보이는데 분기점은 "실패도 결과로 칠 거냐" 한 줄이에요. Promise.race 는 first settled, 즉 fulfill 이든 reject 든 가장 먼저 끝난 것 그대로 settle 해요. Promise.any 는 first fulfilled 만 인정하고요. 모든 입력이 reject 되면 그제야 AggregateError 로 reject 해요.

체감 차이를 그리면 이래요. timeout 패턴은 race 가 어울려요. "5초 안에 응답 없으면 timeout 도 결과로 친다" 는 의도잖아요. 미러 서버 fallback 은 any 가 어울리고요. "어느 한 곳이라도 성공하면 그걸 쓴다" 는 의도라서요. 같은 4개 입력을 네 메서드에 돌려보면 차이가 한눈에 들어와요.

fan-out 의 진짜 비용

여기까지가 JS 의 이야기였어요. 다음은 wire 의 이야기예요.

Promise.all 로 30개 fetch 를 묶어 보냈다고 해서, 그 30개가 정말 동시에 출발하는 건 아니에요. HTTP/1.1 시절 브라우저는 한 origin 당 TCP 연결을 최대 6개까지만 열거든요. JS 가 30개 Promise 를 만들어도, 네트워크 큐는 6개부터 채워지고 나머지 24개는 슬롯이 비기를 기다려요.

여기 두 번째 함정이 있어요. HTTP/1.1 의 한 연결 위에서는 요청 하나가 응답을 다 받기 전에 다음 요청을 보낼 수 없어요. 우회하려고 들어왔던 pipelining 도 한계가 분명했고요.

"Pipelining is subject to the head-of-line blocking." - MDN

요청은 줄세워 보낼 수 있었지만 응답은 보낸 순서대로 받아야 했어요. 앞 응답이 느리면 뒤가 줄줄이 멈추는 거예요. 결국 대부분 브라우저가 pipelining 을 기본 비활성으로 묻었고요.

그러니까 Promise.all 의 효과는 JS 한 줄 안쪽에서만 보장돼요. wire 위에서는 origin 당 6개라는 슬롯, 그 안의 head-of-line blocking 이 진짜 비용을 결정해요. 같은 코드라도 요청이 같은 origin 에 몰려 있으면 30개를 묶어도 6개씩 5번 돌리는 셈이거든요.

HTTP/2 가 풀어준 자리

같은 코드를 HTTP/2 위에서 돌리면 같은 결과가 다르게 나와요.

"It's a multiplexed protocol. Parallel requests can be made over the same connection, removing the constraints of the HTTP/1.x protocol." - MDN

한 TCP 연결 위에서 요청을 마구 섞어 보낼 수 있어요. origin 당 6개라는 슬롯도, 한 연결의 head-of-line blocking 도 응용 계층에서는 사라지거든요.

1

HTTP/1.1 큐잉

한 origin 에 연결 6개. 요청 30개를 묶어도 6개 슬롯이 차례로 5번 돌아요. 일곱 번째 막대가 늦게 시작하는 진짜 이유예요.

2

HTTP/2 멀티플렉싱

한 TCP 연결 위에 frame 단위로 마구 섞어 보내요. 30개가 한꺼번에 출발하는 모양이 돼요.

3

코드는 같아요

Promise.all 의 코드 한 줄은 그대로예요. 바뀐 건 그 한 줄 뒤의 wire 모양이고요.

frame 으로 쪼개고 stream 번호로 끼워 넣고 받는 쪽에서 다시 모으는 구조 자체는 HTTP/2 멀티플렉싱이 어떻게 동작하는지를 풀어둔 글 에서 자세히 다뤘어요. 여기서는 우리가 짠 Promise.all 의 효과가 wire 위에서 살아나는지 죽어가는지가 HTTP 버전에 달려 있다는 사실 한 줄만 들고 갈게요.

서버에서 합쳐 내리면

여기까지 보면 답이 명확해 보여요. HTTP/2 로 넘어가면 fan-out 이 자유로워진다, 그게 결론 같죠. 다만 한 단계 더 가면 다른 선택지가 보여요.

클라이언트가 N 개를 fetch 하지 않고, 서버가 그 N 개를 합쳐 한 번에 내려주는 패턴이에요. Sam Newman 이 정리한 BFF (Backend for Frontend) 가 그 대표예요. UI 가 화면 하나를 그리는 데 필요한 데이터 6 종을 마이크로서비스 6 곳에서 가져와야 한다고 해볼게요. 클라이언트가 Promise.all 로 6개 fetch 를 묶는 대신, BFF 가 자기 안에서 그 6개를 호출해 합쳐 내려줘요. 클라이언트는 그냥 한 번 fetch 하고 끝이에요. 클라이언트의 Promise.all 을 서버가 대신 해주는 셈이에요.

GraphQL 의 그림도 결이 비슷해요. 한 엔드포인트, 한 요청으로 여러 리소스를 받아요. 어느 필드를 받을지를 쿼리가 정한다는 점이 다르긴 한데, "클라이언트가 fan-out 하지 않는다" 는 메시지는 똑같아요.

트레이드오프는 짚고 가야 해요. 서버가 합치면 클라이언트 fan-out 의 비용은 사라지지만, 서버 안의 fan-out 비용은 그대로예요. 거기서 또 Promise.all 같은 도구로 다운스트림 호출을 묶고 있을 거예요. 게다가 BFF 는 화면별로 별도 서버라서 운영 부담이 늘고, GraphQL 은 N+1 같은 또 다른 함정이 따라와요. 그래서 "fan-out 하면 무조건 서버에서 합치자" 가 답이 아니라, fan-out 이 자주 같은 묶음으로 일어나는가가 판단의 기준이에요.

그래서 어디에

네 메서드와 두 aggregation 전략을 한 줄씩 정리하면 이래요.

다 받아야 하고 부분 실패도 받아들일 수 없을 때 Promise.all 이에요. 다 받고 싶지만 일부 실패는 OK 일 때 allSettled 로 가고요. 첫 결과만 필요하고 timeout 으로도 쓰겠다 싶으면 race, fallback 미러 중 첫 성공만 쓰겠다면 any 예요. 그리고 같은 화면에서 같은 묶음의 fan-out 이 자주 일어난다면, 클라이언트에서 Promise.all 을 키우기 전에 서버 측 aggregation 을 한 번쯤 검토해볼 만해요.

저도 처음엔 Promise.all 한 줄로 다 끝난다고 생각했어요. 한 줄 뒤에 6 이라는 숫자가 있고, 그 뒤에 멀티플렉싱이 있고, 또 그 뒤에 서버 모양이 있다는 걸 보고 나면, 같은 코드도 다르게 짜고 싶어지더라고요.

참고 자료

관련 글