Next.js 블로그를 운영한 지 3개월, 아직도 구글에 제대로 노출되지 않는다면
App Router 기반 블로그에서 generateMetadata 누락, OG 이미지 경로 오류, JSON-LD 렌더링 실패, sitemap 구성 실수 등 실제 배포 후에야 드러나는 SEO 취약 지점을 진단하고 수정하는 실전 체크리스트.
DevInsight 편집팀 발행
AI 보조 초안과 편집 검수를 거쳐 발행했습니다.
App Router로 블로그를 만들고 3개월째 운영 중인데, 구글 Search Console에 등록된 페이지가 절반도 안 된다. 제목과 디스크립션은 분명히 넣었고, Lighthouse 점수도 100점인데 말이다. 문제는 대부분 "눈에 보이는 HTML"과 "크롤러가 실제로 읽는 HTML" 사이의 간극에서 발생한다. App Router의 Metadata API는 강력하지만, 그 강력함이 오히려 조용한 실패를 유발하는 지점이 여럿 있다. 소스 보기(View Source)를 열어보지 않으면 절대 알 수 없는 문제들이다.
metadataBase를 설정하지 않았다면 지금 당장 app/layout.tsx를 확인해봐야 한다. App Router에서 Open Graph 이미지나 canonical URL에 상대 경로를 쓰면, metadataBase가 없을 경우 Next.js는 빌드 오류를 내거나 — 더 위험한 시나리오로 — 로컬호스트를 기준으로 URL을 생성한다. og:image 메타 태그가 content="/og-image.png"처럼 루트 상대 경로로만 찍혀 있다면, 크롤러는 이걸 절대 URL로 해석하지 못하고 무시한다. Slack이나 카카오톡에서 링크 미리보기가 깨지는 이유 중 가장 흔한 원인이다.
metadataBase는 루트 레이아웃에 한 번만 설정하면 하위 모든 라우트에 상속된다. new URL('https://yourdomain.com') 형태로 지정해야 하며, 문자열을 그대로 넣으면 동작하지 않는다. 주의할 점은 metadataBase에 트레일링 슬래시가 있든 없든, 메타데이터 필드의 경로와 결합될 때 Next.js가 중복 슬래시를 정규화한다는 사실이다. 즉 metadataBase: new URL('https://acme.com/')와 images: '/og.png'는 https://acme.com/og.png로 합쳐진다. 반면 images: 'https://other-cdn.com/og.png'처럼 절대 URL을 직접 쓰면 metadataBase는 무시된다. CDN에 OG 이미지를 호스팅하는 경우라면 이 동작을 의도적으로 활용할 수 있다.
트위터 카드도 마찬가지다. twitter:image에 상대 경로를 넣고 metadataBase가 없으면 트위터봇은 이미지를 찾지 못한다. twitter:card를 summary_large_image로 설정했는데 이미지가 누락되면, 트위터는 카드 자체를 summary로 강등시켜 작은 썸네일만 보여준다. 큰 이미지로 공유될 거라 기대하고 만든 OG 이미지가 무용지물이 되는 순간이다.
title.template의 동작 방식도 많은 사람이 오해하는 지점이다. 루트 레이아웃에 title: { template: '%s | My Blog' }만 정의해두고 default를 생략하면, 하위 페이지에서 title을 지정하지 않은 라우트는 빈 문자열에 템플릿이 적용되어 <title> | My Blog</title> 같은 결과가 나온다. default는 템플릿을 쓸 때 필수다. 또한 title.template은 자신이 정의된 세그먼트가 아니라 자식 세그먼트에만 적용된다는 점도 자주 간과된다. 루트 레이아웃의 page.tsx에 title: 'Home'을 넣으면, 같은 레이아웃의 template은 이 페이지에 적용되지 않는다. 템플릿을 적용하려면 title: 'Home'이라고만 쓰면 되고, 템플릿을 무시하고 싶다면 title: { absolute: 'Home' }을 써야 한다.
여기까지는 그래도 정적 메타데이터의 영역이다. 진짜 함정은 JSON-LD에서 시작된다.
Pages Router 시절에는 next/head로 <script type="application/ld+json">을 주입하는 게 일반적인 패턴이었다. App Router에서는 next/head가 아무 일도 하지 않는다. 조용히 실패한다. 컴파일 에러도, 런타임 경고도 없다. 그냥 HTML에 포함되지 않는다. 구글 Search Console의 '리치 결과' 리포트에서 구조화된 데이터가 감지되지 않는다는 알림을 받고 나서야 알아차리는 경우가 대부분이다.
generateMetadata로도 JSON-LD를 넣을 수 없다. Metadata API는 <title>, <meta>, <link> 태그만 생성하며, 임의의 <script> 태그를 지원하지 않는다. metadata.other 필드는 <meta name="..." content="..."> 형태로만 렌더링되므로 JSON-LD를 담기에 적합하지 않다. App Router에서 구조화된 데이터를 삽입하는 유일한 신뢰할 수 있는 방법은 서버 컴포넌트의 JSX 안에 <script> 태그를 직접 렌더링하는 것이다.
export default async function BlogPostPage({ params }: { params: Promise<{ slug: string }> }) { const { slug } = await params; const post = await getPost(slug); const jsonLd = { '@context': 'https://schema.org', '@type': 'BlogPosting', headline: post.title, description: post.excerpt, datePublished: post.publishedAt, dateModified: post.updatedAt ?? post.publishedAt, author: { '@type': 'Person', name: post.author.name, }, image: post.coverImage, url: `https://yourdomain.com/blog/${post.slug}`, }; return ( <> <script type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }} /> <article>{/* ... */}</article> </> ); }
여기서 또 하나의 함정은 dangerouslySetInnerHTML의 이름 때문에 회피해서 직접 문자열 연결로 JSON을 만드는 패턴이다. { __html: \{ "name": "${post.title}" }` }처럼 쓰면, 제목에 큰따옴표나 <가 들어가는 순간 JSON이 깨지고 <script>태그가 조기 종료된다.JSON.stringify()만이 유일하게 올바른 이스케이프를 보장한다. JSON.stringify()는 <, >, &를 유니코드 이스케이프 시퀀스로 변환하지 않고 그대로 출력하지만, HTML 파서가 <script>태그 내부에서는</script>시퀀스만 감지하므로 실제로 문제가 되는 건</문자열이 포함된 콘텐츠다. 이론적인 엣지 케이스지만, 사용자 입력을 그대로 JSON-LD에 넣는다면JSON.stringify()출력에</`가 포함될 가능성을 인지하고 있어야 한다.
JSON-LD를 'use client' 컴포넌트 안에 넣는 것도 미묘한 실패를 만든다. 클라이언트 컴포넌트는 하이드레이션 이후에 DOM에 삽입되므로, View Source에는 나타나지 않는다. 구글봇은 JavaScript를 실행한다고 알려져 있지만, 클라이언트에서 렌더링된 구조화 데이터를 신뢰성 있게 파싱한다는 보장은 없다. 검증은 반드시 curl로 받은 원시 HTML에서 <script type="application/ld+json">이 존재하는지 확인하는 방식으로 해야 한다. Chrome DevTools의 Elements 패널이 아니라 View Source(Ctrl+U)를 봐야 한다.
사이트맵도 비슷한 맥락에서 실수가 누적된다. app/sitemap.ts에서 URL 배열을 반환할 때, 각 url 필드에 절대 URL을 하드코딩하는 경우가 많다. 스테이징 환경과 프로덕션 환경이 다르면 사이트맵이 스테이징 도메인을 가리키게 되고, 구글은 존재하지 않는 URL을 크롤링하려다 색인을 거부한다. 환경 변수 NEXT_PUBLIC_SITE_URL을 만들어 모든 사이트맵 항목의 접두사로 사용하는 게 낫다. lastModified는 CMS에서 실제 수정일을 가져오는 게 이상적이지만, 최소한 new Date()라도 넣어두는 편이 빈 문자열보다 낫다. changeFrequency와 priority는 구글이 참고만 할 뿐 색인 순위에 직접적인 영향을 주지 않으므로, 지나치게 세밀하게 튜닝하기보다 weekly와 0.5 정도로 일관되게 유지하는 게 유지보수 비용이 낮다.
사이트맵이 50,000개 URL 제한을 넘을 것으로 예상된다면 generateSitemaps를 써야 한다. app/sitemap.ts 하나만 만들어두고 수천 개의 블로그 글이 색인에서 누락되는 걸 모르는 사례가 흔하다. generateSitemaps는 ID 배열을 반환하고, 각 ID에 대해 sitemap({ id }) 함수가 별도로 호출되면서 /sitemap/0.xml, /sitemap/1.xml 같은 분할된 사이트맵을 생성한다.
robots.ts의 가장 흔한 실수는 사이트맵 URL을 빼먹는 거다. 크롤러가 사이트맵을 발견하는 경로는 크게 두 가지인데, 하나는 Search Console에 직접 제출하는 것이고 다른 하나는 robots.txt의 Sitemap: 지시문이다. 후자를 생략하면 구글이 모든 페이지를 내부 링크만 따라 발견해야 하므로 색인 속도가 현저히 느려진다. 특히 신규 블로그는 백링크도 적고 내부 링크 구조도 빈약하기 때문에 robots.txt의 사이트맵 참조가 더 중요하다.
OG 이미지 생성에 next/og의 ImageResponse를 사용하는 경우, 엣지 런타임과의 충돌을 조심해야 한다. ImageResponse는 기본적으로 Node.js 런타임에서 동작하며, @vercel/og(Satori + resvg)가 내부적으로 사용하는 네이티브 바인딩이 엣지 런타임과 호환되지 않는 경우가 있다. next.config.ts에 runtime: 'nodejs'를 명시하지 않았는데 프로젝트 전체가 엣지로 설정되어 있다면, OG 이미지 생성이 배포 후에야 실패한다. 또한 커스텀 폰트를 로드할 때는 node:fs/promises의 readFile로 파일을 읽어서 ImageResponse의 fonts 옵션에 ArrayBuffer로 전달해야 하는데, 이 경로는 process.cwd() 기준이므로 프로젝트 루트의 assets/ 디렉터리 등 절대 경로가 보장되는 위치에 폰트를 두는 게 안전하다.
opengraph-image.tsx에서 export const size = { width: 1200, height: 630 }과 export const contentType = 'image/png'를 빼먹으면 Next.js가 이미지 크기와 MIME 타입을 추론하지 못해 og:image:width, og:image:height 메타 태그가 생성되지 않는다. 페이스북 크롤러는 이 정보가 없으면 이미지를 크롤링하지 않는 경우가 있으므로, size와 contentType은 반드시 명시해야 한다.
generateMetadata가 페이지와 동일한 데이터를 다시 페치하는 이중 요청 문제도 간과하기 쉽다. generateMetadata 안에서 fetch를 호출하고 페이지 컴포넌트 안에서 동일한 엔드포인트를 다시 호출하면, Next.js는 동일한 fetch 요청을 자동으로 메모이제이션하지만 이는 fetch API를 직접 썼을 때만 해당된다. ORM이나 데이터베이스 클라이언트를 통해 데이터를 가져온다면 React.cache()로 감싸지 않는 한 중복 호출이 발생한다. 간단한 블로그에서는 문제가 되지 않지만, 무거운 쿼리를 실행하는 페이지에서는 TTFB(Time to First Byte)에 직접적인 영향을 준다.
article:published_time과 article:modified_time도 Open Graph에서 자주 누락되는 필드다. openGraph: { type: 'article' }로 설정했으면 publishedTime과 authors를 함께 명시해야 의미가 완성된다. 이 필드가 없으면 페이스북이나 링크드인의 크롤러가 글의 시간 정보를 추론할 수 없어 뉴스피드 표시 순서에 불이익을 받을 수 있다. publishedTime은 ISO 8601 형식(2024-03-15T09:00:00.000Z)이어야 하며, new Date(post.publishedAt).toISOString()으로 간단히 변환할 수 있다.
canonical URL도 사소해 보이지만 강력한 부메랑이다. 블로그 글이 여러 URL로 접근 가능한 경우(예: /blog/my-post와 /blog/my-post?utm_source=twitter), canonical 태그가 없으면 구글은 두 URL을 별개의 페이지로 간주하여 중복 콘텐츠로 처리한다. alternates: { canonical: \/blog/${post.slug}` }처럼 상대 경로로 지정하고 metadataBase에 의존하는 패턴이 일반적이지만, 쿼리 파라미터가 붙은 URL도 동일한 canonical을 가리키게 하려면 generateMetadata안에서searchParams`를 무시하고 항상 동일한 canonical을 반환해야 한다.
마지막으로, 이 모든 설정이 배포 환경에서 제대로 동작하는지 확인하는 검증 루틴이 따로 필요하다. curl -s https://yourdomain.com/blog/some-post | grep -E '<meta|<script type="application/ld\+json"|<link rel="canonical"' 같은 한 줄짜리 스크립트를 CI에 넣어두면, 배포 후에 크롤러 시점에서 실제로 어떤 메타데이터가 전달되는지 자동으로 확인할 수 있다. 구글 Search Console의 URL 검사 도구와 리치 결과 테스트도 수동 점검용으로 유용하지만, 사람이 매번 돌리지 않는 이상 배포 파이프라인에 심어두는 편이 낫다. Next.js는 올바른 설정을 하면 메타데이터를 완벽하게 생성해주지만, 올바른 설정이 무엇인지는 온전히 개발자의 몫이다.
댓글
댓글을 읽어오는 중입니다.
같이 읽으면 좋은 글
방금 읽은 주제와 이어지는 글을 골랐습니다.
Next.js SEO, 아무도 에러를 내지 않는 실패들
Next.js App Router에서 generateMetadata, OG 이미지, JSON-LD, sitemap이 경고 하나 없이 조용히 실패하는 패턴을 파헤친다. metadataBase 누락, 스트리밍 메타데이터가 body로 빠지는 함정, JSON-LD 스크립트 탈출, sitemap 5만 건 자동 절단까지 실무에서 반드시 알아야 할 모든 케이스를 정리한다.
next/image sizes 한 줄이 LCP를 0.5초 당긴다
LCP 개선을 위해 무작정 이미지를 압축하고 CDN을 도입하기 전에, next/image의 sizes 속성과 priority 플래그가 실제로 어떤 영향을 미치는지 정량적으로 이해해야 한다. 이 글은 next/image 설정값이 LCP에 미치는 영향을 실제 코드 레벨에서 분석하고, 이미지 CDN이 진짜 필요한 상황과 불필요하게 최적화를 도입했다가 역효과를 보는 사례까지 함께 다룬다.
TypeScript 설계 부채를 숫자로 드러내고 CI가 대신 막게 하라
타입스크립트 프로젝트에서 CK 메트릭(WMC, DIT, NOC, CBO, RFC, LCOM)을 측정하는 CLI 도구 Artie-lens를 활용해 CI 파이프라인에 설계 품질 게이트를 구축하는 방법을 다룬다. 코드 복잡도와 결합도가 임계치를 넘으면 자동으로 빌드를 실패시켜 기술 부채 누적을 사전에 차단하는 실전 가이드.
이전 글
Decap CMS 유저가 Sveltia로 넘어올 때 가장 많이 묻는 질문
다음 글
LLM이 JSON만 뱉기로 약속했을 때 실제로 일어나는 일
DevInsight Digest
새 글이 쌓이면, 피드에서 바로 이어 읽으세요.
과장된 알림 대신 발행한 글 전체를 RSS로 제공합니다.