Next.js의 App Router와 React 18의 서버 컴포넌트 기능을 활용하면 매우 유연하고 확장성 있는 웹 애플리케이션을 만들 수 있습니다. 하지만 잘못 사용하면 전체 페이지의 로딩 속도를 떨어뜨리거나,
불필요한 서버 요청
을 발생시킬 수 있습니다.
이 포스트에서는 실제로 운영 중인 블로그에서 발견된 성능 병목을 어떻게 해결했는지, 그 과정을 하나하나 정리합니다. 특히 다음과 같은 내용에 집중하겠습니다.
fetch 병렬 처리
중복 API 호출 제거
Suspense 중첩 최적화
날짜 포맷으로 인한 hydration mismatch 해결
generateMetadata의 영향을 줄이는 방법
기존에는 다음과 같이 순차적으로 데이터를 불러오고 있었습니다:
const post = await PostService (). getPost ({ post_id: id });
await PostService (). viewIncrement ({ post_id: id });
이 두 요청은 서로 의존하지 않으므로 병렬 처리로 성능을 향상시킬 수 있습니다.
✅
해결 방법 - Promise.all()
로 병렬 처리 🔗
const [ markdownsource ] = await Promise . all ([
PostService (). getPost ({ post_id: id }),
PostService (). viewIncrement ({ post_id: id }),
]);
이는 특히 viewIncrement
가 느리거나 외부 DB I/O를 포함하는 경우에 큰 차이를 만들 수 있습니다.
🚀
문제 2: Suspense 중첩 렌더링 🔗
초기에는 다음과 같이 Suspense
를 여러 곳에서 중첩 사용하고 있었습니다:
< Suspense fallback = {< Loading />}>
< BreadCrumb params = {markdownsource} />
</ Suspense >
< Suspense fallback = {< Loading />}>
< MdxHeader props = { ... } />
</ Suspense >
< Suspense fallback = {< Loading />}>
< Giscus />
</ Suspense >
이 방식은 컴포넌트 별 로딩 제어는 가능하지만, 실제 렌더링 과정에서
불필요한 React boundary 비용
이 발생하고, 사용자의 체감 속도도 늦어지는 단점이 있었습니다.
✅
해결 방법 - 하나의 Suspense로 통합 🔗
< Suspense fallback = {< Loading />}>
<>
< BreadCrumb params = {markdownsource} />
< MdxHeader props = {{ ... }} />
< div className = "ml-auto text-right" >
📅 < LocalizedDate isoString = {markdownsource.init_date} />
</ div >
< div className = "prose mt-10 ..." >
< MdxBody content = {markdownsource.posting} />
< RightSidebarComp content = {markdownsource.posting} />
</ div >
< Giscus />
</>
</ Suspense >
이로써 로딩 표시가 중복되지 않으며, 컴포넌트 간 렌더링 순서도 일관되게 유지되었습니다.
🚀
문제 3: 날짜 포맷에 의한 Hydration Mismatch 🔗
초기 코드에서는 다음과 같이 서버에서 날짜를 포맷하고 있었습니다:
const dateString = dayjs (markdownsource.init_date). locale ( "ko" ). format ( "YYYY년 MM월 DD일" );
이 방식은 서버와 클라이언트에서 지역(locale)이 다를 경우 서버와 클라이언트 렌더링 결과가 달라짐 → hydration mismatch 발생
✅
해결 방법 - 날짜 포맷을 클라이언트에서 처리 🔗
서버에서는 원본 ISO 문자열만 전달하고, 클라이언트 컴포넌트에서 dayjs로 포맷하도록 변경했습니다.
'use client' ;
import dayjs from 'dayjs' ;
import 'dayjs/locale/ko' ;
export const LocalizedDate = ({ isoString } : { isoString : string }) => {
const [ text , setText ] = useState ( '' );
useEffect (() => {
dayjs. locale ( 'ko' );
setText ( dayjs (isoString). format ( 'YYYY년 MM월 DD일' ));
}, [isoString]);
return < span >{text}</ span >;
};
그리고 사용처:
< LocalizedDate isoString = {markdownsource.init_date} />
이렇게 하면 언제나 클라이언트 환경에 맞는 포맷으로 렌더링할 수 있습니다.
🚀
문제 4: PostService().getPost() 중복 호출 🔗
Next.js App Router 환경에서 흔히 겪는 문제 중 하나는
generateMetadata()
와 페이지 컴포넌트 본문에서
같은 데이터를 각각 fetch하는 문제
입니다.
// metadata.ts에서 호출
const markdownsource = await PostService (). getPost ({ post_id: params.post });
// page.tsx에서도 또 호출
const markdownsource = await PostService (). getPost ({ post_id: params.post });
이렇게 되면
한 페이지를 불러올 때 동일한 데이터를 2번 요청
하게 됩니다. 특히 데이터 양이 많거나 외부 API를 호출하는 구조라면 상당한 낭비가 발생합니다.
✅
해결 방법 - getPostMeta() 메서드 분리 🔗
generateMetadata()는 실제 렌더링을 위한 모든 데이터가 필요하지 않고,
title, description, og:image 등 일부 필드
만 있으면 됩니다. 따라서 다음과 같이 별도의 경량화된 API를 구성했습니다.
const getPostMeta = async ( params : { post_id : string }) => {
const response = await CustomFetch ( `/post/meta${ getParams ( params ) }` , {
method: "GET" ,
});
return response.body.data;
};
generateMetadata()에서는 이 메타 전용 API만 사용하도록 변경:
export async function generateMetadata ({ params }) {
const meta = await PostService (). getPostMeta ({ post_id: params.post });
return GenerateMeta ({ meta, param: params.post });
}
이로써
불필요한 전체 데이터 fetch를 줄이고, 중복 호출을 제거
할 수 있었습니다.
항목 개선 전 개선 후 효과 getPost
중복 호출generateMetadata
와 본문에서 중복메타 전용 API 분리 API 비용 절감 Suspense 여러 개 중첩 사용 하나로 통합 렌더링 최적화 날짜 포맷 서버 포맷 클라이언트 포맷 hydration mismatch 해결 API 호출 순차 호출 병렬 호출 응답 시간 단축
Next.js는 기본적인 성능이 뛰어난 프레임워크이지만, 사용자의 코드 구조에 따라 충분히 병목이 생길 수 있습니다. 오늘 소개한 개선 방법들은 어느 프로젝트에서도 적용할 수 있으며, 체감 속도와 서버 비용 모두에 긍정적인 영향을 줍니다.
불필요한 중복 제거, Suspense 단순화, 렌더 타이밍 최적화, fetch 병렬화라는 기본 원칙만 잘 지켜도 아주 쾌적한 사용자 경험을 만들 수 있습니다.
revalidateTag
, cache: 'no-store'
등 캐시 정책 활용하기
클라이언트 측에서만 필요한 로직은 dynamic(import, { ssr: false })
를 고려해보세요.