PromleeBlog
sitemapaboutMe

posting thumbnail
Next.js 블로그 성능 개선 사례 정리 - fetch 병렬 처리, Suspense 최적화
Optimizing Next.js Blog Performance - Fetch Parallelization, Suspense Optimization

📅

🚀

들어가기 전에 🔗

Next.js의 App Router와 React 18의 서버 컴포넌트 기능을 활용하면 매우 유연하고 확장성 있는 웹 애플리케이션을 만들 수 있습니다. 하지만 잘못 사용하면 전체 페이지의 로딩 속도를 떨어뜨리거나,
불필요한 서버 요청
을 발생시킬 수 있습니다.
이 포스트에서는 실제로 운영 중인 블로그에서 발견된 성능 병목을 어떻게 해결했는지, 그 과정을 하나하나 정리합니다. 특히 다음과 같은 내용에 집중하겠습니다.

🚀

문제 1: API 병렬 호출 최적화 🔗

기존에는 다음과 같이 순차적으로 데이터를 불러오고 있었습니다:
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 병렬화라는 기본 원칙만 잘 지켜도 아주 쾌적한 사용자 경험을 만들 수 있습니다.

더 생각해 보기 🔗

참고 🔗