-
서버컴포넌트에서 리엑트쿼리(prefetch)React/React-query 2025. 1. 18. 23:53
전제
- axios 사용
- react-query 사용
- 서버컴포넌트 사용
"dependencies": { "@tanstack/react-query": "^5.64.1", "@tanstack/react-query-devtools": "^5.64.1", "next": "15.1.0", "react": "^19.0.0", "react-dom": "^19.0.0", },
app ├── products │ └── [id] -- 아이템 상세보기 페이지 │ ├── sections -- UI 하위 컴포넌트 │ │ ├── MainContent.tsx │ │ ├── Profile.tsx │ │ └── Reviews.tsx │ ├── ui │ │ └── Ui.tsx -- 클라이언트 컴포넌트 & UI 컴포넌트 구조 │ └── page.tsx -- 서버 컴포넌트 데이터 페칭 & 메타데이터 관리 │ ├── layout.tsx ... lib ├── react-query │ ├── provider.tsx │ └── queryClient.ts ... service ├── product │ ├── api.ts -- API 호출 함수 │ ├── constants.ts -- 상수 값 관리 │ └── queries.ts -- React Query 관련 쿼리 관리
서론
Product상세조회 페이지를 개발하면서 제일 상위 컴포넌트인 page.tsx에 페칭 및 통신관련 기능을 전담하려 한다.
단 한번의 요청으로, 서버에서 렌더링되어 SEO의 유리한점을 챙기고, 페칭된 데이터를 불필요한 추가 요청없이 안전하게 하위 ui컴포넌트로 prop으로 옮기는 작업을 하고 싶었다. 이러한 구조를 하려는 이유는, 클라이언트 컴포넌트에서 리엑트쿼리를 통해 데이터를 패칭한다면, 서버사이드에서 페칭 한번, 클라이언트에서 한번 총 두번의 중복적인 데이터페칭이 이루어지는 것을 방지하기 위함이다.
그러나 서버에서 데이터페칭과 동시에 클라이언트에 캐싱을 하고싶은데, 리엑트쿼리는 클라이언트라이브러리 이므로,
서버컴포넌트에서 동작할 수 없다.
왜 서버컴포넌트 인가
서버사이드렌더링, 서버렌더링을 하려는 이유는 초기 HTML을 서버에서 미리 생성하여 관련 이점을 다루기 위함이다.
반대로 보통의 클라이언트사이드 렌더링을 행한다면,
1. 페이지 요청
- 사용자가 브라우저에 특정 URL의 페이지 요청
2. 빈껍데기 html 반환
- 요청에 기본 HTML파일을 반환(깡통)
<!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <title>My App</title> <link rel="stylesheet" href="/main.css"> </head> <body> <div id="root"></div> <!-- React가 그려질 컨테이너 --> <script src="/main.js"></script> </body> </html>
3. 브라우저가 JavaScript 파일을 다운로드
- 번들 다운로드
4. JS실행
- HTML 컨테이너를 찾아서 앱을 랜더링하기 시작(DOM에 컴포넌트 추가)
- 그러나 아직 데이터를 불러오지 않았기에 화면인 빈 상태거나 로딩 스피너가 표시
5. 데이터 요청
- 애플리케이션이 초기화가 되고, 클라이언트 측에서 데이터를 요청
6. 데이터 반환 및 렌더링
- 서버에서 데이터가 반환되면 클라이언트는 화면을 업데이트.
지금까지의 과정에서 총 3번의 요청이 발생하게 된다.
1. 페이지 요청(HTML)
2. JS 요청(번들)
3. 데이터 요청(API)
물론 이렇게 단점만 나열하는 것 보다도 클라이언트 사이드렌더링의 장점도 존재하지만 그럼에도 서버사이드렌더링을 고집하는 이유는 SEO(Search Engine Optimization)의 필요성 이라 할 수 있다.
CSR은 브라우저가 JavaScript를 실행해야만 콘텐츠가 로드되므로, 검색 엔진 봇이 JavaScript를 실행하지 못하는 경우 검색 엔진 최적화(SEO)에 불리하다.
- SSR은 서버에서 완전한 HTML을 반환하므로, 검색 엔진이 페이지 콘텐츠를 쉽게 크롤링
- 특히, 마케팅 사이트, 블로그, 전자상거래 사이트에서 중요.
또한 추가로 데이터 요청의 최적화로 데이터를 미리 로드하고 최적화된 방식으로 제공하기 위함이다. 따라서, 단 한번의 서버에서의 호출로 채워진 HTML을 만들면서 동시에 캐싱을 해서 추가적인 데이터 요청이 발생하지 않도록 하는것이 최종 목표이다.
에러
서론에서 말한듯이 서버컴포넌트에서는 useQuery를 사용할 수 없다. 이는 클라이언트 사이드 상태 관리를 위한 도구이다. 서버에서는 상태관리에 관한 개념이 없을 뿐더러, 실행환경의 차이가 있다.
서버 컴포넌트 클라이언트 컴포넌트 - 요청마다 새로 실행됨
- 상태를 유지하지 않음
- 한 번 실행되고 끝남- 브라우저에서 지속적으로 실행됨
- 상태를 유지함
- 사용자 상호작용에 반응함[ Server ] Error: Attempted to call useQuery() from the server but useQuery is on the client. It's not possible to invoke a client function from the server, it can only be rendered as a Component or passed to props of a Client Component. 그럼에도 사용한다면 다음과 같은 에러를 보게 된다. 이 에러가 useQuery는 클라이언트에서 사용하라고 다시한번 상기시켜준다.
prefetch
https://tanstack.com/query/latest/docs/framework/react/guides/ssr#server-rendering--react-query
Server Rendering & Hydration | TanStack Query React Docs
In this guide you'll learn how to use React Query with server rendering. See the guide on for some background. You might also want to check out the before that. For advanced server rendering patterns,...
tanstack.com
(리엑트쿼리 서버사이드 렌더링 공식 가이드)
그럼에도 서버사이드 렌더링을 하며 동시에 캐싱을 할 수 있다. 공식문서에 prefetch를 함으로서 다양한 패턴을 소개한다.
https://tanstack.com/query/latest/docs/framework/react/guides/prefetching#prefetch-in-components
There are a few different prefetching patterns:
- In event handlers
- Incomponents
- Via router integration
- During Server Rendering <<<<<구현코드 - 서버컴포넌트와 클라이언트 컴포넌트
서버 컴포넌트
// products/[id]/page.tsx import { QueryClient, dehydrate, HydrationBoundary } from '@tanstack/react-query'; import { productAPI } from '@/service/product/api'; import { QUERY_KEYS } from '@/service/product/constants'; import Ui from './ui/Ui'; export default async function ProductPage({ params }: { params: { id: string } }) { const queryClient = new QueryClient(); if (!params?.id) { throw new Error('params.id is required but missing'); } await Promise.all([ queryClient.prefetchQuery({ queryKey: QUERY_KEYS.product.detail(params.id), queryFn: () => productAPI.getProduct(params.id), }), queryClient.prefetchQuery({ queryKey: QUERY_KEYS.product.reviews(params.id), queryFn: () => productAPI.getReviews(params.id), }), ]); const dehydratedState = dehydrate(queryClient); return ( <HydrationBoundary state={dehydratedState}> <Ui productId={params.id} /> </HydrationBoundary> ); }
해당 페이지(서버컴포넌트)에서는, 상품에 대한 자세한 정보와, 그 정보에 대한 리뷰 데이터를 요청한다.
const queryClient = new QueryClient();
서버 컴포넌트는 매 요청마다 새로운 인스턴스를 생성한다. 각 요청마다 독립적으로 실행되기 때문이다. 따라 요청이 서로 영향을 주지 않도록 격리되어야 하기때문에 새로운 queryClient 인스턴스가 필요하다. 즉, 서버 컴포넌트에서는 prefetch와 dehydration을 위한 임시 인스턴스가 필요하다.
await Promise.all([ queryClient.prefetchQuery({ queryKey: QUERY_KEYS.product.detail(params.id), queryFn: () => productAPI.getProduct(params.id), }), queryClient.prefetchQuery({ queryKey: QUERY_KEYS.product.reviews(params.id), queryFn: () => productAPI.getReviews(params.id), }), ]);
서로 독립적인 데이터를 가지고있기 떄문에 Promise.all을 사용하여 비동기 작업을 동시에 실행하여 병렬적으로 처리한다.
const dehydratedState = dehydrate(queryClient); return ( <HydrationBoundary state={dehydratedState}> <Ui productId={params.id} /> </HydrationBoundary> ); }
prefetch를 하고 난 후 queryClient의 내부상태는 대략 (예시)
{ queries: { ['product', '123']: { data: { id: '123', name: '상품명', price: 50000 }, dataUpdateCount: 1, state: 'success' }, ['product-reviews', '123']: { data: [ { id: 1, content: '리뷰내용' } ], dataUpdateCount: 1, state: 'success' } } }
다음과 같을 것이다. 여기서 dehydrate을 통해 데이터를 직렬화(serialize)한다.
직렬화가 필요한 이유는 서버와 클라이언트 간의 데이터 전송 문제를 해결하기 위해서이다. 서버와 클라이언트 사이의 데이터는 JSON 형태로만 전송이 가능한데, JavaScript의 Date, Map, Set, Function 등의 복잡한 객체들은 JSON으로 변환 시 원래의 형태나 기능을 잃어버리게 된다. 예를 들어, Date 객체는 문자열로 변환되고, Function은 undefined가 되며, Map이나 Set은 빈 객체가 되어버린다.
따라서 React Query는 이러한 복잡한 데이터 구조를 안전하게 전송하고, 클라이언트에서 다시 올바른 형태로 복원할 수 있도록 dehydrate를 통한 직렬화 과정이 필요하다. 이 과정을 통해 서버에서 가져온 데이터의 무결성을 유지하면서 클라이언트로 전달할 수 있게 된다.
++) 일반적인 내장 fetch를 사용할 때는 이미 JSON 형태로 데이터를 받기 때문에 별도의 직렬화 과정이 필요하지 않다. Next.js는 서버 컴포넌트에서 클라이언트 컴포넌트로 props를 전달할 때 필요한 직렬화를 자동으로 처리한다.
그리고 HydrationBoundary는 서버에서 직렬화된(dehydrate된) React Query의 캐시 데이터를 클라이언트에서 다시 사용 가능한 상태로 복원(hydrate)하는 역할을한다.
다시 정리하자면, 서버에서 prefetch를 통해 데이터를 캐시에 저장하고, 그 저장된 데이터를 클라이언트로 전송하기 위해 직렬화를 했었다. 이제 이 직렬화한 데이터를 클라이언트에서 사용하려면 다시 원래의 캐시 형태로 복원해야 한다. 따라서 HydrationBoundary로 감싸주어 클라이언트의 React Query가 서버에서 prefetch한 데이터를 자연스럽게 인식하고 사용할 수 있게 된다. 만약 감싸지 않는다면, 데이터를 인식하지 못하고 새로운 데이터를 요청하게된다.
클라이언트 컴포넌트
// products/[id]/ui/Ui.tsx 'use client'; import MainContent from '../sections/MainContent'; import Profile from '../sections/Profile'; import Reviews from '../sections/Reviews'; import { useProductDetail, useProductReviews } from '@/service/product/queries'; interface Props { productId: string; } export default function Ui({ productId }: Props) { const { data: productData } = useProductDetail(productId); -- 일반useQuery문 const { data: reviewsData } = useProductReviews(productId); -- 일반useQuery문 if (!productData || !reviewsData) { return null; } return ( <div className="flex justify-center min-w-[1280px]"> <div className="max-w-[1280px] mx-auto px-4 py-8"> <div className="grid grid-cols-12 gap-8"> <div className="col-span-3"> <div className="sticky top-4"> <Profile product={productData} /> </div> </div> <div className="col-span-9"> <div className="space-y-12"> <MainContent product={productData} /> <Reviews product={productData} reviews={reviewsData} /> </div> </div> </div> </div> </div> ); }
클라이언트 컴포넌트에선 HydrationBoundary에 의해 서버에서 prefetch한 데이터가 이미 React Query 캐시에 존재하게된다. 이때 추가적인 네트워크 요청없이 useProductDetail과 useProductReviews는 캐시된 데이터를 즉시 반환하게 된다.
검증 - 추가 요청
service/product/queries.ts
import { useQuery } from '@tanstack/react-query'; import { QUERY_KEYS } from './constants'; import { productAPI } from './api'; import { ProductDetail, Review } from '@/types/products/Products'; export const useProductDetail = (id: string, initialData?: ProductDetail) => { return useQuery({ queryKey: QUERY_KEYS.product.detail(id), queryFn: () => { console.log('Product Detail API 호출됨'); // 로그확인 console추가 return productAPI.getProduct(id); }, initialData, }); }; export const useProductReviews = (id: string, initialData?: Review[]) => { return useQuery({ queryKey: QUERY_KEYS.product.reviews(id), queryFn: () => productAPI.getReviews(id), initialData, }); };
로그를 추가하고 page에서
// ProductPage.tsx import Ui from './ui/Ui'; export default async function ProductPage({ params }: { params: { id: string } }) { return <Ui productId={params.id} />; }
prefetch관련 코드를 전부 삭제한 후, 실행시키면, 브라우저에 console이 출력되지만,
다른에러는 무시해주세요 ㅜ prefetch를 적용하면, console에 출력되지않는다. 이는 추가적인 네트워크 요청을 하지 않았음을 확인할 수 있게됬다.
맺는말
이처럼 React Query의 prefetch 기능과 Next.js의 서버 컴포넌트를 조합하여 최적화된 데이터 페칭 구조를 구현할 수 있었다. 이 방식의 주요 이점은 다음과 같았다.
- SEO 최적화: 서버에서 데이터가 포함된 HTML을 생성하여 검색 엔진 크롤링에 유리
- 성능 최적화: 단 한 번의 서버 요청으로 필요한 데이터를 모두 가져오고, 클라이언트에서 추가 요청 없이 캐시된 데이터를 활용
이제 남은것은 데이터를 페칭하는 동안 리엑트쿼리와 Suspense를 통해 스켈레톤을 구현하는 일만 남은 것 같다.
'React > React-query' 카테고리의 다른 글
react-query로 로그인 기능 구현 (0) 2024.11.09 React-Query를 사용해보자 (1) 2024.11.09