Published on

Next.js 14 RSC로 SEO 깨짐? 원인·해결 7가지

Authors

서버 컴포넌트(RSC)와 App Router는 성능과 DX를 크게 올려주지만, 마이그레이션 직후 “검색 결과에 제목/설명이 이상하다”, “OG 이미지가 안 뜬다”, “크롤러가 빈 페이지를 본다” 같은 SEO 이슈가 종종 터집니다. 문제는 대부분 RSC 자체가 SEO를 망가뜨린다기보다, 메타데이터 생성 위치/캐싱/동적 렌더링/로봇 정책/프리뷰 봇 대응 같은 경계 조건을 잘못 밟아서 발생합니다.

이 글에서는 Next.js 14 기준으로 RSC 환경에서 SEO가 깨져 보이는 원인 7가지를 증상별로 나누고, 바로 적용 가능한 해결책을 코드와 함께 정리합니다. (Hydration 관점의 이슈는 별도로 정리한 글도 참고하세요: Next.js 14 RSC로 생기는 Hydration Error 7가지)

1) metadata / generateMetadata를 클라이언트 컴포넌트에서 처리함

증상

  • title, description, openGraph 등이 기대대로 렌더링되지 않음
  • 개발 모드에서는 되는 것 같은데 배포 후 깨짐
  • 페이지별 메타가 아니라 공통 메타만 노출

원인

App Router에서 메타데이터는 Server Component(기본) 또는 layout.tsx / page.tsxmetadata 혹은 generateMetadata로 선언해야 합니다. 그런데 페이지를 "use client"로 바꾸거나, 메타를 useEffect로 세팅하는 방식으로 구현하면 크롤러가 보는 HTML에 메타가 포함되지 않습니다.

해결

  • 페이지/레이아웃은 가능하면 Server Component로 유지
  • 동적 메타는 generateMetadata에서 서버에서 계산
// app/posts/[slug]/page.tsx
import type { Metadata } from "next";

type Props = { params: { slug: string } };

async function getPost(slug: string) {
  const res = await fetch(`https://api.example.com/posts/${slug}`, {
    // SEO용 메타는 최신성이 중요하면 캐시 정책을 명확히
    next: { revalidate: 60 },
  });
  if (!res.ok) throw new Error("Failed to fetch");
  return res.json();
}

export async function generateMetadata({ params }: Props): Promise<Metadata> {
  const post = await getPost(params.slug);

  return {
    title: post.title,
    description: post.excerpt,
    openGraph: {
      title: post.title,
      description: post.excerpt,
      images: [{ url: post.ogImageUrl }],
      type: "article",
    },
    alternates: {
      canonical: `https://example.com/posts/${params.slug}`,
    },
  };
}

export default async function Page({ params }: Props) {
  const post = await getPost(params.slug);
  return (
    <article>
      <h1>{post.title}</h1>
      <div>{post.body}</div>
    </article>
  );
}

2) fetch 캐싱/재검증 설정 때문에 “다른 글 메타가 섞여” 보임

증상

  • 어떤 페이지는 이전 페이지의 title/description이 노출
  • OG 카드가 랜덤하게 다른 이미지로 뜸
  • 데이터는 바뀌었는데 검색/프리뷰는 계속 옛날 값

원인

RSC에서 fetch는 기본적으로 캐싱과 dedupe가 걸립니다. 메타데이터 생성과 본문 렌더링이 같은 데이터 소스를 공유할 때 캐시 정책이 애매하면, 의도치 않은 공유/고정이 발생합니다. 특히 generateMetadata는 서버에서 실행되므로 캐시의 영향을 크게 받습니다.

해결

  • 데이터 성격에 맞게 revalidate 또는 cache: "no-store"를 명시
  • 메타와 본문이 동일 소스를 쓰면 정책을 통일
// 자주 바뀌는 콘텐츠: 무조건 최신
await fetch("https://api.example.com/page", { cache: "no-store" });

// 5분 단위로 갱신해도 되는 콘텐츠
await fetch("https://api.example.com/page", { next: { revalidate: 300 } });

추가로, 성능과 UX까지 함께 잡으려면 서버 렌더링 비용과 상호작용 지표(INP 등)도 같이 보세요. 관련 최적화는 React/Next.js 프론트 최적화로 INP 200ms 달성에서 깊게 다룹니다.

3) robots / X-Robots-Tag가 의도치 않게 noindex로 설정됨

