테마 전환

Next.js SEO 완전 가이드: Metadata API와 구조화 데이터 실전

Easton editorial illustration: hydration gauge console

Google Search Console 통계 패널에 큼직하게 찍힌 “0”. 제품을 출시한 지 23일째 되는 날이었습니다. 두 달 동안 개발하고, 수없이 밤을 새우고, 공들여 다듬은 UI와 매끄러운 사용자 경험이 그 차가운 “0” 앞에서는 아무 의미가 없어 보였습니다.

더 답답했던 건 Twitter에 링크를 공유했을 때였습니다. 미리보기 영역은 텅 비어 있었고, 제대로 된 커버 이미지조차 나오지 않았습니다.

“Next.js는 SSR을 기본으로 제공하지 않나? 그런데 왜 SEO가 이렇게 나쁘지?” 공식 문서를 뒤지고 나서야 잔인한 사실을 깨달았습니다. SSR은 SEO 친화성과 같지 않습니다. meta 태그가 잘못되어 있거나, 구조화 데이터가 없거나, Open Graph 프로토콜을 모르면 검색 엔진은 여전히 페이지를 공기처럼 취급합니다.

이 고통은 아마 당신도 겪어 봤을 겁니다. 어렵게 만든 제품이 검색되지 않고, 공유해도 전문적으로 보이지 않아 결국 광고비를 태워야 하는 상황 말입니다. 하지만 Next.js 15의 Metadata API와 몇 가지 핵심 설정만 이해하면 이런 문제는 대부분 해결할 수 있습니다.

이 글에서는 Metadata API로 모든 페이지에 고유한 meta 태그를 넣는 방법, 구조화 데이터로 검색 결과를 더 눈에 띄게 만드는 방법, 완성도 높은 소셜 공유 미리보기를 구현하는 방법을 차근차근 다룹니다. 더 중요한 것은 제가 직접 밟았던 SEO 함정 5가지를 함께 정리해 같은 실수를 피할 수 있게 하는 것입니다.

왜 Next.js 사이트의 SEO가 좋지 않을까?

SSR은 SEO 친화성과 같지 않습니다

솔직히 저도 처음에는 그렇게 생각했습니다. Next.js를 쓰고, 서버 사이드 렌더링을 하고, HTML을 크롤러에게 바로 내려주면 SEO는 완벽해지는 것 아니냐고요.

순진했습니다.

나중에 한 친구의 프로젝트 소스 코드를 열어 봤습니다. 브라우저 개발자 도구로 HTML을 확인해 보니 모든 페이지의 <title>이 “My App”이었고, <meta name="description">은 아예 없거나 모든 페이지가 같은 내용이었습니다. 고급 편집숍을 열어 놓고 입구 간판에는 항상 “상점”이라고만 써 둔 것과 같습니다. 손님은 무엇을 파는지 알 수 없습니다.

Next.js는 분명 SSR 기능을 제공합니다. 하지만 meta 태그 설정은 전적으로 개발자의 책임입니다. 설정하지 않으면 빈 껍데기 HTML일 뿐이고, 검색 엔진이 크롤링해도 페이지가 무엇을 말하는지 이해하지 못합니다.

치명적인 SEO 실수 5가지

제가 본 많은 개발자가 이 함정에 빠졌습니다. 저도 예외는 아니었습니다.

1. 모든 페이지가 같은 title과 description을 공유함

가장 흔한 실수입니다. _document.tsx에 title 하나를 고정으로 박아 두거나, 아예 쓰지 않는 경우입니다. 결과적으로 Google에서 홈페이지, 소개 페이지, 제품 페이지를 검색해도 제목과 설명이 모두 똑같이 표시됩니다.

서점에서 모든 책 표지에 같은 제목이 찍혀 있다고 상상해 보세요. 그 책을 집어 들고 싶을까요?

2. canonical URL을 빼먹어 중복 콘텐츠가 생김

이 문제는 특히 숨어 있습니다. 사이트에 페이지네이션(?page=2), 필터(?category=tech), 정렬(?sort=date) 같은 URL 파라미터가 있을 수 있습니다. 실제로는 같은 페이지의 다른 보기이지만, 검색 엔진은 서로 다른 페이지로 판단하고 “중복 콘텐츠”로 불이익을 줄 수 있습니다.

제 고객 중 한 블로그는 이 문제 때문에 트래픽이 40% 떨어졌습니다. canonical URL을 추가한 뒤 2주 안에 정상 수준으로 회복했습니다.

3. 구조화 데이터가 없어 리치 결과 노출을 놓침

“애플파이 레시피”를 검색했을 때 어떤 결과는 평점, 조리 시간, 칼로리를 바로 보여 줍니다. 그것은 Google이 추측한 정보가 아닙니다. 사이트가 구조화 데이터(JSON-LD)를 통해 Google에 직접 알려 준 것입니다.

연구에 따르면 구조화 데이터를 구현한 사이트는 평균 클릭률이 20~30% 상승합니다. 무슨 뜻일까요? 몇 줄의 코드만 추가해도 트래픽이 바로 30% 가까이 늘어날 수 있다는 뜻입니다.

4. 이미지에 alt 속성이 없어 이미지 검색 트래픽을 버림

많은 개발자는 alt 속성이 시각장애인만을 위한 것이라 SEO와는 관계없다고 생각합니다. 틀렸습니다.

Google 이미지 검색은 큰 트래픽 유입 경로입니다. 이미지에 명확한 alt 설명이 있으면 이미지 검색에서 순위를 얻을 가능성이 생깁니다. 제가 본 한 디자인 리소스 사이트는 트래픽의 30%가 Google 이미지 검색에서 왔습니다. 모든 이미지의 alt를 성실하게 작성했기 때문입니다.

5. sitemap.xml과 robots.txt가 없음

이 두 파일은 검색 엔진에 “내 사이트에는 어떤 페이지가 있고, 어떤 페이지를 크롤링해도 되는지” 알려 줍니다. sitemap이 없으면 Google이 새 글을 발견하는 데 몇 달이 걸릴 수도 있습니다. sitemap을 만들고 Search Console에 제출하면 새 페이지가 며칠 안에 인덱싱될 수 있습니다.

