Next.js 성능 최적화 사례 (SSR -> ISR)
2024/01/03
n°42
category : Next.js
☼
2024/01/03
n°42
category : Next.js
지난 글에 이어 쓰는 Next.js의 성능 최적화 사례에 대한 회고입니다.
지난 글에서 작성한 최적화를 위한 체크리스트를 실제로 업무에 적용하여 성능 개선을 이룬 사례에 대해 적어보려 한다. 이 체크 리스트에서 최적화를 마친 이후, 각 항목이 실제로 얼마나 임팩트가 있었는지 확인해보았다.
최적화를 위한 체크 리스트
위 항목을 차례차례 수정하였고, 결과적으로 내가 담당했던 페이지들의 최적화 작업을 마친 이후 다음과 같은 성과를 이룰 수 있었다.
최적화 작업을 하게 된 배경에는 다음과 같은 문제가 있었다.
(성능 개선 이전 1.3~1.4s로 지연된 로딩 속도를 확인할 수 있다. )
문제의 원인을 분석해보니 크게 세 가지였다.
초기 로딩 속도와 성능 이슈
API 워터폴과 통신 지연
페이지 특성에 부적합한 렌더링 전략
특히 문제가 된 것은 페이지에 getServersideRendering을 적용한 것이었다. 실시간 데이터가 필요한 부분은 일부였음에도, 전체 페이지를 매 요청마다 새로 렌더링하고 있었다. 이는 불필요한 서버 부하를 발생시키고 초기 로딩을 지연시키는 주된 원인이었다.
페이지 성능 저하의 근본 원인은 모든 컴포넌트에 일괄적으로 서버사이드 렌더링을 적용하여, 데이터의 성격과 관계없이 매 요청마다 모든 API를 호출하는 구조였다. getServersideRendering을 대체하기 위해 각 api 서버 상태의 변경 주기와 실시간 업데이트의 필요성을 구분할 필요가 있었다.
펀드 디테일 페이지의 느린 초기 로딩의 주요 원인이 서버사이드 렌더링 시의 API 호출이었다. 이를 개선하기 위해 먼저 페이지에서 호출하는 모든 API의 성격과 데이터 업데이트 주기를 분석했다.
1. 펀드 핵심 데이터
2. 실시간 투자 데이터
3. 차트 데이터
하이브리드 렌더링은 하나의 페이지 내에서 여러 렌더링 전략을 혼합해서 사용하는 방식이다. Next.js에서는 페이지 단위로 렌더링 방식을 결정하는 것이 일반적이지만, 실제로는 하나의 페이지 안에서도 데이터의 성격에 따라 다른 렌더링 전략이 필요한 경우가 많다.
분석한 데이터의 성격을 바탕으로, 페이지를 세 가지 렌더링 계층으로 나누어 구현했다:
정적 생성 계층
- 정적 메타데이터: SEO에 필요한 펀드명, 설명, OG 태그
- 기본 UI: 레이아웃, 네비게이션, 컨테이너
- 변경이 거의 없는 펀드 데이터: 트레이더 정보, 펀드 상품 설명, 리스크 설명
- 이점: 최초 페이지 로드 시 즉시 표시 가능하며 SEO 최적화
ISR 계층
- 주기적으로 업데이트되는 펀드 핵심 데이터
- 라운드별 성과, 최대 투자 가능 금액
- 최대 모집액 한도
- 펀드 스케줄에 따른 상태 정보
- ISR revalidate 주기: 펀드 라운드 변경 주기 기반
- 이점: 서버 부하 감소 + 데이터 최신성 확보
클라이언트 사이드 계층
- 사용자별 맞춤 데이터
- 투자 가능 여부, 투자 내역 등 사용자 특정 정보
- 차트 데이터
- 기간별 필터링이 필요한 수익률 차트
- 사용자 인터랙션에 따른 동적 데이터 로드
- 이점: 사용자별 맞춤 데이터 실시간 반영 + 동적 데이터 처리 용이
최적화 이전, 페이지는 서버 사이드 렌더링(SSR)을 사용하여 First Contentful Paint(FCP)까지 876ms의 지연을 겪었다. 이는 서버에서 데이터를 호출하는 과정에서 발생한 지연으로 보였다. 로딩 속도를 개선하기 위해 shallow routing을 적용했으나, 첫 접속 시 여전히 로딩 지연이 발생했다. 캐싱된 데이터가 없으면 페이지 로딩에 여전히 오랜 시간이 걸릴 수 있기 때문이다.
렌더링에 1초 정도 지연되는 것은 큰 성능 저하로 느껴졌다. 이를 해결하기 위해 다른 방식의 서버 사이드 렌더링으로 SSR을 교체하기로 결정했다.
SSR을 도입한 주된 이유는 서버 상태와 일치하는 데이터(예: 차트, PnL, MDD 등)를 실시간에 가깝게 보여주기 위해서였다. 이를 위해 클라이언트에서 자주 API를 호출하도록 개발했으며, 초기 서비스에서 이는 최선의 방법이었다. 그러나 서비스가 안정화되면서 클라이언트에서 API 데이터를 더 정밀하게 관리할 필요가 생겼다.
ISR로 개선하면서 다음과 같이 변경하였다.
/* BEFORE */
export async function getServerSideProps({
locale = 'en',
res,
params,
query,
}: GetServerSidePropsContext) {
res.setHeader('Cache-Control', 'public, maxAge=6000, stale-while-revalidate=5940');
const { symbol } = query;
const queryClient = new QueryClient();
if (!params?.symbol || !symbol) {
return {
redirect: {
destination: '/',
},
};
}
await queryClient.prefetchQuery(
['detailSymbol', 2],
() =>
detailDataFetcher(params.symbol as string)
);
await queryClient.prefetchQuery(['detailSymbolChart', 3], () =>
detailChartDataFetcher(params.symbol as string)
);
return {
props: {
dehydratedProps: dehydrate(queryClient),
params: params.symbol,
info: prefetchedOgTagInfo || defaultOgTagInfo,
...(await serverSideTranslations(
locale,
['common', 'login',...],
nextI18NextConfig
)),
},
};
}
/* AFTER */
export const getStaticProps = async ({ params, locale = 'en' }: AlgoDetailStaticProps) => {
const translations = await serverSideTranslations(
locale,
['common', 'login', ...],
nextI18NextConfig
);
...
const queryClient = new QueryClient();
await queryClient.prefetchQuery({
queryKey: ['detail', false, params.symbol, 1],
queryFn: () =>
axios
.get<DetailData>(`${DETAIL_API_URL}/${params.symbol}`, {
withCredentials: true,
headers: {
'Content-Type': 'application/json',
'Cache-Control': 'public max-age=600',
},
})
.then((res) => res.data),
});
const prefetchedOgTagInfo = await detailOgTagFetcher(params?.symbol as string);
return {
props: {
...translations,
params: params.symbol,
dehydratedState: dehydrate(queryClient),
info: prefetchedOgTagInfo,
},
revalidate: 60 * 10,
};
};
서버사이드의 부하를 줄이기 위해 차트 데이터 호출을 클라이언트로 옮겼다. 시간이 지나면서 차트 데이터가 방대해졌고, 서버 사이드에서의 호출은 문제가 되었다. 차트 데이터는 1시간, 페이지 전체 데이터는 24시간마다 업데이트되므로, SSR을 계속 사용할 필요가 없어졌다. ISR revalidate 주기를 적절히 설정하면 적합한 대안이 될 수 있었다.
ISR의 재생성 순서
ISR로 변경하면서 다음과 같은 이점이 있었다.
결과적으로 이 작업 이후 초기 로딩의 next-before-hydration을 1초에서 200ms 이하로 줄일 수 있었다.
(개선된 결과 155.63ms로 줄일 수 있었다)
서버 사이드 속도 개선으로 FCP(First Contentful Paint)가 기존 SSR의 1초에서 ISR 변경 후 최대 86.12ms로 크게 줄었다. 이 성과는 ISR 전환뿐만 아니라 리액트 쿼리의 prefetch 적용으로 서버 사이드 결과를 캐싱하고, UI 컴포넌트의 함수를 분리하고 정리한 덕분이었다.
렌더링 계층별로 다른 로딩 전략을 적용했다. ISR로 처리되는 펀드 기본 정보는 revalidate 기간 동안 캐시된 데이터를 즉시 보여주고, 백그라운드에서 새로운 데이터를 가져오도록 했다. 차트나 실시간 데이터 등 CSR로 처리되는 동적 컴포넌트들은 dynamic import와 Suspense를 활용해 스켈레톤 UI를 보여주도록 구현했다.
특히 처음에는 API 데이터가 반영되기 전까지 헤더와 푸터 외 영역이 빈 화면으로 남아 CLS 점수가 낮았다. 이를 해결하기 위해 몇 가지 전략을 적용했다:
마지막을 지난 글에서 살펴본 리스트를 바탕으로 이 작업들이 실제로 어떤 임팩트가 있었는지 확인해 볼 수 있었다.
이번 개선 작업을 통해 각 작업이 개별적으로 중요하며, 어느 하나가 누락되더라도 성능 향상을 경험하기 어려웠음을 확인할 수 있었다.