DevInsight

개발의 판단과 맥락을 기록하는 곳

Frontend
조회 1약 6분 읽기

Next.js SEO, 아무도 에러를 내지 않는 실패들

Next.js App Router에서 generateMetadata, OG 이미지, JSON-LD, sitemap이 경고 하나 없이 조용히 실패하는 패턴을 파헤친다. metadataBase 누락, 스트리밍 메타데이터가 body로 빠지는 함정, JSON-LD 스크립트 탈출, sitemap 5만 건 자동 절단까지 실무에서 반드시 알아야 할 모든 케이스를 정리한다.

DevInsight 편집팀 발행

AI 보조 초안과 편집 검수를 거쳐 발행했습니다.

#Next.js#App Router#SEO#metadataBase#JSON-LD#generateMetadata#OG Image#sitemap#robots.txt#structured data

Next.js App Router에서 SEO 메타데이터가 조용히 실패하는 순간들은 대부분 경고 한 줄 없이 지나간다. 빌드는 성공하고, 페이지는 잘 렌더링된다. View Source를 열어보기 전까지는 아무 문제도 없는 것처럼 보인다. 그리고 그 View Source를 여는 일은, 링크를 슬랙에 붙여넣었을 때 썸네일이 텅 비어 있거나 구글 서치 콘솔에 "Duplicate without user-selected canonical"이 수백 개 쌓이기 전에는 잘 하지 않는다.

이 글에서 다룰 모든 문제는 Next.js 14와 15, 그리고 16의 App Router를 기준으로 한다. Pages Router의 next/head 방식은 이 글의 범위가 아니다.


metadataBase는 있어야 동작하는 게 아니라, 없으면 localhost로 찍힌다

가장 먼저 부딪히는 함정이다. 루트 레이아웃에서 metadataBase를 설정하지 않고 openGraph.images: ['/og.png']처럼 상대 경로를 쓰면, Next.js는 이걸 http://localhost:3000/og.png로 해석해서 메타 태그에 박아버린다. 개발 서버에서는 당연히 이미지가 보이니까 눈치채지 못하고 배포한다. 트위터 카드 검증 도구에 URL을 넣었을 때 비로소 이미지가 깨져 있다는 걸 알게 된다.

Next.js는 metadataBase가 없을 때 자체적으로 폴백을 시도한다. Vercel에 배포했다면 VERCEL_URL 환경 변수를 읽어 프리뷰 도메인을 기본값으로 삼는다. 이 동작 자체는 합리적인데, 문제는 프리뷰 도메인이 실제 프로덕션 도메인과 다르다는 점이다. 프리뷰 배포의 OG 이미지 URL이 myapp-git-feature-x.vercel.app/og.png로 찍히고, 그 링크를 프로덕션에서 공유하면 이미지가 404로 빠진다.

해결책은 단순하지만 환경 변수를 잘못 구성하면 또 다른 실패 모드가 생긴다.

// app/layout.tsx export const metadata: Metadata = { metadataBase: new URL( process.env.NEXT_PUBLIC_SITE_URL ?? 'https://myapp.com' ), }

여기서 NEXT_PUBLIC_SITE_URL은 빌드 타임에 평가된다는 점을 기억해야 한다. Vercel에서 VERCEL_URL을 동적으로 읽고 싶다면 process.env.VERCEL_URL을 직접 metadataBase에 넣는 패턴도 가능한데, 이 값은 요청 시점에 결정되므로 generateMetadata 안에서 처리해야 하고, 그 시점에 metadataBase는 이미 루트 레이아웃의 정적 metadata 객체로 결정돼 있다. 결국 루트 레이아웃에서 metadataBase를 고정된 프로덕션 도메인으로 설정하고, 프리뷰 환경에서는 별도로 OG 이미지 URL에 절대 경로를 사용하는 편이 더 예측 가능하다.

검증 방법은 간단하다. 배포된 페이지의 소스 보기에서 og:image 메타 태그를 확인하고, 해당 URL을 브라우저에서 직접 열어본다. 200이 돌아오고 이미지가 제대로 보이면 통과다.


메타데이터가 <body>로 스트리밍되고 있다