게다가 Next.js App Router는 이제 sitemap.ts와 robots.ts 자동 생성을 지원합니다. 직접 XML을 손으로 쓸 필요도 없는데, 설정하지 않을 이유가 없습니다.

Metadata API 완전 이해하기(Next.js 15)

Next.js 15의 Metadata API는 이 프레임워크가 SEO에 준 가장 큰 선물입니다. 예전에는 각 페이지마다 <Head>를 직접 써야 했지만, 이제는 객체나 함수 하나만 export하면 Next.js가 자동으로 처리합니다.

정적 metadata: 고정 콘텐츠 페이지에 적합

가장 단순한 경우입니다. “About” 페이지나 개인정보 처리방침 페이지처럼 거의 변하지 않는 콘텐츠라면 page.tsx에서 metadata 객체를 바로 export하면 됩니다.

// app/about/page.tsx
import { Metadata } from 'next'

export const metadata: Metadata = {
  title: '소개 - TechBlog',
  description: '우리는 기술을 사랑하는 개발자들로, 프론트엔드, 백엔드, DevOps 실전 경험을 공유합니다.',
  keywords: ['기술 블로그', '프론트엔드 개발', 'Next.js', 'React'],
  authors: [{ name: '김철수' }],
  openGraph: {
    title: '소개 - TechBlog',
    description: '개발 실전 경험을 공유하는 기술 블로그',
    url: 'https://yourdomain.com/about',
    siteName: 'TechBlog',
    images: [
      {
        url: 'https://yourdomain.com/og-about.jpg',
        width: 1200,
        height: 630,
      }
    ],
    type: 'website',
  },
  twitter: {
    card: 'summary_large_image',
    title: '소개 - TechBlog',
    description: '개발 실전 경험을 공유하는 기술 블로그',
    images: ['https://yourdomain.com/og-about.jpg'],
  },
}

export default function AboutPage() {
  return <div>소개 콘텐츠...</div>
}

보이시나요? 타입 안전하고, IDE 자동 완성이 되고, 철자 오류를 걱정할 필요가 없습니다. 게다가 Next.js는 중복도 자동으로 처리합니다. 여러 곳에서 같은 meta 태그를 정의해도 지능적으로 병합합니다.

베스트 프랙티스:

  • title은 60자 이내로 유지합니다. 넘치면 검색 결과에서 잘립니다
  • description은 150~160자로 유지합니다. Google 검색 결과 요약에 적합한 길이입니다
  • openGraph.images 크기는 1200x630 픽셀을 권장합니다. Twitter, Facebook, LinkedIn에서 모두 범용으로 쓰기 좋습니다

동적 metadata: 블로그와 제품 페이지의 구원투수

진짜 강력한 부분은 generateMetadata 함수입니다. 예를 들어 블로그 글 페이지라면 글마다 제목과 설명이 다릅니다. 이를 전부 손으로 쓸 수는 없습니다.

// app/blog/[slug]/page.tsx
import { Metadata } from 'next'
import { getPostBySlug } from '@/lib/posts'

export async function generateMetadata(
  { params }: { params: { slug: string } }
): Promise<Metadata> {
  // 데이터베이스나 CMS에서 글 데이터를 가져옵니다
  const post = await getPostBySlug(params.slug)

  return {
    title: `${post.title} - TechBlog`,
    description: post.excerpt,
    authors: [{ name: post.author }],
    openGraph: {
      title: post.title,
      description: post.excerpt,
      images: [post.coverImage],
      type: 'article',
      publishedTime: post.publishedAt,
      authors: [post.author],
    },
    twitter: {
      card: 'summary_large_image',
      title: post.title,
      description: post.excerpt,
      images: [post.coverImage],
    },
  }
}

export default async function BlogPostPage({ params }: { params: { slug: string } }) {
  const post = await getPostBySlug(params.slug)
  return <article>{post.content}</article>
}

이렇게 하면 모든 글이 고유한 SEO 정보를 갖습니다. Google이 크롤링할 때 완성된 HTML을 보게 되고, 클라이언트 JavaScript를 기다릴 필요가 없습니다.

핵심 팁: metadataBase

위 코드에서 이미지 URL이 모두 절대 경로라는 점을 눈치챘나요? 이미지 경로가 상대 경로(예: /images/cover.jpg)라면 root layout에 metadataBase를 설정해야 합니다.

// app/layout.tsx
export const metadata: Metadata = {
  metadataBase: new URL('https://yourdomain.com'),
}

이 설정을 하면 모든 상대 경로가 자동으로 완전한 URL로 조합됩니다. 그렇지 않으면 Open Graph 크롤링이 실패하고, 소셜 공유 시 이미지가 나오지 않습니다.

템플릿 시스템: 모든 페이지 제목 형식 통일하기

많은 사이트의 페이지 제목은 “페이지명 | 사이트명” 형식입니다. 예를 들어 “Home | TechBlog”, “About | TechBlog”처럼요.

모든 페이지에 접미사를 손으로 쓰는 것은 번거롭습니다. title.template을 쓰면 해결됩니다.

// app/layout.tsx (root layout)
export const metadata: Metadata = {
  title: {
    template: '%s | TechBlog',
    default: 'TechBlog - 기술 블로그',
  },
  description: '프론트엔드, 백엔드, DevOps 실전 경험을 공유하는 기술 블로그',
  metadataBase: new URL('https://yourdomain.com'),
}

이제 하위 페이지는 페이지명만 쓰면 됩니다.

// app/about/page.tsx
export const metadata: Metadata = {
  title: '소개', // 최종 렌더링 결과: "소개 | TechBlog"
}

홈페이지에는 접미사를 붙이고 싶지 않다면 title.absolute를 사용합니다.

// app/page.tsx
export const metadata: Metadata = {
  title: {
    absolute: 'TechBlog - 기술 블로그 홈', // template이 적용되지 않습니다
  },
}

