프리렌더링 성능개선 + generateMetadata 리팩토링
Next.js 강의를 들으며 다양한 성능 최적화 기법을 학습한 뒤,
내가 만든 프로젝트에서도 개선이 필요한 부분들이 눈에 보이기 시작했다.
다시 보면서 점검해볼 겸 성능 개선을 어떻게 개선할 수 있는지 기록해보려고 한다.
1. 메인페이지 분석 및 성능 개선
여기서 데이터를 불러오는 섹션은 크게 `앨범`, `자유게시판`, `굿즈샵` 섹션이다.
여기서 사용자가 접속을 했을 때, 제일 빠르게 보여야 하는곳은 `앨범` 섹션이다.
자유게시판은 수정과 삭제가 빈번하기 때문에 프리렌더링 과정을 하는 것 보다 그때마다 패칭해서 불러오는게 맞겠다고 생각했다.
마지막으로 굿즈샵 섹션은 페이지 하단에 위치해 있기 때문에
사용자가 해당 섹션에 도달하지 않고 다른 페이지로 이동할 가능성도 있다고 판단했다.
즉, 모든 사용자에게 반드시 필요한 정보가 아니고,
초기 렌더링에 포함시키는 것이 오히려 번들 크기 증가로 이어져서 전체 성능에 부정적인 영향을 줄 수 있도록 생각했다.
그래서 결국에는 제일 중요한 `앨범 섹션`만 프리렌더링을 진행하게 되었다.
export const revalidate = 60 * 60 * 24;
export default async function Home() {
const queryClient = new QueryClient();
//데이터 prefetch
await queryClient.prefetchQuery({
queryKey: ["shops", "carousel"],
queryFn: getCarouselShop,
})
return (
<HydrationBoundary state={dehydrate(queryClient)}>
<main>
/* 섹션 컴포넌트 */
</main>
</HydrationBoundary>
);
}
`revalidate`는 하루(24시간)로 설정했고,
TanStack Query에서 제공하는 `prefetchQuery`, `dehydrate`, `HydrationBoundary`를 사용해서
Next.js 13의 서버 컴포넌트 환경에서도 데이터를 사전 패칭(prefetch)할 수 있도록 구성했다.
이를 통해 클라이언트에서 별도의 네트워크 요청 없이도,
서버 측에서 가져온 데이터를 바로 활용할 수 있도록 했고 초기 렌더링 속도를 개선할 수 있었다!
캐싱 전략 측면에서는 `revalidateTag` 같은 고급 캐싱 기능도 고려했지만,
내 프로젝트는 별도의 관리자 페이지 없이 Supabase에서 직접 DB를 읽어오는 구조이기 때문에
태그 기반 캐싱까지는 필요하지 않다고 판단했다.
따라서 브라우저 단의 `revalidate` 정도만 적용해도 충분하다고 생각했고,
현재 구조에선 이 정도 선에서 적절한 트레이드오프라고 판단했다.
하지만 prefetch 후 문제점이 발생!
앨범 섹션에는 이렇게 swiper 라이브러리를 사용해서 슬라이드를 구성하고 있었다.
하지만 프리렌더링을 진행한 이후, 데이터는 이미 프리패칭 되어 있었지만
Swiper의 CSS 스타일링이 적용되기 전 상태로 화면에 렌더링되어 UI가 일시적으로 틀어져 보이는 현상이 발생했다.
스켈레톤을 보여줄 필요가 없어서 데이터 패칭 이전에 만들었던 스켈레톤도 사라지고 없었다.
이 문제는 SSR 환경에서 Swiper의 내부 스타일이 클라이언트에서 늦게 적용되기 때문에 생긴 현상이었다.
이전 코드
if (isLoading) return <AlbumListLoading />;
if (isError) return <Error />;
새로운 변경 코드
const [isSwiperReady, setIsSwiperReady] = useState(false);
useEffect(() => {
setIsSwiperReady(true);
}, []);
if (isLoading || !isSwiperReady) return <AlbumListLoading />;
그래서 `useEffect`를 활용해서 컴포넌트가 마운트된 이후에 `isSwiperReady`를 `true`로 변경하고,
`isLoading`이 끝나고 `isSwiperReady`가 준비되었을 때만 앨범 리스트를 렌더링하도록 구성했다.
그 이전에는 Swiper의 CSS 번들이 완전히 입혀질 때까지 Skeleton UI를 보여주도록 설정했다.
그럼 이전이랑 똑같은거 아님?
맞는 말이지만, 조금 다르다.
기존에는 데이터를 패칭할 때까지 Skeleton UI를 보여주고, 이후에 앨범 리스트를 보여주는 단순한 구조였다.
하지만 앨범 섹션은 메인 페이지에서 가장 먼저 사용자에게 노출되는 핵심 영역이기 때문에,
단순히 데이터를 패칭해오는 것뿐만 아니라 시각적으로 안정적인 콘텐츠 노출이 필요했다.
특히 앨범 이미지의 용량이 상대적으로 크기 때문에, 데이터 패칭이 끝난 후에도
이미지가 천천히 렌더링되거나 레이아웃이 깨지는 문제가 있었다. 😂
그래서 데이터를 미리 패칭(prefetch)한 다음, Swiper의 스타일이 적용되는 시간까지 고려하여
완전히 준비된 상태에서 콘텐츠를 보여주도록 구성했다.
결과적으로, 데이터는 미리 가져오고, 스타일은 안정적으로 적용된 뒤 렌더링되므로
첫 진입 시 로딩 스트레스 없이 빠르고 쾌적한 사용자 경험을 제공할 수 있게 되었다!
2. 소식 페이지 분석 및 성능 개선
현재 `소식 페이지`는 대부분의 콘텐츠가 자주 변경되지 않는 뉴스 데이터로 구성되어 있어서
정적인 페이지(SSG)로 구성하는 것이 적합하다고 판단했다.
다만 향후 확장성을 고려했을 때, 소식 페이지는 새로운 뉴스나 공지사항이 업데이트 된다면?
이를 사용자에게 반영하여 보여줘야 하는 페이지를 가지게 된다.
따라서 `관리자 페이지에서 새로운 소식이 추가되는 경우`를 가정해서
캐싱 전략은 메인페이지와 동일하게 하루 단위 (revalidate: 60 * 60 * 24)로 설정했다.
콘텐츠 업데이트가 더 자주 발생한다면, 추후 캐싱 주기를 조정하는 방향으로 대응할 수 있으니까...!
일단은 하루 단위로 설정해 주었다.
그래서 초기 페이지에 접속해서 보이는 뉴스 데이터를 5개만 미리 패칭하여 렌더링하고,
사용자가 더 많은 소식 보기 버튼을 클릭하면
클라이언트 측에서 추가 데이터를 불러오게 했다!
갤러리 섹션 또한 동일한 방식으로 구성했다.
최신 사진 6개를 사전 렌더링하고, 이후 더 많은 사진 보기 버튼을 눌렀을 때
추가 데이터를 클라이언트에서 요청해 불러오도록 구현하였다.
추가된 코드
export const revalidate = 60 * 60 * 24;
const queryClient = new QueryClient();
//데이터 prefetch
await Promise.all([
queryClient.prefetchQuery({
queryKey: ["news", LATEST_DEFAULT_LIMIT],
queryFn: () => getNewsGallery(LATEST_DEFAULT_LIMIT),
}),
queryClient.prefetchQuery({
queryKey: ["gallery", GALLERY_DEFAULT_LIMIT],
queryFn: () => getGallery(GALLERY_DEFAULT_LIMIT),
}),
]);
이전 메인페이지와 마찬가지로, TanStack Query로 뉴스와 갤러리 데이터를
사전 패칭(prefetch) 하도록 설정했다!
두개의 데이터를 비동기로 요청하고 있기 때문에 Promise.all 로 묶어서
뉴스와 갤러리 데이터를 동시에 병렬로 요청해서 응답속도를 줄였다!
3. 기타 페이지
굿즈샵, 장바구니, 마이페이지 등 나머지 페이지들은
데이터 변경 빈도가 낮거나, 초기에 정적인 콘텐츠 중심으로 구성되어 있어서 별도의 최적화 작업을 적용하진 않았다.
마이페이지는 로그인한 사용자에게 보여주는 정보(배송지, 찜 목록,결제 내역, 내가 쓴 글)가 다르기 때문에 CSR 방식을 그대로 유지했다.
가장 고민했던 부분은 굿즈샵 페이지인데
굿즈샵은 관리자 페이지 없이 내가 DB에 넣어서 불러오는 방식으로, 즉 수동으로 상품 데이터를 업데이트하고 있었기 때문에
상품 정보를 미리 사전 렌더링 해두면 사용자에게 더 빠르게 보여줄 수 있을 거라고 생각했다.
다만, 상품의 찜 기능과 결제 기능은 로그인 상태와 사용자 정보에 따라 달라지기 때문에
완전한 정적 렌더링보다는 하이브리드 전략이 적합하다고 판단했다.
현재 구조에서는 로컬스토리지 기반의 찜 목록 동기화와 같은 로직이 존재해서
이를 프리렌더링 구조로 전환하려면 코드 전체 흐름에 대한 재구성이 필요했다 😂
기능적 리스크와 시간 대비 효과를 고려하여 이번 프로젝트에서는 기존 구조를 유지하기로 결정했다.
자유게시판처럼 성능 저하가 체감될 정도였다면 다시 뜯어고쳤을 수도 있지만,
굿즈샵은 평균적으로 속도가 빠른 편이라 큰 불편함 없이 유지할 수 있었다.
추후에는 상품 리스트만 정적으로 렌더링하고,
찜 목록 등...사용자 정보는 클라이언트측 하이드레이션 방식 구조로 리팩토링을 고려해볼 계획이다...!
generateMetadata
기존에는 모든 페이지에서 메타데이터를 정적으로 처리하고 있었지만,
`자유게시판 상세페이지`와 `굿즈샵 상세페이지`처럼 콘텐츠가 유동적으로 바뀌는 경우에는
동적인 메타데이터가 더 적합하다고 판단해 generateMetadata를 활용하여 리팩토링을 진행했다.
generateMetadata 추가하기
import { getGoodsShopDetail } from "@/lib/supabase/shop";
import type { ShopDetailPageParams } from "@/types/shop";
const fallbackMetadata = {
title: "굿즈샵 상세페이지 - IVE DIVE",
description: "굿즈샵 상세정보 페이지 - IVE DIVE",
openGraph: {
title: "굿즈샵 상세페이지 - IVE DIVE",
description: "굿즈샵 상세정보 페이지 - IVE DIVE",
images: [
"https://res.cloudinary.com/dknj7kdek/image/upload/v1737888335/og_nb8ueg.png",
],
type: "website",
},
};
export const generateMetadata = async ({ params }: ShopDetailPageParams) => {
try {
const shopId = params.id;
const shopData = await getGoodsShopDetail(shopId);
if (!shopData) return fallbackMetadata;
return {
title: `${shopData.title} - 굿즈샵 상세페이지`,
description: shopData.description ?? "굿즈 상세 설명입니다.",
openGraph: {
title: `${shopData.title} - 굿즈샵 상세페이지`,
description: shopData.description ?? "굿즈 상세 설명입니다.",
images: [shopData.thumbnail],
type: "website",
},
};
} catch (err) {
console.error("메타데이터 에러:", err);
return fallbackMetadata;
}
};
params에서 id를 추출하고, 해당 데이터에 맞는 상세 정보를 가져오고
제목, 설명, 썸네일 등을 기반으로 동적으로 메타데이터를 생성하도록 구현하였다.
데이터가 존재하지 않거나 오류가 발생할 경우에는 기본 메타데이터(fallback)을 제공하도록 처리해서
예외 상황에서 문제가 없도록 구성했다.
결과를 확인해보면 동적 메타데이터로 잘 반영이 되어있는 것을 볼 수 있다!
오늘의 리팩토링은 끝 ~~~ 😊