back

Posts

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):

  1. 브라우저가 서버에 HTML을 요청합니다
  2. 서버가 getServerSideProps를 실행해 데이터를 가져옵니다
  3. 완성된 HTML을 브라우저에 전달합니다
  4. 브라우저가 HTML을 즉시 렌더링합니다

→ 사용자는 서버 렌더링이 완료된 페이지를 바로 봅니다.

앱 내부 전환 (CSR):

  1. Link 클릭 시 클라이언트 라우팅이 시작됩니다
  2. Next.js가 /api/__next/data 엔드포인트로 요청을 보냅니다
  3. 서버가 getServerSideProps를 실행해 모든 데이터를 JSON으로 직렬화합니다
  4. 클라이언트가 JSON을 받아 react-query로 하이드레이션합니다
  5. 컴포넌트가 렌더링됩니다

→ 사용자는 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-age600초 (10분)브라우저 캐시 유효 시간
s-maxage86400초 (1일)CDN 캐시 유효 시간
stale-while-revalidate604800초 (7일)캐시 만료 후에도 오래된 캐시를 제공하고 백그라운드에서 갱신

동작 방식:

  1. 첫 방문자: 서버에서 SSR 실행 → CDN에 1일간 캐싱
  2. 이후 방문자: CDN에서 즉시 응답 (응답 시간 ~20ms로 감소)
  3. 1일 후: 오래된 캐시를 제공하면서 백그라운드에서 새 데이터 갱신
  4. 서버 부하가 대폭 감소하고 대부분의 요청이 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-ISTO-BE개선율
주요 페이로드 크기3.4 kB0.9 kB↓ 74%
응답 시간~283 ms~90 ms↓ 68%