이 메커니즘은 제목 일관성을 유지해 주고, 수정도 매우 편하게 만듭니다. root layout에서 템플릿을 한 번만 바꾸면 모든 페이지에 자동 반영됩니다.

구조화 데이터(Schema.org)로 검색 결과에서 눈에 띄기

구조화 데이터란 무엇이고 왜 중요한가?

“애플파이 만드는 법”을 검색해 본 적 있나요?

검색 결과를 자세히 보면 어떤 레시피는 평점(별 4.8개), 조리 시간(45분), 칼로리(320kcal), 심지어 단계 미리보기까지 보여 줍니다. 반면 어떤 결과는 제목과 설명만 있습니다.

차이는 구조화 데이터입니다.

구조화 데이터는 검색 엔진에 “이것은 블로그 글이고, 작성자는 XXX이며, 발행일은 XXX입니다” 또는 “이것은 제품이고, 가격은 XXX이며, 평점은 XXX입니다”라고 알려 주는 표준 형식(JSON-LD)입니다. 검색 엔진은 이 정보를 바탕으로 검색 결과에 리치 스니펫(Rich Snippets)을 표시할 수 있습니다. 바로 평점, 가격, 작성자 정보가 붙은 카드입니다.

데이터는 거짓말하지 않습니다. 구조화 데이터를 구현한 사이트는 평균 클릭률이 20~30% 상승합니다. 트래픽이 30% 늘어나는 셈인데, 투입 비용은 거의 0에 가깝습니다.

자주 쓰는 Schema 타입

Schema.org에는 수백 가지 타입이 정의되어 있지만, 대부분의 사이트에서 자주 쓰는 것은 몇 가지뿐입니다.

  1. Organization - 회사/조직 정보(홈페이지에 배치)
  2. BlogPosting - 블로그 글(각 글에 추가)
  3. Product - 제품 정보(이커머스 필수, 가격/평점/재고 포함)
  4. FAQPage - 자주 묻는 질문(FAQ 페이지, 검색 결과에서 바로 펼쳐질 수 있음)
  5. LocalBusiness - 지역 비즈니스(식당, 미용실 등. 주소, 영업시간, 전화번호 표시)

여기서는 앞의 두 가지를 중점적으로 다룹니다. 블로그와 기업 사이트에 가장 필요하기 때문입니다.

Next.js에서 JSON-LD 구현하기

Next.js 15의 <Script> 컴포넌트를 쓰면 구조화 데이터 구현이 매우 간단합니다. 저는 보통 범용 컴포넌트를 하나 만듭니다.

// components/StructuredData.tsx
import Script from 'next/script'

type StructuredDataProps = {
  data: object
}

export default function StructuredData({ data }: StructuredDataProps) {
  return (
    <Script
      id="structured-data"
      type="application/ld+json"
      dangerouslySetInnerHTML={{ __html: JSON.stringify(data) }}
    />
  )
}

그리고 페이지에서 사용합니다.

예시 1: Organization(회사 정보)

// app/layout.tsx (root layout)
import StructuredData from '@/components/StructuredData'

const organizationData = {
  '@context': 'https://schema.org',
  '@type': 'Organization',
  name: 'TechBlog',
  url: 'https://yourdomain.com',
  logo: 'https://yourdomain.com/logo.png',
  sameAs: [
    'https://twitter.com/yourusername',
    'https://github.com/yourcompany',
    'https://linkedin.com/company/yourcompany',
  ],
  contactPoint: {
    '@type': 'ContactPoint',
    email: '[email protected]',
    contactType: 'Customer Service',
  },
}

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html>
      <body>
        {children}
        <StructuredData data={organizationData} />
      </body>
    </html>
  )
}

예시 2: BlogPosting(블로그 글)

// app/blog/[slug]/page.tsx
import StructuredData from '@/components/StructuredData'
import { getPostBySlug } from '@/lib/posts'

export default async function BlogPostPage({ params }: { params: { slug: string } }) {
  const post = await getPostBySlug(params.slug)

  const articleData = {
    '@context': 'https://schema.org',
    '@type': 'BlogPosting',
    headline: post.title,
    description: post.excerpt,
    image: post.coverImage,
    author: {
      '@type': 'Person',
      name: post.author,
      url: `https://yourdomain.com/author/${post.authorSlug}`,
    },
    publisher: {
      '@type': 'Organization',
      name: 'TechBlog',
      logo: {
        '@type': 'ImageObject',
        url: 'https://yourdomain.com/logo.png',
      },
    },
    datePublished: post.publishedAt,
    dateModified: post.updatedAt,
    mainEntityOfPage: {
      '@type': 'WebPage',
      '@id': `https://yourdomain.com/blog/${post.slug}`,
    },
  }

  return (
    <>
      <article>{post.content}</article>
      <StructuredData data={articleData} />
    </>
  )
}

코드가 조금 길어 보이지만, 실제로는 글의 메타 정보를 표준 형식으로 Google에 알려 주는 작업입니다. 한 번 설정하면 이후에는 거의 복사해 붙여 넣는 수준입니다.

구조화 데이터 검증하기

