- Published on
Next.js 14 RSC로 SEO 깨짐? 원인·해결 7가지
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버 컴포넌트(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.tsx의 metadata 혹은 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 혼합 환경에서는 브라우저에서 보는 것과 크롤러가 보는 것이 다를 수 있으니, 아래 순서로 확인하면 원인을 빨리 좁힐 수 있습니다.
view-source:로 HTML 소스에title,meta name="description",meta property="og:*"가 실제로 있는지 확인curl로 HTML을 받아 동일하게 메타가 포함되는지 확인- 특정 봇에서만 문제면 User-Agent 차단/리다이렉트/인증 여부 확인
- 서치 콘솔 URL 검사에서 렌더링 HTML과 스크린샷 확인
noindex/robots.txt/X-Robots-Tag를 동시에 점검- 배포 환경에서 응답 시간과 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가지를 순서대로 점검하면, “왜 내 페이지는 제목이 안 바뀌지?” 같은 증상부터 “프리뷰 봇이 가끔 실패한다” 같은 간헐 문제까지 대부분 해결할 수 있습니다. 특히 generateMetadata와 fetch 캐싱 정책은 RSC SEO의 핵심 축이므로, 페이지 유형(정적/준정적/동적)에 맞는 정책을 먼저 확정하고 구현을 맞추는 것을 권합니다.