지난 글에 이어 쓰는 Next.js의 성능 최적화 사례에 대한 회고입니다.
지난 글에서 작성한 최적화를 위한 체크리스트를 실제로 업무에 적용하여 성능 개선을 이룬 사례에 대해 적어보려 한다. 이 체크 리스트에서 최적화를 마친 이후, 각 항목이 실제로 얼마나 임팩트가 있었는지 확인해보았다.
0. 최적화 리스트와 성과
최적화를 위한 체크 리스트
- 데이터 fetching과 (적절한 서버사이드) 렌더링
- 불필요한 로직과 파일 정리
- 라이트하우스 가이드라인, 커버리지, 퍼포먼스 확인
- 번들 사이즈 확인 및 최적화
- 적절한 캐싱
위 항목을 차례차례 수정하였고, 결과적으로 내가 담당했던 페이지들의 최적화 작업을 마친 이후 다음과 같은 성과를 이룰 수 있었다.
- ISR, prefetch 적용하여 lighthouse score 67점에서 95 - 100점으로 개선
- 퀀트 상품 디테일 페이지 : LCP 80% (2.1 -> 0.4s) CLS 35% (0.416 -> 0.063) 개선
- 매뉴얼 상품 디테일 페이지 : LCP 50% (1.8 -> 0.8s) , CLS 76% (0.762 -> 0) 개선
- 초기 로딩의 hydration 단계를 81% 단축 (1s -> 189.58ms)
- 페이지 전체 로딩 속도를 약 70% (1.312s -> 399ms) 개선
최적화 작업을 하게 된 배경에는 다음과 같은 문제가 있었다.
- 페이지 첫진입 시 로딩이 체감이 될 정도로 느렸다.
- 1차적으로 최적화를 진행하고 난 후였지만, 전반적인 성능이 크게 개선되지 않았다.
(성능 개선 이전 1.3~1.4s로 지연된 로딩 속도를 확인할 수 있다. )
1. 문제 분석
문제의 원인을 분석해보니 크게 세 가지였다.
초기 로딩 속도와 성능 이슈
- 전체 페이지 로딩에 1.312초가 소요됨
- FCP(First Contentful Paint)가 1초, LCP(Largest Contentful Paint)가 2.1초로 매우 느림
- 하이드레이션에 1초가 소요되어 사용자 인터랙션 지연
API 워터폴과 통신 지연
- 시간이 지남에 따라 증가하는 데이터로 인해 서버사이드에서 API 호출의 통신 지연 발생
- 여러 API를 동시에 호출하면서 발생하는 워터폴 현상
- 데이터 의존성으로 인한 순차적 API 호출
페이지 특성에 부적합한 렌더링 전략
- getServersideRendering를 사용하여 페이지를 일괄적으로 서버사이드 렌더링
- 정적 콘텐츠임에도 불구하고 매 요청마다 서버사이드 렌더링 수행
- 서버 상태 업데이트 주기에 맞는 적절한 캐싱과 최적화 전략 부재
특히 문제가 된 것은 페이지에 getServersideRendering을 적용한 것이었다. 실시간 데이터가 필요한 부분은 일부였음에도, 전체 페이지를 매 요청마다 새로 렌더링하고 있었다. 이는 불필요한 서버 부하를 발생시키고 초기 로딩을 지연시키는 주된 원인이었다.
2. 하이브리드 렌더링으로 전환
페이지 성능 저하의 근본 원인은 모든 컴포넌트에 일괄적으로 서버사이드 렌더링을 적용하여, 데이터의 성격과 관계없이 매 요청마다 모든 API를 호출하는 구조였다. getServersideRendering을 대체하기 위해 각 api 서버 상태의 변경 주기와 실시간 업데이트의 필요성을 구분할 필요가 있었다.
API 업데이트 주기와 컴포넌트 성격 분석
펀드 디테일 페이지의 느린 초기 로딩의 주요 원인이 서버사이드 렌더링 시의 API 호출이었다. 이를 개선하기 위해 먼저 페이지에서 호출하는 모든 API의 성격과 데이터 업데이트 주기를 분석했다.
1. 펀드 핵심 데이터
- 포함 정보: 트레이더 정보, 펀드 정보, 상품 설명, 리스크 설명, 라운드별 성과, 최대 투자 가능 금액, 최대 모집액 한도, 현재 모집액
- 업데이트 특성: 대부분의 정보가 펀드 라운드(준비-모집-운용-정산) 주기로 변경
- 렌더링 고려사항: SEO를 위해 HTML에 포함되어야 하며, 라운드 변경 시점에 맞춰 업데이트 필요
2. 실시간 투자 데이터
- 포함 정보: 사용자별 투자 내역과 가능 금액
- 업데이트 특성: 투자 이벤트 발생 시 실시간 변경
- 렌더링 고려사항: 실시간성이 중요하며 사용자별 맞춤 정보 포함
3. 차트 데이터
- 포함 정보: 수익률, 거래 내역 등의 시계열 데이터
- 업데이트 특성: 1시간 주기로 정기 업데이트
- 렌더링 고려사항: CSR 데이터 양이 많아 별도 요청으로 처리 필요
하이브리드 렌더링의 이해와 적용
하이브리드 렌더링은 하나의 페이지 내에서 여러 렌더링 전략을 혼합해서 사용하는 방식이다. Next.js에서는 페이지 단위로 렌더링 방식을 결정하는 것이 일반적이지만, 실제로는 하나의 페이지 안에서도 데이터의 성격에 따라 다른 렌더링 전략이 필요한 경우가 많다.
분석한 데이터의 성격을 바탕으로, 페이지를 세 가지 렌더링 계층으로 나누어 구현했다:
정적 생성 계층
- 정적 메타데이터: SEO에 필요한 펀드명, 설명, OG 태그
- 기본 UI: 레이아웃, 네비게이션, 컨테이너
- 변경이 거의 없는 펀드 데이터: 트레이더 정보, 펀드 상품 설명, 리스크 설명
- 이점: 최초 페이지 로드 시 즉시 표시 가능하며 SEO 최적화
ISR 계층
- 주기적으로 업데이트되는 펀드 핵심 데이터
- 라운드별 성과, 최대 투자 가능 금액
- 최대 모집액 한도
- 펀드 스케줄에 따른 상태 정보
- ISR revalidate 주기: 펀드 라운드 변경 주기 기반
- 이점: 서버 부하 감소 + 데이터 최신성 확보
클라이언트 사이드 계층
- 사용자별 맞춤 데이터
- 투자 가능 여부, 투자 내역 등 사용자 특정 정보
- 차트 데이터
- 기간별 필터링이 필요한 수익률 차트
- 사용자 인터랙션에 따른 동적 데이터 로드
- 이점: 사용자별 맞춤 데이터 실시간 반영 + 동적 데이터 처리 용이
SSR에서 ISR로
최적화 이전, 페이지는 서버 사이드 렌더링(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,
};
};
API 호출 줄이기
서버사이드의 부하를 줄이기 위해 차트 데이터 호출을 클라이언트로 옮겼다. 시간이 지나면서 차트 데이터가 방대해졌고, 서버 사이드에서의 호출은 문제가 되었다. 차트 데이터는 1시간, 페이지 전체 데이터는 24시간마다 업데이트되므로, SSR을 계속 사용할 필요가 없어졌다. ISR revalidate 주기를 적절히 설정하면 적합한 대안이 될 수 있었다.
ISR의 재생성 순서
- 초기 페이지 생성: 빌드 시, getStaticProps는 처음 실행되어 페이지를 생성하고, 이 페이지는 서버나 CDN에 캐싱
- 재생성 트리거: revalidate에 설정된 시간이 지나면, 다음 사용자의 요청에 의해 페이지는 "재생성". 이는 페이지가 새로고침되거나 새 사용자에 의해 요청될 때 자동으로 발생.
- 백그라운드 업데이트: 사용자의 요청이 있을 때, 현재 캐시된 페이지가 즉시 제공되고, 동시에 백그라운드에서 새로운 페이지가 생성. 새 페이지가 준비되면, 이전에 캐시된 페이지를 대체.
- 업데이트된 페이지 제공: 다음 요청부터는 새로 생성된 페이지가 제공. 이 페이지에는 최신 API 데이터가 반영
ISR로 변경하면서 다음과 같은 이점이 있었다.
- 페이지는 빌드 시점에 정적으로 생성되고, revalidate 주기마다 최신 상태로 재생성
- 이로 인해, 정적 페이지의 빠른 로딩 속도와 동적 콘텐츠의 최신성을 동시에 제공
- 서버 부하 감소와 효율적인 캐싱 전략을 통한 성능 개선
결과적으로 이 작업 이후 초기 로딩의 next-before-hydration을 1초에서 200ms 이하로 줄일 수 있었다.
(개선된 결과 155.63ms로 줄일 수 있었다)
FCP, CLS 개선
서버 사이드 속도 개선으로 FCP(First Contentful Paint)가 기존 SSR의 1초에서 ISR 변경 후 최대 86.12ms로 크게 줄었다. 이 성과는 ISR 전환뿐만 아니라 리액트 쿼리의 prefetch 적용으로 서버 사이드 결과를 캐싱하고, UI 컴포넌트의 함수를 분리하고 정리한 덕분이었다.
렌더링 계층별로 다른 로딩 전략을 적용했다. ISR로 처리되는 펀드 기본 정보는 revalidate 기간 동안 캐시된 데이터를 즉시 보여주고, 백그라운드에서 새로운 데이터를 가져오도록 했다. 차트나 실시간 데이터 등 CSR로 처리되는 동적 컴포넌트들은 dynamic import와 Suspense를 활용해 스켈레톤 UI를 보여주도록 구현했다.
특히 처음에는 API 데이터가 반영되기 전까지 헤더와 푸터 외 영역이 빈 화면으로 남아 CLS 점수가 낮았다. 이를 해결하기 위해 몇 가지 전략을 적용했다:
- 스켈레톤 UI를 실제 컨텐츠와 동일한 레이아웃으로 구성하여 로딩 중에도 페이지 구조 유지
- 데이터가 없는 경우 '-' 표시하여 레이아웃 붕괴 방지
- 이미지나 차트 영역은 aspect-ratio 속성으로 비율을 고정하여 로드 시 레이아웃 시프트 방지
마지막을 지난 글에서 살펴본 리스트를 바탕으로 이 작업들이 실제로 어떤 임팩트가 있었는지 확인해 볼 수 있었다.
- 데이터 Fetching과 서버사이드 렌더링: ISR 전환과 prefetch 적용으로 초기 로딩 속도가 크게 향상되었다. 특히, next-before-hydration 단계를 1초에서 200ms 이하로 단축하는 데 중요한 역할을 했다. 특히 api 호출 위치를 클라이언트와 서버사이드로 나누고, 적절한 fallback ui를 제공하여 CLS 함께 개선할 수 있었다.
- 적절한 캐싱: ISR과 리액트 쿼리의 prefetch를 통해 서버 사이드 결과를 캐싱함으로써 서버 부하가 개선되었다. ISR의 revalidate 기능은 API 데이터의 최신 상태를 유지하면서 정적 페이지의 빠른 로딩을 가능하게 했다.
- 적절한 코드 분리 및 정리: 함수를 utils로 분리하고 코드를 공용화하여 성능 향상에 기여했다. 이 작업은 단독으로 큰 성능 향상을 가져오지는 않았지만, 전체적인 최적화 과정에서 중요한 부분이었다.
- 라이트하우스 가이드라인, 커버리지, 퍼포먼스 확인: 성능 지표 측정 도구는 최적화 작업의 기본이 되었다.
- 번들 사이즈 확인 및 최적화: 불필요한 라이브러리 제거와 _app.tsx 내 컴포넌트 및 함수 정리로 번들 사이즈를 줄였다. 이번 작업에서는 상대적으로 임팩트가 작았다.
이번 개선 작업을 통해 각 작업이 개별적으로 중요하며, 어느 하나가 누락되더라도 성능 향상을 경험하기 어려웠음을 확인할 수 있었다.