Next.js SSR, 정말 전부 서버에서 렌더링해야 할까?
2025-11-01상품 상세 페이지의 SSR 데이터를 SEO 전용으로 분리해 페이로드를 74% 줄이고, 페이지 전환 속도를 68% 개선한 경험을 공유합니다.
Next.js 13 버전 PageRouter 기준으로 작성된 글입니다.
1. 문제 발견
앱 내부 페이지 전환이 너무 느렸습니다
상품 목록에서 상세 페이지로 이동할 때, 즉 Link 컴포넌트나 router.push()를 통해 페이지를 전환할 때 체감 속도가 매우 느렸습니다.
// 상품 목록 페이지
<Link href={`/product/${productId}`}>
<ProductCard />
</Link>
이렇게 이동하면 페이지가 보이기까지 심하면 약 1~2초가 걸렸습니다. 분명 데이터가 많지 않은 상품인데도 동일했었죠
URL 직접 접근은 괜찮았습니다
신기하게도, 브라우저 주소창에 https://example.com/product/123을 직접 입력하거나 외부 링크로 접근하면 페이지가 빠르게 로드되었습니다.
이 차이가 의아했고, 성능 측정을 시작했습니다.
측정 결과
개발자 도구 네트워크 탭에서 확인한 결과:
| 항목 | 값 |
|---|---|
| 주요 페이로드 크기 | 3.4 kB |
| 응답 시간 | ~283 ms |
JSON파일의 페이로드가 생각보다 크고, 응답 시간이 길었습니다.
2. 문제 원인
URL 직접 접근 vs 앱 내부 전환의 차이
URL 직접 접근 (SSR):
- 브라우저가 서버에 HTML을 요청합니다
- 서버가
getServerSideProps를 실행해 데이터를 가져옵니다 - 완성된 HTML을 브라우저에 전달합니다
- 브라우저가 HTML을 즉시 렌더링합니다
→ 사용자는 서버 렌더링이 완료된 페이지를 바로 봅니다.
앱 내부 전환 (CSR):
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에 전혀 영향이 없습니다.
3. 최적화 진행
해결 방법: SEO 전용 쿼리 분리
핵심 아이디어는 간단합니다:
SEO에 필요한 최소한의 데이터만 SSR로 가져오고, 나머지는 CSR로 처리한다.
AS-IS: 전체 데이터를 SSR로 가져옴
// ❌ 기존: 모든 필드를 SSR로
GET_PRODUCT_BY_ID: {
id, name, description, thumbnail,
shop, options, reviews, shipping, seller,
relatedProducts, ...
}
TO-BE: SEO 전용 쿼리 분리
// ✅ 개선: 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) } };
}
Cache-Control 헤더 설명
getServerSideProps에서도 Cache를 설정 할 수 있습니다. 이 캐시 헤더는 CDN과 브라우저에게 "이 페이지를 얼마나 오래 캐싱할지"를 알려주는 설정입니다.
각 옵션의 의미:
| 옵션 | 값 | 의미 |
|---|---|---|
public | - | CDN, 프록시, 브라우저 모두 캐싱 가능 |
max-age | 600초 (10분) | 브라우저 캐시 유효 시간 |
s-maxage | 86400초 (1일) | CDN 캐시 유효 시간 |
stale-while-revalidate | 604800초 (7일) | 캐시 만료 후에도 오래된 캐시를 제공하고 백그라운드에서 갱신 |
동작 방식:
- 첫 방문자: 서버에서 SSR 실행 → CDN에 1일간 캐싱
- 이후 방문자: CDN에서 즉시 응답 (응답 시간 ~20ms로 감소)
- 1일 후: 오래된 캐시를 제공하면서 백그라운드에서 새 데이터 갱신
- 서버 부하가 대폭 감소하고 대부분의 요청이 CDN에서 처리됩니다
실제 트러블 슈팅 경험
위 캐시 설정을 잘 사용하면 네트워크 비용을 줄일 수 있지만, 서비스 도메인에 맞게 잘 사용해야 합니다.
실제로 저는 처음에 위 예시처럼 긴 캐싱 기간을 적용했지만, 커머스 특성상 가격, 상품 재고 여부 등이 바뀌는 주기가 너무 짧아 문제가 발생했습니다. 실제로는 상품 가격이 변경되었는데 캐시 때문에 변경된 가격이 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% |