SSR에 대해 다들 많이 들어봤을 것이다.
Next.js 13부터는 app routing 방식이 새로 출시되었다. 기존 page routing은 getServierSideProps와 같은 SSR과 관련된 함수가 있었지만, app routing 방식은 약간 달라졌다.
저번 react-query 글
SSR에 대해 모른다면 간단하게 설명을 읽기 바란다.
SSR이란 서버에서 컴포넌트를 직접 컴파일해서(html파일 1개 = 1페이지를 만드는 것) 웹브라우저에게 전달해주는 것이다.
단순히 페이지를 next.js 서버를 켤 때 컴파일을 최초 1회 해주는 것이다.
보통 정적인 페이지(스플래시 화면, 정적인 메인 화면 같은)에서 자주 사용하는 방식이다.
SSG와 달리 정해진 시간에 따라 SSG를 주기적으로 해주는 것이다.
어제의 인기 검색어같은 하루단위로 바뀌는 컴포넌트에서 자주 사용하는 방식이다.
이런 SSR방식들을 사용하는 이유는 간단하게 2가지정도라고 생각한다.
서버부하 최소화는 일리있는 말인게, 기존 CSR은 API 요청하는 함수가 있으면 전부 실행해버린다.
하지만 SSR은 ISR이나 SSG같이 API 결과를 Next.js가 미리해주고 이를 기반으로 똑같이 생긴 html만을 웹브라우저에 전달하기 때문에, API 요청 횟수를 적게 가져갈 수 있다.
각설하고, Next.js App Routing에서 SSR을 어떻게 하는지 예시를 설명하겠다.
이전 글인 react-query 글에서 연장해서 설명해보겠다.
게임의 공지사항을 가져오는데 굳이 실시간으로 사용자가 요청해서 그때마다 다르게 갱신시킬필요는 없다.
실제로 공지사항은 1주일에 2~3회만 올라오기 때문에, 뭐 간혹 버그로 인해 핫픽스가 난다면 하루에 3~4개가 올라오기도한다.
또한 API Key가 1분이 100회 제한이라 이에 대한 부담을 줄이고 싶었다.
그래서 캐시타임을 1시간으로 정해서 API요청을 하고 1시간마다 정적 html 파일을 만드는 것을 목표로한다.
기존 코드 useQuery를 사용했기에 CSR 기반이었다. (훅을 호출하는 컴포넌트는 'use client'를 넣어야함)
// useQuery에서 useSuspsneQuery로 변경한다.
export const useGetNoticesQuery = (params?: GetNoticesParams) => {
return useSuspenseQuery<GetNoticesResponse[]>({
queryKey: ['notices', params],
queryFn: () => getNotices(params),
});
};
// 컴포넌트 내부
const { data: _notices, isLoading } = useGetNoticesQuery({
type: '공지',
});
const notices = _notices?.slice(0, NOTICES_COUNT);
return (
<TitleInfoList
title={'로스트아크 공지사항'}
renderInfoList={() =>
<Suspense fallback={<SkeletonView width="100%" height="20px" gap="4px" length={NOTICES_COUNT} />} >
<하위컴포넌트 data={data} />
</Suspense>
}
/>
);
이제 useGetNoticesQuery를 사용하지않고 Suspense도 사용하지 않을 것이다.
왜냐면 서버자체에서 API를 요청하고 응답받아 그 결과를 그리고 클라이언트인 웹브라우저로 전달하기 때문에, 로딩여부는 상관이 없어진다.
어차피 클라이언트에선 로딩할 필요가 없다는 것이다. (F12 개발자도구에서 Network-fetch 가 없다는 말)
그래서 내부에 페칭한 데이터를 표현하는 부분을 하위컴포넌트로 새로만들어 분리한다.
그리고 useGetNoticesQuery를 getNotices로 Promise를 반환하는 함수로 변경한다.
export const LostarkNotice = () => {
return (
<TitleInfoList
title={'로스트아크 공지사항'}
renderInfoList={() => {
return (
<ul className="flex w-full flex-col items-start gap-1">
{/* @ts-expect-error Async Server Component */}
<LostarkNoticeRows />
</ul>
);
}}
/>
);
};
export const LostarkNoticeRows = async () => {
const notices = await getNotices();
return (
<>
{notices?.map((notice) => (
<li key={notice.Title} className="flex w-full items-center truncate">
<span className="mr-2 rounded-full bg-gray-100 px-1 text-xs text-gray-500">
{notice.Type}
</span>
<p className="text-sm">
<a href={notice.Link}>{notice.Title}</a>
</p>
</li>
))}
</>
);
};
페칭하는 함수는 async이기 때문에 React.Node가 아닌 Promise<React.Node>의 타입을 갖는다.
그래서 상위컴포넌트에선 JSX 타입에러가 뜨게되는데 {/* @ts-expect-error Async Server Component */}이걸 넣어서 해결해주면 된다.
이제는 getNotices 함수가 궁금해질 것이다.
export const getNotices = async (params?: GetNoticesParams): Promise<GetNoticesResponse[]> => {
const queryString = createQueryString(params);
const response = await fetch(
`${LOSTKARK_API_URL}${ApiConstants.NOTICES}?${queryString}`,
{
method: 'GET',
next: { revalidate: 3600 },
// authorization 생략
}
);
if (!response.ok) {
throw new Error(`Failed to fetch notices: ${response.statusText}`);
}
const data = await response.json();
return data.slice(0, NOTICES_COUNT);
};
fetch 함수를 때려박으면 끝이다.
fetch 함수말고 react-query로 SSR을 할 순 없을까?
방법은 있다. 바로 prefetch라는 기능이다.
CSR 레벨에서의 react-query는 QueryClient를 만들어 이를 QueryClientProvider로 제공받아 호출을 했었다.
SSR 레벨에서는 다음의 예시코드처럼 사용한다.
// app/posts/page.tsx
import {
dehydrate,
HydrationBoundary,
QueryClient,
} from '@tanstack/react-query'
import Posts from './posts'
import CommentsServerComponent from './comments-server'
export default async function PostsPage() {
const queryClient = new QueryClient()
await queryClient.prefetchQuery({
queryKey: ['posts'],
queryFn: getPosts,
})
return (
<HydrationBoundary state={dehydrate(queryClient)}>
<Posts />
<CommentsServerComponent />
</HydrationBoundary>
)
}
// app/posts/comments-server.tsx
import {
dehydrate,
HydrationBoundary,
QueryClient,
} from '@tanstack/react-query'
import Comments from './comments'
export default async function CommentsServerComponent() {
const queryClient = new QueryClient()
await queryClient.prefetchQuery({
queryKey: ['posts-comments'],
queryFn: getComments,
})
return (
<HydrationBoundary state={dehydrate(queryClient)}>
<Comments />
</HydrationBoundary>
)
}
공식 문서에서 가져왔다. queryClient를 페이지별로 가져와서 prefetchQuery라는 함수를 바로 사용한다.
서버 컴포넌트에서는 훅을 사용할 수 없기 때문에 Context API로 내부구현된 QueryClientProvider를 사용하는게 아니라 바로 객체를 생성해서 요청하는 것으로 보인다.
그러면 프리페치하는 API가 한두개가아니고 다양하며 각각 캐싱정책이 다르다면 이렇게 따로 생성하면되는데, 같다면 공유할 수 있을 것 같지않나?
그래서 React 18부터 나온 cache 기능이 있다고 한다. 이를 통해 객체(함수, 결과값, 콜백 등)의 값을 저장해서 공유할 수 있다고 한다. 마치 싱글톤 패턴처럼 말이다.
// app/getQueryClient.tsx
import { QueryClient } from '@tanstack/react-query'
import { cache } from 'react'
// cache() is scoped per request, so we don't leak data between requests
const getQueryClient = cache(() => new QueryClient())
export default getQueryClient
SSG는 왜 간단하게 설명하냐면, 캐싱을 최신화하는 로직이 없으면 그게 바로 SSG이다.
export async function fetchData() {
const res = await fetch('https://api.example.com/data', {
cache: 'force-cache', // 강제로 캐싱하여 SSG로만 동작
});
if (!res.ok) {
throw new Error('Failed to fetch data');
}
return res.json();
}
fetch 요청에 cache: 'force-cahe'라고 오직 캐싱값만 사용한다는 옵션이 있는데 이를 사용해 서버 컴포넌트를 만들면 API요청을 최초 1회만하는 SSG 페이지가 완성된다.
아무튼 결과를 보게 되면 SSR로 변경되어서 페이지자체 최초 접근 로딩은 기존보다 좀 느려졌지만(최초 요청에 한해 컴파일을 해야하므로), 데이터 로딩없이 바로 전달받을 수 있게 된다.
새로고침하면 거의 로딩없이 바로 새로고침이 된다.
https://tanstack.com/query/latest/docs/framework/react/guides/advanced-ssr