TL;DR
- React·Next.js 사이트는 프레임워크만 선택한다고 구글 색인이 자동으로 해결되지 않습니다. 구글은 모든 200 응답 페이지를 렌더링 큐에 넣고 크롤 → 렌더 → 색인 3단계로 처리하므로, 각 단계에서 실수가 누적되면 색인이 누락됩니다.
- CSR만 쓰고 메타데이터를 런타임에 주입하면 초기 HTML이 비어 있어 색인이 지연되거나 누락됩니다. Next.js App Router의
MetadataAPI와generateMetadata는 Server Component에서만 동작합니다. - 스테이징용
noindex가 프로덕션에 흘러 들어가는 실수, 동적 라우트의 sitemap 누락, 잘못된 canonical, hydration 오류, LCP 미달, Client Component에 박힌 JSON-LD는 실제 한국 팀에서 반복적으로 관측되는 실수입니다.
왜 React/Next.js 사이트가 색인에서 사라지는가
구글은 JavaScript 사이트를 3단계로 처리합니다. 첫째, Googlebot이 URL을 크롤링하여 HTTP 응답을 받습니다. 둘째, 200 응답을 받은 모든 페이지를 렌더링 큐에 넣어 headless Chromium으로 JavaScript를 실행합니다. 셋째, 렌더링된 HTML을 파싱해 링크를 추출하고 본문을 색인합니다.
출처: Google Search Central — Understand JavaScript SEO Basics
Googlebot queues the page for both crawling and rendering. It is not immediately obvious to the bot when the page is waiting to be rendered versus when rendering is finished.
문제는 이 렌더링 큐가 즉시 처리되지 않는다는 점입니다. 구글 공식 문서는 "몇 초일 수도, 더 길어질 수도 있다"고만 표기하며, Vercel의 분석에 따르면 CSR 사이트는 2차 렌더링 결과가 수 일 지연되는 경우도 있습니다.
출처: Vercel — How Google Handles JavaScript Throughout the Indexing Process
즉 SSR·SSG로 초기 HTML에 콘텐츠를 내보내지 못하면, 그동안 구글은 빈 껍데기만 보고 색인 결정을 미루거나 중단합니다. React·Next.js 사이트가 "배포는 됐는데 구글에 안 잡힌다"는 상황은 대부분 이 렌더링 경제학을 무시한 결과입니다.
Next.js 16은 App Router의 기본 캐시 전략을 변경했고(fetch 기본값 no-store), Server Components·Suspense 스트리밍·Partial Prerendering을 결합해 렌더링 전략을 페이지 단위로 섞을 수 있습니다. 도구는 이미 충분히 강력합니다. 문제는 도구가 아니라 설정 실수입니다.
실수 1: CSR만 쓰고 메타데이터를 런타임에 주입한다
가장 흔한 실수이자 가장 치명적인 실수입니다. React로 만든 SPA에서 react-helmet 류 라이브러리로 클라이언트 사이드에서 title·description을 주입하면, 초기 HTML에는 기본 템플릿 메타데이터만 남습니다. 구글이 렌더링 큐에 페이지를 넣기 전까지 이 상태로 대기하므로 검색결과 제목과 스니펫이 비어 있거나 잘못 노출됩니다.
Next.js App Router에서는 Metadata 객체 또는 generateMetadata 함수를 Server Component에서 export해야 합니다. Client Component에서는 동작하지 않습니다.
// 잘못된 패턴 — Client Component에서 document.title 조작
'use client';
import { useEffect } from 'react';
export default function ProductPage({ product }) {
useEffect(() => { document.title = product.name; }, [product.name]);
return <article>{/* ... */}</article>;
}
이 코드는 초기 HTML에 <title>을 비워둔 채로 내보냅니다. 구글이 렌더링 큐 처리 전까지 제목을 알지 못하는 상태가 지속됩니다.
// 올바른 패턴 — Server Component + generateMetadata
import type { Metadata } from 'next';
export async function generateMetadata(
{ params }: { params: Promise<{ slug: string }> }
): Promise<Metadata> {
const { slug } = await params;
const product = await fetchProduct(slug).catch(() => null);
if (!product) {
return { title: '상품을 찾을 수 없습니다 | 스토어명' };
}
return {
title: `${product.name} | 스토어명`,
description: product.shortDescription,
openGraph: { images: [product.ogImage] },
};
}
Pages Router를 사용하는 프로젝트라면 next/head의 <Head> 컴포넌트를 쓰되, 반드시 getServerSideProps 또는 getStaticProps에서 데이터를 받아 서버 사이드에서 렌더링해야 합니다. 클라이언트 전용 useEffect에서 <Head>를 조건부로 렌더링하면 동일한 문제가 재발합니다.
실수 2: robots.txt·noindex를 실수로 배포한다
스테이징 환경을 구글에 노출하지 않기 위해 전역 noindex를 설정한 뒤, 프로덕션에 그대로 배포하는 실수가 실제로 빈번합니다. 한 번 noindex가 초기 HTML에 포함되면 구글은 해당 페이지를 렌더링 자체를 건너뜁니다. JavaScript로 noindex를 제거해도 소용이 없습니다.
출처: Google Search Central — JavaScript SEO Basics (noindex rule)
If a page contains the noindex meta tag or header in the initial HTML response, Google won't render the page. Client-side removal of noindex is not effective.
Vercel 프리뷰 환경은 기본적으로 프리뷰 URL에 X-Robots-Tag: noindex를 자동으로 부여합니다. 프로덕션 도메인으로 alias가 연결되면 해제되지만, 커스텀 헤더를 덮어썼거나 app/robots.ts에서 전역 Disallow: /를 남겼을 때 프로덕션이 노출 차단 상태로 배포됩니다.
// app/robots.ts — 환경별 분기 필수
import type { MetadataRoute } from 'next';
const isProd = process.env.VERCEL_ENV === 'production';
export default function robots(): MetadataRoute.Robots {
if (!isProd) {
return { rules: { userAgent: '*', disallow: '/' } };
}
return {
rules: [{ userAgent: '*', allow: '/', disallow: ['/api/', '/admin/'] }],
sitemap: 'https://example.com/sitemap.xml',
};
}
루트 layout.tsx의 metadata.robots도 동일하게 환경 분기 필수입니다.
배포 후 확인은 두 가지만 합니다. 첫째, curl -I https://your-domain.com/ 응답에 X-Robots-Tag: noindex가 없는지. 둘째, view-source:로 초기 HTML의 <meta name="robots">에 noindex가 섞이지 않았는지.
실수 3: canonical URL이 잘못 설정되어 중복 색인된다
동일 콘텐츠가 /?utm_source=..., 대소문자 다른 경로, trailing slash 유무로 여러 URL을 생성할 때 canonical을 지정하지 않으면 구글이 정본을 찾지 못해 색인 품질이 떨어집니다. App Router에서는 metadataBase와 alternates.canonical을 조합해 자동화할 수 있습니다.
// app/layout.tsx
export const metadata: Metadata = {
metadataBase: new URL('https://example.com'),
alternates: { canonical: './' },
};
// app/blog/[slug]/page.tsx
export async function generateMetadata({ params }) {
const { slug } = await params;
return { alternates: { canonical: `/blog/${slug}` } };
}
metadataBase를 설정하고 각 페이지에서 canonical을 상대 경로(./ 또는 /blog/${slug})로 지정하면 Next.js가 절대 URL로 변환합니다. trailing slash는 metadataBase와 metadata 필드 간에 정규화됩니다.
실수 패턴은 세 가지입니다. 첫째, metadataBase를 로컬 개발 URL(http://localhost:3000)로 둔 채 배포. 둘째, 국제화 라우트에서 alternates.languages를 누락해 언어 버전끼리 중복 색인. 셋째, Pages Router 시절 <link rel="canonical">을 _app.tsx에 하드코딩해 모든 페이지가 같은 canonical을 갖는 버그.
실수 4: 동적 라우트에서 sitemap이 비어 있다
/products/[id] 같은 동적 라우트는 빌드 시점에 어떤 경로가 존재하는지 프레임워크가 자동으로 알지 못합니다. generateStaticParams로 정적 파라미터를 공급하지 않았거나, sitemap 생성 로직에서 DB·CMS 쿼리를 빠뜨리면 sitemap에 리스트·홈만 남고 디테일 페이지가 전부 누락됩니다.
// app/sitemap.ts
import type { MetadataRoute } from 'next';
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
const posts = await fetchAllPosts();
const products = await fetchAllProducts();
return [
{ url: 'https://example.com/', lastModified: new Date(), priority: 1.0 },
...posts.map((p) => ({
url: `https://example.com/blog/${p.slug}`,
lastModified: p.updatedAt,
changeFrequency: 'weekly' as const,
})),
...products.map((p) => ({
url: `https://example.com/products/${p.id}`,
lastModified: p.updatedAt,
changeFrequency: 'daily' as const,
})),
];
}
sitemap 항목이 5만 개를 초과하거나 용량이 50MB를 넘으면 sitemap index로 분할해야 합니다. Next.js는 배열을 반환하는 함수에 generateSitemaps를 추가해 분할을 지원합니다. 또한 sitemap에 포함하는 URL은 반드시 200 응답을 반환하는 canonical URL이어야 하며, noindex 페이지나 리디렉션 URL이 섞이면 구글 Search Console에 경고가 쌓입니다.
실수 5: hydration 오류로 구글봇이 콘텐츠를 못 읽는다
React·Next.js의 hydration은 서버에서 생성한 HTML에 클라이언트 JavaScript를 "연결"하는 과정입니다. 서버 렌더 결과와 클라이언트 초기 렌더가 다르면 Text content does not match server-rendered HTML 오류가 발생하고, React 18+는 해당 트리를 클라이언트에서 재구축합니다.
구글봇 관점에서 문제는 두 가지입니다. 첫째, 트리가 재구축되는 동안 본문이 잠깐 사라지면 크롤러가 해당 순간을 포착할 수 있습니다. 둘째, hydration 실패가 반복되면 INP가 나빠지고 Core Web Vitals 평가가 하락합니다.
흔한 원인은 new Date()·Math.random() 등 비결정적 값의 직접 렌더, typeof window 분기로 다른 결과 반환, 서드파티 스크립트가 DOM에 노드 삽입, 로컬 스토리지·쿠키 값을 초기 렌더에 반영 등입니다.
// 잘못된 패턴 — 서버와 클라이언트 출력이 다름
export default function Greeting() {
const hour = new Date().getHours();
const message = hour < 12 ? '좋은 아침입니다' : '안녕하세요';
return <h1>{message}</h1>;
}
// 올바른 패턴 — SSR fallback + useEffect로 클라이언트에서 교체
'use client';
import { useEffect, useState } from 'react';
export default function Greeting() {
const [message, setMessage] = useState('안녕하세요');
useEffect(() => {
const hour = new Date().getHours();
setMessage(hour < 12 ? '좋은 아침입니다' : '안녕하세요');
}, []);
return <h1>{message}</h1>;
}
시간·사용자 상태 같은 표시는 되어야 하지만 SEO와 무관한 값은 초기 렌더에 fallback을 두고 클라이언트에서 교체하는 패턴이 안전합니다.
실수 6: 이미지·폰트가 LCP를 갉아먹어 Core Web Vitals 미달
Core Web Vitals는 2024년 3월부터 INP가 FID를 대체했고, 2026년 기준 전체 사이트의 절반 이상이 INP 200ms 기준선을 넘지 못합니다. LCP는 여전히 2.5초 이내가 목표입니다.
React·Next.js 사이트에서 LCP가 나빠지는 구조적 원인은 세 가지입니다. 첫째, 히어로 이미지를 <img> 태그로 넣어 최적화·우선순위 힌트가 빠짐. 둘째, @import url(...) 방식의 외부 폰트 로딩으로 FOUT·FOIT 발생. 셋째, Client Component에 히어로 섹션을 통째로 넣어 번들·hydration이 LCP 경로에 끼어듦.
// 올바른 패턴 — next/image + priority + next/font
import Image from 'next/image';
import localFont from 'next/font/local';
const pretendard = localFont({
src: './PretendardVariable.woff2',
display: 'swap',
variable: '--font-pretendard',
});
export default function Hero() {
return (
<section className={pretendard.variable}>
<Image
src="/hero.webp"
alt="제품 메인 이미지"
width={1200}
height={630}
priority
sizes="(max-width: 768px) 100vw, 1200px"
/>
</section>
);
}
next/image의 priority 속성은 브라우저에 preload 힌트를 자동으로 삽입합니다. next/font는 빌드 타임에 폰트 파일을 셀프호스팅하고 font-display: swap을 강제해 외부 요청과 CLS를 함께 줄입니다. Google Fonts 호출을 <link> 태그로 그대로 남겨두면 이 최적화가 모두 무효화됩니다.
실수 7: Client Component에 Structured Data를 넣어 렌더링 지연
구조화 데이터(JSON-LD)는 리치 결과와 AI 인용의 기본 재료입니다. 문제는 Client Component 안에 <script type="application/ld+json">을 넣으면 hydration 타이밍에 주입되거나, 서버와 클라이언트에서 모두 렌더되어 스크립트가 중복 삽입되는 경우입니다.
공식 권장 패턴은 Server Component에서 dangerouslySetInnerHTML로 주입하는 것입니다.
// app/products/[slug]/page.tsx (Server Component)
export default async function ProductPage({ params }) {
const { slug } = await params;
const product = await fetchProduct(slug);
const jsonLd = {
'@context': 'https://schema.org',
'@type': 'Product',
name: product.name,
image: product.images,
offers: {
'@type': 'Offer',
priceCurrency: 'KRW',
price: product.price,
availability: 'https://schema.org/InStock',
},
};
return (
<>
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
/>
<article>{/* ... */}</article>
</>
);
}
Server Component에서 직접 렌더링되므로 초기 HTML에 JSON-LD가 포함되며, 구글이 렌더링 큐 처리 전에 구조화 데이터를 수집할 수 있습니다. 검증은 Google Rich Results Test로 수행합니다. 타입 안전성이 필요하면 schema-dts 같은 TypeScript 정의 라이브러리를 같이 씁니다.
빠른 진단 체크리스트 10개
배포 전 또는 색인 이슈가 발견된 직후 순서대로 점검하세요.
view-source:로 초기 HTML을 열어<title>,<meta name="description">이 실제 값으로 채워져 있는지 확인curl -I https://도메인/응답 헤더에X-Robots-Tag: noindex가 없는지 확인- 루트
<meta name="robots">와app/robots.ts가 환경별로 분기되어 있는지 확인 - 각 페이지의
<link rel="canonical">이 정확한 절대 URL로 출력되는지 확인 /sitemap.xml이 200으로 응답하고 동적 라우트 URL이 실제로 포함되어 있는지 확인- Search Console URL 검사 도구에서 "페이지 가져오기 → 렌더링된 HTML"을 열어 본문이 존재하는지 확인
- DevTools Console에
Hydration오류가 없는지 확인 (프로덕션 빌드에서도 재현 필요) - PageSpeed Insights에서 LCP 2.5초 이하, INP 200ms 이하, CLS 0.1 이하 충족 여부 확인
- Rich Results Test에서 구조화 데이터가 오류 없이 파싱되는지 확인
- 네이버 서치어드바이저에
sitemap.xml과robots.txt등록 후 수집 통계 확인 (한국 시장 운영 시)
한국 B2B SaaS 사이트에서 가장 자주 반복되는 실수는 스테이징 noindex 프로덕션 유출과 동적 라우트 sitemap 누락 두 가지입니다. 배포 파이프라인에 위 10번 중 1, 2, 5번을 자동 검사하는 스모크 테스트를 넣으면 대부분의 참사를 막을 수 있습니다.
자주 묻는 질문
Q1. CSR만 쓰는 React SPA는 절대 색인되지 않나요?
색인은 됩니다. 다만 구글이 렌더링 큐를 처리하기까지 시간이 걸리고, 그동안 제목·설명이 비어 있거나 틀리게 노출됩니다. Vercel 분석에서는 2차 렌더 결과가 수 일 지연되는 사례가 보고됩니다. 새 페이지가 빠르게 노출되어야 하는 미디어·커머스·B2B 사이트라면 SSR 또는 SSG 경로로 전환하는 편이 안전합니다.
Q2. Pages Router에서 App Router로 마이그레이션한 뒤 트래픽이 떨어졌습니다
가장 먼저 점검할 항목은 세 가지입니다. 첫째, next/head 기반 메타데이터가 Metadata API로 모두 이전되었는지. 둘째, 기존 URL 구조 변경 시 301 리디렉션이 구성되어 있는지. 셋째, 이전에는 getStaticProps로 SSG였던 페이지가 App Router에서는 기본값이 동적 렌더링으로 바뀌어 LCP가 나빠졌는지. Next.js 16부터 fetch 기본 캐시가 no-store이므로 명시적으로 cache: 'force-cache' 또는 revalidate를 지정해야 합니다.
Q3. 구글은 잡히는데 네이버는 안 잡히면 어디를 봐야 하나요?
네이버 크롤러(Yeti)는 구글 대비 JavaScript 렌더링 효율이 낮아 네이버 공식 가이드가 SSR을 권장합니다. URL의 # 프래그먼트는 수집 시 제거되므로 해시 라우팅 SPA는 네이버에서 특히 불리합니다. Pages Router·App Router 모두 SSR·SSG 경로로 운영하고 네이버 서치어드바이저에 sitemap을 별도로 등록해야 합니다.
Q4. App Router의 Partial Prerendering(PPR)은 SEO에 안전한가요?
PPR은 정적 셸을 먼저 내보내고 동적 영역은 Suspense로 스트리밍하는 하이브리드 전략입니다. 초기 HTML에 메인 콘텐츠와 메타데이터가 담긴 정적 셸이 포함되므로 크롤링 관점에서는 SSG와 유사한 이점을 얻습니다. 다만 동적 영역에 주요 본문·구조화 데이터를 두면 의미가 없어지므로, Suspense fallback 이후에 나오는 영역은 "비핵심 정보"로 한정해야 합니다.
Q5. CSR 사이트를 전면 재구축할 여력이 없습니다. 단기 처방이 있을까요?
세 가지 단기 처방이 있습니다. 첫째, 서버에서 최소한의 메타데이터(title, description, OG)를 주입하도록 index.html 레벨에서 템플릿 치환. 둘째, prerender.io 같은 동적 렌더링 서비스로 Googlebot·Yeti에만 미리 렌더된 HTML을 제공. 셋째, 색인 우선순위가 높은 리스트·상세 페이지부터 Next.js로 이식하고 점진적으로 마이그레이션. 다만 동적 렌더링은 구글이 임시방편으로 규정한 상태이므로 SSR·SSG 전환을 장기 로드맵에 반드시 포함해야 합니다.
출처: Google Search Central — Dynamic Rendering as a Workaround
마무리: 프레임워크가 SEO를 해결해주지 않는다
Next.js 16은 App Router, Server Components, Metadata API, sitemap·robots 파일 컨벤션, next/image, next/font 등 SEO에 필요한 거의 모든 원시 도구를 제공합니다. 이 글의 7가지 실수는 모두 도구를 쓰지 않거나 잘못 쓴 결과이지, 프레임워크 한계 때문에 발생하는 문제가 아닙니다.
개발팀과 PM이 배포 전에 위 10개 체크리스트를 루틴에 넣고, 스테이징 환경의 noindex·환경별 metadataBase·동적 sitemap 생성·hydration 오류 모니터링을 CI에 자동화하면 "구글에 안 잡힌다"는 상황은 대부분 사라집니다. 색인은 운 문제가 아니라 배포 파이프라인의 품질 관리 문제입니다.
기술 SEO 진단이 필요하시다면 소요유의 기술 SEO 컨설팅을 확인해보세요. React·Next.js 사이트의 렌더링 전략, 색인 누락, Core Web Vitals 이슈를 배포 파이프라인 단계에서 해결합니다.