
- #Frontend
- #ReactQuery
리액트 쿼리로 선언형 프로그래밍 달성하기
Prolip
2025-10-11
리액트 쿼리를 활용해 선언형 프로그래밍을 어떻게 달성했는지, 부록으로 쿼리 키 팩터리에 대한 내용과 Next.js 환경에서 리액트 쿼리로 프리패치하는 방법을 정리한 글입니다.
시작하며..
이번 게시글에선 리액트 쿼리를 활용해 어떻게 선언형 프로그래밍을 달성했는지 정리해보려고 합니다.
먼저 선언형 프로그래밍에 앞서 명령형 프로그래밍은 무엇이고, 이 둘의 차이에 대해서 작성해볼까 합니다.
명령형 프로그래밍
명령형 프로그래밍은 프로그램이 수행할 절차를 개발자가 명확하게 지정하는 방식이라고 볼 수 있는데요. 쉽게 “이런 상황에선 이렇게 동작해야 해!” 라고 절차적인 흐름을 하나하나 명시한다고 보면 됩니다.
그럼 모든 동작을 순차적으로 지시해야하기 때문에 구현 과정에서 부수적인 코드가 점점 쌓이게 되는데요.
async function fetchTodo() { try { const res = await fetch("endpoint3"); if (!res.ok) throw new Error("투두를 받아오다 에러가 발생했어요. 하지만 무슨 에러일까요?") const data = res.json(); setTodo(data); } catch(error) { console.error(error); setErrorState(error); } }
이 코드는 명령형 방식으로 작성된 코드로 fetch를 통한 데이터 요청 ⇒ 상태 업데이트 ⇒ 에러 처리. 절차적인 흐름이 명확하게 명시되어 있습니다.
만약 추가적인 상태 업데이트나 요청 과정에 필요한 기능이 추가되면 어디에 상태를 추가해야 하는지 혹은 어떤 로직을 수정해야 하는지 고려해야 하니 유지보수가 점점 어려워집니다.
또, 내부적으로 복잡도가 증가하며 코드 수정 간에 생기는 부수효과도 고려해야 합니다.
명령형 방식의 코드가 쌓이며 발생하는 문제
처음에는 단순한 API 호출일 뿐이지만, 기능이 늘어나면 점점 아래와 같은 문제가 쌓이게 됩니다.
- 중복 코드가 증가합니다. 비슷한 API 요청이 추가될 때마다 똑같은 try-catch문을 반복해야 합니다. 지금은
fetchTodo함수만 존재하지만, 이후fetchUser,fetchProduct… 비슷한 요청이 생길 때마다 각각 모든 함수에 try-catch 문을 사용해줘야만 합니다. - 유지보수가 힘들어집니다. 데이터 요청 + 상태 업데이트 + 에러 처리 흐름이 여러 곳에서 반복되어있는 상태에서 서버와 합의된 API 구조, 혹은 상태 업데이트 로직이 변경되면 모든 API 호출 함수를 수정해야 합니다.
- 만약 로딩 상태를 추가해야 한다면 위에서 정의한
fetchUser,fetchProduct,fetchTodo함수에 각각 setLoading 함수를 추가해야 합니다.
- 만약 로딩 상태를 추가해야 한다면 위에서 정의한
- 에러처리의 일관성이 부족해집니다. 각각의 API 호출마다 setError를 따로 관리하면, 에러 메세지가 일관되지 않을 수 있습니다. 또, 특정 컴포넌트에서는 alert() 로 에러를 표시할 수 있고, 다른 컴포넌트에서는 console.error() 만 찍는 식으로 관리되면 UX가 불안정해집니다.
선언형 프로그래밍
선언형 프로그래밍은 결과를 기술하는 방식인데요. “무엇” 을 할지 선언만하고, “어떻게” 할 것인지는 라이브러리 혹은 프레임워크에게 맡기는 방식입니다.
명령형 프로그래밍은 “이런 상황에선 이렇게 동작해야 해!” 라고 명령하는 방식이고, 선언형 프로그래밍은 “이 때는 이런 UI가 나와야 해!” 라고 선언하는 방식입니다.
우리가 많이 사용하는 리액트도 본질적으로 상태 기반의 선언형 UI 라이브러리입니다. “어떻게” 그리는지가 아니라 “어떤 상태(state)에서 어떤 UI가 나와야하는지” 를 선언하는 방식이니까요.
리액트를 사용하면서 아래 코드 처럼 데이터와 로딩, 에러 상태를 핸들링한 경험이 다들 있으실텐데요.
// 명령형: useEffect, try-catch const [loading ,setLoading] = useState(false); // 로딩 상태를 저장할게.. const [error, setError] = useState(false); // 에러 상태를 저장할게.. const [data, setData] = useState(null); // 데이터를 저장할게.. // 컴포넌트가 마운트 됐을 때 실행할게... useEffect(() => { try { // 한 번 시도해볼게.. setLoading(true); // 로딩할게... const res = await fetchSomething(); // 데이터 요청할게.. setData(res); // 데이터가 생겼으니까 상태에 저장할게... } catch (error) { // 에러를 잡을게.. setError(error); // 에러 상태를 업데이트할게... } finally { setLoading(false); // 로딩 끝낼게.. } }, []) // 한 번만 실행할게... // 만약에 위에서 로딩 상태를 저장했어..? if(loading) { return <Loading /> // 그럼 로딩 상태를 보여줘... } // 만약에 에러가 발생했어..? if(error) { return <Error /> // 그럼 에러 상태를 보여줘... } // 만약에 로딩도 아니고 에러도 발생하지 않았어..? if(data) { return <Render data={data} /> // 그럼 데이터를 보여줘... }
코드가 어떻게 수행되어야 할지 하나 하나 그 상황에 맞는 코드를 명령하다보니 보일러 플레이트 코드가 정말 길어지는 문제가 발생해요. 반면, 선언형으로 작성한다면 아래와 같이 간결하게 작성할 수 있습니다.
// 선언형: Suspense, ErrorBoundary // <Render /> 컴포넌트에서 에러가 발생하면 <Error /> 컴포넌트를 보여줘! // <Render /> 컴포넌트에서 데이터 요청 중이면 <Loading /> 컴포넌트를 보여줘! // 데이터가 요청이 완료되면 <Render /> 컴포넌트를 보여줘! <ErrorBoundary fallback={<Error />}> <Suspense fallback={<Loading />}> <Render /> </Suspense> </ErrorBoundary> function Render() { const data = 요청(); return ( <section> {/** data를 이용해 무엇을 보여줄지만 생각! **/} </section> ) }
이렇게 직접 확인해보니 선언형과 명령형의 차이점이 확연히 보이는데요.
UI가 복잡해질수록 유지보수는 어려워집니다. 특히 비동기 데이터 요청, 에러 핸들링, 상태 관리 등 명령형 방식은 코드가 점점 복잡해지고 버그를 유발할 가능성이 큽니다.
반면 Suspense와 에러 바운더리를 사용하면 UI의 로딩/에러 분기를 라이브러리에게 위임해 무엇을 보여줄지에 대한 코드만 작성하면 됩니다.
실제로 리액트의 철학은 기본적으로 상태(state)에 맞게 UI를 선언적으로 표현한다는 것인데요. 위에서 살펴본 Suspense, 에러 바운더리, 여러 훅들 (특히 use), 등등 리액트 개발팀은 점점 선언형 프로그래밍을 강조하고 있지 않나 생각합니다.
하지만 명령형 방식이 무조건 좋지 않다거나 사라진다는 것은 아니고, 로우 레벨에서 직접 제어해야 할 때는 여전히 좋은 방식이라고 생각합니다. 무엇이든 어디 한 곳에 치우치지 않고 조화롭게 사용하는게 좋다고 생각합니다.
이제 리액트 쿼리를 사용해 위의 선언형 프로그래밍을 달성한 방법에 대해 기록해보겠습니다.
리액트 쿼리
라이브러리 없이는?
위에서 Suspense와 에러 바운더리를 사용해 간단하게나마 UI를 어떻게 선언적으로 관리할 수 있나 확인해봤습니다. 로딩과 에러 상태 처리 로직을 UI 렌더링 로직과 분리해 무엇을 보여줄지에만 집중할 수 있게 됐는데요.
하지만 라이브러리 없이 순수 Suspense만으로는 현실적으로 한계에 부딪히게 됩니다. 바로 Suspense로 감싼 자식 요소에서 프로미스를 던지는(throw) 패턴이 필요해지기 때문인데요. (lazy나 use 훅을 사용하는 경우는 제외하겠습니다.)
function DisplayMessage() { const [message, setMessage] = useState(null); useEffect(() => { const fetchMessage = async () => { const res = await fetch('...'); const data = await res.json(); setMessage(data); } fetchMessage(); }, []); return ( <div>{message}</div> ) } function App() { return ( <Suspense fallback={<div>로딩 중...</div>}> <DisplayMessage /> </Suspense> ) }
일반적으로 데이터를 요청할 때 useEffect 을 사용하곤 합니다. 하지만 위 코드에선 Suspense가 데이터 요청 중 fallback을 보여주지 않는데요.
이유는 useEffect 내부에 등록된 콜백 함수가 렌더 페이즈가 아니라 커밋 페이즈 이후에 실행되기 때문입니다. (리액트는 렌더링 중 throw된 프로미스를 suspend 상태로 인식하고 가장 가까운 Suspense 경계의 fallback을 찾아 렌더링합니다.)
위의 코드는 이런 순서로 실행될 뿐입니다.
- 렌더 페이즈가 시작되어 App 컴포넌트가 실행되고 자식 컴포넌트인 Suspense, DisplayMessage 컴포넌트를 렌더링하려고 시도합니다.
- DisplayMessage를 렌더링하기 위해 DisplayMessage 함수를 호출하는데요. useState에 의해 message는 null, useEffect 훅에 등록한 콜백 함수는 실행되지 않고 단순히 등록만 되고
<div>{null}</div>를 리턴합니다. - DisplayMessage는 프로미스를 throw 하지 않았습니다. null이긴 하지만 정상적인 JSX를 반환했기 때문에 리액트는 이 컴포넌트의 렌더링이 성공했다고 판단합니다. Suspense의 핵심인 프로미스 던지기(throw)는 발생하지 않았습니다.
- 이제 커밋 페이즈가 실행되어 렌더링 결과를 실제 DOM에 반영합니다. 그럼 빈 화면에 div가 그려지는데 Suspense는 자식 컴포넌트가 잘 렌더링되었기 때문에 fallback을 보여줄 필요도 없습니다.
- 커밋 페이즈 이후 브라우저 페인팅이 일어나면 등록한 useEffect의 콜백 함수를 실행합니다.
- useEffect 내부의 fetchMessage 함수가 호출되고 API 요청이 시작됩니다. 이 시점은 프로미스를 감지할 수 있는 렌더 페이즈가 이미 끝난 후로 fallback은 보이지 않습니다. (공식 문서에서도 이벤트 핸들러나 useEffect 내부에서 발생하는 데이터 요청은 Suspense가 감지하지 못한다고 말하고 있습니다.)
- 데이터 요청이 끝나면 setMessage를 호출해 message 상태를 업데이트하며 리렌더링을 트리거합니다.
- 다시 렌더 페이즈가 발생하며 상태가 변경된 DisplayMessage를 렌더링합니다.
- 이 시점엔 fetchMessage를 통해 message 상태가 업데이트되어 데이터가 들어있는 상태로 만약 데이터가 ‘안녕!’이라면
<div>{안녕!}</div>을 리턴합니다. 물론 이번에도 프로미스를 throw하지 않았습니다. - 이제 다시 커밋 페이즈가 실행되어 변경된 내용을 DOM에 반영해 사용자는 메세지를 보게 됩니다.
그럼 어떻게 프로미스를 throw 하도록 구현할 수 있을까요?
const fetchMessage = () => { let data = null const promise = fetch("...") .then((res) => res.json()) .then((json) => { data = json; }); return { read() { if (!data) throw promise; return data; }, }; }; const messageData = fetchMessage(); const Message = () => { const { message } = messageData.read(); return <article>{message}</article>; }; export default function MessageWrapper() { return ( <section> <Suspense fallback={<div>로딩 중...</div>}> <Message /> </Suspense> </section> ); }
코드를 보면, Message 컴포넌트에서 messageData 객체의 read 함수를 호출하고 있는데요. messageData 객체는 fetchMessage 함수 호출을 통해 이미 전역 레벨에서 생성된 상태입니다.
이 시점에 fetch 요청은 이미 시작되어 있고, promise 변수에는 pending 상태의 프로미스가 할당되어 있습니다. 아직 요청이 처리되지 않았으니 data 변수에는 null이 할당되어 있습니다.
그럼 MessageWrapper 컴포넌트가 렌더링을 시작할 때, Message 컴포넌트는 messageData 객체의 read 함수를 호출하고, 이 시점에 data 변수에는 null이 할당되어 있기 때문에 promise 변수를 throw하게 됩니다.
렌더링 중 Message 컴포넌트에서 프로미스를 throw했기 때문에 MessageWrapper의 Suspense는 fallback인 <div>로딩 중…</div> 을 화면에 표시하게 됩니다.
이런 방식으로 라이브러리 없이도 Suspense를 활용할 수는 있는데요. 하지만 모든 데이터 요청 코드를 이런 프로미스 throw 패턴에 맞게 작성하는 것은 꽤나 비효율적일 수도 있습니다.
사실 위 코드처럼 모듈 스코프에서 fetch를 시작하는 리소스 패턴은 원리를 이해하기엔 좋은데, 실제 서비스에서는 이렇게 단순하게 처리하는 데 무리가 있다고 생각합니다.
요청마다 키도 관리해야 하고, 중복 요청을 막거나 캐싱, 무효화, 가비지 컬렉션 같은 여러 정책도 직접 다뤄야 할테니까요.
Suspense does not detect when data is fetched inside an Effect or event handler.
Suspense-enabled data fetching without the use of an opinionated framework is not yet supported. The requirements for implementing a Suspense-enabled data source are unstable and undocumented.
이건 공식 문서의 내용으로 아직 프레임워크의 도움 없이 데이터 요청에 Suspense를 사용하는건 불안정하며, 문서화도 되어있지 않다고 합니다.
useQuery
리액트 쿼리를 사용한다면 보다 쉽게 Suspense와 에러 바운더리를 활용할 수 있는데요. 물론 useQuery 훅을 사용한다면 아래와 같이 명령형 프로그래밍 방식을 따라야만 합니다.
function Component() { const {data, isLoading, isError} = useQuery({ queryKey, queryFn }) if (isLoading) { return ... // 로딩 처리 } if (isError) { return ... // 에러 처리 } return ( <section> { data && data.map(...) // 데이터가 있음을 보장할 수 없음. } </section> ) }
로딩, 에러 상태를 데이터 소비 컴포넌트에서 직접 관리하진 않지만 훅을 통해 반환된 isLoading, isError를 사용해 여전히 각각 분기 처리해주고 있으며 데이터가 있음을 보장받지 못하고 있습니다.
useSuspenseQuery
useSuspenseQuery는 이런 문제점들을 모두 보완해주고 있는데요.
function Component() { const {data} = useSuspenseQuery({ ... }) return ( <section> { data.map(...) // 데이터가 있음을 보장할 수 있음. } </section> ) } function Wrapper() { return ( <QueryErrorResetBoundary> {({ reset }) => ( <ErrorBoundary onReset={reset} fallbackRender={({ resetErrorBoundary }) => ( <Button onClick={() => resetErrorBoundary()}>다시 시도</Button> )} > => 에러 처리 위임 <Suspense fallback={<div>로딩 중..</div>}> => 로딩 처리 위임 <Component /> </Suspense> </ErrorBoundary> )} </QueryErrorResetBoundary> ) ) }
이렇게 로딩 중엔 프로미스를 throw해 가장 가까운 Suspense의 fallback을 표시하고, 에러가 발생하면 에러를 throw해 가장 가까운 에러 바운더리의 fallback을 표시합니다.
로딩이면 렌더 자체가 일어나지 않고 에러가 발생하면 에러 바운더리로 넘어가기 때문에 훅이 반환하는 data는 타입상 항상 정의됩니다. (타입스크립트의 관점입니다!)
덕분에 데이터를 소비하는 컴포넌트는 데이터가 있다는 전제하에 무엇을 보여줄지에만 집중할 수 있게 됩니다.
throwOnError
주의할 점이 하나 있다면, tanstack query 공식 문서의 Suspense 탭에서 확인할 수 있는 내용으로 모든 에러를 항상 던지는 것은 아니라고 합니다.
throwOnError: (error, query) => typeof query.state.data === 'undefined'
throwOnError는 useQuery 훅을 사용할 때 적용 가능한 옵션으로 true로 지정한 경우 useSuspenseQuery와 동일하게 에러가 발생했을 때 상위로 에러를 던집니다.
하지만 useSuspenseQuery는 이 설정을 변경할 수는 없고 고정 동작은 위와 같다고 합니다.
캐시에 유효한 데이터가 있으면 백그라운드에서 refetch 도중 에러가 발생했을 때는 에러를 던지지 않겠다는 의미로 보입니다. 이해를 위해 시나리오로 설명해보자면,
- 브라우저를 새로 켭니다. 게시글 목록 페이지에 들어가 컴포넌트가 렌더링됩니다.
- useSuspenseQuery로 데이터를 가져오려고 합니다.
- 처음부터 요청에 실패한 경우 ⇒ 바로 에러를 던져 에러 바운더리로 전파.
- 최초 요청으로 캐시된 데이터가 없습니다. 그럼 query.state.data는 undefined입니다.
- API 요청 중 에러가 발생합니다. 그럼 query.state.data === ’undefined’가 true가 되어 에러를 throw합니다.
- 가까운 에러 바운더리가 에러를 잡고 fallback UI를 렌더링합니다.
- 처음 요청은 성공해 데이터가 있는데 백그라운드에서 refetch에 실패한 경우 ⇒ 에러를 던지지 않고 기존의 데이터를 유지해 렌더, error 객체에 에러가 담김.
- 이전 요청은 성공해 캐시 중인 데이터가 있습니다. 그럼 query.state.data에는 데이터가 존재합니다.
- API 요청 중 에러가 발생합니다. 그럼 query.state.data === ‘undefined’가 false가 되어 에러를 throw하지 않습니다.
- useSuspenseQuery는 기존에 stale 데이터와 error 객체를 반환합니다. ⇒ { data: 이전에 받은 데이터, error: 이번에 발생한 에러}

메인테이너 TkDodo는 ‘오래된 데이터라도 없는 것보다는 낫다’며, 의도된 설계라고 합니다. 아무래도 사용자가 보고 있던 데이터가 이미 있는데 백그라운드에서 업데이트가 실패했다는 이유로 화면 전체를 에러 페이지로 바꾸면 사용자 경험에 좋지 않으니 이렇게 설계하지 않았나 싶습니다.
const {data} = useQuery({ queryKey, queryFn, throwOnError: true | false | (error) => true or false // 오버라이드 가능! }) const {data} = useSuspenseQuery({ queryKey, queryFn // throwOnError => 설정 못함! })
useQuery의 경우 options 객체나 QueryClient 객체 생성 시점에 defaultOptions 객체를 통해 설정이 가능하지만, useSuspenseQuery는 수정이 불가능합니다.
const {data, error, isFetching} = useSuspenseQuery({ queryKey, queryFn }) // 요청이 끝났는데 에러가 발생했으면 무조건 에러를 던져! if (error && !isFetching) throw error;
따라서 공식 문서에선 위와 같이 에러가 발생했을 때때 기본 동작을 무시하고 에러를 던지는 방법도 설명하고 있습니다.
QueryErrorResetBoundary
useSuspenseQuery를 사용함으로써 에러 처리 로직을 에러 바운더리로 위임할 수 있었습니다. 에러가 발생해 데이터를 가져오지 못했을 경우, fallback에 재시도를 유도할 수 있는 버튼을 제공하면 사용자에게 보다 나은 경험을 줄 수 있다고 생각합니다.
에러 바운더리는 아래와 같이 3가지 방법으로 사용할 수 있는데요.
-
fallback
<ErrorBoundary fallback={<div>에러 발생!!</div>}> <Component /> </ErrorBoundary> -
fallbackRender
<ErrorBoundary fallbackRender={({error, resetErrorBoundary}: FallbackProps) => ( <div> <p>에러 발생: {error.message}</p> <button onClick={() => resetErrorBoundary()}>다시 시도</button> </div> )}> <Component /> </ErrorBoundary> -
fallbackComponent
function ErrorFallback({error, resetErrorBoundary}: FallbackProps) { return ( <div> <p>에러 발생: {error.message}</p> <button onClick={() => resetErrorBoundary()}>다시 시도</button> </div> ) } <ErrorBoundary fallbackComponent={ErrorFallback}> <Component /> </ErrorBoundary>
1번 방법의 경우 에러 객체나 리셋 핸들러에는 접근할 수 없어 단순히 정적인 UI 요소만을 표시할 때 사용하고 2번과 3번의 경우 FallbackProps를 인자로 받을 수 있습니다.
type FallbackProps = { error: Error; resetErrorBoundary: () => void; }
error는 발생한 에러 객체, resetErrorBoundary는 반환 값이 없는 함수로 호출 시 에러 상태를 초기화하고 자식 컴포넌트를 리렌더링합니다.
function Component() { const {data} = useSuspenseQuery({ queryKey, queryFn, }) } function Wrapper() { return ( <ErrorBoundary fallbackRender={({ resetErrorBoundary }) => ( <Button onClick={() => resetErrorBoundary()}>다시 시도</Button> )} > <Suspense fallback={<div>로딩 중..</div>}> <Component /> </Suspense> </ErrorBoundary> ) }
이렇게 작성된 코드는 데이터 요청 중 에러가 발생했을 때 다시 시도라는 버튼을 표시하고, 버튼을 클릭하면 자식 요소인 Component를 리렌더링하며 데이터를 재요청해야 합니다.

하지만 다시 시도 버튼을 아무리 클릭해도 해당 쿼리에 refetch는 일어나지 않는데요. 이건 리액트 쿼리가 에러 상태도 캐싱하기 때문입니다.
에러 바운더리의 resetErrorBoundary 함수는 컴포넌트 자체의 에러 상태만 초기화해 UI를 다시 렌더링합니다.
리액트 쿼리의 내부 캐시에 저장된 에러 상태는 그대로 남아있고 컴포넌트가 다시 렌더링되어도 리액트 쿼리가 보기엔 해당 queryKey에 해당하는 데이터가 여전히 에러 상태기 때문에 새로운 네트워크 요청을 보내지 않고 캐시 중인 에러를 그대로 반환합니다.
이를 해결하기 위해선 에러가 발생했을 때 총 두 곳을 리셋해줘야 하는데요. 첫 번째로 에러 바운더리 자체, 두 번째로 리액트 쿼리의 에러 상태입니다.
function Wrapper() { return ( <QueryErrorResetBoundary> {({ reset }) => ( <ErrorBoundary onReset={reset} fallbackRender={({ resetErrorBoundary }) => ( <Button onClick={() => resetErrorBoundary()}>다시 시도</Button> )} > <Suspense fallback={<div>로딩 중..</div>}> <Component /> </Suspense> </ErrorBoundary> )} </QueryErrorResetBoundary> ) ) } function Wrapper() { const { reset } = useQueryErrorResetBoundary(); return ( <ErrorBoundary onReset={reset} fallbackRender={({ resetErrorBoundary }) => ( <Button onClick={() => resetErrorBoundary()}>다시 시도</Button> )} > <Suspense fallback={<div>로딩 중..</div>}> <Component /> </Suspense> </ErrorBoundary> ) }
공식 문서에선 에러 상태를 초기화하기 위해 QueryErrorResetBoundary와 useQueryErrorResetBoundary를 사용하라고 안내하고 있는데요.
QueryErrorResetBoundary는 render props 패턴으로 reset 함수를 인자로 받고, useQueryErrorResetBoundary는 훅 형태로 reset 함수를 반환합니다.
onReset은 에러 바운더리의 resetErrorBoundary 함수가 호출되어 다시 렌더링을 시도하기 전 실행되는 함수입니다.
리액트 쿼리의 reset 함수를 연결해 에러 바운더리가 화면을 다시 렌더링하기 전에 에러 상태의 쿼리를 캐시에서 제거합니다.

적용 이후 위와 같이 쿼리의 에러 상태를 초기화해 Refetch 상태가 되며 네트워크 요청을 다시 만들어냅니다.
function Wrapper() { return ( <QueryErrorResetBoundary> {({ reset }) => ( <ErrorBoundary onReset={reset} fallbackRender={({ resetErrorBoundary }) => ( <Button onClick={() => resetErrorBoundary()}>다시 시도</Button> )} > <Suspense fallback={<div>로딩 중..</div>}> <Component /> </Suspense> </ErrorBoundary> )} </QueryErrorResetBoundary> ) ) } function Component() { const {data} = useSuspenseQuery({ queryKey, queryFn, }) return ( <section> <p>{data.message}</p> </section> ) }
결과적으로 이제 자식 요소에선 로딩, 에러 처리를 신경쓰지 않고 데이터가 준비됐을 때 어떻게 보여줄지만 선언할 수 있게 됩니다.
리액트 쿼리를 사용하지 않아도 에러 바운더리는 이용할 수 있으나(에러를 throw하기만 하면 끝!) Suspense를 사용하기 위한 복잡한 코드(렌더 중 프로미스를 throw하는 패턴)는 직접 구현해야만 합니다.
하지만 리액트 쿼리가 Suspense를 지원해 손쉽게 로딩 상태를 UI 외부로 추출할 수 있었기 때문에 도입할 이유가 충분하다고 생각했습니다.
재사용 가능한 RQBoundary 만들기
실제로 프로젝트에 리액트 쿼리를 도입하면서 Suspense와 에러 바운더리를 활용해 선언형 코드로 UI를 구성할 수 있었는데요. 하지만 새로운 문제가 발생했습니다.
function WrapperA() { return ( <QueryErrorResetBoundary> {({ reset }) => ( <ErrorBoundary onReset={reset} fallbackRender={({ resetErrorBoundary }) => ( <Button onClick={() => resetErrorBoundary()}>다시 시도</Button> )} > <Suspense fallback={<div>로딩 중..</div>}> <ComponentA /> </Suspense> </ErrorBoundary> )} </QueryErrorResetBoundary> ) ) } function WrapperB() { return ( <QueryErrorResetBoundary> {({ reset }) => ( <ErrorBoundary onReset={reset} fallbackRender={({ resetErrorBoundary }) => ( <Button onClick={() => resetErrorBoundary()}>다시 시도</Button> )} > <Suspense fallback={<div>로딩 중..</div>}> <ComponentB /> </Suspense> </ErrorBoundary> )} </QueryErrorResetBoundary> ) ) }
이렇게 사용하는 모든 지점마다 Suspense, ErrorBoundary, QueryErrorResetBoundary를 중첩해 사용해야만 해서 보일러 플레이트가 점점 쌓이기 시작했는데요.
import {Suspense} from 'react'; import {QueryErrorResetBoundary} from '@tanstack/react-query'; import {ErrorBoundary} from 'react-error-boundary'; import {ErrorFallback, LoadingSpinner} from '../ui/components'; type Props = { children: React.ReactNode; ErrorFallbackicon?: React.ReactNode; ErrorFallbackClassName?: HTMLDivElement['className']; LoadingFallback?: React.ReactNode; }; export default function RQBoundary({ children, ErrorFallbackicon, ErrorFallbackClassName, LoadingFallback = <LoadingSpinner />, }: DefaultProps) { return ( <QueryErrorResetBoundary> {({reset}) => ( <ErrorBoundary onReset={reset} fallbackRender={({error, resetErrorBoundary}) => ( <ErrorFallback error={error} resetErrorBoundary={resetErrorBoundary} icon={ErrorFallbackicon} className={ErrorFallbackClassName} /> )} > <Suspense fallback={LoadingFallback}>{children}</Suspense> </ErrorBoundary> )} </QueryErrorResetBoundary> ); }
이 반복적인 패턴을 줄이고자 RQBoundary라는 래퍼 컴포넌트를 만들어 추상화했습니다.
내부적으로 QueryErrorResetBoundary, ErrorBoundary로 감싸고 에러가 발생했을 때 공통으로 사용하는 ErrorFallback 컴포넌트를 표시하도록 구현했는데요.
'use client'; import {cva, VariantProps} from 'class-variance-authority'; import {cn} from '@/shared/lib/utils/cn'; import {Button} from '@/shared/shadcnComponent/ui/button'; const errorFallbackVariants = cva( 'flex flex-col items-center p-4 text-sm md:text-base', { variants: { withIcon: { true: 'h-full justify-center pb-20', false: '', }, }, defaultVariants: { withIcon: false, }, }); type ErrorFallbackProps = { icon?: React.ReactNode; className?: string; title?: string; error: Error; resetErrorBoundary: () => void; } & VariantProps<typeof errorFallbackVariants>; export default function ErrorFallback({ error, resetErrorBoundary, icon, className, title = '데이터를 가져오는 데 실패했어요.', }: ErrorFallbackProps) { const withIcon = icon ? true : false; return ( <section className={cn(errorFallbackVariants({withIcon}), className)}> {icon && icon} <h2 className="text-xl md:text-2xl mb-3">{title}</h2> <p className="mb-4">실패 이유: {error.message}</p> <p>인터넷 연결 상태 혹은 서버의 응답 오류일 수 있어요.</p> <p className="mb-4">아래 버튼을 클릭해 다시 시도해주세요.</p> <Button onClick={resetErrorBoundary}>다시 시도하기</Button> </section> ); }
ErrorFallback 컴포넌트는 이렇게 프로젝트 전역에서 공통적으로 사용할 수 있는 대체 UI로 내부적으로 다시 시도하기 버튼을 제공해 사용자에게 재시도를 유도하고 있습니다.
마지막으로 가장 하위에 Suspense를 감싸 로딩 상태를 위임한 상태로 로딩 UI는 사용하는 위치마다 달라질 수 있어 LoadingFallback 컴포넌트를 인자로 받아 사용할 수 있게 구현했습니다.
<RQBoundary> <UserAvatar /> </RQBoundary> <RQBoundary LoadingFallback={<NotificationListLoading />}> <NotificationList /> </RQBoundary> <RQBoundary LoadingFallback={<ReviewsLoading />} icon={<LucideIcon name="Bug" className="w-28 h-28 md:w-40 md:h-40 mb-4" />} > <ReviewsWithPagination sort={sort} /> </RQBoundary>
이제 사용하는 위치에서 로딩 UI, 에러 발생 시 보이고 싶은 아이콘을 전달하면 QueryErrorResetBoundary, ErrorBoundary, Suspense의 로직은 알아서 적용됩니다.
이렇게 데이터 패칭 과정의 상태 관리 코드를 데이터 소비 컴포넌트 외부로 추출해 데이터가 준비됐을 때 무엇을 보여줄지에만 집중할 수 있는 구조를 만들게 되었습니다.
마무리
여기까지 리액트 쿼리를 기반으로 선언형 프로그래밍을 어떻게 달성했는지 정리해봤습니다..
핵심은 로딩, 에러 분기를 컴포넌트에서 분리해 UI는 오직 무엇을 보여줄지에만 집중할 수 있는 구조를 만드는 것인데요.
비동기 로직을 컴포넌트 내부에서 명령형으로 처리하는 흐름만 분리해도 UI를 읽거나 유지보수하는 데 훨씬 쉬워진다고 생각합니다.
이제 다음으로 제가 프로젝트에서 리액트 쿼리를 사용하며 몇 가지 시도했던 다른 방법들을 부록으로 정리해보려고 합니다.
부록1 - 쿼리 키, 옵션 팩터리
리액트 쿼리를 사용하다보면 쿼리 키를 문자열이나 배열 형태로 직접 하드코딩하는 경우가 많은데요. 프로젝트 규모가 커질수록 이 키들을 일관성 있게 관리하기가 어려워집니다.
예를 들자면, 쿼리 키가 reviews일 때 reviewList로 바꾼다면 그 키를 참조하는 모든 useQuery, useSuspenseQuery, invalidateQueries, setQueryData 등등 키를 참조하는 모든 구문을 찾아 수정해야 하는데요.
이렇게 키 구조가 조금만 중첩되어도 누락되거나 오타가 생기는 등 휴먼 에러가 발생하기 쉽습니다. 그래서 이번에 쿼리 키를 팩터리 형태로 관리하는 방식을 도입해봤는데요.
이 방식은 도메인별로 사용하는 키를 한 곳에서 관리하기 때문에 키 이름이 변경되어도 수정 범위가 한 곳으로 한정되고 캐시 무효화나 낙관적 업데이트에 동일한 키를 재사용할 수 있습니다.
그리고 쿼리 키를 계층적 구조로 관리하면 특정 카테고리, 특정 아이디에 대한 선택적 캐시 무효화가 가능해 세밀한 캐시 제어가 가능해집니다.
reviewsQueryKeys
export const reviewsQueryKeys = { all: () => ['reviews'] as const, my: { all: () => [...reviewsQueryKeys.all(), 'my'] as const, page: (page: number) => [...reviewsQueryKeys.my.all(), page] as const, }, myBookmarks: { all: () => [...reviewsQueryKeys.all(), 'myBookmarks'] as const, page: (page: number) => [...reviewsQueryKeys.myBookmarks.all(), page] as const, }, keyword: { all: () => [...reviewsQueryKeys.all(), 'keyword'] as const, keyword: (keyword: string) => [...reviewsQueryKeys.keyword.all(), keyword] as const, page: (keyword: string, page: number, sort: string) => [...reviewsQueryKeys.keyword.keyword(keyword), page, sort] as const, }, category: { all: () => [...reviewsQueryKeys.all(), 'category'] as const, category: (categoryId: Category) => [...reviewsQueryKeys.category.all(), categoryId] as const, page: (categoryId: Category, sort: string) => [...reviewsQueryKeys.category.category(categoryId), sort] as const, }, recent: () => [...reviewsQueryKeys.all(), 'recent'] as const, };
이건 제가 이번 프로젝트에 적용했던 쿼리 키 팩터리 중 하나인데요. ‘리뷰’라는 도메인에 해당하는 쿼리 키 팩터리입니다.
reviewsQueryKeys는 리뷰 도메인의 쿼리 키를 계층 구조로 정의한 객체인데요. 각 키에 대한 간단한 설명을 붙이자면
- reviewsQueryKeys.all() - 모든 리뷰의 루트 키로 [’reviews’] 와 동일합니다.
- reviewsQueryKeys.my.all() - 내가 작성한 리뷰 목록의 루트 키로 [’reviews’, ‘my’] 와 동일합니다.
- reviewsQueryKeys.my.page(1) - 내가 작성한 리뷰 목록 중 1페이지를 의미하며 [’reviews’, ‘my’, 1] 와 동일합니다.
- reviewsQueryKeys.category.category(’food’) - ‘food’ 카테고리 리뷰 목록의 루트 키로 [’reviews’, ‘category’, ‘food’] 와 동일합니다.
이정도로 키를 세분화해 관리하는 이유는 캐시 무효화에서 보다 정교하게 관리하기 위함인데요. 실제로 이번 프로젝트에서 있었던 일을 예로 들어보겠습니다.
먼저 우리 프로젝트는 사용자들이 경험한 모든 것들을 게시글로 작성해 공유할 수 있는 커뮤니티형 프로젝트로 각 카테고리별로 리뷰를 작성할 수 있는데요.
조회 시 ‘food’, ‘book’ 등 세부 카테고리로 조회할 수 있으며 전체 목록의 경우 ‘all’이라는 카테고리로 조회하고 있습니다. (백엔드 측 구현 사항입니다.) 또는 키워드로 직접 검색하는 방법도 존재합니다.
- 모든 데이터는 기본값 최신순으로 정렬하고 있으며 댓글, 북마크 순 정렬도 존재합니다.
사용자가 전체 목록과 음식 카테고리를 조회하고 피자를 검색했다고 가정해보겠습니다. 그렇다면 아래와 같은 키들에 데이터가 캐싱됩니다.
reviewsQueryKeys.category.category('all') => 전체라는 카테고리 최신 데이터 reviewsQueryKeys.category.category('food') => 음식이라는 카테고리 최신 데이터 reviewsQueryKeys.keyword.page('피자', 1, 'recent') => 피자라는 검색어에 1페이지 데이터
이후 사용자가 음식 카테고리에 ‘피자’라는 키워드가 포함된 새로운 리뷰를 등록합니다.
그럼 새로운 리뷰가 등록된 시점엔 사용자가 이전에 조회 중이던 위의 캐싱 데이터들은 최신 데이터가 아니게 됩니다.
- ‘전체’라는 카테고리 데이터에 새로운 리뷰가 등록됐고,
- ‘음식’이라는 카테고리 데이터에 새로운 리뷰가 등록됐으며,
- ‘피자’라는 키워드가 포함된 새로운 리뷰가 등록됐기 때문입니다.
이 때 쿼리 키 팩터리를 사용하면 아래와 같이 관련된 여러 캐시를 세밀하고 일관되게 무효화할 수 있습니다.
const invalidateKeys = [ reviewsQueryKeys.category.category('all') reviewsQueryKeys.category.category('food') reviewsQueryKeys.keyword.page('피자', 1, 'recent') ]; invalidateKeys.forEach(key => { queryClient.invalidateQueries({queryKey: key}); });
만약 팩터리 없이 [’reviews’, ‘keyword’, ‘피자’, 1, ‘recent’] 처럼 문자열 배열을 직접 사용한다면 키 구조가 변경되었을 때 데이터 호출 지점과 캐시 무효화 지점의 캐시 키를 모두 수정해야만 합니다.
물론 쿼리 키는 계층 구조로 동작하기 때문에 [’reviews’]만 무효화해도 하위 키들에 대한 캐시가 모두 무효화되긴 합니다.
하지만 최상위 루트 키를 무효화 해버린다면, 사용자가 등록하지 않은 ‘book’, ‘car’, ‘cosmetic’ 등 최신화할 필요가 없는 캐시도 무효화해 불필요한 네트워크 요청을 만들어낼 수도 있습니다.
특정 캐시만을 무효화해 갱신하면 불필요한 리렌더나 네트워크 요청 없이 필요한 영역만 갱신할 수 있습니다.
reviewsQueryOptions
useQuery, useSuspenseQuery 등 훅의 첫 번째 인자로 options 객체를 전달해야 하는데요. 이 객체는 쿼리 키, 데이터 패칭 로직, initialPageParam, placeholderData 등이 포함됩니다.
이 객체도 쿼리 키 팩터리와 마찬가지로 중앙화 시킬 수 있는데요.
export const reviewsQueryOptions = { category: (categoryId: Category, sort: string) => ({ queryKey: reviewsQueryKeys.category.page(categoryId, sort), queryFn: ({pageParam}: {pageParam: number}) => getCategoryReviews(pageParam, categoryId, sort), initialPageParam: 0, }), keyword: (keyword: string, page: number, sort: string) => ({ queryKey: reviewsQueryKeys.keyword.page(keyword, page, sort), queryFn: () => getKeywordReviews(keyword, page, sort), placeholderData: keepPreviousData, }), my: (page: number) => ({ queryKey: reviewsQueryKeys.my.page(page), queryFn: () => getMyReviews(page), placeholderData: keepPreviousData, }), myBookmarks: (page: number) => ({ queryKey: reviewsQueryKeys.myBookmarks.page(page), queryFn: () => getMyBookmarkedReviews(page), placeholderData: keepPreviousData, }), recent: () => ({ queryKey: reviewsQueryKeys.recent(), queryFn: getRecentReviews, }), };
이렇게 쿼리에 필요한 모든 설정을 중앙화해 관리하면, useQuery, useSuspenseQuery 등을 사용할 때 매번 queryKey, queryFn을 조합해 사용하지 않아도 됩니다.
실제로 제가 프로젝트를 진행하며 쿼리 키, 옵션 팩터리를 사용했던 예시들을 가져와봤는데요.
1. 프리패칭
페이지네이션을 사용하는 경우 앞, 뒤 페이지를 미리 프리패치하는 경우가 있습니다. 아래 코드는 프로젝트에서 사용했던 키워드 리뷰 목록을 반환하는 커스텀 훅입니다.
export default function useKeywordReviews(keyword: string, page: number, sort: string) { const {data} = useSuspenseQuery(reviewsQueryOptions.keyword(keyword, page, sort)); const queryClient = useQueryClient(); useEffect(() => { if (page < data.total_pages) { queryClient.prefetchQuery(reviewsQueryOptions.keyword(keyword, page + 1, sort)); } if (page > 1) { queryClient.prefetchQuery(reviewsQueryOptions.keyword(keyword, page - 1, sort)); } }, [keyword, page, data.total_pages, queryClient, sort]); return data; }
현재 페이지의 데이터를 가져오며 useEffect을 사용해 이전, 다음 페이지의 데이터를 미리 가져오는 코드인데요.
useSuspenseQuery와 prefetchQuery 모두 동일한 reviewsQueryOptions 팩터리의 keyword 속성을 참조해 사용하기 때문에 동일한 queryKey로 데이터가 캐싱되도록 보장할 수 있습니다.
2. 낙관적 업데이트
댓글 작성이나 북마크 등록, 해제 등 사용자 경험에 즉각적인 피드백이 중요한 기능에 낙관적 업데이트를 사용하곤 합니다.
const previousComments = queryClient.getQueryData( reviewQueryKeys.comments.page(reviewId, page) ); queryClient.setQueryData( reviewQueryKeys.comments.page(reviewId, page), updatedComments ); await queryClient.cancelQueries({ queryKey: reviewQueryKeys.comments.page(reviewId, page) }); queryClient.removeQueries({ queryKey: reviewQueryKeys.comments.page(reviewId, page + 1) });
제가 댓글 작성을 위해 구현한 뮤테이션 훅에서 쿼리 키 팩터리를 사용한 일부들을 가져와봤는데요. 항상 팩터리에 정의된 동일한 키를 사용하기 때문에 서버 응답 전후의 캐시 일관성을 쉽게 유지할 수 있습니다.
이런 구조는 단순히 중앙화한다는 것에서 끝나지 않고 키를 기억하지 않아도 되는 코드를 만들 수 있게 해줍니다.
사실 제가 혼자 떠올린 코드는 아니고 메인테이너인 TkDodo의 블로그에서 효과적으로 키를 관리하는 방법에서 소개한 내용을 참고해 구현한 사항들입니다.
부록1 마무리
리액트 쿼리의 캐싱, 무효화, 낙관적 업데이트, 프리패칭 등 여러 방면에서 일관된 키를 사용한다면 유지보수나 코드 작성에서 실수를 최소화할 수 있다고 생각합니다.
각 키들을 하드코딩하면 어딘가 누락이 발생할 수밖에 없으니까요.
그래서 쿼리 키, 옵션을 팩터리 형태로 관리하는 방법이 단순히 코드 스타일의 문제가 아니라 각 상태를 도메인 단위로 구조화하기 위한 설계 패턴이지 않나? 싶습니다.
부록2 - 스트리밍, 프리패칭
저는 이번 프로젝트에 앞서 작은 프로젝트를 만들어 Next.js 환경에서 리액트 쿼리를 활용할 수 있는 방법 몇 가지를 실험해봤습니다. (부록1의 쿼리 키 팩터리도 이 때 시도했습니다.)
그 중 리액트 쿼리의 캐시를 서버 환경에서 미리 구성한 뒤 클라이언트로 전달하는 방식이 꽤나 흥미로웠기 때문에 해당 내용도 부록으로 정리해보려고 합니다.
Next.js는 서버 컴포넌트 기반으로 동작하기 때문에 클라이언트에서 데이터를 요청하기 전에 서버에서 미리 데이터를 요청해 HTML 문서에 직렬화된 형태로 포함시켜 전송할 수 있는데요.
직렬화?
먼저 개념적으로 직렬화란 자바스크립트 객체처럼 메모리 안에 존재하는 데이터를 문자열로 변환해 전송이 가능한 형태로 만드는 과정입니다.
HTTP는 네트워크 프로토콜로 자바스크립트 객체를 이해할 수 없습니다. 바이트 스트림만 전송할 수 있기 때문에 객체를 문자열로 변환해야 패킷에 담아 보낼 수 있습니다.
클라이언트 사이드에서 서버로 요청 본문에 데이터를 담아 보낼 때 JSON.stringify로 변환해 보낸 경험이 있으실텐데요. 이 패턴 역시 자바스크립트 객체를 JSON 문자열로 변환하는 과정으로 직렬화입니다.
반대로 서버가 보낸 JSON 문자열을 클라이언트 측에서 JSON.parse로 다시 자바스크립트 객체로 바꾸기도 합니다. 이건 역직렬화라고 합니다.
핵심 개념
다시 돌아와서 이런 동작 덕분에 사용자는 데이터를 기다릴 필요 없이, 이미 데이터가 채워진 UI를 즉시 받아볼 수 있게 되는데요.
먼저 핵심 동작부터 정리하고 가보겠습니다. 우선 전제 조건은 자식 요소는 평범한 리액트 쿼리 사용 패턴을 가져야만 하고 상위의 부모 컴포넌트는 서버 컴포넌트여야만 합니다.
function Component() { const {data} = useSuspenseQuery(); }
자식 요소는 이렇게 평범하게 내부에서 데이터를 요청하는 쿼리 훅이 작성된 상태여야만 합니다. 이제 부모 컴포넌트에서의 동작을 정리해보자면,
-
서버 컴포넌트에서 쿼리 클라이언트 객체를 생성합니다.
const queryClient = new QueryClient(); -
쿼리 클라이언트 객체를 사용해 데이터를 서버에서 미리 요청합니다.
queryClient.prefetchQuery({ queryKey, queryFn }); -
쿼리 클라이언트 객체를 클라이언트로 보내기 위해 정적인 리소스로 바꿉니다. (쿼리 클라이언트 객체에 캐시가 구성된 상태입니다.)
const dehydratedQuery = dehydrate(queryClient) -
하이드레이션 바운더리로 자식 요소를 감싸고 state 속성에 직렬화된 캐시를 주입합니다.
<HydrationBoundary state={dehydratedQuery}> <Component /> </HydrationBoundary>
사실 핵심 코드는 이게 전부입니다. 하지만 동작은 크게 달라지는데요. 예시를 위해 프로젝트가 Next.js로 구성된 상태로 가정해보겠습니다.
먼저 클라이언트 사이드에서 발생하는 요청 흐름은 아래와 같습니다.
- 서버에서 HTML 문서를 만들어 클라이언트로 전송.
- 클라이언트에서 JS가 실행되며 리액트 쿼리 초기화.
- 데이터 요청 및 대기.
이 경우 화면 뼈대는 빠르게 보이지만 사용자가 보길 기대하는 데이터가 채워지기까지 네트워크 지연이 발생합니다. 하지만 서버 단에서 미리 프리패치하는 경우는 어떨까요?
- 서버 컴포넌트 실행 시점에 필요한 데이터를 미리 요청.
- 응답이 완료된 데이터를 직렬화해 HTML에 포함.
- 클라이언트에서 JS가 실행될 때 리액트 쿼리가 이미 채워진 캐시를 복원.
즉, HTML 문서가 전송될 때 이미 데이터가 포함되어 있기 때문에 화면과 데이터가 동시에 준비됩니다. 클라이언트는 추가적인 네트워크 요청 없이 완성된 화면을 볼 수 있게 됩니다.
이 방식은 클라이언트가 데이터를 요청하는 주체가 아니라 서버에서 준비된 데이터를 그대로 받아 활용하는 구조가 되는데요.
그만큼 데이터를 요청하기 위한 JS 실행과 네트워크 왕복 비용이 줄고 사용자가 데이터를 보기까지 걸리는 시간이 크게 줄어듭니다.
- 프리패치된 쿼리는 이미 캐시에 존재하기 때문에 동일한 데이터를 사용하는 하위 컴포넌트에선 중복 요청이 발생하지 않습니다. 즉 부모 컴포넌트와 자식 컴포넌트가 동일한 쿼리를 요청하지만 요청은 부모 컴포넌트인 서버에서만 발생합니다.
- 하지만 staleTime, gcTime 등으로 데이터의 신선도가 보장되지 않을 경우엔 refetch가 발생합니다.
또, 첫 화면이 렌더링되는 시점에 이미 데이터가 존재하기 때문에 fallback ui를 사용하지 않아도 된다거나 리액트 쿼리를 사용하면서도 SSR 수준의 SEO 최적화도 가능하다는 장점도 있습니다.
프리패칭을 위한 유틸리티 함수 구현하기
이제 제가 실제로 프로젝트에서 프리패치를 어떻게 했는지 기록해보려고 하는데요. 먼저 매번 서버 컴포넌트에서 쿼리 클라이언트 객체를 만들고, 프리패치하고, 직렬화하는 코드를 작성하는건 번거롭습니다.
그래서 재사용 가능한 유틸리티 함수를 만들어 핵심 동작 1,2,3번을 추상화했는데요.
import { defaultShouldDehydrateQuery, dehydrate, QueryClient, QueryFunction, QueryKey, } from "@tanstack/react-query"; import { cache } from "react"; type QueryProps<ResponseType = unknown> = { queryKey: QueryKey; queryFn: QueryFunction<ResponseType>; }; type InfiniteQueryProps<ResponseType = unknown, PageParamType = number> = { queryKey: QueryKey; queryFn: (context: { pageParam: PageParamType }) => Promise<ResponseType>; initialPageParam: PageParamType; }; type DehydratedQueryProps<Q> = { query: Q; shouldAwait?: boolean; }; type DehydratedQueriesProps<Q> = { queries: Q; shouldAwait?: boolean; }; export const getQueryClient = cache( () => new QueryClient({ defaultOptions: { queries: { staleTime: 1000 * 60, gcTime: 1000 * 60, }, dehydrate: { shouldDehydrateQuery: (query) => defaultShouldDehydrateQuery(query) || query.state.status === "pending", }, }, }) ); export async function getDehydratedQuery<Q extends QueryProps>({ query, shouldAwait, }: DehydratedQueryProps<Q>) { const queryClient = getQueryClient(); if (shouldAwait) { await queryClient.prefetchQuery(query); } else { queryClient.prefetchQuery(query); } return { query: dehydrate(queryClient).queries }; } export async function getDehydratedQueries<Q extends QueryProps[]>({ queries, shouldAwait = false, }: DehydratedQueriesProps<Q>) { const queryClient = getQueryClient(); const promises = queries.map((query) => queryClient.prefetchQuery(query)); if (shouldAwait) { await Promise.all(promises); } return { queries: dehydrate(queryClient).queries }; } export async function getDehydratedInfiniteQuery<Q extends InfiniteQueryProps>({ query, shouldAwait = false, }: DehydratedQueryProps<Q>) { const queryClient = getQueryClient(); const queryOptions = { ...query, initialPageParam: 0 }; if (shouldAwait) { await queryClient.prefetchInfiniteQuery(queryOptions); } else { queryClient.prefetchInfiniteQuery(queryOptions); } return { query: dehydrate(queryClient).queries }; }
이건 제가 구현한 유틸리티 함수 코드입니다. 중요한 포인트를 짚고 가볼게요.
1. getQueryClient의 cache
export const getQueryClient = cache( () => new QueryClient({ ... }) );
아주 중요한 부분인데요. 쿼리 클라이언트 객체를 생성하는 getQueryClient 함수를 리액트의 cache 함수로 감싸놓은 상태입니다.
왜 cache를 사용했을까요?
const globalQueryClient = new QueryClient(); const getQueryClient = () => globalQueryClient;
만약 cache 없이 전역에 싱글톤 객체로 생성하면 모든 사용자가 동일한 쿼리 캐시를 공유하게 됩니다. A가 민감한 데이터를 요청한 상태로 B가 요청했을 때 이 캐시가 공유된다면 꽤나 심각한 데이터 오염이 발생하게 됩니다.
const getQueryClient = () => new QueryClient();
반대로 cache 없이 만들면 매번 새로운 쿼리 클라이언트 객체를 생성한다면 어떨까요?
같은 요청을 처리하는 동안 getQueryClient 함수가 호출되면 호출될 때마다 새로운 쿼리 클라이언트 객체가 만들어집니다. 그럼 layout.tsx에서 프리패치한 데이터를 page.tsx에서 알지 못하는 등 데이터 불일치가 발생합니다.
리액트에서 제공하는 cache 함수는 요청 단위로 함수를 캐시해주기 때문에 서로 다른 요청 간 데이터를 격리시켜주고, 하나의 요청 내에서는 인스턴스를 공유해줍니다.
2. defaultOptions의 staleTime, gcTime
이건 공식문서에서도 다루는 내용으로 0으로 설정하지 말라고 합니다. 0으로 설정할 경우 서버에서 데이터를 프리패치한 후 클라이언트로 전달되면 즉시 stale 상태가 되어 refetch가 발생하게 됩니다.
서버에서 데이터를 미리 가져왔는데 클라이언트가 동일한 데이터를 받자마자 다시 요청하는 불필요한 중복 호출이 발생하기 때문에 저는 1분의 시간을 설정해놓은 상태입니다.
gcTime 0으로 설정해놓고 자꾸 데이터가 사라져서 30분간 삽질을 했습니다…
3. defaultOptions의 dehydrate
다음으로 defaultOptions의 dehydrate 설정입니다.
dehydrate: { shouldDehydrateQuery: (query) => defaultShouldDehydrateQuery(query) || query.state.status === "pending", },
동작부터 설명하자면, 직렬화할 때 pending 상태의 쿼리도 직렬화하겠다는 의미입니다.
queryClient.prefetchQuery({queryKey, queryFn})

서버에서 prefetch를 await하기 않고 시작만 해두면, pending 상태의 쿼리가 생성되는데요. 위 옵션을 설정하지 않으면 직렬화 대상에 포함되지 않아 클라이언트 측에서 미리 구성된 캐시를 받을 수 없기 때문에 설정했습니다.
이 동작이 필요한 이유는 아래 shouldAwait 절에서 추가로 설명해보겠습니다.
4. shouldAwait 옵션
getDehydratedQuery, getDehydratedQueries, getDehydratedInfiniteQuery 함수의 인자를 보면 모두 shouldAwait라는 옵션이 있는데요.
-
true: 이 값이 true일 경우 queryClient.prefetchQuery를 await해 데이터가 올 때까지 대기하게 됩니다. 즉, HTML이 클라이언트에 도착했을 때 이미 데이터가 모두 채워진 상태로 도착하게 되는데요. SEO가 중요하거나 첫 화면에 데이터가 반드시 채워져야 할 때 true로 설정할 수 있습니다.
- true로 설정한 경우 쿼리 캐시는 success 상태로 들어옵니다.

-
false: 이 값이 false일 경우 서버에서 prefetchQuery를 실행만 하고 기다리지 않습니다. 그럼 서버는 빠르게 HTML 문서를 생성해 전송하고 클라이언트는 바로 위에서 설정한 pending 상태의 쿼리를 이어받고 나머지 데이터 패칭을 마무리하게 됩니다.
- false로 설정할 경우 pending 상태의 쿼리를 전송해야 하기 때문에 위의 3번 dehydrate 옵션에서 pending 상태의 쿼리도 직렬화하겠다고 설정해야 합니다.
첫 하면에 반드시 데이터가 포함되어야 하는(SEO를 위해) 경우엔 true, 초기 렌더링 속도가 중요한 경우엔 false로 설정하면 됩니다.
5. 유틸리티 함수 사용하기
export default async function BestReviewPage() { const { query } = await getDehydratedQuery({ query: reviewQueryOptions.best(), shouldAwait: true, }); return ( <HydrationBoundary state={{ queries: query }}> <BestReview /> </HydrationBoundary> ); }
이제 서버 컴포넌트에서 프리패치하기 위해 만들어진 유틸리티 함수들을 이용하면 되는데요. 주의할 점으로 서버 컴포넌트에서 프리패치를 실행한다고 자식 컴포넌트에 데이터가 알아서 흘러들어가진 않습니다.
export default function BestReview() { const { data } = useSuspenseQuery(reviewQueryOptions.best()); return <div>{data.title}</div>; }
자식 컴포넌트는 일반적인 리액트 쿼리 사용 패턴대로 구현하고, 부모 컴포넌트에 프리패치 코드를 작성하면 됩니다. HydrationBoundary에 의해 서버가 구성한 쿼리 캐시가 다시 역직렬화되기 때문에 하위 컴포넌트에서 데이터 요청이 발생하지 않습니다.
RQBoundary 수정하기
바로 위 유틸리티 함수 사용하기의 예제에서 HydrationBoundary만 사용하고 있는데요.
하지만 프로젝트의 리액트 쿼리 훅 대부분이 useSuspenseXXX 훅을 사용하기 때문에 Suspense 컴포넌트도 필요하고, 데이터 요청이 실패했을 때를 대비한 ErrorBoundary, 쿼리를 리셋하기 위한 QueryErrorResetBoundary도 필요합니다.
하지만 이미 RQBoundary라는 컴포넌트를 만들어 3가지 컴포넌트를 조합해놓았기 때문에 이 컴포넌트를 적절히 수정해주기만 하면 되는데요.
import {Suspense} from 'react'; import {QueryErrorResetBoundary} from '@tanstack/react-query'; import {ErrorBoundary} from 'react-error-boundary'; import {ErrorFallback, LoadingSpinner} from '../ui/components'; type Props = { state?: DehydratedState["queries"]; children: React.ReactNode; ErrorFallbackicon?: React.ReactNode; ErrorFallbackClassName?: HTMLDivElement['className']; LoadingFallback?: React.ReactNode; }; export default function RQBoundary({ state, children, ErrorFallbackicon, ErrorFallbackClassName, LoadingFallback = <LoadingSpinner />, }: DefaultProps) { return ( <QueryErrorResetBoundary> {({reset}) => ( <ErrorBoundary onReset={reset} fallbackRender={({error, resetErrorBoundary}) => ( <ErrorFallback error={error} resetErrorBoundary={resetErrorBoundary} icon={ErrorFallbackicon} className={ErrorFallbackClassName} /> )} > <Suspense fallback={LoadingFallback}> {state ? ( <HydrationBoundary state={{ queries: state }}> {children} </HydrationBoundary> ) : ( children )} </Suspense> </ErrorBoundary> )} </QueryErrorResetBoundary> ); }
이렇게 인자로 state가 들어온 경우에만 HydrationBoundary로 감싸주면 됩니다. 그럼 위 예제는 아래처럼 간결하게 수정할 수 있습니다.
export default async function BestReviewPage() { const { query } = await getDehydratedQuery({ query: reviewQueryOptions.best(), shouldAwait: true, }); return ( <RQProvider state={query}> <BestReview /> </RQProvider> ); }
이제 페이지 컴포넌트는 Suspense나 ErrorBoundary 등을 신경쓰지 않고 어떤 데이터를 프리패치할지에만 집중할 수 있게 됩니다.
실제로 데이터가 어떻게 오는지?
위에서 하위 컴포넌트에서 중복 요청이 발생하지 않는다고 했었는데요. 먼저 프리패치하지 않고 오직 클라이언트 사이드에서만 요청할 때의 네트워크 탭을 보여드리겠습니다.
프리패치를 사용하지 않았을 때

이렇게 초기 응답에 데이터가 포함되지 않고, 상품 정보를 위한 GET/products와 사용자 정보를 위한 GET/id253. 네트워크 요청이 2개 만들어져있습니다.
프리패치 사용 - shouldAwait: false
export default async function ProductsPage() { const { queries } = await getDehydratedQueries({ queries: [userQueryOptions.all("253"), productsQueryOptions.all()], }); return ( <section className="h-full p-4 flex flex-col gap-4"> <RQBoundary state={queries}> <UserCard /> </RQBoundary> <RQBoundary state={queries}> <ProductList /> </RQBoundary> </section> ); }
다음으로 기본값인 shouldAwait: false일 때의 네트워크 탭입니다.

이번엔 초기에 필요한 데이터가 담겨있진 않지만 추가적인 데이터 요청이 발생하진 않았습니다. 서버 단에서 이미 요청이 시작된 상태로 클라이언트로 도착하기 때문에 추가적인 네트워크 요청은 발생하지 않습니다.
프리패치 사용 - shouldAwait: true
export default async function ProductsPage() { const { queries } = await getDehydratedQueries({ queries: [userQueryOptions.all("253"), productsQueryOptions.all()], shouldAwait: true }); return ( <section className="h-full p-4 flex flex-col gap-4"> <RQBoundary state={queries}> <UserCard /> </RQBoundary> <RQBoundary state={queries}> <ProductList /> </RQBoundary> </section> ); }
이번엔 서버단에서 요청을 시작하고, 응답을 모두 대기하도록 shouldAwait를 true로 설정했을 때입니다.

이번엔 응답 HTML에 필요한 데이터가 모두 채워져 오는걸 확인할 수 있습니다.
부록2 마무리
이렇게 Next.js 서버 컴포넌트 환경에서 리액트 쿼리를 활용해 프리패칭을 구현하는 방법을 정리해봤는데요.
서버에서 쿼리를 미리 요청하고 직렬화된 캐시 상태를 HTML 문서에 포함해 전송하는 방식은 크게 2가지 장점을 가진다고 생각합니다.
- 클라이언트에서 별도의 데이터 요청이 일어나지 않기 때문에 초기 로딩 속도가 크게 개선된다.
- 서버 렌더링 시점에 데이터가 이미 존재해 첫 화면부터 데이터가 채워져 HTML이 내려오기 때문에 SEO 최적화에도 유리하다.
이제 부록2에 대한 정리는 여기까지입니다. Next.js의 서버 컴포넌트 환경에서 리액트 쿼리의 프리패칭을 활용하는 방식은 데이터를 언제, 어디서 패칭하느냐에 대한 선택지의 확장이라고 볼 수 있습니다.
기존처럼 리액트 쿼리를 사용할 때 클라이언트가 모든 요청을 담당하던 구조에서 서버가 먼저 데이터를 준비해둘 수 있게 되니 초기 화면이 더 빠르고 자연스럽게 채워집니다.
물론 HTML 렌더링만 담당하던 서버가 API 요청까지 담당하게 되니 그만큼 부하는 더 커질 수밖에 없는데요.
하지만 Next.js 자체가 데이터 패칭을 서버 단에서 수행하는 것을 전제로 하는 구조니 상황에 따라 shouldAwait 설정만 적절히 조절해주면 SEO와 초기 렌더링 속도 사이의 균형을 충분히 맞출 수 있다고 생각합니다.

센트리로 에러 모니터링하기