Next.js 15.2에서 메타데이터 스트리밍이 도입됐다. generateMetadata 안에서 네트워크 요청이 발생하거나 비동기 데이터 페칭이 필요한 경우, 메타데이터 해석이 페이지 렌더링을 블로킹하지 않도록 <body> 태그 안으로 스트리밍한다. TTFB를 개선하기 위한 의도된 동작이다.

문제는 이게 구글 서치 콘솔에서 "Duplicate without user-selected canonical" 오류를 유발한다는 점이다. 크롤러가 <head>에서 canonical 태그를 찾지 못하면, Next.js가 <body> 하단에 스트리밍한 canonical을 인식하지 못하고 해당 페이지를 중복 콘텐츠로 분류한다. Lighthouse에서는 "Document does not have a meta description"이라고 경고하고, <link rel="manifest"><body>에 있으면 브라우저가 매니페스트를 인식하지 못하는 부작용도 생긴다.

Next.js 팀은 구글봇을 포함한 주요 봇의 User-Agent를 감지해서 <head>에 메타데이터를 정상 배치하는 로직을 넣어두었지만, 이 감지 목록에 포함되지 않은 크롤러나 구글의 모바일 봇 일부는 여전히 <body>에 있는 메타데이터를 놓친다. 실제로 Next.js 15.3.1부터 15.3.3까지 이 문제로 검색 노출이 급감했다는 리포트가 깃허브 이슈에 여러 건 올라왔고, 15.3.4에서 일부 수정이 들어갔다. 하지만 15.4.x에서도 동일한 현상이 보고되고 있다.

가장 확실한 우회책은 next.config.ts에서 htmlLimitedBots를 모든 User-Agent로 확장하는 것이다.

// next.config.ts const nextConfig = { htmlLimitedBots: /.*/, }

이 설정은 모든 요청에 대해 메타데이터를 <head>에 위치시키므로 스트리밍 최적화의 이점은 사라지지만, SEO가 TTFB보다 우선되는 상황이라면 망설일 이유가 없다. 또는 export const dynamic = 'force-static'을 페이지에 명시해서 메타데이터가 빌드 타임에 완전히 해석되도록 만드는 방법도 있다. 이 경우 스트리밍 자체가 발생하지 않으므로 <body> 이탈 문제도 원천 차단된다.


JSON-LD가 <script> 태그에서 탈출한다

App Router에서 JSON-LD를 심는 공식 권장 방식은 서버 컴포넌트 안에 있는 <script type="application/ld+json"> 태그에 dangerouslySetInnerHTML을 쓰는 것이다. next/head는 Pages Router 전용이고, next/script는 실행 가능한 자바스크립트 로딩에 최적화되어 있어 구조화 데이터에는 맞지 않는다.

여기에 두 가지 함정이 겹친다.

첫째, RSC 하이드레이션으로 인해 <script> 태그가 HTML에 두 번 찍힌다. 서버에서 한 번 렌더링되고, 클라이언트에서 하이드레이션할 때 다시 한 번 DOM에 삽입된다. 구글의 구조화 데이터 테스트 도구는 중복된 JSON-LD를 오류로 처리하거나 무시한다. 이걸 막으려면 suppressHydrationWarning을 쓰고 클라이언트 사이드에서 DOM에 이미 존재하는지 확인한 뒤 조건부로 렌더링을 건너뛰는 커스텀 훅을 붙여야 한다. 아니면 아예 JSON-LD를 layout.tsx에서 서버 컴포넌트로 한 번만 찍고, 하이드레이션이 발생하지 않는 구조로 우회한다.

둘째, XSS다. JSON.stringify< 문자를 이스케이프하지 않는다. 블로그 포스트 제목에 </script><script>alert(1)</script> 같은 문자열이 들어오면 JSON-LD <script> 태그가 닫히고 악성 스크립트가 실행된다. 공식 문서에서는 .replace(/</g, '\\u003c')로 치환하라고 안내하고 있지만, 이걸 모르고 넘어가는 프로젝트가 많다. serialize-javascript 같은 라이브러리를 쓰거나, 최소한 <, >, & 세 문자는 유니코드 이스케이프해야 한다.

검증은 구글의 Rich Results Test와 Schema Markup Validator로 한다. View Source에서 <script type="application/ld+json">이 정확히 하나만 존재하고, 그 안의 JSON이 파싱 가능한 상태인지 확인한다.


