Next.js SSR, 정말 전부 서버에서 렌더링해야 할까?
2025-11-01상품 상세 페이지의 SSR 데이터를 SEO 전용으로 분리해서 페이로드를 74% 줄이고, 페이지 전환 속도를 68% 개선한 경험을 공유해볼게요.
Next.js 13 버전 PageRouter 기준으로 작성된 글이에요.
문제 발견
앱 내부 페이지 전환이 너무 느렸습니다
상품 목록에서 상세 페이지로 이동할 때, 즉 Link 컴포넌트나 router.push()로 페이지를 전환할 때 체감 속도가 매우 느렸어요.
// 상품 목록 페이지
<Link href={`/product/${productId}`}>
<ProductCard />
</Link>
이렇게 이동하면 페이지가 보이기까지 심하면 약 1~2초가 걸렸어요. 분명 데이터가 많지 않은 상품인데도 똑같았거든요.
URL 직접 접근은 괜찮았습니다
신기하게도, 브라우저 주소창에 https://example.com/product/123을 직접 입력하거나 외부 링크로 접근하면 페이지가 빠르게 로드됐어요.
이 차이가 의아해서 성능 측정을 시작했어요.
측정 결과
개발자 도구 네트워크 탭에서 확인한 결과예요.
| 항목 | 값 |
|---|---|
| 주요 페이로드 크기 | 3.4 kB |
| 응답 시간 | ~283 ms |
JSON 파일의 페이로드가 생각보다 크고, 응답 시간이 길었어요.
문제 원인
URL 직접 접근 vs 앱 내부 전환의 차이
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로 처리하는 거예요.
SEO 전용 쿼리 분리
// 기존: 모든 필드를 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가 무겁게 느껴진다면, 실제로 서버에서 내려와야 하는 데이터가 무엇인지 한 번 다시 점검해볼 만해요.