증상

  • 구글 서치 콘솔에서 “색인 생성 제외됨: noindex 감지”
  • 페이지는 정상 노출되는데 검색에는 안 뜸

원인

  • metadata.robots를 환경별로 다르게 주는 과정에서 프로덕션도 noindex가 됨
  • 미들웨어/서버에서 X-Robots-Tag: noindex 헤더를 전역으로 추가
  • 프리뷰/스테이징 도메인에서만 noindex하려다 조건문이 잘못됨

해결

  • metadata에서 환경 분기 시 조건을 보수적으로
  • 헤더 기반 noindex는 적용 범위를 명확히
// app/layout.tsx
import type { Metadata } from "next";

const isProd = process.env.NODE_ENV === "production";

export const metadata: Metadata = {
  title: {
    default: "Example",
    template: "%s | Example",
  },
  robots: isProd
    ? { index: true, follow: true }
    : { index: false, follow: false },
};

4) 동적 라우트에서 generateStaticParams/정적화 전략이 어긋남

증상

  • 크롤러가 접근할 때 404 또는 빈 페이지
  • 일부 slug만 정상, 일부는 메타가 기본값으로만 나옴

원인

동적 라우트에서 정적 생성(SSG)과 동적 렌더링을 섞을 때,

  • generateStaticParams가 누락되었거나
  • dynamicParams 설정이 의도와 다르거나
  • 빌드 시점에 존재하지 않던 slug를 404로 처리 하는 문제가 생깁니다.

해결

  • “존재하는 slug는 정적 생성 + 나머지는 런타임 생성” 또는 “전부 런타임” 중 하나로 전략을 명확히
// app/posts/[slug]/page.tsx
export const dynamicParams = true; // 빌드에 없던 slug도 런타임 처리

export async function generateStaticParams() {
  const res = await fetch("https://api.example.com/posts", {
    next: { revalidate: 3600 },
  });
  const posts: Array<{ slug: string }> = await res.json();

  return posts.map((p) => ({ slug: p.slug }));
}

만약 SEO가 매우 중요하고 모든 slug를 사전에 알고 있다면, generateStaticParams로 전부 생성하고 dynamicParams = false로 강제하는 편이 예측 가능성이 높습니다.

5) OG/Twitter 이미지가 깨짐: 절대 URL, 콘텐츠 타입, 봇 접근 문제

증상

  • Slack/카카오/트위터에서 링크 프리뷰 이미지가 안 뜸
  • og:image가 상대 경로로 들어가거나 404
  • 이미지 응답이 text/html로 내려가 프리뷰 봇이 실패

원인

  • openGraph.images에 상대 URL을 넣음
  • 이미지 라우트(예: app/og/route.ts)가 인증/리다이렉트/쿠키 의존
  • CDN/프록시가 봇 User-Agent를 차단

해결

  • OG 이미지는 반드시 절대 URL
  • 이미지 응답 헤더의 content-type 확인
  • 인증이 필요한 페이지라도 OG 이미지는 공개 경로로 분리
// app/posts/[slug]/page.tsx 의 generateMetadata 일부
return {
  openGraph: {
    images: [
      {
        url: `https://example.com/og/posts/${params.slug}.png`,
        width: 1200,
        height: 630,
      },
    ],
  },
};

6) canonical/alternates 설정 누락 또는 잘못된 기준 URL

증상

  • 동일 콘텐츠가 여러 URL로 색인(파라미터, 트레일링 슬래시, 로케일 등)
  • 검색 결과에서 원치 않는 URL이 대표로 뜸

원인

App Router로 오면서 라우팅 규칙이 바뀌거나, 프록시/배포 환경에서 실제 외부 도메인과 내부 도메인이 달라 canonical이 틀어지는 경우가 많습니다.

해결

  • 페이지 단위로 alternates.canonical을 명시
  • 사이트 전역 metadataBase를 올바른 외부 도메인으로 설정
// app/layout.tsx
import type { Metadata } from "next";

export const metadata: Metadata = {
  metadataBase: new URL("https://example.com"),
};
// app/products/[id]/page.tsx
import type { Metadata } from "next";

type Props = { params: { id: string } };

export async function generateMetadata({ params }: Props): Promise<Metadata> {
  return {
    title: `Product ${params.id}`,
    alternates: {
      canonical: `https://example.com/products/${params.id}`,
    },
  };
}