사이트맵 5만 건이 조용히 잘린다

sitemap.ts 하나로 6만 개의 URL을 반환하면, 나머지 1만 건은 구글에 전달되지 않는다. Next.js는 이 초과분을 자동으로 분할하지 않는다. 경고도 없다. 빌드 로그 어디에도 "sitemap이 5만 건 제한을 초과했습니다" 같은 메시지는 찍히지 않는다. 단지 /sitemap.xml을 열어보면 URL이 5만 개에서 멈춰 있을 뿐이다.

generateSitemaps()를 익스포트해야 다중 사이트맵이 생성된다. 반환 배열의 각 항목 id가 사이트맵 청크의 식별자가 되고, Next.js는 /sitemap/0.xml, /sitemap/1.xml 경로로 각 청크를 제공한 뒤 자동으로 사이트맵 인덱스를 구성한다. Next.js 16에서는 idPromise<string>으로 전달되므로 await가 필요하다.

분할이 필요한 기준은 5만 개가 아니라 실제로는 더 빡빡하다. 구글은 사이트맵 하나당 50MB(압축 전)라는 용량 제한도 두고 있다. URL 자체가 길거나 lastmod, changefreq, priority까지 포함하면 5만 개에 도달하기 전에 50MB를 먼저 초과할 수 있다. 특히 다국어 사이트에서 hreflang 대체 URL을 사이트맵에 포함시키는 경우 용량이 빠르게 늘어난다.

사이트맵 인덱스가 잘 동작하는지 확인하려면 /sitemap.xml로 접속해서 <sitemapindex>가 반환되는지, 각 <sitemap><loc>가 실제로 접근 가능한 URL인지 확인한다. 구글 서치 콘솔에 사이트맵 인덱스 URL을 직접 제출하고 "발견된 URL" 수와 실제 사이트 페이지 수를 비교한다.


generateMetadata'use client' 앞에서 무력화된다

페이지 파일 최상단에 'use client'를 선언하면 generateMetadata 익스포트는 완전히 무시된다. 에러도, 경고도, 타입스크립트 오류도 없다. 해당 경로의 메타데이터는 부모 레이아웃에서 정의한 기본값으로 덮이고, <title>은 사이트 전체 디폴트 타이틀이 되며, canonical과 description은 증발한다. 서치 콘솔에서 페이지 절반이 동일한 제목으로 잡히고 나서야 알아차리게 된다.

이건 React Server Components의 구조적 제약이라 린트로 잡을 수 있는 영역도 아니다. SEO가 필요한 모든 페이지는 서버 컴포넌트여야 하고, 인터랙티브한 부분만 클라이언트 컴포넌트로 분리해서 'use client'를 해당 컴포넌트 파일에만 적용해야 한다. page.tsx 자체가 클라이언트 컴포넌트가 될 이유는 거의 없다.


opengraph-image.tsx가 Satori의 경계를 넘으면 깨진다

Next.js의 opengraph-image.tsx 파일 컨벤션은 편리해 보이지만, 그 내부 엔진인 Satori는 브라우저가 아니다. Yoga 레이아웃 엔진으로 JSX를 SVG로 변환하고, resvg로 PNG를 래스터화한다. 지원하는 CSS는 flexnone 뿐이다. display: grid는 작동하지 않고, ::before::after 같은 가상 요소도 없다.

더 위험한 건 조용히 실패하는 속성들이다. vw, vh, em, z-index는 에러 없이 무시된다. WOFF2 폰트는 파싱되지 않고(Google Fonts의 기본 포맷이다), SVG 이미지는 렌더링되지 않는다. 다중 자식 요소를 가진 divdisplay: flex를 명시하지 않으면 런타임 에러가 발생한다. 폰트, 이미지, JSX를 모두 합친 번들 크기가 500KB를 넘으면 Edge 런타임에서 실패한다.

Turbopack을 쓰는 Next.js 15+에서는 opengraph-image.tsx가 첫 로드 시 dev 서버를 멈춰 세우는 현상도 보고된다. Satori의 WebAssembly 모듈이 지연 컴파일될 때 CPU를 300%까지 점유하고 복구되지 않는 문제다.

