useSuspenseQuery로 데이터를 가져오면 코드가 선언적으로 정리된다는 것은, TanStack Query를 쓰는 사람들 사이에서 거의 정설처럼 통합니다. 여기서 TanStack Query란 서버 상태를 다루는 데이터 fetching 라이브러리를 말합니다. 예전 이름은 React Query입니다. isLoading을 따지던 if 문이 사라지고, data는 항상 존재한다고 믿고 쓸 수 있습니다.이 강점은 분명합니다. 다만 강점은 거저 유지되지 않습니다.
useSuspenseQuery는 이 선언성을 얻는 대가로 suspend 상태를 위로 던집니다. 여기서 suspend란 컴포넌트 렌더링을 잠시 멈추는 상태를 말합니다. 요청이 줄줄이 늘어지는 waterfall, 화면이 통째로 비는 흰 화면, 앱 전체를 무너뜨리는 전역 에러는 모두 이 하나의 동작에서 파생됩니다.Suspense Query는 제값을 하는 도구입니다. 그럼에도 그 값을 온전히 받으려면 그 던져진 suspend를 client나 server에서 받아 낼 준비가 돼 있어야 합니다. 여기서 선언적(declarative)이라는 말은, 데이터를 어떻게 단계별로 처리할지 나열하는 대신 무엇을 원하는지만 적고 나머지는 React에 맡기는 방식을 가리킵니다.
useSuspenseQuery의 이점
먼저 이 hook이 무엇을 없애 주는지 볼까요? TanStack Query 공식 문서는
useSuspenseQuery의 반환값을 이렇게 설명합니다.data is guaranteed to be defined
data가 타입 수준에서 항상 정의된 값으로 보장된다는 뜻입니다. 이 한 줄이 컴포넌트에서 꽤 많은 것을 걷어 냅니다. 현재 표준에 가까운 useQuery 코드입니다. 로딩과 에러를 매번 분기합니다.// useQuery: 로딩과 에러를 직접 분기해야 합니다 const { data, isLoading, isError } = useQuery({ queryKey: ['post', postId], queryFn: () => fetchPost(postId), }) if (isLoading) return <Spinner /> if (isError) return <ErrorView /> return <Article post={data} /> // data의 타입은 Post | undefined
같은 화면을
useSuspenseQuery로 바꾼 모습입니다. 분기가 사라집니다.// useSuspenseQuery: 에러 및 로딩 분기가 사라지고 data가 보장됩니다 const { data } = useSuspenseQuery({ queryKey: ['post', postId], queryFn: () => fetchPost(postId), }) return <Article post={data} /> // data의 타입은 Post (undefined가 아님)
동시에
data의 타입이 Post | undefined에서 Post로 바뀌었습니다. 항상 데이터가 존재한다고 믿고 쓸 수 있다는 뜻입니다. 데이터 자체가 undefined일 수 없다는 보장 하나가, 로딩 분기와 에러 분기를 컴포넌트에서 통째로 들어냅니다. 선언적인 표현은 결국 이 보장에서 출발합니다.suspend는 결국 throw다
앞서 suspend를 컴포넌트 렌더링이 잠시 멈추는 상태라고 적었습니다. 이 멈춤이 실제로 어떻게 일어나는지 보면 이름 그대로입니다. render 도중 어떤 컴포넌트가 "아직 못 그리겠다"고 손을 들어, 그 컴포넌트의 렌더링이 그 자리에서 멈추는 것입니다. 어딘가에 저장되는 값이 아니라 render 단계에서 일어나는 사건입니다.
문제는 React에서 render가 동기 함수 호출이라는 점입니다. 컴포넌트 함수가 도는 도중에 "여기서 잠깐 멈췄다가 데이터가 오면 이어서 진행"할 방법이 없습니다. 함수 실행을 중간에 빠져나가는 수단은
throw 하나뿐입니다. 그래서 React는 "지금은 못 그린다"는 신호를 throw로 표현합니다. 데이터가 없으면 컴포넌트가 promise를 던지고, 함수는 거기서 끝납니다.// suspend의 본질: render 도중 promise를 throw합니다 function Component() { if (!data) { throw fetchData() // 이 한 줄이 곧 suspend입니다 } return <div>{data}</div> }
던져진 promise는 가장 가까운
<Suspense>가 받습니다. React는 그 자리에 fallback을 띄우고, 던져진 promise에 "resolve되면 알려 달라"를 걸어 둡니다. promise가 resolve되면 React는 그 컴포넌트를 다시 그립니다. 멈춘 지점부터 이어가는 것이 아니라, 컴포넌트 함수를 처음부터 다시 호출하는 재시도입니다. 이번에는 데이터가 있으니 if에 걸리지 않고 정상적으로 JSX를 반환합니다.
받아 줄 경계가 없으면 화면이 사라진다
이 강점에는 따라오는 책임이 하나 있습니다.
useSuspenseQuery는 분기를 지운 게 아니라, 분기를 처리할 책임을 상위로 옮긴 것입니다.앞서 적었듯
useSuspenseQuery는 선언성을 얻는 대가로 suspend 상태를 위로 던집니다. suspend 상태가 되면 React는 가장 가까운 <Suspense> 경계까지 거슬러 올라가 fallback을 띄웁니다. 여기서 fallback이란 데이터가 준비되는 동안 대신 보여주는 화면을 말합니다. 에러가 나면 가장 가까운 error boundary까지 올라갑니다. 여기서 error boundary란 에러를 잡아 대체 화면으로 바꿔 주는 경계를 말합니다. 게다가 useSuspenseQuery는 에러를 위로 던질지 결정하는 throwOnError 옵션을 끌 수 없습니다. 실패하면 무조건 위로 던집니다.받아 줄 경계가 없으면 어떻게 되는지를 도식으로 보면 이렇습니다.
<App> ◄── 여기까지 경계가 하나도 없으면 트리 전체가 무너집니다. └ <Layout> 에러가 트리를 끝까지 타고 올라갑니다. └ <PostList> ── 여기서 throw 됩니다.
최초에 Suspense 상태만 두고, ErrorBoundary를 두지 않은 적이 있습니다. query 하나가 실패하자 그 에러가 트리 위로 끝까지 올라갔고, 받아 줄 경계가 없어 React가 트리 전체를 정리하면서 서비스가 멈췄습니다. 분기를 지운 자리에 경계를 두지 않았으니, 기존에 컴포넌트 내에서 처리하던 에러를 받아 줄 곳이 어디에도 없었던 것입니다.
또 한 번은 Suspense 경계를 화면 맨 위에 단 하나만 두었습니다. 안쪽의 작은 영역 하나가 suspend됐을 뿐인데 화면 전체가 fallback으로 덮였고, 그 fallback 자리를 비워 둔 탓에 흰 화면이 됐습니다.
두 경험 모두 같은 처방으로 풀립니다. suspend와 에러를 받아 줄 경계를 컴포넌트 가까이에 두는 것입니다.
// 경계가 없으면 suspend도 에러도 받아 줄 곳이 없습니다 function Page() { return <PostList /> // 내부에서 useSuspenseQuery를 사용합니다 }
// Suspense와 ErrorBoundary로 감싸야 화면이 살아 있습니다 <ErrorBoundary fallback={<ErrorView />}> <Suspense fallback={<Skeleton />}> <PostList /> </Suspense> </ErrorBoundary>
여기서
ErrorBoundary는 react-error-boundary라는 라이브러리의 컴포넌트입니다. react-error-boundary는 에러를 잡아 fallback UI로 바꿔 줍니다. 에러 이후 다시 시도하게 하려면 useQueryErrorResetBoundary로 query 에러를 reset해 주어야 합니다. 그냥 두면 캐시에 남은 이전 에러가 다시 throw되어, 재시도해도 같은 화면에 머뭅니다.선언적으로 쓴다는 것은 분기를 없애는 게 아니라, 분기를 경계로 옮기는 일입니다. 그래서 경계 설계는 선택 사항이 아니라,
useSuspenseQuery를 쓰기로 한 순간 함께 따라오는 약속입니다.경계를 어느 깊이에 둘 것인가
경계를 둬야 한다는 것까지는 정해졌습니다. 남은 질문은 어느 깊이에, 화면을 어떤 단위로 쪼개 둘 것인가입니다.
경계를 너무 높게, 화면 맨 위에 하나만 두면 안쪽의 작은 영역 하나가 suspend되어도 화면 전체가 fallback으로 덮입니다. 반대로 경계를 너무 낮게, 요소 하나하나마다 두면 화면이 팝콘처럼 됩니다. 작은 조각들이 제각기 다른 시점에 따로따로 튀어 들어오고, 그 과정에서 CLS가 발생합니다. CLS란 Cumulative Layout Shift, 즉 요소가 뒤늦게 들어오며 레이아웃이 누적해서 밀리는 현상입니다.
두 함정 사이의 적당한 깊이를 도식으로 보면 이렇습니다.
너무 높음: 화면 전체에 <Suspense> 하나 ◄── 작은 suspend에도 전체가 깜빡임 적당함: 의미 있는 영역마다 <Suspense> ── 느린 영역만 fallback, 나머지는 즉시 너무 낮음: 요소 하나마다 <Suspense> ◄── 팝콘처럼 따로따로 튀어 들어옴
기준은 "함께 나타나야 하는 단위"입니다. 사용자가 한 덩어리로 인식하는 영역, 예를 들어 본문 피드나 사이드바를 단위로 잡고, 그 단위마다 경계를 둡니다. 서로 독립적인 영역은 각자의 경계를 가져야, 느린 영역 하나가 빠른 영역을 붙잡지 않습니다.
// 화면을 의미 단위로 나눠, 영역마다 경계를 둡니다 <PageShell> {/* 셸과 레이아웃은 즉시 그려집니다 */} <ErrorBoundary fallback={<FeedError />}> <Suspense fallback={<FeedSkeleton />}> <Feed /> {/* 본문 피드는 한 덩어리로 등장합니다 */} </Suspense> </ErrorBoundary> <ErrorBoundary fallback={<SidebarError />}> <Suspense fallback={<SidebarSkeleton />}> <Sidebar /> {/* 사이드바는 피드와 독립적으로 채워집니다 */} </Suspense> </ErrorBoundary> </PageShell>
경계가 데이터에 가까울수록 좋다는 원칙에는 한 가지 조건이 붙습니다. 경계가 감싸는 가장 작은 영역이, 동시에 사용자가 한 덩어리로 보는 영역이어야 한다는 것입니다. 그보다 더 잘게 쪼개면 부산스럽고, 더 크게 묶으면 화면 전체가 깜빡이게 됩니다.
fallback으로 쓰는 skeleton도 같은 기준을 따릅니다. 여기서 skeleton이란 실제 내용이 채워질 자리를 미리 잡아 주는 빈 화면을 말합니다. skeleton의 크기와 모양을 실제 내용과 맞춰 두면, 데이터가 도착할 때 배치가 밀리지 않습니다. 경계의 위치가 "어디서 멈출지"를 정한다면, skeleton의 모양은 "멈춰 있는 동안 자리를 얼마나 잘 지킬지"를 정합니다.
어느 영역이 함께 나타나고 어느 영역이 독립적인지를 먼저 그린 뒤, 그 선을 따라 경계를 두는 편이 안전합니다.
경계만이 문제가 아닌 경우도 있다
경계를 잘 두었다고 끝이 아닙니다. 다음에 다뤄볼 문제는 waterfall입니다. 여기서 waterfall이란 요청이 병렬로 나가지 못하고, 하나가 끝나야 다음이 시작되어 폭포처럼 줄줄이 늘어지는 현상을 말합니다.
한 컴포넌트에서
useSuspenseQuery를 연달아 호출했더니 두 요청이 동시에 나가지 않았습니다. 첫 hook이 suspend되는 순간 이것이 해소되기까지 컴포넌트 렌더링이 거기서 멈추기 때문에, 아래 hook은 첫 요청이 끝날 때까지 실행조차 되지 않습니다.// user가 suspend되는 동안 posts 요청은 시작도 못 합니다 (waterfall) const { data: user } = useSuspenseQuery({ queryKey: ['user'], queryFn: fetchUser, }) const { data: posts } = useSuspenseQuery({ queryKey: ['posts'], queryFn: fetchPosts, })
이 구조는
async/await를 연달아 쓰는 것과 같습니다. useSuspenseQuery가 render를 동기적으로 멈춰 세우기 때문에, 컴포넌트 본문은 위에서 아래로 한 줄씩 기다리며 읽힙니다.// 위 waterfall은 이 async/await와 같은 모양입니다 const user = await fetchUser() // 끝나야 const posts = await fetchPosts() // 다음이 시작됩니다
첫
await가 끝나기 전에는 다음 줄이 시작되지 않듯, 첫 useSuspenseQuery가 suspend되어 있는 동안에는 두 번째 hook이 실행조차 되지 않습니다. 정확히는 await가 그 자리에서 이어가는 반면 suspend는 컴포넌트를 처음부터 다시 부르지만, 요청이 직렬로 늘어진다는 결과는 같습니다.해법도 같은 모양입니다.
await라면 둘을 Promise.all로 묶어 병렬로 보내듯, useSuspenseQuery라면 useSuspenseQueries로 묶습니다. 두 요청에 의존성이 따로 없다면, useSuspenseQueries로 묶어 병렬로 내보냅니다.// useSuspenseQueries로 묶으면 두 요청이 병렬로 나갑니다 const [{ data: user }, { data: posts }] = useSuspenseQueries({ queries: [ { queryKey: ['user'], queryFn: fetchUser }, { queryKey: ['posts'], queryFn: fetchPosts }, ], })
비슷한 제약이 하나 더 있습니다.
useSuspenseQuery에는 placeholderData가 없습니다. placeholderData는 이전 데이터를 잠깐 유지해 화면이 비지 않게 해 주던 옵션입니다. 그래서 목록의 필터를 바꾸면 이전 결과를 붙들지 못하고 매번 fallback으로 깜빡입니다. 이 깜빡임은 React의 useTransition으로 눌러 주어야 합니다. useTransition은 상태 변경을 급하지 않은 작업으로 표시해, 갱신 중에도 이전 화면을 유지하게 해 주는 hook입니다.Next.js에서 다시 보기
지금까지는 client(브라우저) 쪽 이야기입니다. 그런데 server를 끼면 그림이 한 번 더 달라집니다.
SSR을 쓰는 환경, 예를 들어
Next.js에서 useSuspenseQuery를 client에서만 돌리면, 컴포넌트가 mount된 뒤에야 요청이 시작됩니다. 여기서 SSR이란 Server-Side Rendering, 즉 서버에서 HTML을 미리 그려 client에 보내는 방식을 말합니다. 그리고 컴포넌트가 mount된 뒤에야 요청이 시작되는 이 방식을 fetch-on-render라고 합니다. fetch-on-render란 렌더링이 시작돼야 fetch가 따라 나가는 방식을 말합니다. 서버에서 그릴 수 있는데도 데이터를 client에서야 받아 오면, SSR을 쓰는 의미가 절반은 사라집니다.정석은 서버에서 미리 받아 두는 것입니다. 서버에서
prefetchQuery로 채워 두고, HydrationBoundary로 client에 넘깁니다. prefetchQuery는 데이터를 미리 받아 캐시에 넣어 두는 함수입니다. hydration이 끝나면 컴포넌트는 이미 채워진 캐시를 읽습니다. 여기서 hydration이란 서버가 그린 HTML에 client JavaScript를 붙여 상호작용이 가능하게 만드는 과정을 말합니다.// 서버에서 미리 받아 두고 HydrationBoundary로 client에 넘깁니다 const queryClient = new QueryClient() await queryClient.prefetchQuery({ queryKey: ['post', id], queryFn: () => fetchPost(id), }) return ( <HydrationBoundary state={dehydrate(queryClient)}> <Post id={id} /> {/* 내부에서 useSuspenseQuery로 캐시된 데이터를 읽습니다 */} </HydrationBoundary> )
두 번째 방법은 streaming입니다. streaming은 서버가 완성된 HTML을 한 번에 보내지 않고, 준비된 부분부터 조금씩 흘려보내는 방식입니다.
@tanstack/react-query-next-experimental를 쓰면 useSuspenseQuery 하나로 서버에서 fetch를 시작해 그 결과를 client로 흘려보낼 수 있습니다. @tanstack/react-query-next-experimental은 Next.js에서 서버의 suspend를 streaming으로 흘려보내 주는 실험적 adapter입니다. 다만 이름 그대로 experimental이라는 점은 감안해야 합니다.client에서만 도는
useSuspenseQuery는, 서버에서 데이터를 미리 흘려보낼 기회를 통째로 버립니다. waterfall을 client에서 막아 봐야, 첫 요청이 브라우저에서야 출발하는 구조라면 늦는 것은 매한가지입니다.잠깐, 컴파일타임에 막을 수는 없는가
여기까지 보면 자연스러운 의문이 듭니다. 경계를 빠뜨리면 흰 화면이 되고 전역 에러가 난다면, 그 누락을 컴파일타임에 잡아 줄 수는 없는가 하는 것입니다. 결론부터 적으면 일반적인 형태로는 막을 수 없습니다.
Suspense와 ErrorBoundary는 throw로 동작합니다. suspend는 promise를 던지고 가장 가까운 Suspense가 잡고, 에러는 가장 가까운 error boundary가 잡습니다. "내 위 어딘가에 받아 줄 경계가 있는가"를 정적으로 알 수는 없습니다.
부분적으로 잡히는 것은 있습니다. 한 컴포넌트 안에서
useSuspenseQuery를 연달아 부르는 waterfall 패턴은 코드에 적힌 그대로 보이므로, lint가 원리상 짚어 줄 수 있습니다. 다만 의도한 의존 쿼리일 수도 있어 휴리스틱에 가깝고, 공식 도구인 @tanstack/eslint-plugin-query에도 그런 규칙은 없습니다. 여기서 @tanstack/eslint-plugin-query란 TanStack Query의 권장 사용법을 강제해 주는 ESLint 플러그인입니다. 그쪽 규칙은 query key 누락이나 deprecated 옵션처럼 한 호출 안에서 결정되는 것들을 봅니다. 경계 누락을 막는 규칙도 같은 이유로 들어 있지 않습니다.컴파일타임에 가깝게 강제하고 싶다면 방향을 틀어야 합니다. 런타임 트리의 규칙을 값과 API의 규칙으로 바꾸는 것입니다. 경계를 포함한 컴포넌트만 모듈 밖으로 내보내고, suspend를 부르는 훅은 안에 숨기면 됩니다.
// 경계까지 포함한 컴포넌트만 내보냅니다. 훅은 모듈 밖으로 노출하지 않습니다 export function Posts() { return ( <ErrorBoundary fallback={<ErrorView />}> <Suspense fallback={<Skeleton />}> <PostsInner /> {/* 내부에서만 useSuspenseQuery를 부릅니다 */} </Suspense> </ErrorBoundary> ) }
이렇게 두면 동료가 데이터를 쓰려고
Posts를 import하는 순간 경계가 함께 따라옵니다. 경계 없이 데이터에 닿는 길 자체가 막히는 것입니다. Suspensive라는 라이브러리는 이 패턴을 더 쉽게 쓰도록 돕습니다. Suspensive는 경계를 선언적 컴포넌트로 만들어 데이터 옆에 두게 해 주는 라이브러리입니다. 다만 이것도 엄밀한 타입 보장은 아닙니다. 경계 없이 쓰기를 어렵게 만드는 설계 강제에 가깝습니다.조금 정리해보자면, 컴파일러가 막지 못하는 이유는
useSuspenseQuery가 선언적인 이유와 같습니다. 분기를 조상 트리에 위임하기에 깔끔해지는 것인데, 컴파일러는 트리가 아니라 모듈과 타입만 봅니다. 통제를 트리에 위임한 대가가 곧 "정적으로 검증되지 않는다"는 것입니다.그래서, 언제 의미있는가
useSuspenseQuery의 본질은 "의도적으로 suspend를 만든다"는 데 있습니다. 그 의도를 반영하려면 client와 server 양쪽에서 suspend 상태를 잘 처리해 주어야 합니다.먼저 client 쪽입니다. suspend와 에러를 받아 줄
Suspense와 ErrorBoundary를 적절한 깊이에 배치하고, 전환의 깜빡임은 useTransition으로 누르고, 병렬은 useSuspenseQueries로 설계합니다.다음은 server 쪽입니다. Next.js라면 서버에서
prefetchQuery로 미리 받아 두거나 streaming으로 흘려보내, suspend가 client에 닿기 전에 데이터가 먼저 와 있게 합니다.이 두 가지가 갖춰졌을 때
useSuspenseQuery는 분기 없는 깔끔한 컴포넌트와 빠른 첫 화면을 동시에 줍니다. 그때가 이 도구가 진짜 의미있는 순간입니다. 반대로 둘 중 하나라도 빠지면, 선언성은 서비스 정지, 흰 화면, waterfall이라는 모습으로 되돌아옵니다.Suspense Query가 의미있는 건 코드가 깔끔해질 때가 아니라, 그 깔끔함을 떠받치는 경계와 server 흐름까지 잘 설계되어 갖춰졌을 때입니다.
마무리
useSuspenseQuery는 분기를 걷어 내고 데이터를 보장하는 확실한 강점을 가진 도구입니다. 그 강점은 분기를 경계와 서버로 옮긴 결과이므로, 옮긴 책임을 받아 줄 구조를 함께 갖출 때 온전해집니다. 그 통제 범위가 정해지기 전이라면, 평범한 useQuery로 분기를 눈에 보이게 두는 편이 정직할 때도 있습니다.
kyu-log