7) 크롤러/프리뷰 봇이 받는 응답이 “느리거나 불안정”해서 SEO가 깨져 보임

증상

  • 서치 콘솔 “크롤링됨 - 현재 색인 생성되지 않음”이 늘어남
  • 프리뷰 봇이 간헐적으로 실패
  • 서버 로그에 5xx, 타임아웃, 콜드스타트 흔적

원인

RSC는 서버에서 더 많은 일을 하게 만들 수 있습니다. 특히 배포 환경이 서버리스/컨테이너 기반이라면 콜드스타트나 순간 트래픽에서 HTML 생성이 늦어져 크롤러가 제대로 수집하지 못할 수 있습니다. 이 경우 “SEO가 깨졌다”기보다 크롤링 안정성이 무너진 것에 가깝습니다.

해결

  • 페이지의 동적 렌더링 범위를 줄이고(정적화/재검증)
  • API 호출 수를 줄이며(서버에서 합치기, 캐시)
  • 인프라 콜드스타트를 튜닝

Cloud Run을 쓴다면 콜드스타트와 503을 줄이는 방법을 함께 보세요: GCP Cloud Run 503·Cold Start 지연 최소화 7가지

또한 크롤러 관점에서 중요한 것은 “첫 HTML 응답이 안정적으로 나오느냐”입니다. RSC에서 외부 API 의존이 많은 페이지는 다음처럼 타임아웃/폴백을 설계해 장애 전파를 줄이세요.

// app/lib/fetchWithTimeout.ts
export async function fetchWithTimeout(url: string, ms: number) {
  const ac = new AbortController();
  const id = setTimeout(() => ac.abort(), ms);

  try {
    const res = await fetch(url, { signal: ac.signal, next: { revalidate: 60 } });
    return res;
  } finally {
    clearTimeout(id);
  }
}
// app/page.tsx
import { fetchWithTimeout } from "./lib/fetchWithTimeout";

export default async function Home() {
  let data: any = null;
  try {
    const res = await fetchWithTimeout("https://api.example.com/home", 1200);
    if (res.ok) data = await res.json();
  } catch {
    // 크롤러에게도 최소한의 HTML은 제공
  }

  return (
    <main>
      <h1>Example</h1>
      <p>{data?.headline ?? "Welcome"}</p>
    </main>
  );
}

진단 체크리스트: “정말 SEO가 깨졌는지” 빠르게 확인하기

RSC/CSR 혼합 환경에서는 브라우저에서 보는 것과 크롤러가 보는 것이 다를 수 있으니, 아래 순서로 확인하면 원인을 빨리 좁힐 수 있습니다.

  1. view-source:로 HTML 소스에 title, meta name="description", meta property="og:*"가 실제로 있는지 확인
  2. curl로 HTML을 받아 동일하게 메타가 포함되는지 확인
  3. 특정 봇에서만 문제면 User-Agent 차단/리다이렉트/인증 여부 확인
  4. 서치 콘솔 URL 검사에서 렌더링 HTML과 스크린샷 확인
  5. noindex/robots.txt/X-Robots-Tag를 동시에 점검
  6. 배포 환경에서 응답 시간과 5xx 비율 확인(콜드스타트 포함)
# HTML에 메타가 들어오는지 서버 응답 기준으로 확인
curl -sL https://example.com/posts/hello | head -n 60

# OG 메타만 빠르게 grep
curl -sL https://example.com/posts/hello | grep -i "og:"

마무리: RSC는 SEO의 적이 아니라 “규칙이 바뀐 환경”

Next.js 14 RSC에서 SEO가 깨져 보이는 상황은 대부분 다음 두 부류로 정리됩니다.

  • 메타데이터가 클라이언트에서만 만들어지거나, 캐시/정적화 전략이 꼬여서 잘못된 값이 고정되는 경우
  • 크롤러가 안정적으로 HTML을 받지 못하는 인프라/성능 문제

위 7가지를 순서대로 점검하면, “왜 내 페이지는 제목이 안 바뀌지?” 같은 증상부터 “프리뷰 봇이 가끔 실패한다” 같은 간헐 문제까지 대부분 해결할 수 있습니다. 특히 generateMetadatafetch 캐싱 정책은 RSC SEO의 핵심 축이므로, 페이지 유형(정적/준정적/동적)에 맞는 정책을 먼저 확정하고 구현을 맞추는 것을 권합니다.