이 모든 제약을 감수하기 어렵다면, OG 이미지 생성을 app/api/og/route.tsx 같은 API 라우트로 분리하고 generateMetadata에서 명시적으로 URL을 지정하는 편이 낫다. Node.js 런타임을 사용하면 sharp 같은 이미지 처리 라이브러리도 자유롭게 쓸 수 있다.

OG 이미지가 제대로 생성되는지 확인하려면 /blog/some-post/opengraph-image 경로로 직접 접속해서 PNG가 반환되는지, Content-Typeimage/png인지 확인한다. 캐시가 프레시한 상태인지도 Cache-Control 헤더로 점검한다.


robots.ts가 프리뷰 환경을 막지 않는다

public/robots.txt에 하드코딩된 파일을 두면, 모든 배포 환경에서 동일한 크롤링 정책이 적용된다. Vercel의 프리뷰 URL이 그대로 구글에 인덱싱되고, 프로덕션 도메인과 중복 콘텐츠로 처리된다. robots.tsapp/ 디렉토리에 두고 환경 변수에 따라 분기하는 방식이 필수인 이유다.

// app/robots.ts export default function robots(): MetadataRoute.Robots { if (process.env.VERCEL_ENV !== 'production') { return { rules: { userAgent: '*', disallow: '/' } } } return { rules: { userAgent: '*', allow: '/', disallow: ['/api/', '/_next/'] }, sitemap: 'https://myapp.com/sitemap.xml', } }

주의할 점은 robots.txt가 접근 제어 수단이 아니라는 사실이다. Disallow는 크롤링만 막을 뿐, URL 자체에 접근하는 걸 차단하지 않는다. 진짜 민감한 페이지는 generateMetadata에서 robots: { index: false }를 반환하거나 서버 측 인증을 걸어야 한다. 프리뷰 환경 보호를 위해서는 robots.tsDisallow: /와 함께 루트 레이아웃의 metadata.robots도 환경 분기로 noindex를 걸어주는 이중 장치가 안전하다.


존재하지 않는 페이지가 인덱싱된다

generateMetadata에서 params.slug로 데이터를 조회하고, 결과가 없을 때 아무것도 반환하지 않으면 부모 레이아웃의 기본 메타데이터가 적용된 채 200 또는 404 상태로 응답된다. 구글은 이걸 정상 페이지로 간주하고 인덱싱한다. 블로그에 존재하지 않는 글이 수십 개 색인되는 흔한 패턴이다.

export async function generateMetadata({ params }: Props): Promise<Metadata> { const post = await getPost(params.slug) if (!post) { return { title: 'Not Found', robots: { index: false, follow: false }, } } // ... 정상 메타데이터 }

robots: { index: false }만으로는 부족할 수 있다. notFound()를 호출해서 페이지 자체를 404로 만들고, generateMetadata와 본문 컴포넌트 모두에서 가드를 걸어야 구글이 해당 URL을 인덱스에서 제외한다.


metadataBase 한 줄, generateSitemaps 함수 하나, robots.ts 분기 하나, JSON-LD <script> 태그의 유니코드 이스케이프 한 줄. 대부분의 실패는 이 네 가지 장치 중 하나를 빼먹었을 때 발생한다. 공통된 특징은 빌드가 깨지지 않고, 타입스크립트가 잡아주지 않으며, 개발 서버에서는 멀쩡해 보인다는 점이다. 배포된 페이지의 View Source, 구글 서치 콘솔의 URL 검사 도구, Rich Results Test, 그리고 트위터·슬랙·페이스북의 공유 디버거. 이 네 가지를 정기적으로 확인하는 습관이, 조용히 실패하는 메타데이터를 유일하게 들춰내는 방법이다.

댓글

댓글을 읽어오는 중입니다.

같이 읽으면 좋은 글

방금 읽은 주제와 이어지는 글을 골랐습니다.

Frontend 전체 보기

이전 글

Vercel Functions 크론 잡, 매번 딱 한 건만 처리하도록 설계한 이유

다음 글

1년 동안 DB 테이블로 큐 대신 쓰면서 내가 놓친 것들

DevInsight Digest

새 글이 쌓이면, 피드에서 바로 이어 읽으세요.

과장된 알림 대신 발행한 글 전체를 RSS로 제공합니다.

RSS 구독