설정한 뒤 제대로 되었는지 어떻게 알 수 있을까요? 다음 두 도구를 사용하면 됩니다.

  1. Google Rich Results Test(https://search.google.com/test/rich-results)

    • 페이지 URL을 입력하면 Google이 어떤 리치 결과를 표시할 수 있는지 알려 줍니다
    • 오류가 있으면 어디가 잘못되었는지 명확히 알려 줍니다
  2. Schema Markup Validator(https://validator.schema.org/)

    • JSON-LD 형식이 Schema.org 표준에 맞는지 검사합니다
    • Google 도구보다 더 엄격하므로 둘 다 테스트하는 것을 권장합니다

제가 가장 자주 본 오류는 publisher를 빼먹는 경우입니다. BlogPosting에서는 필수 항목입니다. 또는 이미지 URL이 절대 경로가 아닌 경우도 많습니다. 검증 도구가 무엇이 빠졌는지 바로 알려 주므로, 수정한 뒤 다시 테스트하면 됩니다.

Open Graph와 Twitter Cards 실전

소셜 공유가 왜 중요한가?

Twitter나 Facebook에 글 링크를 공유했는데 미리보기 영역이 텅 비거나, 이미지가 전혀 엉뚱하게 나온 경험이 있나요? 예를 들어 사이트 로고나 임의의 장식 이미지가 뜨는 경우입니다.

이런 모습은 매우 비전문적으로 보입니다. 반대로 올바르게 설정된 사이트는 링크를 공유할 때 자동으로 보기 좋은 커버 이미지, 제목, 설명이 표시됩니다. 클릭률은 2~3배까지 높아질 수 있습니다.

Open Graph와 Twitter Cards를 설정하는 이유는 소셜 미디어에서 링크가 어떻게 보일지 직접 제어하기 위해서입니다.

Open Graph 프로토콜 자세히 보기

Open Graph는 원래 Facebook이 만든 표준이지만, 지금은 Twitter, LinkedIn, Slack, Discord 같은 주요 플랫폼이 모두 지원합니다.

앞에서 Metadata API를 설명하면서 이미 openGraph 객체를 설정했습니다. 여기서는 핵심 필드를 조금 더 자세히 보겠습니다.

export const metadata: Metadata = {
  openGraph: {
    // 필수 필드
    title: '글 제목',              // 소셜 미디어에 표시될 제목
    description: '글 요약',        // 약 150자 설명
    url: 'https://yourdomain.com/article', // 이 콘텐츠의 URL
    siteName: 'TechBlog',          // 사이트 이름

    // 이미지(가장 중요합니다!)
    images: [
      {
        url: 'https://yourdomain.com/og-image.jpg',
        width: 1200,
        height: 630,  // 권장 크기 1200x630
        alt: '이미지 설명(접근성과 SEO)',
      },
    ],

    // 콘텐츠 타입
    type: 'article',  // 글에는 article, 그 외 페이지에는 website

    // 글 전용 필드(type이 article일 때)
    publishedTime: '2025-01-15T08:00:00.000Z',
    modifiedTime: '2025-01-16T10:30:00.000Z',
    authors: ['김철수', '이영희'],
    tags: ['Next.js', 'SEO', '프론트엔드 개발'],

    // 현지화(다국어 버전이 있는 경우)
    locale: 'ko_KR',
    alternateLocale: ['en_US', 'ja_JP'],
  },
}

핵심: 이미지 크기

1200x630 픽셀은 황금 비율(1.91:1)이라 모든 플랫폼에서 안정적으로 보입니다.

  • Facebook, LinkedIn: 전체 표시
  • Twitter: 2:1로 잘려도 크게 어색하지 않음
  • Slack, Discord: 동일하게 적합

이미지 크기는 8MB를 넘으면 안 됩니다. 넘으면 빌드가 실패할 수 있습니다.

Twitter Cards 설정

Twitter에는 자체 meta 태그 시스템이 있습니다. Open Graph로 fallback되기도 하지만, 따로 설정하는 편이 좋습니다.

export const metadata: Metadata = {
  twitter: {
    card: 'summary_large_image',  // 큰 이미지 모드(권장)
    site: '@yourusername',         // 사이트의 Twitter 계정
    creator: '@authorusername',    // 작성자의 Twitter 계정
    title: '글 제목',
    description: '글 요약',
    images: ['https://yourdomain.com/twitter-image.jpg'],
  },
}

card 필드에는 두 가지 값이 있습니다.

  • summary: 작은 이미지 모드(이미지는 왼쪽, 텍스트는 오른쪽)
  • summary_large_image: 큰 이미지 모드(이미지가 카드 상단 대부분을 차지하며, 이 값을 권장합니다)

Twitter 이미지 제한은 5MB입니다. OG보다 더 엄격합니다.

소셜 공유 이미지 동적 생성하기(고급)

글마다 1200x630 커버 이미지를 직접 만들까요? 금방 지칩니다.

Next.js 13.3 이후에는 코드로 OG 이미지를 동적으로 생성할 수 있습니다. 블로그에 특히 잘 맞습니다.

// app/blog/[slug]/opengraph-image.tsx
import { ImageResponse } from 'next/og'
import { getPostBySlug } from '@/lib/posts'

export const runtime = 'edge'
export const alt = 'Blog post cover'
export const size = {
  width: 1200,
  height: 630,
}
export const contentType = 'image/png'

export default async function Image({ params }: { params: { slug: string } }) {
  const post = await getPostBySlug(params.slug)

  return new ImageResponse(
    (
      <div
        style={{
          fontSize: 60,
          background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
          width: '100%',
          height: '100%',
          display: 'flex',
          flexDirection: 'column',
          alignItems: 'center',
          justifyContent: 'center',
          color: 'white',
          padding: '80px',
        }}
      >
        <h1 style={{ fontSize: 72, fontWeight: 'bold', textAlign: 'center' }}>
          {post.title}
        </h1>
        <p style={{ fontSize: 36, marginTop: 20, opacity: 0.9 }}>
          by {post.author}
        </p>
      </div>
    ),
    {
      ...size,
    }
  )
}

이렇게 하면 모든 글의 공유 이미지가 제목을 바탕으로 동적으로 생성됩니다. 직접 디자인할 필요가 없습니다.

커스텀 폰트나 배경 이미지를 쓰고 싶다면 더 세밀하게 확장할 수도 있습니다. 자세한 내용은 Next.js 공식 문서의 next/og 부분을 보면 됩니다.

소셜 공유 효과 테스트와 검증

설정을 마쳤다고 바로 공유하지 말고, 먼저 다음 도구로 테스트하세요.

  1. Facebook Sharing Debugger(https://developers.facebook.com/tools/debug/)

    • URL을 입력하면 Facebook이 어떻게 크롤링하고 표시하는지 확인할 수 있습니다
    • 주의: Facebook은 OG 정보를 캐시합니다. 코드를 수정했다면 이 도구에서 “Scrape Again”을 눌러 캐시를 갱신해야 합니다
  2. Twitter Card Validator(https://cards-dev.twitter.com/validator)

    • URL을 입력해 Twitter 카드 미리보기를 확인합니다
    • 참고: 2023년 이후 이 도구는 Twitter 개발자 계정이 필요합니다. 대신 Twitter에 직접 테스트 글을 올려 확인할 수도 있습니다
  3. LinkedIn Post Inspector(https://www.linkedin.com/post-inspector/)

    • LinkedIn 미리보기 도구이며, 캐시 갱신도 가능합니다

제가 밟았던 함정이 있습니다. OG 이미지를 바꿨는데도 Facebook이 계속 예전 이미지를 보여 줬습니다. 반드시 Sharing Debugger에서 캐시를 갱신해야 합니다. 그렇지 않으면 내가 뭘 잘못했는지 끝없이 의심하게 됩니다.

그 밖의 필수 SEO 설정

앞에서 Metadata API, 구조화 데이터, Open Graph를 SEO의 핵심으로 다뤘습니다. 하지만 그만큼 중요한 설정이 몇 가지 더 있습니다. 빠뜨리지 마세요.

sitemap.xml - 검색 엔진에 페이지 목록 알려 주기

Sitemap은 사이트의 모든 페이지 URL을 나열한 XML 파일입니다. Google과 Bing 크롤러는 이 파일을 읽고 사이트 구조를 이해하며, 인덱싱 속도를 높입니다.

Next.js App Router에서는 이 작업이 매우 간단합니다. app/sitemap.ts 파일 하나만 만들면 됩니다.

// app/sitemap.ts
import { MetadataRoute } from 'next'
import { getAllPosts } from '@/lib/posts'

export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
  const posts = await getAllPosts()
  const baseUrl = 'https://yourdomain.com'

  // 정적 페이지
  const staticPages: MetadataRoute.Sitemap = [
    {
      url: baseUrl,
      lastModified: new Date(),
      changeFrequency: 'daily',
      priority: 1,
    },
    {
      url: `${baseUrl}/about`,
      lastModified: new Date(),
      changeFrequency: 'monthly',
      priority: 0.8,
    },
  ]

  // 블로그 글 페이지를 동적으로 생성합니다
  const blogPages: MetadataRoute.Sitemap = posts.map((post) => ({
    url: `${baseUrl}/blog/${post.slug}`,
    lastModified: new Date(post.updatedAt),
    changeFrequency: 'weekly' as const,
    priority: 0.7,
  }))

  return [...staticPages, ...blogPages]
}

Next.js는 이 파일을 자동으로 https://yourdomain.com/sitemap.xml에 생성합니다.

설정 후에는 이 두 가지를 꼭 하세요:

  1. Google Search Console(https://search.google.com/search-console)에 제출
  2. Bing Webmaster Tools(https://www.bing.com/webmasters)에 제출

제출 후에는 새 페이지 인덱싱 속도가 50% 이상 빨라질 수 있습니다. 예전에 블로그를 쓸 때 sitemap을 제출하지 않으면 새 글이 인덱싱되기까지 2주가 걸렸습니다. 제출한 뒤에는 3일 만에 Google에서 검색되었습니다.

robots.txt - 크롤러 접근 권한 제어

robots.txt는 검색 엔진 크롤러에게 “어떤 페이지를 크롤링해도 되고, 어떤 페이지는 안 되는지” 알려 줍니다.

마찬가지로 Next.js App Router는 코드 생성을 지원합니다.

// app/robots.ts
import { MetadataRoute } from 'next'

export default function robots(): MetadataRoute.Robots {
  return {
    rules: [
      {
        userAgent: '*',  // 모든 크롤러에 적용
        allow: '/',      // 모든 페이지 크롤링 허용
        disallow: ['/admin', '/api', '/private'],  // 이 경로는 크롤링 금지
      },
    ],
    sitemap: 'https://yourdomain.com/sitemap.xml',  // sitemap을 가리킵니다
  }
}

이 코드는 https://yourdomain.com/robots.txt를 생성하며, 내용은 대략 다음과 같습니다.

User-agent: *
Allow: /
Disallow: /admin
Disallow: /api
Disallow: /private

Sitemap: https://yourdomain.com/sitemap.xml

자주 쓰는 상황:

  • 관리자 페이지(/admin)는 검색 엔진에 노출하고 싶지 않습니다
  • API 라우트(/api)도 크롤링할 필요가 없습니다
  • 초안이나 미리보기 페이지에는 noindex meta 태그 또는 Disallow 규칙을 사용할 수 있습니다

canonical URL - 중복 콘텐츠 불이익 피하기

Canonical URL은 검색 엔진에 “이 페이지에는 여러 URL이 있지만, 정식 주소는 이것입니다”라고 알려 줍니다.

대표적인 상황은 다음과 같습니다.

  • 페이지네이션: /blog?page=1, /blog?page=2
  • 필터: /products?category=tech
  • 정렬: /products?sort=price

실제로는 같은 페이지의 다른 보기인데 canonical을 선언하지 않으면 검색 엔진은 중복 콘텐츠로 판단하고 권한을 분산시킬 수 있습니다.

Next.js에서 canonical을 설정하는 방법은 다음과 같습니다.

// app/blog/page.tsx
export const metadata: Metadata = {
  alternates: {
    canonical: 'https://yourdomain.com/blog',
  },
}

또는 동적으로 생성할 수도 있습니다.

// app/blog/[slug]/page.tsx
export async function generateMetadata({ params }: { params: { slug: string } }): Promise<Metadata> {
  return {
    alternates: {
      canonical: `https://yourdomain.com/blog/${params.slug}`,
    },
  }
}

페이지에 다국어 버전이 있다면 alternates.languages로 Google에 알려 줄 수도 있습니다.

export const metadata: Metadata = {
  alternates: {
    canonical: 'https://yourdomain.com/blog/nextjs-seo',
    languages: {
      'en-US': 'https://yourdomain.com/en/blog/nextjs-seo',
      'ja-JP': 'https://yourdomain.com/ja/blog/nextjs-seo',
    },
  },
}

이미지 최적화 - next/image와 alt 속성

많은 개발자가 이미지 SEO를 놓칩니다. 하지만 Google 이미지 검색은 큰 트래픽 유입 경로입니다.

핵심은 두 가지입니다.

  1. 기본 img 태그 대신 next/image 사용
import Image from 'next/image'

<Image
  src="/cover.jpg"
  alt="Next.js SEO 완전 가이드 커버"
  width={1200}
  height={630}
  priority  // 첫 화면 이미지는 우선 로드합니다
/>

next/image는 다음 최적화를 자동으로 수행합니다.

  • 지연 로딩(첫 화면 밖 이미지는 늦게 로드)
  • WebP 형식 변환(더 작은 파일 크기)
  • 반응형 크기(기기에 맞는 크기 로드)
  • CLS 방지(누적 레이아웃 이동, Core Web Vitals 지표 중 하나)
  1. 반드시 alt 속성을 작성

alt는 선택 사항이 아닙니다. SEO 요구사항이면서 접근성 요구사항이기도 합니다. 스크린 리더가 alt를 읽습니다.

좋은 alt 설명:

  • ✅ “Next.js Metadata API 설정 코드 예시”
  • ✅ “Google 검색 결과에 표시된 블로그 글 리치 결과”

나쁜 alt 설명:

  • ❌ “이미지”
  • ❌ “screenshot.png”
  • ❌ alt를 쓰지 않음

Google 이미지 검색은 alt 내용을 바탕으로 이미지 순위를 매깁니다. 제가 본 한 디자인 리소스 사이트는 트래픽의 30%가 이미지 검색에서 왔는데, 모든 이미지의 alt를 성실하게 작성했기 때문입니다.

실전 사례 - 블로그 사이트 SEO 전체 설정

앞에서 이론과 코드 조각을 많이 다뤘습니다. 이제 이를 하나로 묶어, 블로그 프로젝트에서 SEO를 어떻게 완성할지 살펴보겠습니다.

프로젝트 구조

Next.js 15 App Router로 기술 블로그를 만든다고 가정하면 디렉터리 구조는 대략 다음과 같습니다.

app/
├── layout.tsx                 # Root layout - 전역 설정
├── page.tsx                   # 홈
├── about/page.tsx            # 소개 페이지
├── blog/
│   ├── page.tsx              # 블로그 목록 페이지
│   └── [slug]/
│       ├── page.tsx          # 블로그 상세 페이지
│       └── opengraph-image.tsx  # OG 이미지 동적 생성(선택)
├── sitemap.ts                # Sitemap 생성
└── robots.ts                 # Robots.txt 생성

전체 코드 예시

1. Root Layout - 전역 SEO 설정

// app/layout.tsx
import { Metadata } from 'next'
import StructuredData from '@/components/StructuredData'

export const metadata: Metadata = {
  metadataBase: new URL('https://yourdomain.com'),  // 상대 경로를 조합하기 위해 반드시 설정합니다
  title: {
    template: '%s | TechBlog',  // 하위 페이지 제목 템플릿
    default: 'TechBlog - 프론트엔드 개발 기술 블로그',
  },
  description: 'Next.js, React, TypeScript 실전 경험을 공유하는 기술 블로그',
  keywords: ['Next.js', 'React', 'TypeScript', '프론트엔드 개발', '기술 블로그'],
  authors: [{ name: '김철수', url: 'https://yourdomain.com/about' }],
  openGraph: {
    type: 'website',
    siteName: 'TechBlog',
    locale: 'ko_KR',
  },
  twitter: {
    card: 'summary_large_image',
    site: '@yourusername',
  },
}

// Organization 구조화 데이터(전역, root layout에 배치)
const organizationData = {
  '@context': 'https://schema.org',
  '@type': 'Organization',
  name: 'TechBlog',
  url: 'https://yourdomain.com',
  logo: 'https://yourdomain.com/logo.png',
  sameAs: [
    'https://twitter.com/yourusername',
    'https://github.com/yourcompany',
  ],
}

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="ko-KR">
      <body>
        {children}
        <StructuredData data={organizationData} />
      </body>
    </html>
  )
}

2. 홈 - 정적 metadata

// app/page.tsx
import { Metadata } from 'next'

export const metadata: Metadata = {
  title: {
    absolute: 'TechBlog - 프론트엔드 개발 기술 블로그',  // template을 적용하지 않습니다
  },
  description: 'Next.js, React, TypeScript 등 프론트엔드 기술 실전 경험을 공유하며 개발자의 역량 향상을 돕습니다',
  openGraph: {
    title: 'TechBlog - 프론트엔드 개발 기술 블로그',
    description: '프론트엔드 기술 실전 경험을 공유합니다',
    url: 'https://yourdomain.com',
    images: [
      {
        url: 'https://yourdomain.com/og-home.jpg',
        width: 1200,
        height: 630,
        alt: 'TechBlog 홈 커버',
      },
    ],
  },
}

export default function HomePage() {
  return <div>홈 콘텐츠...</div>
}

3. 블로그 상세 페이지 - 동적 metadata + 구조화 데이터

// app/blog/[slug]/page.tsx
import { Metadata } from 'next'
import { notFound } from 'next/navigation'
import { getPostBySlug } from '@/lib/posts'
import StructuredData from '@/components/StructuredData'

// metadata를 동적으로 생성합니다
export async function generateMetadata(
  { params }: { params: { slug: string } }
): Promise<Metadata> {
  const post = await getPostBySlug(params.slug)
  if (!post) return {}

  return {
    title: post.title,  // template이 적용되어 "글 제목 | TechBlog"이 됩니다
    description: post.excerpt,
    keywords: post.tags,
    authors: [{ name: post.author }],
    openGraph: {
      title: post.title,
      description: post.excerpt,
      url: `https://yourdomain.com/blog/${post.slug}`,
      images: [post.coverImage],
      type: 'article',
      publishedTime: post.publishedAt,
      authors: [post.author],
    },
    twitter: {
      card: 'summary_large_image',
      title: post.title,
      description: post.excerpt,
      images: [post.coverImage],
    },
    alternates: {
      canonical: `https://yourdomain.com/blog/${post.slug}`,
    },
  }
}

export default async function BlogPostPage({ params }: { params: { slug: string } }) {
  const post = await getPostBySlug(params.slug)
  if (!post) notFound()

  // BlogPosting 구조화 데이터
  const articleData = {
    '@context': 'https://schema.org',
    '@type': 'BlogPosting',
    headline: post.title,
    description: post.excerpt,
    image: post.coverImage,
    datePublished: post.publishedAt,
    dateModified: post.updatedAt || post.publishedAt,
    author: {
      '@type': 'Person',
      name: post.author,
    },
    publisher: {
      '@type': 'Organization',
      name: 'TechBlog',
      logo: {
        '@type': 'ImageObject',
        url: 'https://yourdomain.com/logo.png',
      },
    },
    mainEntityOfPage: {
      '@type': 'WebPage',
      '@id': `https://yourdomain.com/blog/${post.slug}`,
    },
  }

  return (
    <>
      <article>
        <h1>{post.title}</h1>
        <div dangerouslySetInnerHTML={{ __html: post.content }} />
      </article>
      <StructuredData data={articleData} />
    </>
  )
}

4. Sitemap과 Robots

// app/sitemap.ts
import { getAllPosts } from '@/lib/posts'

export default async function sitemap() {
  const posts = await getAllPosts()
  const baseUrl = 'https://yourdomain.com'

  const blogUrls = posts.map((post) => ({
    url: `${baseUrl}/blog/${post.slug}`,
    lastModified: new Date(post.updatedAt),
    changeFrequency: 'weekly' as const,
    priority: 0.7,
  }))

  return [
    {
      url: baseUrl,
      lastModified: new Date(),
      changeFrequency: 'daily' as const,
      priority: 1,
    },
    {
      url: `${baseUrl}/about`,
      lastModified: new Date(),
      changeFrequency: 'monthly' as const,
      priority: 0.8,
    },
    ...blogUrls,
  ]
}

// app/robots.ts
export default function robots() {
  return {
    rules: {
      userAgent: '*',
      allow: '/',
      disallow: ['/api', '/admin'],
    },
    sitemap: 'https://yourdomain.com/sitemap.xml',
  }
}

배포 후 검증 체크리스트

설정을 마친 뒤 배포 전에 반드시 확인해야 합니다.

  1. HTML 소스 확인

    • 브라우저에서 F12 → Elements → <head> 태그 확인
    • <title>, <meta name="description">, OG 태그가 올바르게 렌더링되는지 확인
  2. sitemap과 robots 테스트

    • https://yourdomain.com/sitemap.xml에 접속해 정상 생성 여부 확인
    • https://yourdomain.com/robots.txt에 접속해 내용 확인
  3. 구조화 데이터 검증

    • Google Rich Results Test로 몇 개 페이지를 테스트
    • 오류나 경고가 없는지 확인
  4. 소셜 공유 테스트

    • Facebook Sharing Debugger와 Twitter Card Validator로 테스트
    • 이미지, 제목, 설명이 올바르게 표시되는지 확인
  5. 검색 엔진에 제출

    • Google Search Console에 sitemap 제출
    • Bing Webmaster Tools에 sitemap 제출

이 과정을 마치면 Next.js 사이트의 SEO 설정은 꽤 완성도 있게 갖춰진 것입니다.

결론

여기까지 꼼꼼히 읽었다면 이제 분명해졌을 겁니다. Next.js의 SSR은 SEO 친화성과 같지 않지만, Next.js 15가 제공하는 도구 덕분에 SEO 설정은 훨씬 쉬워졌습니다.

핵심을 다시 정리해 보겠습니다.

  • Metadata API는 타입 안전한 방식으로 meta 태그를 설정하게 해 줍니다. 정적 페이지는 metadata 객체를, 동적 페이지는 generateMetadata 함수를 사용합니다
  • **구조화 데이터(JSON-LD)**는 클릭률을 20~30% 끌어올릴 수 있는 비밀 무기입니다. <Script> 컴포넌트로 쉽게 구현할 수 있습니다
  • Open Graph와 Twitter Cards는 소셜 미디어에서 링크가 어떻게 보일지 결정합니다. 이미지 크기는 1200x630이 범용입니다
  • sitemap.xml과 robots.txt는 .ts 파일로 자동 생성할 수 있습니다. Search Console 제출도 잊지 마세요
  • 이미지 최적화는 next/image와 성실한 alt 작성이 핵심입니다. 이미지 검색도 의미 있는 트래픽을 가져올 수 있습니다

솔직히 이 설정들은 처음 보면 번거로워 보입니다. 하지만 투자 대비 효과는 매우 큽니다. 기술은 좋은데 SEO를 제대로 하지 않아 트래픽이 오르지 않고, 결국 광고비에 의존하는 제품을 너무 많이 봤습니다. 반대로 제대로 설정한 사이트는 거의 비용 없이 자연 유입을 꾸준히 얻습니다.

SEO는 미신이 아니라 과학입니다. 이 글의 체크리스트를 따라 하나씩 설정하고, 도구로 검증하면 효과는 반드시 나타납니다. 당장 내일 폭발적인 변화가 생기지는 않을 수 있지만, 3개월 뒤에는 분명한 트래픽 성장을 확인할 수 있을 것입니다.

트래픽이 완전히 사라진 뒤에야 SEO를 떠올리지 마세요. 지금 프로젝트를 열고 반나절만 투자해 설정해 보세요. 문제가 생기면 이 글을 저장해 두었다가 필요할 때 다시 확인하면 됩니다.

이 글이 도움이 되었다면 다른 개발자 친구에게도 공유해 주세요. 그들이 몇 가지 함정을 덜 밟게 된다면 그것만으로도 충분히 가치 있는 일입니다.

Next.js SEO 최적화 전체 설정 흐름

Metadata API 설정부터 구조화 데이터, sitemap, robots.txt까지 이어지는 전체 SEO 최적화 절차입니다.

⏱️ Estimated time: 4 hr

  1. 1

    Step 1: 기본 Metadata 설정

    Next.js 15 Metadata API를 사용합니다:
    • layout.js 또는 page.js에서 metadata 객체를 export합니다
    • title, description, keywords를 설정합니다
    • Open Graph와 Twitter Cards를 설정합니다

    예시:
    export const metadata = {
    title: '페이지 제목',
    description: '페이지 설명',
    openGraph: {
    title: 'OG 제목',
    description: 'OG 설명',
    images: ['/og-image.jpg']
    }
    }
  2. 2

    Step 2: 동적 Metadata 설정

    동적 라우트에서는:
    • generateMetadata 함수를 사용합니다
    • 라우트 파라미터에 따라 metadata를 생성합니다
    • async 함수로 데이터를 가져올 수 있습니다

    예시:
    export async function generateMetadata({ params }) {
    const post = await getPost(params.id)
    return {
    title: post.title,
    description: post.description
    }
    }
  3. 3

    Step 3: 구조화 데이터 추가

    JSON-LD 형식을 사용합니다:
    • script 태그를 사용하고 type을 application/ld+json으로 설정하며, 내용은 JSON으로 넣습니다(구현할 때는 태그로 감쌉니다)
    • Article, Product, FAQ 같은 타입을 지원합니다
    • Schema.org 어휘를 사용합니다

    예시(JSON 본문. 페이지에서는 이 객체를 JSON.stringify 한 뒤 script 안에 넣습니다):
    {
    "@context": "https://schema.org",
    "@type": "Article",
    "headline": "글 제목"
    }

    Next.js에서는 next/script 등으로 JSON을 출력할 수 있습니다.
  4. 4

    Step 4: sitemap과 robots.txt 설정

    sitemap.ts를 만듭니다:
    • default 함수를 export해 sitemap 배열을 반환합니다
    • 모든 페이지의 URL, lastModified, changeFrequency를 포함합니다
    • 동적 생성을 지원합니다

    robots.ts를 만듭니다:
    • 허용/차단할 크롤러를 설정합니다
    • sitemap 경로를 설정합니다
    • 크롤링 규칙을 설정합니다
  5. 5

    Step 5: 이미지 SEO 최적화

    이미지 최적화 핵심:
    • next/image 컴포넌트를 사용합니다
    • 의미 있는 alt 텍스트를 추가합니다
    • 이미지 크기를 설정합니다(1200x630은 OG 이미지에 적합합니다)
    • WebP/AVIF 형식을 사용합니다
    • 이미지 구조화 데이터(ImageObject)를 추가합니다
  6. 6

    Step 6: 검증과 테스트

    검증 도구:
    • Google Rich Results Test: 구조화 데이터 검증
    • Facebook Sharing Debugger: OG 태그 테스트
    • Twitter Card Validator: Twitter Cards 테스트
    • Google Search Console: sitemap 제출 및 모니터링

    체크리스트:
    • 모든 페이지에 고유한 title과 description이 있음
    • OG 이미지 크기가 올바름(1200x630)
    • 구조화 데이터 형식이 올바름
    • sitemap을 Search Console에 제출했음

FAQ

SSR과 SEO는 어떤 관계가 있나요?
SSR(서버 사이드 렌더링)은 HTML을 서버에서 생성하게 해 줄 뿐입니다. SEO에는 올바른 meta 태그, 구조화 데이터, Open Graph 설정도 필요합니다. SSR이 곧 SEO 친화성을 보장하지는 않으며, 검색 엔진이 콘텐츠를 정확히 이해하고 인덱싱하도록 Metadata API를 직접 설정해야 합니다.
Metadata API와 Head 컴포넌트는 무엇이 다른가요?
Metadata API는 Next.js 15에서 권장하는 방식입니다. 타입 안전성이 있고, 정적 metadata와 동적 metadata를 모두 지원하며, 중복 태그를 자동으로 처리합니다. Head 컴포넌트는 React 방식이라 직접 관리해야 하고 실수하기 쉽습니다. 새 프로젝트라면 Metadata API를 권장합니다.
동적 라우트에는 metadata를 어떻게 설정하나요?
generateMetadata 함수를 사용합니다. 이 함수는 params를 받고, async로 데이터를 가져온 뒤 metadata 객체를 반환할 수 있습니다.

예:
export async function generateMetadata({ params }) {
const data = await getData(params.id)
return { title: data.title }
}
Open Graph 이미지 크기는 어느 정도가 좋나요?
권장 크기는 1200x630 픽셀입니다. Facebook, Twitter, LinkedIn 등 여러 플랫폼에서 안정적으로 지원되는 범용 크기입니다. 이미지 파일 크기는 1MB 이하로 관리하고, 형식은 JPEG 또는 PNG를 사용하는 편이 좋습니다.
구조화 데이터는 반드시 필요한가요?
필수는 아니지만 강력히 권장합니다. 구조화 데이터는 검색 결과에 평점, 가격, FAQ 같은 리치 결과를 표시하게 해 클릭률을 높일 수 있습니다. Google과 Bing 같은 검색 엔진은 JSON-LD 형식의 구조화 데이터를 지원합니다.
sitemap과 robots.txt를 직접 만들어야 하나요?
그럴 필요는 없습니다. Next.js는 sitemap.ts와 robots.ts 파일을 만들어 sitemap.xml과 robots.txt를 자동 생성할 수 있습니다. sitemap.ts는 모든 페이지 URL을 동적으로 생성할 수 있고, robots.ts는 크롤링 규칙을 설정할 수 있습니다.
SEO 최적화 후 효과는 언제쯤 보이나요?
보통 1~3개월이 필요합니다. 검색 엔진이 새 콘텐츠를 크롤링하고 인덱싱하는 데 시간이 걸리기 때문입니다.

권장 순서:
1) sitemap을 Google Search Console에 제출합니다
2) Google Rich Results Test로 검증합니다
3) Search Console 데이터를 지속적으로 모니터링합니다
4) 콘텐츠 업데이트를 꾸준히 유지합니다

7분 읽기 · 게시일: 2025년 12월 19일 · 수정일: 2026년 10월 21일

댓글

GitHub로 로그인하여 댓글을 남기세요

Easton BlogEaston Blog