안녕하세요.
이번 글은 다름아닌 기존 CSR로만 구성 되어있던 프로젝트를 ISR방식을 도입하면서 생긴 일들의 대해서 공유 하고자 글을 작성하였습니다.
이 부분은 글이 길어져 다른분의 정리를 첨부합니다!..
https://medium.com/@zero86/next-js-csr-ssr-isr-ssg-%ED%95%98%EC%9D%B4%EB%B8%8C%EB%A6%AC%EB%93%9C-hybrid-%EB%A0%8C%EB%8D%94%EB%A7%81-%ED%95%98%EC%9D%B4%EB%93%9C%EB%A0%88%EC%9D%B4%EC%85%98-hydration-e2f6a487fe95
이 말은 실제로 ISR(Incremental Static Regeneration) + SSR(Server-Side Rendering) 방식으로 렌더링 방식을 변경하기 전, 팀원들에게서 가장 많이 들었던 질문 중 하나였습니다. 저희 회사는 기존에 CSR 웹뷰를 통한 하이브리드 앱을 제공하고 있었고, SSR + ISR 방식을 도입하려는 결정이 간단하지 않았습니다.
팀원을 설득하기 위해 저 스스로도 도입에 명확한 이유가 필요 했습니다. 제가 정리한 도입 이유는 다음과 같습니다.
빠른 초기 로딩 속도 및 효율적인 성능 관리: ISR(Incremental Static Regeneration)을 사용하면, 빌드시점에 서버 측에서 미리 HTML을 렌더링하여 클라이언트에 전달하므로, 페이지가 처음 로드될 때 빠른 초기 렌더링 속도를 제공합니다. 사용자에게는 즉각적으로 콘텐츠를 제공할 수 있어, 웹 앱의 초기 로딩 시간에서 발생할 수 있는 지연을 크게 줄일 수 있습니다.
또한 서버에서 페이지를 정적으로 생성하되, 필요할 때마다 백그라운드에서 자동으로 새로고침됩니다. 즉, 처음 로드된 페이지는 정적으로 제공되며, 그 후 변경 사항이 생길 때만 새로운 페이지를 생성하므로, 서버의 부담을 줄이고 빠른 성능을 유지할 수 있습니다. 또한 페이지가 변경될 때마다 모든 페이지를 새로 고치는 것이 아니라, 필요한 페이지만 리프레시되기 때문에 성능이 최적화됩니다.
팀원들에게 SSR + ISR 방식의 필요성을 설득하는 과정에서 가장 강조 했던 점은, 최종 사용자에게 미치는 영향과 개발 및 유지보수의 효율성이었습니다.
더 빠른 페이지 로딩: 초기 로딩 속도를 빠르게 하여, 유저가 첫 화면을 볼 때의 지연을 최소화할 수 있습니다. 이 점은 사용자 경험을 크게 향상시키며, 이탈률 감소로 이어질 수 있습니다.
효율적인 데이터 처리: 서버 자원을 효율적으로 활용하면서도, 실시간 데이터를 반영할 수 있게 도와주므로, 성능과 확장성 측면에서 큰 장점이 있습니다.
NextJs 에서 제공하는 SSR 방식을 도입할려고 하니, 서버컴포넌트에서 hook을 호출할수 없는 오류에 직면 했습니다. "그러면 swr로 데이터 호출을 할 수 없자나..?" 라는 생각이 들면서 이때부터 불안감에 휩 싸이면서 구글 서치와 문서를 미친듯이 봤습니다.
공식 문서에서는 page라우터 방식에서의 사용법이 나와있었는데, 이 부분에서 저희는 app 라우터를 사용 하고 있었기 때문에, 적용이 되지 않았고, 서치를 통해 다른 분들이 만든 부분을 확인 하던 도중 react-query의 "hydrate"방식을 발견 했습니다.
react-query의 "hydrate" 방식에 대해서 먼저 설명을 하겠습니다.
React Query의 "hydrate" 방식은 SSR과 클라이언트 간의 데이터를 효율적으로 이어주는 역할을 합니다. 이를 통해 서버에서 렌더링된 데이터를 클라이언트로 전달하고, 클라이언트 측에서 이를 재사용할 수 있게 합니다. 아래에서 그 동작 과정을 단계별로 설명하겠습니다.
- SSR에서 데이터 가져오기
SSR 환경에서는 서버에서 React Query를 사용하여 필요한 데이터를 미리 가져옵니다. 이 데이터를 React Query의 dehydrate 메서드를 사용해 직렬화(serialize)하여 JSON 형태로 클라이언트에 전달합니다.
// 서버 측 예시
import { dehydrate, QueryClient } from '@tanstack/react-query';
export async function getServerSideProps() {
const queryClient = new QueryClient();
await queryClient.prefetchQuery(['key', params], fetchData);
return {
props: {
dehydratedState: dehydrate(queryClient),
},
};
}
클라이언트에서 "hydrate" 처리
클라이언트는 서버에서 전달받은 데이터를 React Query의 hydrate 메서드를 통해 복구(deserialize)합니다. 이를 통해 클라이언트는 동일한 데이터를 다시 요청하지 않고도 서버에서 전달받은 데이터를 그대로 활용할 수 있습니다.
// 클라이언트 측 예시
import { Hydrate, QueryClient, QueryClientProvider } from '@tanstack/react-query';
function MyApp({ Component, pageProps }) {
const queryClient = new QueryClient();
return (
<QueryClientProvider client={queryClient}>
<Hydrate state={pageProps.dehydratedState}>
<Component {...pageProps} />
</Hydrate>
</QueryClientProvider>
);
}
"오...? 이런 기능을 제공하는 구나..?" 라는 생각을 하며, SWR 문서를 뒤지기 시작했습니다. 같은 회사에서 만든 거니까 당연히 제공 하겠지 라는 생각을 했지만, 제 생각과 달리 hydrate같은 기능을 제공 하지는 않았습니다.
"react-query 로 넘어가야 하나.." 라는 생각이 들었습니다.
"react-query 로 넘어가야 하나.." 라는 생각이 들었습니다. 생각이 90%정도 있었지만 제공 하는 것처럼 만들어 보며, 더 깊게 이해 할수 있을 것이라는 믿음으로 swr로 hydrate 기능을 구현하기 시작했습니다.
기존코드
'use client';
...
const Page = () => {
const { datas, error, isLoadingMore } = useInfiniteScroll(
fetchOrderListByPartner,
10,
1000,
'careList',
);
if (datas.length === 0) {
<section className='pt-[70px]'>
<NoDataFoundForm contents={['간병목록이', '없습니다.']} />
</section>;
}
return (
<article
className={`${datas && datas.length >= 1 ? 'bg-Neutral-White' : ''} h-full pt-[48px]`}
>
<section>
<section className='Title05 bg-Neutral-White flex flex-col space-y-[6px] pb-4 pl-4 pr-4 pt-[30px]'>
<span>환자님들이</span>
<span>케어매니저님을</span>
<span>기다리고 있어요!</span>
</section>
<section className='bg-Neutral-White h-full'>
{datas && datas.length >= 1 && (
<div className='bg-Neutral-White pb-[80px]'>
{datas.map((care) => {
return (
<div key={care.order_id} className='bg-Neutral-White pl-4 pr-4 pb-4'>
{<PartnerJobPreviewForm care={care} />}
</div>
);
})}
</div>
)}
</section>
</section>
{isLoadingMore && <SkeletonWorkSearch />}
</article>
);
};
export default Page;
기존 코드 (CSR 방식)
기존 코드에서는 use client 디렉티브를 사용하여 클라이언트 사이드 렌더링(CSR)만 적용되었습니다. 즉, 페이지가 로드될 때 모든 콘텐츠는 클라이언트 측에서 동적으로 렌더링됩니다. 이 방식은 서버에서 데이터를 요청한 후 클라이언트에서 처리하는 구조이기 때문에 초기 로딩 속도가 상대적으로 느릴 수 있습니다.
export const revalidate = 60;
import { fetchOrderListByPartner } from '@/service/server/orders';
import { unstable_serialize as infinite_unstable_serialize } from 'swr/infinite';
import WorkSearchList from './_components/work-search-list';
import SwrProvider from '@/app/_provider/swr_provider';
const Page = async () => {
const orderList = await fetchOrderListByPartner();
const key = 'careList';
const pageSize = 10;
const fallbackKey = infinite_unstable_serialize(
(index) => `${key}-${index * pageSize}`,
);
return (
<article className={`bg-Neutral-White w-full h-full pt-[48px]`}>
<section className='h-full bg-Neutral-White'>
<section className='w-full h-full'>
<SwrProvider fallbackKey={fallbackKey} fallbackData={[orderList]}>
<WorkSearchList />
</SwrProvider>
</section>
</section>
</article>
);
};
export default Page;
개선된 코드에서는 ISR 방식을 통해 60초 마다 페이지 재검증을 진행 하였습니다.
revalidate와 infinite_unstable_serialize를 사용하여 데이터 로딩 및 페이지 전환의 효 율성을 높였으며, ISR(Incremental Static Regeneration)와 SWR을 조합하여 성능 최적화와 사용자 경험을 향상시켰습니다.SwrProvider에 fallbackData를 설정하여, 서버에서 데이터를 미리 받아와 초기 화면 로딩 속도를 빠르게 합니다. SWR이 초기 데이터를 바탕으로 빠르게 UI를 렌더링하고, 이후에는 배경에서 데이터를 갱신했습니다.
infinite_unstable_serialize를 사용하여 무한 스크롤을 효율적으로 구현할 수 있습니다. 페이지 번호를 동적으로 계산하여 필요한 데이터를 점진적으로 가져오게 하였습니다
"동적 경로 페이지 처리"
기존 코드
'use client';
import { SlideRightArticle } from '@/components/animation';
import BackHeader from '@/components/back-header';
import CareDetailForm from '@/components/care/care-detail-form';
import { useRouter } from 'next/navigation';
const Page = () => {
const router = useRouter();
return (
<article className='h-full'>
<section className='fixed w-full bg-white z-40'>
<section className='pl-4 pr-4'>
<BackHeader
title='간병 상세정보'
prePathFn={() => {
router.push('/partner/my-care');
}}
/>
</section>
</section>
<SlideRightArticle key={''} className={'pt-[2.25rem]'}>
<CareDetailForm />
</SlideRightArticle>
</article>
);
};
export default Page;
기존 코드 (CSR 방식)
기존 코드에서는 use client 디렉티브를 사용하여 클라이언트 사이드 렌더링(CSR)만 적용되었습니다. 즉, 페이지가 로드될 때 모든 콘텐츠는 클라이언트 측에서 동적으로 렌더링됩니다. 이 방식은 서버에서 데이터를 요청한 후 클라이언트에서 처리하는 구조이기 때문에 초기 로딩 속도가 상대적으로 느릴 수 있습니다.
개선 코드
export const revalidate = 60;
export const dynamicParams = true;
import { SlideRightArticle } from '@/components/animation';
import BackHeader from '@/components/back-header';
import CareDetailForm from '@/components/care/care-detail-form';
import { fetchOrderListByPartner } from '@/service/server/orders';
export const generateStaticParams = async () => {
const managerList = await fetchOrderListByPartner();
return managerList.map((manager) => ({
id: manager.id.toString(),
}));
};
const Page = async ({ params }: { params: { id: string } }) => {
const { id } = params;
return (
<article className='h-full'>
<section className='fixed w-full bg-white z-40'>
<section className='pl-4 pr-4'>
<BackHeader title='간병 상세정보' />
</section>
</section>
<SlideRightArticle key={'care-detail'} className={'pt-[2.25rem]'}>
<CareDetailForm orderId={id} />
</SlideRightArticle>
</article>
);
};
export default Page;
개선된 코드에서는 ISR(Incremental Static Regeneration) 방식이 적용되었습니다. 이 방식은 페이지를 빌드 시점에 정적으로 렌더링하고, 이후에는 일정한 주기(여기서는 revalidate = 60)마다 페이지를 재생성합니다. 이는 초기 로딩 속도를 크게 개선하며, 서버 부하도 줄였으며,동적 경로에 대해 정적 파일을 제공하므로, SEO 최적화에도 유리해 졌습니다.
기존 CSR(Client-Side Rendering)에서 ISR(Incremental Static Regeneration)로 변경하면서, 렌더링 시점과 서버 상태 관리에 대해 한층 더 깊이 고민할 수 있는 시간이었습니다.
CSR에서는 페이지의 렌더링이 클라이언트에서 발생하고, 데이터 패칭도 클라이언트에서 이루어지므로 초기 렌더링이 느릴 수 있고, 데이터의 상태 관리도 클라이언트에서만 이루어졌습니다. 그러나 ISR을 도입함으로써, 서버에서 정적 페이지를 미리 생성하고, 이후에는 필요한 경우에만 페이지를 갱신할 수 있게 되어, 사용자 경험과 성능 최적화 측면에서 많은 장점이 있었습니다.
ISR을 적용하면서 중요한 점은 렌더링 시점의 차이를 이해하는 것이었습니다. CSR은 클라이언트에서 데이터를 가져온 후 렌더링하는 반면, ISR은 서버에서 미리 페이지를 생성하고, 필요 시점에만 갱신하는 방식이기 때문에 서버 상태 관리와 클라이언트 상태 관리의 차이를 명확히 이해하고 적절하게 분리할 수 있는 기회가 되었습니다.
이 과정에서 데이터의 일관성, 성능, 그리고 사용자 경험을 어떻게 최적화할 것인지에 대한 고민을 더욱 깊게 할 수 있었습니다.😃