Next.js SSR, 정말 전부 서버에서 렌더링해야 할까?
상품 상세 페이지의 SSR 데이터를 SEO 전용으로 분리해서 페이로드를 74% 줄이고, 페이지 전환 속도를 68% 개선한 경험을 공유해보겠습니다.
Next.js 13 버전 PageRouter 기준으로 작성된 글입니다.
문제 발견
앱 안에서만 유독 느렸다
상품 목록에서 상세 페이지로 이동할 때, 즉 Link 컴포넌트나 router.push()로 페이지를 전환할 때 체감 속도가 매우 느렸습니다.
// 상품 목록 페이지
<Link href={`/product/${productId}`}>
<ProductCard />
</Link>
이렇게 이동하면 페이지가 보이기까지 심하면 약 1~2초가 걸렸습니다. 분명 데이터가 많지 않은 상품인데도 똑같았기 때문입니다.
주소창으로 들어가면 멀쩡했다
신기하게도, 브라우저 주소창에 https://example.com/product/123을 직접 입력하거나 외부 링크로 접근하면 페이지가 빠르게 로드됐습니다.
같은 페이지인데 들어가는 경로에 따라 속도가 다르다니. 이 차이가 의아해서 성능 측정을 시작했습니다.
측정 결과
개발자 도구 네트워크 탭에서 확인한 결과입니다.
| 항목 | 값 |
|---|---|
| 주요 페이로드 크기 | 3.4 kB |
| 응답 시간 | ~283 ms |
JSON 파일의 페이로드가 생각보다 크고, 응답 시간이 길었습니다.
문제 원인
같은 페이지인데 렌더링 경로가 달랐다
URL을 직접 접근하면 SSR로 동작합니다. 브라우저가 서버에 HTML을 요청하고, 서버가 getServerSideProps를 실행해서 데이터를 가져온 뒤, 완성된 HTML을 브라우저에 전달합니다. 사용자는 서버 렌더링이 완료된 페이지를 바로 볼 수 있죠.
반면 앱 내부에서 Link를 클릭하면 클라이언트 라우팅이 시작됩니다. Next.js가 /api/__next/data 엔드포인트로 요청을 보내고, 서버가 getServerSideProps를 실행해서 모든 데이터를 JSON으로 직렬화합니다. 클라이언트는 이 JSON을 받아서 react-query로 하이드레이션한 뒤 컴포넌트를 렌더링하고요. 사용자는 JSON 다운로드와 하이드레이션과 렌더링을 모두 기다려야 합니다. 직접 접근하는 SSR보다 속도가 느릴 수밖에 없는 구조죠.
서버가 상품의 모든 것을 내려주고 있었다
getServerSideProps에서 상품의 모든 데이터를 가져오고 있었습니다.
// 기존 코드
export async function getServerSideProps({ params }) {
const productId = Number(params.productId);
const queryClient = new QueryClient();
// 전체 필드를 가져오는 쿼리
await queryClient.prefetchQuery(
[PRODUCT, productId],
() => getProductById(productId) // 상품명, 설명, 옵션, 리뷰, 배송정보 등 전부
);
return { props: { dehydratedState: dehydrate(queryClient) } };
}
이 데이터에는 상품 기본 정보(이름, 가격, 설명), 상품 옵션(색상, 사이즈 등), 리뷰 목록, 배송 정보, 판매자 정보, 연관 상품 등 수십 개의 필드가 포함돼 있었습니다.
정작 SEO가 쓰는 건 몇 개 안 됐다
검색 엔진과 OG 스크래퍼가 필요로 하는 건 <head> 태그의 메타 정보뿐입니다.
<head>
<title>상품명 | 브랜드명</title>
<meta name="description" content="상품 설명" />
<meta property="og:title" content="상품명" />
<meta property="og:image" content="상품 이미지 URL" />
<meta property="product:price:amount" content="가격" />
<meta property="product:brand" content="브랜드" />
</head>
실제로 SEO에 필요한 필드는 상품 ID, 상품명, 설명, 썸네일 이미지, 가격, 브랜드 정보, 판매 상태가 전부입니다. 나머지 데이터(옵션, 리뷰, 배송 정보 등)는 페이지 본문 렌더링을 위한 것이라, 클라이언트에서 가져와도 SEO에 전혀 영향이 없습니다.
최적화 진행
핵심 아이디어는 간단합니다. SEO에 필요한 최소한의 데이터만 SSR로 가져오고, 나머지는 CSR로 처리하는 것입니다.
쿼리를 두 벌로 쪼갰다
// 기존: 모든 필드를 SSR로
GET_PRODUCT_BY_ID: {
id, name, description, thumbnail,
shop, options, reviews, shipping, seller,
relatedProducts, ...
}
// 개선: SEO에 필요한 최소 필드만
GET_PRODUCT_BY_ID_SEO: {
id,
name,
description,
thumbnail,
shop,
price,
}
// 본문 UI용 쿼리는 기존 유지 (CSR로 사용)
GET_PRODUCT_BY_ID: { /* 전체 필드 */ }
구현: getServerSideProps
SEO 데이터만 서버에서 가져오도록 변경했습니다.
export async function getServerSideProps(ctx: GetServerSidePropsContext) {
const { params, res } = ctx;
const productId = Number(params!.productId);
// 캐시 헤더 설정
res.setHeader(
"Cache-Control",
"public, max-age=600, s-maxage=86400, stale-while-revalidate=604800"
);
const queryClient = new QueryClient();
// SEO 전용 쿼리만 실행
const getProductById = (productId: number) =>
gqlClient.request<QueryResponse<{ product: ProductSeo }>>(
GET_PRODUCT_BY_ID_SEO,
{ input: { productId } }
);
const result = await queryClient.fetchQuery([PRODUCT_SEO, productId], () =>
getProductById(productId)
);
return { props: { dehydratedState: dehydrate(queryClient) } };
}
getServerSideProps에서도 Cache-Control 헤더를 설정할 수 있습니다. 이 헤더는 CDN과 브라우저에게 "이 페이지를 얼마나 오래 캐싱할지"를 알려주는 역할입니다.
| 옵션 | 값 | 의미 |
|---|---|---|
public | - | CDN, 프록시, 브라우저 모두 캐싱 가능 |
max-age | 600초 (10분) | 브라우저 캐시 유효 시간 |
s-maxage | 86400초 (1일) | CDN 캐시 유효 시간 |
stale-while-revalidate | 604800초 (7일) | 캐시 만료 후에도 오래된 캐시를 제공하고 백그라운드에서 갱신 |
동작 방식을 설명하자면, 첫 방문자가 접근하면 서버에서 SSR이 실행되고 결과가 CDN에 1일간 캐싱됩니다. 이후 방문자는 CDN에서 즉시 응답을 받아서 응답 시간이 ~20ms로 크게 줄어듭니다. 1일이 지나면 오래된 캐시를 사용자에게 제공하는 동시에 백그라운드에서 새 데이터를 갱신합니다.
다만, 이 캐시 설정은 서비스 도메인에 맞게 잘 조정해야 합니다. 저도 처음엔 위 예시처럼 긴 캐싱 기간을 적용했는데, 커머스 특성상 가격이나 상품 재고 여부가 바뀌는 주기가 너무 짧아서 문제가 발생했습니다. 상품 가격이 변경됐는데 캐시 때문에 변경된 가격이 SEO에 반영되지 않는 상황이었기 때문입니다.
구현: 컴포넌트
SEO 데이터와 본문 데이터를 분리해서 씁니다.
const ProductDetailPage = ({ productId }) => {
// SEO 데이터: 서버에서 온 데이터 (즉시 사용 가능)
const { data: seoData } = useProductSEO(productId);
// 본문 데이터: 클라이언트에서 fetch (CSR)
const { data } = useProduct(productId);
const productSeo = seoData?.product;
const product = data?.product;
// SEO 데이터만 있는 경우: Head는 완성, 본문은 Skeleton
if (productSeo && !product) {
return (
<>
<CustomHead
title={productSeo.shop}
description={productSeo.description}
url={`${SITE_URL}/product/${productSeo.id}`}
ogImage={encodeURI(productSeo.thumbnail)}
/>
<Head>
<meta property="product:brand" content={productSeo.shop} />
<meta property="product:availability" content="in stock" />
<meta property="product:price:amount" content={productSeo.originalPrice?.toString()} />
<meta property="product:price:currency" content="KRW" />
</Head>
<ProductDetailTopSkeleton />
</>
);
}
// 모든 데이터가 준비된 경우: 전체 UI 렌더링
if (product) {
return (
<>
<CustomHead {...} />
<Head>{/* 동일한 메타 */}</Head>
<NavigationBar />
<Container>
<ProductDetail product={product} />
</Container>
</>
);
}
};
측정 결과
실제 운영 배포 후 측정한 결과 이런 지표를 얻었습니다.
| 항목 | AS-IS | TO-BE | 개선율 |
|---|---|---|---|
| 주요 페이로드 크기 | 3.4 kB | 0.9 kB | ↓ 74% |
| 응답 시간 | ~283 ms | ~90 ms | ↓ 68% |
SSR에서 내려오는 데이터를 SEO에 필요한 최소한으로 줄이는 것만으로 이 정도 개선이 가능했습니다.
getServerSideProps가 무겁게 느껴진다면, 실제로 서버에서 내려와야 하는 데이터가 무엇인지 한 번 다시 점검해봐도 좋겠습니다. 생각보다 클라이언트로 미뤄도 되는 필드가 많을지도 모릅니다.