Next.js + Prisma로 DB를 조회하면서 API를 구현해 작업하고 있는 프로젝트에서,
개발 후 셀프 QA를 하면서 초기 페이지 렌더링 + 데이터 fetching 시간까지 더해 로딩이 무지하게 느려지는 부분이 아쉽게 느껴졌다.
방법을 고민하다 Tanstack Query(React Query)에서 제공하는 Hydration API 를 발견! 이 기능을 통해 Next.js의 서버 컴포넌트에 data prefetching을 사용할 수 있다고 하여 도입해 보기로 했다.
원리는 다음과 같다.
- 서버 컴포넌트에서
queryClient.prefetchQuery
를 사용해 데이터를 불러오고 이를dehydrate
하여 하위 컴포넌트를HydrationBoundary
로 감싸state
를 넘겨 준다.- 데이터를 사용하는 컴포넌트에서
useQuery
로 동일한 데이터를 불러오면 해당 데이터는prefetch
된 상태로 넘어와 이를 사용한다.
useReactQuery.tsx
'use client';
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
export default function ReactQueryProviders({
children,
}: React.PropsWithChildren) {
function makeQueryClient() {
return new QueryClient({
defaultOptions: {
queries: {
staleTime: 60 * 1000,
},
},
});
}
let browserQueryClient: QueryClient | undefined = undefined;
function getQueryClient() {
if (typeof window === 'undefined') {
// Server일 경우
return makeQueryClient();
} else {
// Browser일 경우
if (!browserQueryClient) browserQueryClient = makeQueryClient();
return browserQueryClient;
}
}
const queryClient = getQueryClient();
return (
<QueryClientProvider client={queryClient}>
{children}
{/* <ReactQueryDevtools initialIsOpen={false} /> */}
</QueryClientProvider>
);
}
function makeQueryClient() {
return new QueryClient({
defaultOptions: {
queries: {
staleTime: 60 * 1000,
},
},
});
}
SSR에서 클라이언트에서 즉시 refetch하는 것을 피하기 위해 staleTime
을 0보다 크게 설정하는 것이 좋다.
실제로 staleTime을 설정하지 않았다가 query key 변경 사항이 없음에도 무한 refetch
를 경험한 적이 있다..b
function getQueryClient() {
if (typeof window === 'undefined') {
// Server
return makeQueryClient();
} else {
// Browser
if (!browserQueryClient) browserQueryClient = makeQueryClient();
return browserQueryClient;
}
}
typeof window === 'undefined'
(서버인 경우) : 매번 새로운 queryClient를 만든다.queryClient
를 만든다.기존에 React Query를 설정할 때는 클라이언트에서 QueryClient를 생성하여 지속적으로 사용하는 방식이다. 그러나 prefetching 기능을 사용할 때는 서버에서도 QueryClient를 생성해야 하므로, 서버에서 이미 생성된 경우에는 다시 생성하지 않도록 로더 함수에 조건문을 추가해 주는 방식이다.
app/layout.tsx
import type { Metadata } from 'next';
import Script from 'next/script';
import { Noto_Sans_KR } from 'next/font/google';
import '../styles/globals.css';
import ReactQueryProviders from './_hooks/useReactQuery';
export const metadata: Metadata = {
title: '나의 산책 일기',
description: '나의 산책 일기 - my walk log',
};
const font = Noto_Sans_KR({
subsets: ['latin'],
});
export default async function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang='en'>
<body className={`${font.className} text-sm lg:text-base`}>
<Script
async
type='text/javascript'
src={`//dapi.kakao.com/v2/maps/sdk.js?appkey=${process.env.KAKAO_JS_KEY}&libraries=services,clusterer&autoload=false`}
></Script>
<ReactQueryProviders>
{children}
</ReactQueryProviders>
</body>
</html>
);
}
ReactQueryProviders
를 chiildren
에 싹 감싸준다~
기본 세팅 방법은 아래와 같다.
내 프로젝트에서는 레이아웃 컴포넌트를 모두 서버 컴포넌트로 분리해 두고 있기 때문에
페이지 컴포넌트에 GET 요청이 필요한 컴포넌트가 있는 경우 prefetch
& dehydrate
를 적용해 주었다.
prefetch
&dehydrate
는 useQuery를 사용하는 컴포넌트 바로 상위에서 적용해 주는 것이 좋다.
한 번 상위에서 적용하면 모든 하위 컴포넌트에 전역적으로 적용이 가능하지만, 모든 컴포넌트에서 해당 쿼리 데이터를 미리 불러오게 되기 때문에 불필요한 데이터 로딩이 이뤄질 수 있기 때문이다.
import Header from '@/app/_component/common/Header';
import getQueryClient from '@/app/shared/utils/getQueryCLient';
import { getDiaryDetail } from '@/app/store/server/diary';
import { dehydrate, HydrationBoundary } from '@tanstack/react-query';
export interface DiaryLayoutProps {
children: React.ReactNode;
params: { diaryId: number };
}
const DiaryLayout = async ({ children, params }: DiaryLayoutProps) => {
const queryClient = getQueryClient();
await queryClient.prefetchQuery({
queryKey: ['diaryDetail', Number(params?.diaryId)],
queryFn: () => getDiaryDetail(Number(params?.diaryId)),
});
return (
<div className='flex basis-full flex-col overflow-y-auto'>
<Header title='일기 상세' enableBackButton />
<HydrationBoundary state={dehydrate(queryClient)}>
{children}
</HydrationBoundary>
</div>
);
};
export default DiaryLayout;
export const getDiaryDetail = async (diaryId: number) => {
const response = await axios.get(
`${process.env.NEXT_PUBLIC_DOMAIN}/api/diary/${diaryId}`,
);
return response.data.data;
};
여기서 주의할 점 ⚠️
보통 axios를 사용한다면const response = await axios.get(`/api/diary/${diaryId}`,);
이렇게 위와 같이 도메인을 빼고 사용하는데, 서버에서 API를 요청할 때에는 도메인을 꼭 붙여 사용해야 한다.
이는 서버와 클라이언트 환경에서의 실행 방식 차이로, API 요청을 서버에서도 처리하기 위해서는 localhost나 로컬 환경의 도메인과 무관하게 외부 도메인을 포함한 URL이 필요 절대 경로가 필요하기 때문이다!
(이 부분을 모르고 세팅했다가 전혀 적용이 되지 않아 바보 고생을 했다는 이야기✌️)
export const useGetDiaryDetail = (diaryId: number) =>
queryOptions({
queryKey: ['diaryDetail', diaryId],
queryFn: () => getDiaryDetail(diaryId),
enabled: !!diaryId,
});
'use client';
import PlaceDetailModal from '@/app/_component/common/Modal/PlaceDetailModal';
import Commentform from '@/app/_component/diary/Commentform';
import CommentList from '@/app/_component/diary/CommentList';
import DiaryItem from '@/app/_component/diary/DiaryItem';
import { useModalStore } from '@/app/store/client/modal';
import { useUserStore } from '@/app/store/client/user';
import {
useDiaryLike,
useDeleteDiary,
useGetDiaryDetail,
} from '@/app/store/server/diary';
import { useSuspenseQuery } from '@tanstack/react-query';
import { useEffect, useState } from 'react';
export interface DiaryPageProps {}
const DiaryPage = ({ params }: { params: { diaryId: number } }) => {
const { diaryId } = params;
const queryOptions = useGetDiaryDetail(params.diaryId);
const { data: diary, isLoading, error } = useSuspenseQuery(queryOptions);
// ...나머지 코드
return (
<>
<div className='flex basis-full flex-col overflow-y-scroll bg-white'>
<DiaryItem
diary={diary}
onConfirm={handleConfirm}
onClick={() => handleClick(diary?.id)}
/>
{/* ...나머지 코드*/}
</div>
{/* ...나머지 코드*/}
</>
);
};
export default DiaryPage;
그리고 하위 컴포넌트인 Page 컴포넌트에서 useSuspenseQuery
/ useQuery
를 적용해 주면 끝!
이렇게 사용하면 Next.js와 React Query를 함께 사용하여 SSR페이지를 만들 수 있다.
이번엔 하위 컴포넌트에서 useInfiniteQuery
를 사용하는 경우이다. 이때는 prefetchInfiniteQuery
를 사용하면 된다.
export const getFeed = async (pageParam = 1) => {
const response = await axios.get(
`${process.env.NEXT_PUBLIC_DOMAIN}/api/feed`,
{
params: { page: pageParam, size: 10 },
},
);
return response.data;
};
import Header from '@/app/_component/common/Header';
import { IDiary } from '@/app/shared/types/diary';
import getQueryClient from '@/app/shared/utils/getQueryCLient';
import { getFeed } from '@/app/store/server/feed';
import { HydrationBoundary, dehydrate } from '@tanstack/react-query';
export interface FeedLayoutProps {
children: React.ReactNode;
}
interface FeedData {
pages: IDiary[];
pageParams: number[];
}
const FeedLayout = async ({ children }: FeedLayoutProps) => {
const queryClient = getQueryClient();
await queryClient.prefetchInfiniteQuery({
queryKey: ['feed'],
queryFn: () => getFeed(1),
getNextPageParam: (lastPage: any) => {
const { page, totalPages } = lastPage;
return page < totalPages ? page + 1 : undefined;
},
initialPageParam: 1,
retry: 1,
staleTime: 60 * 1000,
});
queryClient.setQueryData(['feed'], {
pages: (queryClient.getQueryData(['feed']) as FeedData)?.pages || [],
pageParams: [0],
});
return (
<div
className={`sm-md:overflow-y-hidden relative z-20 flex w-full shrink-0 basis-full flex-col bg-white lg:flex lg:w-96 lg:min-w-96 lg:basis-auto`}
>
<div className='flex h-full w-full basis-full flex-col'>
<Header title='피드' />
<div className='flex basis-full flex-col overflow-y-scroll'>
<HydrationBoundary
state={JSON.parse(JSON.stringify(dehydrate(queryClient)))}
>
{children}
</HydrationBoundary>
</div>
</div>
</div>
);
};
export default FeedLayout;
prefetchInfiniteQuery
에서는 몇가지 에러를 방지하기 위한 추가 설정이 필요하다.
pageParam이 undefined로 반환되는 문제
클라이언트에서 data fetching 시에는 잘 불러와 지는 데이터가 서버 prefetch 시 불러와 지지 않는 문제가 있었다.
queryClient.setQueryData(['feed'], {
pages: (queryClient.getQueryData(['feed']) as FeedData)?.pages || [],
pageParams: [0],
});
queryClient.setQueryData
를 적용해 초기 데이터 로딩 시 항상 첫 페이지를 불러오도록 pageparams
[0]으로 명시해 준다.
데이터 무결성 문제 방지
<HydrationBoundary
state={JSON.parse(JSON.stringify(dehydrate(queryClient)))}
>
{children}
</HydrationBoundary>
JSON.stringify
와 JSON.parse
를 사용하여 불필요한 속성을 제거하고 불변성을 유지하기 위함인데, pageParam undefined 문제가 발생하지 않는다면 JSON.parse
, stringify
는 지워줘도 된다.
export const useGetFeed = () =>
infiniteQueryOptions({
queryKey: ['feed'],
queryFn: () => getFeed(1),
getNextPageParam: (lastPage: any) => {
const { page, totalPages } = lastPage;
return page < totalPages ? page + 1 : undefined;
},
initialPageParam: 1,
refetchOnWindowFocus: false,
refetchOnMount: true,
refetchOnReconnect: true,
retry: 1,
});
'use client';
import React, { useRef, useEffect } from 'react';
import { useDiaryLike } from '@/app/store/server/diary';
import { WEATHERS } from '@/app/shared/constant';
import { formatTimeAgo } from '@/app/shared/function/format';
import Link from 'next/link';
import Image from 'next/image';
import { useUserStore } from '@/app/store/client/user';
import useInfiniteScroll from '@/app/_hooks/useInfiniteScroll';
import { useGetFeed } from '@/app/store/server/feed';
import { useInfiniteQuery } from '@tanstack/react-query';
import { IDiary } from '@/app/shared/types/diary';
const FeedPage = () => {
const { user } = useUserStore();
const queryOptions = useGetFeed();
const { data, fetchNextPage, hasNextPage, isFetchingNextPage } =
useInfiniteQuery(queryOptions);
// ... 나머지 코드
return (
<>
<ul className='grid grid-cols-2 gap-2 bg-white p-4'>
{diaries?.map((diary: IDiary) => (
<li
className='rounded-2xl'
key={diary.id}
style={{
backgroundImage: `linear-gradient(rgba(0, 0, 0, 0.5), rgba(0, 0, 0, 0.5)),url('${diary.diaryImages[0]}')`,
backgroundSize: 'cover',
backgroundPosition: 'center',
}}
>
{/* ...나머지 코드 */}
</li>
))}
</ul>
</>
);
};
export default FeedPage;
아래는 fetching 후 가장 페이지 내 마지막 데이터를 로딩하기까지의 시간을 비교한 결과이다.
prefetch 적용 전 (4~5초)
이미지 기준 5.73초
prefetch 적용 후 (1초대)
1.21초
1.53초