병렬인 줄 알았던 세 줄의 쿼리
useSuspenseQuery 를 세 줄로 두면 왜 요청이 계단처럼 줄을 서는지 풀어요.
한 화면에 사용자, 팀, 프로젝트 세 데이터를 같이 그려야 했어요. 서버 쪽에선 Promise.all 로 셋을 묶어 한 번에 받았는데, 같은 걸 React 컴포넌트로 옮기면서 useSuspenseQuery 를 세 줄로 나란히 뒀거든요. 그랬더니 Network 탭 막대가 계단처럼 한 칸씩 밀려서 시작하더라고요. Promise.all 이 공짜로 갖던 '한 배열에 다 적어 넣는' 구조를, React 렌더에선 useQueries 와 useSuspenseQueries 가 되찾아줘요.
배열에 다 적어 넣으면 병렬은 공짜예요
Promise.all 의 병렬성이 어디서 오는지부터 짚을게요. 비밀은 배열이에요. 보낼 요청을 한 배열에 다 적어 넣는 순간, 셋은 코드 흐름상 동시에 출발해요. 개수가 고정이 아니어도 상관없어요. userIds.map((id) => fetchUser(id)) 처럼 배열을 만들어 넘기면 되니까요.
React 렌더에서 같은 일을 하려면 한 가지 벽을 만나요. useQuery 를 map 으로 돌려 부를 수가 없거든요. 훅은 매 렌더에서 같은 순서로 같은 개수만큼 호출돼야 하는데, 루프 안에서 부르면 그 규칙이 깨져요. 그래서 TanStack Query 가 useQueries 를 따로 둔 거예요. 가변 개수의 쿼리를 한 배열로 받아서 병렬로 실행하고, 결과도 쿼리별 배열로 돌려줘요.
// 개수가 고정이 아니어도 배열로 만들면 돼요
const results = await Promise.all(userIds.map((id) => fetchUser(id)));
// React 렌더에서 같은 모양을 만드는 훅
import { useQueries } from "@tanstack/react-query";
const results = useQueries({
queries: userIds.map((id) => ({
queryKey: ["user", id],
queryFn: () => fetchUser(id),
})),
});useQueries 는 Promise.all(items.map(...)) 의 렌더 짝이라고 보면 편해요. 배열을 넣으면 배열이 나오고, 그 안의 요청들은 같이 출발해요. 여기까지는 직관대로예요.
suspend가 멈추면 그 아래 줄은 안 돌아요
문제는 Suspense 를 쓰는 순간 생겨요. Suspense 경계는 children 이 렌더 도중 suspend 하면 fallback 으로 화면을 바꿔요.
그럼 suspend 가 정확히 무엇이냐면, 컴포넌트가 아직 안 끝난 promise 를 읽는 순간이에요. use 가 그 표준 통로인데, 넘긴 promise 가 pending 인 동안 그 컴포넌트는 렌더를 멈춰요.
멈춘다는 건 그 아래 코드가 실행되지 않는다는 뜻이에요. 그래서 useSuspenseQuery 를 세 줄로 줄세우면, 첫 줄이 suspend 되는 순간 나머지 두 줄은 시작도 못 해요. 첫 요청이 끝나야 두 번째가 출발하고요. 계단처럼 밀리던 그 막대가 바로 이거예요. TanStack Query 문서는 이걸 직렬(serial)이라고 부르거든요.
"The following queries will execute in serial, causing separate roundtrips to the server ... since the queries above suspend rendering, no data gets rendered until all of the queries finished." - TanStack Query
위쪽 쿼리가 렌더를 멈춰버리니까, 아래 쿼리는 앞이 끝날 때까지 출발조차 못 해요. 병렬인 줄 알았던 코드가 한 칸씩 줄을 서는 거죠.
풀어주는 방법은 다시 배열이에요. useSuspenseQueries 로 묶으면 세 요청을 한 번에 발사하고, 셋 다 끝난 뒤에야 본문이 함께 그려져요.
// 직렬: 첫 줄이 suspend 되면 아래 두 줄은 시작도 안 해요
const users = useSuspenseQuery({ queryKey: ["users"], queryFn: fetchUsers });
const teams = useSuspenseQuery({ queryKey: ["teams"], queryFn: fetchTeams });
const projects = useSuspenseQuery({ queryKey: ["projects"], queryFn: fetchProjects });
// 병렬: 배열로 묶으면 셋이 한 번에 나가요
import { useSuspenseQueries } from "@tanstack/react-query";
const [users, teams, projects] = useSuspenseQueries({
queries: [
{ queryKey: ["users"], queryFn: fetchUsers },
{ queryKey: ["teams"], queryFn: fetchTeams },
{ queryKey: ["projects"], queryFn: fetchProjects },
],
});체감 차이를 코드로 보면 이래요. 각 요청이 300ms 걸린다고 할 때, 줄세운 await 와 배열로 묶은 Promise.all 이 어떻게 갈리는지 직접 눌러보세요. 위 useSuspenseQuery 직렬이 앞쪽, useSuspenseQueries 병렬이 뒤쪽과 같은 모양이에요.
suspend 가 안쪽에서 어떻게 렌더를 멈추고 다시 그리는지, 그 메커니즘은 Suspense 안쪽과 useSuspenseQuery 에서 더 깊이 다뤘어요. 여기서는 "멈추면 아래 줄이 안 돈다" 한 줄만 들고 갈게요.
어느 쪽이 all이고 어느 쪽이 allSettled인가
두 훅을 갈라 쓰는 기준이 하나 더 있어요. 실패를 어떻게 다루느냐예요. 그리고 이 갈림은 Promise.all 과 allSettled 의 차이와 그대로 포개져요.
useQueries 는 쿼리별 결과를 배열로 줘요. 각 칸이 자기 isPending, 자기 error 를 따로 들고 있거든요. 그래서 셋 중 하나가 실패해도 나머지 둘은 멀쩡히 데이터를 그려요. 대시보드에서 위젯 하나가 망가져도 나머지는 살아남는 그림이죠. Promise.allSettled 가 부분 실패를 받아내던 자리와 같아요.
useSuspenseQueries 는 정반대예요. 한 경계 안에서 단일 단위로 묶이거든요. 모든 쿼리가 끝나야 컴포넌트가 re-mount 돼서 그려지고, 그래서 각 data 는 절대 undefined 가 아니에요. 대신 하나라도 reject 하면 그 throw 가 가장 가까운 ErrorBoundary 로 올라가서 경계 전체가 fallback 으로 넘어가요. 하나가 넘어지면 줄 전체가 멈추는 Promise.all 의 fail-fast 와 닮았어요.
"The component will only re-mount after all queries have finished loading." - TanStack Query
다 끝나기 전엔 한 글자도 안 그려요. 셋을 하나의 로딩 단위로 묶는다는 뜻이에요. 부분만 먼저 보여주고 싶다면 이 훅이 아닌 거죠.
그러니까 선택은 취향이 아니라 의도예요. 부분 실패를 화면에 받아들일 거면 useQueries, 화면 한 덩어리를 통째로 로딩 단위로 묶을 거면 useSuspenseQueries 고요. Promise.all 과 allSettled 가 실패를 어떻게 가르는지는 Promise.all 의 진짜 비용 에서 따로 풀어뒀어요.
wire 천장은 라이브러리가 못 피해요
여기까지 보면 useSuspenseQueries 로 다 묶으면 끝 같죠. 근데 코드에서 병렬로 풀어도, 그 요청들이 wire 위에서 정말 동시에 나가는지는 또 다른 이야기예요. 브라우저는 HTTP/1.1 에서 한 origin 당 연결을 흔히 6개까지만 열거든요.
"Default was once 2 to 3 connections, but this has now increased to a more common use of 6 parallel connections." - MDN
useSuspenseQueries 로 30개를 한 배열에 묶어도, 같은 origin 이면 6개씩 줄을 서요. 라이브러리가 코드 흐름의 직렬은 풀어줬지만, 연결 한도까지 풀어주진 않아요.
이 천장은 HTTP/2 로 올라가면 달라져요. 한 연결 위에서 요청을 다중화하니까 6개 한도가 응용 계층에선 사라지거든요. 그래서 같은 useQueries 코드라도 요청이 어느 origin 에, 어느 HTTP 버전 위에 얹히느냐가 진짜 비용을 정해요. 이 부분은 위에 링크한 글에서 더 자세히 다뤘고요.
다만 TanStack Query 에는 Promise.all 에 없는 손잡이가 하나 더 있어요. 같은 queryKey 면 중복 요청을 하나로 합치고, 캐시에 신선한 값이 있으면 네트워크에 아예 안 나가요. wire 천장은 못 피하지만, 안 보내는 건 할 수 있는 거죠. 그래서 여러 요청을 한꺼번에 펼쳐 보내는 fan-out 의 진짜 비용을 줄이는 첫 수는 더 잘 병렬화하는 게 아니라, 안 보내도 되는 요청을 캐시로 걸러내는 거예요.
그래서 어디에
Promise.all 한 줄이 React 렌더에선 두 손잡이로 갈라져요. 가변 개수를 병렬로 받고 부분 실패도 화면에 받아들이겠다면 useQueries, 화면을 한 덩어리로 묶어 Suspense 에 태우겠다면 useSuspenseQueries 고요. 그 아래 wire 는 어느 쪽이든 origin 당 6개라는 같은 천장을 봐요.
저도 처음엔 useSuspenseQuery 세 줄이면 알아서 병렬로 나갈 줄 알았어요. 한 줄이 멈추면 아래 줄이 안 돈다는 걸, Network 탭 계단을 보고 나서야 알았거든요. 배열에 다 적어 넣어야 병렬이 공짜라는 Promise.all 의 교훈은, 렌더 안에서도 똑같더라고요.
참고 자료
- TanStack Query - Parallel Queries
가변 개수 쿼리에서 useQuery 를 루프로 못 부르는 이유와 useQueries 의 배열 입출력
- TanStack Query - useSuspenseQueries
모든 쿼리가 끝난 뒤 re-mount 되는 단일 단위 동작과 data 보장
- TanStack Query - Request Waterfalls
여러 useSuspenseQuery 가 직렬로 실행되는 이유와 useSuspenseQueries 로 푸는 법
- React -
<Suspense>children 이 suspend 하면 경계가 fallback 으로 전환되는 동작
- React -
useAPI Referencepromise 가 pending 인 동안 컴포넌트가 suspend 된다는 정의
- TanStack Query - Overview
같은 queryKey 요청을 dedupe 하고 캐시 히트 시 네트워크를 생략하는 동작
- MDN - Connection management in HTTP/1.x
origin 당 연결을 흔히 6개까지 여는 한도
- MDN - Evolution of HTTP
HTTP/2 가 한 연결 위에서 요청을 다중화해 연결 한도를 없애는 동작