Next.js 프로젝트를 하면서 SSR을 잘 활용하지 못한 거 같아서 마음 한구석이 찝찝했다...😃
그래서 이번 기회에 SSR을 활용하여 리팩토링 해봤다.
Next.js에서 제공하는 data fetch과 캐싱 등을 이용할 수 있지만 무한스크롤, 로딩 처리 등 더 다양한 기능이 있어 react query가 더 유용할 거 같아 react query를 선택했다.
(+ Next.js 14 App router와 react query v5를 사용하고 있습니다!)
클라이언트 렌더링과 서버 렌더링
서버 렌더링은 사용자가 페이지를 로드하는 즉시 볼 수 있는 내용을 제공하기 위해 초기 HTML을 서버에서 생성하는 것을 말한다. 이는 페이지 요청 시마다 발생(SSR)할 수 있고 빌드 시 미리 발생(SSG)할 수 도 있다.
- 일반적인 클라이언트 렌더링의 경우
1. |-> Markup (without content)
2. |-> JS
3. |-> Query
페이지가 나타나기까지 3번의 과정이 필요하다.
- 서버 렌더링의 경우
1. |-> Markup (with content AND initial data)
2. |-> JS
1이 완료되자마자 사용자는 콘텐츠를 볼 수 있고
2가 완료되면 상호작용이 가능해진다.
마크업에는 필요한 초기 데이터가 포함되어 있으므로 특정 이유로 데이터를 재검증하고자 하지 않는 한 3은 클라이언트에서 실행될 필요가 없다.
서버에서는 마크업을 생성하기 전에 데이터를 prefetch(미리 가져오기) 해야 하고 해당 데이터를 마크업에 포함할 수 있는 직렬화 가능한 형식으로 dehydrate 시켜야 한다. 클라이언트에서는 해당 데이터를 react query 캐시로 hydrate 해야 한다. 그러면 클라이언트에서 새로운 데이터를 재요청하는 것을 막을 수 있다.
초기 세팅
아래 방식 외에도 useState를 활용해 new QueryClient를 설정하는 방법도 있는데 내가 참고한 공식문서에 따라 세팅을 진행했다.
'use client'
import { QueryClientProvider, QueryClient } from '@tanstack/react-query'
import { ReactQueryDevtools } from '@tanstack/react-query-devtools'
function makeQueryClient() {
return new QueryClient({
defaultOptions: {
queries: {
retry: 0,
refetchOnWindowFocus: false,
},
},
})
}
let browserQueryClient: QueryClient | undefined
function getQueryClient() {
if (typeof window === 'undefined') {
// Server: 항상 새로운 query client를 만든다.
return makeQueryClient()
}
// Browser: 이미 가지고 있지 않다면 새로운 query client를 만든다.
// 이는 초기 렌더링 중에 React가 일시 중단(suspense)될 경우 새로운 client를 만들지 않도록 하기 위해서이다.
if (!browserQueryClient) browserQueryClient = makeQueryClient()
return browserQueryClient
}
function ReactQueryProvider({
children,
}: {
children: React.ReactNode
}) {
const queryClient = getQueryClient()
return (
<QueryClientProvider client={queryClient}>
{children}
<ReactQueryDevtools initialIsOpen={false} />
</QueryClientProvider>
)
}
export default ReactQueryProvider
src/app/layout.tsx에 ReactQueryProvider를 적용해 준다.
import ReactQueryProvider from '@/providers/ReactQueryProvider'
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode
}>) {
return (
<html lang="ko">
<body>
<ReactQueryProvider>{children}</ReactQueryProvider>
</body>
</html>
)
}
React Query에서 SSR을 적용하기
React Query에서 SSR을 하기 위해서는 initialData를 전달해서 사용하는 방법과 Hydration을 사용하는 방법 2가지가 있다.
initialData로 전달하기
이 방식은 페이지 라우터 관련 예제만 보이고 한계점이 있어 간단히 한계점만 짚고 넘어가겠다.
이 방법의 한계점은 아래와 같다.
- 더 깊은 컴포넌트에서 useQuery를 호출하는 경우 해당 지점까지 initialData를 전달해야 한다.
- 여러 위치에서 동일한 쿼리를 호출하는 경우 그중 하나만 initialData를 전달하는 것은 앱이 변경될 때 문제가 발생할 수 있다. useQuery에 initialData를 가진 컴포넌트를 제거하거나 이동하는 경우 더 깊이 중첩된 useQuery는 더 이상 데이터가 없을 수도 있다. initialData를 필요로 하는 모든 쿼리에 전달하는 것은 번거롭다.
- 서버에서 쿼리가 언제 가져온 것인지 알 수 없기 때문에 dataUpdatedAt과 쿼리를 다시 가져와야 하는지 결정하는 방법은 페이지가 로드된 시점을 기준으로 한다.
- 쿼리에 대해 이미 캐시에 데이터가 있는 경우 새로운 데이터가 이전 데이터보다 최신이라도 initialData는 이를 덮어쓰지 않는다. (getServerSideProps가 매번 호출되어 새 데이터를 가져오지만, initialData 옵션을 사용하기 때문에 클라이언트 캐시와 데이터는 절대 업데이트되지 않는다.)
보다 자세한 내용은 아래 공식 문서에서 확인할 수 있다.
Hydration API
dehydrate
react query의 상태를 서버에서 클라이언트로 전달할 수 있는 형태로 만들기 위해 사용된다. 서버에서 데이터를 가져온 후 이를 직렬화하여 클라이언트로 전달한다. 이는 다시 클라이언트에서 hydrate를 통해 다시 react query 상태로 변환된다.
hydrate
클라이언트 측에서 직렬화된 데이터를 받아 이를 react query의 상태로 변환한다. 이 과정은 서버에서 미리 가져온 데이터를 클라이언트 쿼리 캐시에 적용해 네트워크 요청 없이 데이터를 사용할 수 있도록 한다.
나는 initialData 방식의 한계점으로 Hydration API 방식을 사용해서 구현했다.
queryClient를 생성하고 prefetchQuery 혹은 prefetchInfiniteQuery로 데이터를 미리 가져온다.
이후 queryClient를 dehydrate 해서 HydrationBourndary에 전달해 주면 감싸준 곳에서는 캐싱된 데이터를 queryKey를 통해 접근할 수 있다.
나의 경우 무한 스크롤로 구현해 prefetchInfiniteQuery를 사용했다.
import BaseLayout from '@/components/layout/BaseLayout'
import RecentPortfolioList from '@/containers/main/RecentPortfolioList'
import { getPortfolioList } from '@/hooks/queries/portfolio'
import {
HydrationBoundary,
QueryClient,
dehydrate,
} from '@tanstack/react-query'
export default async function Home() {
const queryClient = new QueryClient()
await queryClient.prefetchInfiniteQuery({
queryKey: ['portfolioList'],
queryFn: ({ pageParam }) => getPortfolioList(pageParam as number),
initialPageParam: 1,
staleTime: 30 * 1000, // 바로 stale 상태로 변경되는 것을 방지하기 위해 30초로 설정
})
const dehydratedState = dehydrate(queryClient)
return (
<BaseLayout>
<HydrationBoundary state={dehydratedState}>
<RecentPortfolioList />
</HydrationBoundary>
</BaseLayout>
)
}
직접 사용하는 곳에서는 useInfinityQuery를 사용해 같은 queryKey로 요청하면 된다.
'use client'
import PortfolioItem from '@/components/portfolio/PortfolioItem'
import PortfolioItemSkeleton from '@/components/portfolio/PortfolioItemSkeleton'
import { useInfinitePortfolioQuery } from '@/hooks/queries/portfolio'
import useInfiniteScroll from '@/hooks/useInfiniteScroll'
import { Fragment, useRef } from 'react'
function RecentPortfolioList() {
const { data, fetchNextPage, isFetchingNextPage, hasNextPage } =
useInfinitePortfolioQuery()
const fetchMorePortfolio = () => {
if (!isFetchingNextPage && hasNextPage) {
fetchNextPage()
}
}
const loaderRef = useRef<HTMLDivElement>(null)
useInfiniteScroll(loaderRef, fetchMorePortfolio)
return (
<>
<ul className="grid gap-6 xl:grid-cols-4 lg:grid-cols-3 md:grid-cols-2 sm:grid-cols-1 lg:px-20 px-10">
{data?.pages.map((page, index) => (
<Fragment key={index}>
{page.data.map((portfolio) => (
<PortfolioItem key={portfolio.id} portfolio={portfolio} />
))}
</Fragment>
))}
{isFetchingNextPage &&
Array(12)
.fill(0)
.map((_, index) => <PortfolioItemSkeleton key={index} />)}
</ul>
<div ref={loaderRef} className="h-8" />
</>
)
}
export default RecentPortfolioList
useInfiniteProfolioQuery의 상세한 소스는 아래와 같다.
export const useInfinitePortfolioQuery = () => {
return useInfiniteQuery<PortfolioData, Error, InfiniteData<PortfolioData>>({
queryKey: ['portfolioList'],
queryFn: ({ pageParam }) => getPortfolioList(pageParam as number),
getNextPageParam: (lastPage) => {
const {
meta: { currentPage, hasNextPage },
} = lastPage
return hasNextPage ? currentPage + 1 : undefined
},
initialPageParam: 1,
staleTime: 60 * 1000,
})
}
초기 HTML 데이터가 포함되어 전달되는 것을 확인할 수 있다!! 👍
SSR으로 요청하며 개선된 부분
왼쪽은 기존에 CSR으로 구현한 경우고 오른쪽은 SSR로 구현한 경우이다.
Performance에서도 Largest Contentful Paint(LCP)가 정말 많이 개선된 것을 확인할 수 있었다!!
그럼 LCP는 어떤 것이고 왜 개선된 것인가?!!!!!!! 🤔
LCP는 웹 페이지의 성능을 측정하는 중요한 지표 중 하나로, 이 지표는 사용자가 페이지를 로드했을때 화면에 가장 크고 의미 있는 콘텐츠가 얼마나 빨리 표시되는지를 측정한다고 한다.
더 자세한 LCP 최적화와 관련된 내용은 이 글에 설명되어있다.
이 글에 따라 개선된 이유를 생각해보면
서버 측 렌더링을 사용하는 경우 사용자에게 초기 페이지 로드 시 완성된 HTML을 빠르게 전달할 수 있다. 이는 사용자가 보는 첫 화면의 콘텐츠가 더 빨리 로드될 수 있도록 해주며, LCP 개선에 도움이 된다! 즉, 서버에서 렌더링된 페이지는 사용자의 브라우저에 추가적인 자바스크립트 실행을 기다리지 않고 바로 보여줄 수 있는 컨텐츠를 포함하고 있기 때문이다.
정리
이번 글에서는
react query를 사용해서 서버에서 data를 prefetch하고 이 data를 클라이언트의 react query 캐시로 hydration하는 과정을 통해 서버에서 HTML을 생성하는 SSR을 구현해봤다.
- prefetch
서버에서 prefetch를 통해 필요한 data를 미리 가져온다. 이 과정은 페이지 요청에 대응해 서버에서 data를 가져오고 이를 페이지의 초기 HTML에 포함시키는 것이다. - dehydrate
가져온 data를 직렬화해 HTML에 포함시킨다. 이로 인해 클라이언트 측에서 추가적인 data 요청없이 초기 상태를 빠르게 로드할 수 있다. - hydration
클라이언트 측에서는 직렬화된 data를 react query 캐시로 복구해(hydrate) 이 data를 초기 상태로 사용한다. 이 과정은 클라이언트에서 추가적인 네트워크 요청을 하지 않고도 초기 data를 활용할 수 있게 해준다.
이런 방식은 서버에서 완성된 HTML을 생성하고 전송하여 클라이언트에서는 이를 빠르게 로드하고 상호작용할 수 있게 해준다. 초기 사용자 경험을 개선하고 상황에 따라서 성능을 향상시킬 수 있다.
순조롭고 간단하게 완성한 거 같지만... 알듯 말듯한 에러가 발생했었다.
그 에러에 대한 내용은 다음 포스팅으로 돌아오겠담 ㅎㅎ 👋
'Project > foliohub' 카테고리의 다른 글
[Next.js] Hydration 실패! (Uncaught Error: Minified React error #418, #423) (0) | 2024.05.10 |
---|---|
[Next.js] fill을 사용한 이미지에 sizes 추가 (Image with src "~~" has "fill" but is missing "sizes" prop. Please add it to improve page performance.) (0) | 2024.04.14 |
[Next.js] 이미지 next/image 적용해보기 (0) | 2024.04.12 |
[Express] Custom Error Handling (0) | 2024.04.06 |
[Next.js] NotFound / Error 페이지 Custom하기 (0) | 2024.04.06 |