홈 [Frontend] Metadata 완벽 이해하기: HTML 원리부터 SEO, Open Graph, Next.js 사용법까지
포스트
취소

[Frontend] Metadata 완벽 이해하기: HTML 원리부터 SEO, Open Graph, Next.js 사용법까지

들어가며: 화면에 보이지 않는데 왜 중요할까?

웹 페이지를 만들 때 사용자는 주로 <body> 안의 콘텐츠를 봅니다.

하지만 브라우저, 검색엔진, 메신저, SNS는 화면에 보이는 본문만 읽지 않습니다. 문서에 함께 들어 있는 metadata를 보고 페이지를 이해합니다.

1
2
3
4
5
6
7
8
9
10
11
<head>
  <title>프론트엔드 Metadata 완벽 이해하기</title>
  <meta
    name="description"
    content="HTML metadata의 원리와 사용법을 설명합니다."
  />
  <link
    rel="canonical"
    href="https://example.com/blog/frontend-metadata"
  />
</head>

metadata가 잘못되면 페이지 본문이 정상이어도 다음과 같은 문제가 생길 수 있습니다.

브라우저 탭 제목이 모든 페이지에서 같다.
검색 결과에 엉뚱한 설명이 표시된다.
카카오톡이나 Slack에 공유했는데 이미지가 나오지 않는다.
같은 콘텐츠의 여러 URL이 서로 다른 페이지로 인식된다.
개발용 페이지가 검색 결과에 노출된다.

이 글에서는 태그를 복사하는 데서 끝나지 않고 다음 흐름을 이해해 보겠습니다.

metadata란 무엇인가?
누가 metadata를 읽는가?
HTML에서는 어떻게 표현하는가?
SEO와 SNS 공유에는 어떤 값이 필요한가?
React와 Next.js에서는 어떻게 관리하는가?
작성한 metadata가 실제로 적용됐는지 어떻게 검증하는가?

Metadata란?

metadata는 데이터를 설명하는 데이터입니다.

웹 페이지 자체가 데이터라면 metadata는 그 페이지의 제목, 설명, 대표 이미지, 언어, 작성자, 공개 여부 같은 정보를 설명합니다.

예를 들어 블로그 글에는 다음 두 종류의 정보가 있습니다.

본문 데이터
글 제목, 문단, 코드 예제, 이미지

metadata
검색 결과용 제목, 요약, 대표 이미지, canonical URL, 작성 날짜

metadata는 대개 본문처럼 직접 표시되지 않지만 여러 소비자가 각자의 목적에 맞게 사용합니다.

소비자사용하는 정보결과
브라우저title, charset, viewport, icon탭 제목, 문자 인코딩, 모바일 화면, 파비콘
검색엔진title, description, robots, canonical, 구조화 데이터검색 결과와 색인 처리
SNS·메신저Open Graph, 공유 이미지링크 미리보기 카드
운영체제·모바일theme color, manifest, icon설치 화면과 앱 형태의 표시
웹 분석·검증 도구verification, referrer 정책 등사이트 소유권 확인과 요청 정책

따라서 metadata는 SEO만을 위한 기능이 아닙니다.


Metadata는 <meta> 태그만 뜻하지 않는다

이름 때문에 metadata를 모두 <meta> 태그로 작성한다고 생각하기 쉽습니다.

하지만 HTML 문서를 설명하는 요소는 여러 종류입니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
<head>
  <meta charset="UTF-8" />
  <meta name="viewport" content="width=device-width, initial-scale=1" />
  <meta name="description" content="페이지 설명" />

  <title>페이지 제목</title>

  <link rel="canonical" href="https://example.com/page" />
  <link rel="icon" href="/favicon.ico" />

  <script type="application/ld+json">
    { "@context": "https://schema.org", "@type": "Article" }
  </script>
</head>

역할을 구분하면 다음과 같습니다.

<title>
문서 제목

<meta>
다른 전용 요소로 나타내기 어려운 문서 정보와 지시

<link>
현재 문서와 다른 리소스 또는 URL의 관계

<script type="application/ld+json">
문서 내용을 기계가 이해할 수 있게 구조화한 데이터

즉, 프론트엔드에서 말하는 metadata는 <head>에 들어가는 문서 설명 정보 전반을 가리키는 경우가 많습니다.


브라우저는 metadata를 어떻게 처리할까?

브라우저가 HTML을 받으면 위에서 아래로 파싱하며 DOM을 만듭니다.

서버에서 HTML 응답 수신
        ↓
<head> 파싱
        ↓
문자 인코딩, viewport, title, 외부 리소스 관계 확인
        ↓
<body> 파싱 및 화면 렌더링

일부 metadata는 문서를 올바르게 해석하는 데 직접 영향을 줍니다.

1
<meta charset="UTF-8" />

문자 인코딩을 늦게 알게 되면 브라우저가 앞부분을 다시 해석해야 할 수 있으므로 charset은 <head> 앞쪽에 두는 것이 좋습니다.

1
<meta name="viewport" content="width=device-width, initial-scale=1" />

viewport 설정은 모바일 브라우저가 페이지의 가상 화면 너비와 초기 배율을 어떻게 다룰지 알려 줍니다.

반면 검색엔진이나 SNS 크롤러는 HTML을 가져간 뒤 자신이 이해하는 metadata만 골라 사용합니다.

브라우저는 viewport를 사용한다.
검색엔진은 canonical과 robots를 해석한다.
SNS 크롤러는 og:title과 og:image를 찾는다.

모든 소비자가 모든 태그를 같은 방식으로 처리하는 것은 아닙니다.


가장 기본적인 HTML metadata 구성

일반적인 페이지의 출발점은 다음과 같습니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
<!doctype html>
<html lang="ko">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1" />

    <title>상품 상세 | Acme Store</title>
    <meta
      name="description"
      content="Acme Store의 무선 키보드 특징과 가격을 확인하세요."
    />

    <link
      rel="canonical"
      href="https://store.example.com/products/wireless-keyboard"
    />
    <link rel="icon" href="/favicon.ico" />
  </head>
  <body>
    <main>
      <h1>무선 키보드</h1>
    </main>
  </body>
</html>

이제 각 항목의 역할을 살펴보겠습니다.


<title>: 문서의 대표 제목

<title>은 브라우저 탭, 방문 기록, 북마크, 검색 결과의 제목 후보에 사용됩니다.

1
<title>React useMemo와 useCallback 이해하기 | Taewok</title>

좋은 title은 현재 페이지를 구체적으로 설명합니다.

나쁜 예
홈
상세 페이지
게시글

좋은 예
React useMemo와 useCallback 이해하기 | Taewok
무선 키보드 K100 상세 정보 | Acme Store
주문 내역 | Acme

<title>과 <h1>은 역할이 다르다

1
2
3
4
5
<title>React Suspense 완벽 이해하기 | Taewok</title>

<body>
  <h1>Suspense는 무엇을 기다리는가?</h1>
</body>

<title>은 문서와 브라우저·검색 결과를 위한 제목이고, <h1>은 페이지 본문 구조의 최상위 제목입니다.

두 값이 완전히 같을 필요는 없지만 서로 다른 주제를 말하면 안 됩니다.

title과 h1의 표현은 달라도 된다.
하지만 둘 다 같은 페이지 내용을 설명해야 한다.

검색엔진은 <title>만 무조건 그대로 보여 주는 것이 아니라 본문의 큰 제목, 링크 문구, og:title 등 여러 신호를 참고해 검색 결과 제목을 다시 만들 수도 있습니다.


description: 검색 결과를 위한 요약 후보

1
2
3
4
<meta
  name="description"
  content="React Suspense가 Promise를 기다리는 원리부터 데이터 패칭과 오류 처리까지 설명합니다."
/>

description은 페이지 내용을 짧고 구체적으로 요약합니다.

나쁜 예
이 페이지는 React에 관한 페이지입니다.

좋은 예
React Suspense가 렌더링을 멈추고 재시도하는 원리와 실전 데이터 패칭 패턴을 예제로 설명합니다.

검색엔진이 description을 항상 그대로 표시하는 것은 아닙니다. 검색어에 더 적합하다고 판단하면 본문의 일부를 검색 결과 설명으로 사용할 수 있습니다.

따라서 description은 검색 노출을 강제로 제어하는 문장이 아니라, 페이지의 좋은 요약 후보를 제공하는 metadata입니다.

페이지마다 같은 설명을 복사하지 말고 고유한 내용을 작성하는 편이 좋습니다.


keywords는 필요한가?

과거에는 다음 태그를 많이 사용했습니다.

1
<meta name="keywords" content="React, JavaScript, Frontend" />

하지만 Google Search는 meta keywords를 색인과 순위에 사용하지 않는다고 명시하고 있습니다.

키워드를 반복해서 넣는 것보다 페이지의 title, description, 제목 구조, 실제 본문 품질을 명확하게 만드는 것이 중요합니다.

metadata를 많이 넣는 것
≠ 검색 순위가 자동으로 올라가는 것

canonical: 이 콘텐츠의 대표 URL 알리기

같은 콘텐츠가 여러 URL에서 열릴 수 있습니다.

https://example.com/products/keyboard
https://example.com/products/keyboard?ref=home
https://example.com/products/keyboard?utm_source=newsletter

이때 대표로 취급하고 싶은 URL을 canonical로 알릴 수 있습니다.

1
2
3
4
<link
  rel="canonical"
  href="https://example.com/products/keyboard"
/>

canonical은 현재 문서와 대표 URL의 관계를 나타내므로 <meta>가 아니라 <link>를 사용합니다.

canonical은 리다이렉트가 아니다

redirect
사용자를 다른 URL로 실제 이동시킨다.

canonical
현재 페이지는 열어 두되 검색엔진에 대표 URL 신호를 제공한다.

canonical은 명령이라기보다 검색엔진이 참고하는 강한 신호입니다. 사이트맵, 내부 링크, 리다이렉트가 서로 다른 URL을 가리키면 검색엔진이 다른 canonical을 선택할 수도 있습니다.

절대 URL을 사용하는 편이 안전합니다.

1
2
<!-- 권장 -->
<link rel="canonical" href="https://example.com/blog/metadata" />

robots: 색인과 검색 결과 표시 제어하기

일반 페이지는 보통 별도의 robots metadata 없이도 색인될 수 있습니다.

검색 결과에 나오면 안 되는 페이지는 noindex를 사용할 수 있습니다.

1
<meta name="robots" content="noindex" />

링크를 따라가지 않도록 요청하려면 nofollow를 함께 사용할 수 있습니다.

1
<meta name="robots" content="noindex, nofollow" />

자주 사용하는 값은 다음과 같습니다.

값의미
index페이지 색인 허용
noindex검색 결과에 페이지를 표시하지 않도록 요청
follow페이지의 링크 탐색 허용
nofollow페이지의 링크를 따라가지 않도록 요청
nosnippet검색 결과에 텍스트 요약을 표시하지 않도록 요청
noimageindex페이지의 이미지를 색인하지 않도록 요청

robots.txt와 noindex는 다르다

robots.txt
크롤러가 URL을 가져오는 행위를 제어

meta robots noindex
가져온 페이지를 검색 결과에 색인하지 않도록 제어

크롤링을 robots.txt에서 막으면 크롤러가 페이지 안의 noindex를 읽지 못할 수 있습니다.

로그인 페이지는 metadata만으로 보호하지 않습니다. noindex는 보안 기능이 아니며, 인증과 권한 검사가 반드시 필요합니다.


Open Graph: 링크 공유 카드의 정보

웹 페이지 링크를 SNS나 메신저에 붙여 넣으면 제목, 설명, 이미지가 있는 카드가 나타납니다.

이때 널리 사용되는 규약이 Open Graph입니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
<meta property="og:title" content="프론트엔드 Metadata 완벽 이해하기" />
<meta
  property="og:description"
  content="HTML metadata의 원리부터 Next.js 사용법까지 설명합니다."
/>
<meta property="og:type" content="article" />
<meta
  property="og:url"
  content="https://example.com/blog/frontend-metadata"
/>
<meta
  property="og:image"
  content="https://example.com/images/frontend-metadata-og.png"
/>
<meta
  property="og:image:alt"
  content="브라우저와 검색엔진이 metadata를 읽는 흐름"
/>
<meta property="og:site_name" content="Taewok" />
<meta property="og:locale" content="ko_KR" />

Open Graph의 기본 핵심 속성은 다음과 같습니다.

og:title
공유 카드 제목

og:type
페이지 객체 유형, 예: website 또는 article

og:image
공유 카드 대표 이미지

og:url
페이지를 식별하는 대표 URL

og:description, og:site_name, og:image:alt도 함께 제공하는 편이 좋습니다.

OG 이미지 URL은 절대 URL로 작성하기

외부 크롤러는 내 애플리케이션의 라우팅 문맥을 모를 수 있습니다.

1
2
3
4
5
6
7
8
<!-- 피하는 편이 좋은 형태 -->
<meta property="og:image" content="/images/og.png" />

<!-- 안전한 형태 -->
<meta
  property="og:image"
  content="https://example.com/images/og.png"
/>

대표 이미지는 외부에서 인증 없이 접근 가능해야 하고 올바른 Content-Type으로 응답해야 합니다.

1200×630 비율의 이미지를 흔히 사용하지만 플랫폼별 요구사항과 잘림 영역이 다를 수 있으므로 중요한 텍스트를 가장자리에 붙이지 않는 것이 좋습니다.


X Card metadata

X의 링크 미리보기에는 다음과 같은 metadata를 사용할 수 있습니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:title" content="프론트엔드 Metadata 완벽 이해하기" />
<meta
  name="twitter:description"
  content="HTML metadata의 원리부터 Next.js 사용법까지 설명합니다."
/>
<meta
  name="twitter:image"
  content="https://example.com/images/frontend-metadata-og.png"
/>
<meta
  name="twitter:image:alt"
  content="브라우저와 검색엔진이 metadata를 읽는 흐름"
/>

Open Graph와 내용이 많이 겹치지만 각 플랫폼의 표시 방식을 더 명확하게 제어할 수 있습니다.

검색 결과용 metadata
title, description, canonical, robots

SNS 공유용 metadata
Open Graph, X Card

목적이 겹치는 값은 재사용할 수 있지만 소비자는 다릅니다.


favicon, theme color와 모바일 표시

파비콘은 브라우저 탭과 북마크 등에서 사이트를 구분하게 해 줍니다.

1
2
3
<link rel="icon" href="/favicon.ico" sizes="any" />
<link rel="icon" href="/icon.svg" type="image/svg+xml" />
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />

지원 환경의 브라우저 UI 색상을 제안할 수도 있습니다.

1
<meta name="theme-color" content="#ffffff" />

다크 모드에 따라 값을 나눌 수 있습니다.

1
2
3
4
5
6
7
8
9
10
<meta
  name="theme-color"
  content="#ffffff"
  media="(prefers-color-scheme: light)"
/>
<meta
  name="theme-color"
  content="#111827"
  media="(prefers-color-scheme: dark)"
/>

이 값은 웹 페이지 본문의 배경색을 자동으로 바꾸지 않습니다. 지원하는 브라우저가 주소창 같은 UI 표현에 참고할 수 있는 metadata입니다.


언어와 다국어 페이지 metadata

문서의 기본 언어는 <html lang>으로 표시합니다.

1
<html lang="ko">

이는 스크린 리더의 발음, 브라우저 번역, 접근성 도구 등에 중요합니다.

같은 콘텐츠가 여러 언어 URL로 제공된다면 hreflang 관계를 알릴 수 있습니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
<link
  rel="alternate"
  hreflang="ko"
  href="https://example.com/ko/metadata"
/>
<link
  rel="alternate"
  hreflang="en"
  href="https://example.com/en/metadata"
/>
<link
  rel="alternate"
  hreflang="x-default"
  href="https://example.com/metadata"
/>

다국어 페이지에서는 title, description, Open Graph의 문구도 해당 언어로 작성해야 합니다.

URL만 언어별로 나눔
metadata는 모든 페이지에서 한국어
→ 검색 결과와 공유 카드의 언어가 맞지 않을 수 있음

구조화 데이터와 JSON-LD

title과 description이 문서를 짧게 설명한다면 구조화 데이터는 문서 속 정보의 의미와 관계를 더 구체적인 형식으로 전달합니다.

Google은 JSON-LD 형식을 권장하며 다음처럼 작성할 수 있습니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
<script type="application/ld+json">
  {
    "@context": "https://schema.org",
    "@type": "BlogPosting",
    "headline": "프론트엔드 Metadata 완벽 이해하기",
    "description": "HTML metadata의 원리와 사용법을 설명합니다.",
    "image": "https://example.com/images/frontend-metadata-og.png",
    "datePublished": "2026-11-02T09:30:00+09:00",
    "dateModified": "2026-11-02T09:30:00+09:00",
    "author": {
      "@type": "Person",
      "name": "Taewok"
    }
  }
</script>

페이지 종류에 따라 Article, Product, Recipe, Event, Organization, BreadcrumbList 같은 타입을 사용할 수 있습니다.

구조화 데이터는 화면의 사실과 일치해야 한다

화면에는 가격이 없음
JSON-LD에만 할인 가격 작성

실제 후기 없음
JSON-LD에 임의 별점 작성

이처럼 사용자에게 보이지 않는 내용을 구조화 데이터에만 넣으면 안 됩니다.

구조화 데이터는 검색 결과의 특별한 표시 가능성을 제공하지만 리치 결과 노출을 보장하지는 않습니다.


metadata가 SEO를 보장하지는 않는다

metadata는 페이지를 이해하기 위한 중요한 신호지만 SEO 전체는 아닙니다.

metadata
콘텐츠를 설명하고 색인·표시 방식을 돕는다.

콘텐츠 품질
사용자의 질문을 실제로 해결한다.

기술적 품질
크롤링 가능성, 성능, 모바일 사용성, 상태 코드, 내부 링크를 포함한다.

다음처럼 metadata만 채운 빈약한 페이지가 자동으로 좋은 평가를 받는 것은 아닙니다.

1
2
<title>세계 최고의 React 가이드</title>
<meta name="description" content="React의 모든 것을 완벽히 설명합니다." />

본문이 제목과 설명의 약속을 실제로 충족해야 합니다.


SPA에서 metadata가 어려운 이유

전통적인 서버 렌더링은 URL마다 완성된 HTML을 응답할 수 있습니다.

/products/keyboard 요청
→ keyboard용 title과 description이 포함된 HTML 응답

/products/mouse 요청
→ mouse용 title과 description이 포함된 HTML 응답

클라이언트 렌더링 SPA는 처음에 공통 HTML을 보내고 JavaScript가 실행된 뒤 화면과 metadata를 바꾸는 경우가 많습니다.

1
2
3
4
<!-- 모든 경로에서 처음 받은 HTML -->
<title>My App</title>
<div id="root"></div>
<script src="/app.js"></script>
HTML 다운로드
→ JavaScript 다운로드
→ React 실행
→ API 요청
→ metadata 변경

JavaScript를 실행하지 않는 공유 크롤러는 변경된 metadata를 보지 못할 수 있습니다. 검색엔진도 렌더링 시점과 구현 방식에 따라 처리가 늦어지거나 달라질 수 있습니다.

검색과 공유가 중요한 공개 페이지는 SSR이나 정적 생성을 통해 초기 HTML에 metadata가 포함되도록 만드는 것이 안정적입니다.


React에서 metadata 작성하기

현재 React는 <title>, <meta>, <link> 같은 metadata 컴포넌트를 렌더링하면 문서의 <head>로 배치할 수 있습니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
export function ProductPage({ product }: ProductPageProps) {
  return (
    <>
      <title>{product.name} | Acme Store</title>
      <meta name="description" content={product.summary} />
      <link
        rel="canonical"
        href={`https://store.example.com/products/${product.slug}`}
      />

      <main>
        <h1>{product.name}</h1>
      </main>
    </>
  );
}

React 버전과 렌더링 환경에 따라 지원 방식이 다를 수 있습니다. 기존 React SPA에서는 Helmet 계열 라이브러리나 라우터·프레임워크의 metadata 기능을 사용하기도 합니다.

중요한 것은 React 코드에서 태그를 만들 수 있느냐만이 아닙니다.

최초 HTML에 metadata가 포함되는가?
JavaScript 실행 후에만 생기는가?
라우트 이동 후 이전 metadata가 남지 않는가?
공유 크롤러가 최종 값을 읽을 수 있는가?

공개 페이지라면 배포된 URL의 원본 HTML을 직접 확인해야 합니다.


Next.js Metadata API 이해하기

Next.js App Router는 metadata 객체와 generateMetadata 함수를 제공합니다.

정적 metadata
export const metadata

동적 metadata
export async function generateMetadata()

파일 기반 metadata
favicon.ico, opengraph-image.png, robots.ts, sitemap.ts 등

Next.js가 반환값을 적절한 <head> 요소로 변환합니다.


Next.js 정적 metadata

값이 고정된 페이지나 레이아웃에서는 metadata 객체를 사용합니다.

1
2
3
4
5
6
7
8
9
10
11
// app/about/page.tsx
import type { Metadata } from "next";

export const metadata: Metadata = {
  title: "회사 소개",
  description: "Acme가 해결하는 문제와 팀을 소개합니다.",
};

export default function AboutPage() {
  return <h1>회사 소개</h1>;
}

Next.js는 다음과 비슷한 HTML을 생성합니다.

1
2
3
4
5
<title>회사 소개</title>
<meta
  name="description"
  content="Acme가 해결하는 문제와 팀을 소개합니다."
/>

metadata 객체와 generateMetadata는 Server Component에서만 export할 수 있습니다.

1
2
3
4
5
6
// 잘못된 구성
"use client";

export const metadata = {
  title: "Client Page",
};

클라이언트 상호작용이 필요한 부분은 자식 Client Component로 분리할 수 있습니다.


Next.js의 기본 charset과 viewport

App Router는 별도로 작성하지 않아도 기본 charset과 viewport metadata를 추가합니다.

1
2
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />

기본 viewport를 변경해야 한다면 metadata 객체 안에 넣지 않고 별도의 viewport를 export합니다.

1
2
3
4
5
6
7
8
9
10
11
// app/layout.tsx
import type { Viewport } from "next";

export const viewport: Viewport = {
  width: "device-width",
  initialScale: 1,
  themeColor: [
    { media: "(prefers-color-scheme: light)", color: "#ffffff" },
    { media: "(prefers-color-scheme: dark)", color: "#111827" },
  ],
};

요청 정보에 따라 동적으로 만들어야 한다면 generateViewport를 사용할 수 있습니다. 일반적인 반응형 페이지라면 Next.js의 기본 viewport로 충분한 경우가 많습니다.


루트 layout에서 공통 metadata 설정하기

사이트 전체 공통값은 루트 layout에 둡니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
// app/layout.tsx
import type { Metadata } from "next";

export const metadata: Metadata = {
  metadataBase: new URL("https://example.com"),
  title: {
    default: "Acme",
    template: "%s | Acme",
  },
  description: "팀의 업무를 단순하게 만드는 협업 서비스입니다.",
  openGraph: {
    siteName: "Acme",
    type: "website",
    locale: "ko_KR",
    images: [
      {
        url: "/images/default-og.png",
        width: 1200,
        height: 630,
        alt: "Acme",
      },
    ],
  },
  twitter: {
    card: "summary_large_image",
  },
};

export default function RootLayout({
  children,
}: Readonly<{
  children: React.ReactNode;
}>) {
  return (
    <html lang="ko">
      <body>{children}</body>
    </html>
  );
}

하위 페이지에서 title을 지정하면 template이 적용됩니다.

1
2
3
4
// app/pricing/page.tsx
export const metadata = {
  title: "요금제",
};

최종 title은 다음과 같습니다.

1
<title>요금제 | Acme</title>

metadataBase를 지정하면 Open Graph 이미지나 canonical에 상대 경로를 사용할 수 있고 Next.js가 절대 URL로 조합합니다.


Next.js 동적 metadata

상품이나 게시글처럼 데이터에 따라 값이 바뀌면 generateMetadata를 사용합니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
// app/posts/[slug]/page.tsx
import type { Metadata } from "next";
import { notFound } from "next/navigation";
import { getPost } from "@/lib/posts";

type PageProps = {
  params: Promise<{ slug: string }>;
};

export async function generateMetadata({
  params,
}: PageProps): Promise<Metadata> {
  const { slug } = await params;
  const post = await getPost(slug);

  if (!post) {
    return {
      title: "게시글을 찾을 수 없습니다",
      robots: {
        index: false,
        follow: false,
      },
    };
  }

  const canonicalPath = `/posts/${post.slug}`;

  return {
    title: post.title,
    description: post.summary,
    alternates: {
      canonical: canonicalPath,
    },
    openGraph: {
      title: post.title,
      description: post.summary,
      type: "article",
      url: canonicalPath,
      publishedTime: post.publishedAt,
      modifiedTime: post.updatedAt,
      images: [
        {
          url: post.ogImage,
          width: 1200,
          height: 630,
          alt: post.title,
        },
      ],
    },
    twitter: {
      card: "summary_large_image",
      title: post.title,
      description: post.summary,
      images: [post.ogImage],
    },
  };
}

export default async function PostPage({ params }: PageProps) {
  const { slug } = await params;
  const post = await getPost(slug);

  if (!post) notFound();

  return (
    <article>
      <h1>{post.title}</h1>
      <div>{post.content}</div>
    </article>
  );
}

같은 route segment에서 metadata 객체와 generateMetadata 함수를 동시에 export할 수는 없습니다.

정적 값이면 metadata
데이터와 params가 필요하면 generateMetadata

정적 metadata와 동적 metadata의 처리 시점

정적으로 렌더링할 수 있는 페이지의 metadata는 빌드 또는 사전 렌더링 결과에 포함됩니다.

동적 페이지에서는 generateMetadata가 요청 시 실행될 수 있습니다. 현재 Next.js는 상황에 따라 시각적인 UI를 먼저 보내고, 완료된 metadata를 이어서 스트리밍할 수 있습니다.

정적 페이지
metadata를 사전 렌더링된 HTML에 포함

동적 페이지
요청 중 generateMetadata 실행 가능
→ 조건에 따라 metadata 스트리밍

Next.js는 metadata가 <head>에 먼저 있어야 하는 HTML 제한 크롤러를 감지해 스트리밍하지 않고 metadata가 준비될 때까지 기다리는 동작도 제공합니다.

대부분의 프로젝트에서는 기본 동작을 따르면 됩니다. 중요한 것은 metadata를 무조건 클라이언트의 useEffect에서 늦게 바꾸는 방식과 달리, Next.js가 서버 렌더링 과정 안에서 metadata를 관리한다는 점입니다.


metadata 데이터 요청 중복 줄이기

동적 페이지에서는 metadata와 본문이 같은 데이터를 필요로 할 수 있습니다.

generateMetadata → getPost(slug)
Page             → getPost(slug)

fetch를 사용하는 경우 Next.js가 같은 렌더링 과정의 요청을 메모이제이션할 수 있습니다. 데이터베이스 SDK처럼 fetch가 아닌 함수를 사용한다면 React cache로 요청을 공유할 수 있습니다.

1
2
3
4
5
6
7
8
9
// lib/posts.ts
import { cache } from "react";
import { db } from "@/lib/db";

export const getPost = cache(async (slug: string) => {
  return db.post.findUnique({
    where: { slug },
  });
});

이 함수는 페이지와 generateMetadata에서 함께 사용할 수 있습니다.


Next.js metadata 병합 시 주의점

Next.js는 루트 layout에서 최종 page 방향으로 metadata를 평가합니다.

app/layout.tsx
→ app/blog/layout.tsx
→ app/blog/[slug]/page.tsx

일반 필드는 하위 segment의 값이 상위 값을 덮어쓸 수 있습니다.

중첩 객체는 깊게 합쳐지는 것이 아니라 얕게 병합됩니다.

1
2
3
4
5
6
7
// app/layout.tsx
export const metadata = {
  openGraph: {
    siteName: "Acme",
    images: ["/default-og.png"],
  },
};
1
2
3
4
5
6
// app/blog/page.tsx
export const metadata = {
  openGraph: {
    title: "Blog",
  },
};

하위에서 openGraph를 새로 정의하면 상위의 siteName과 images가 자동으로 깊은 병합되지 않을 수 있습니다.

공통 객체를 분리해 명시적으로 재사용하는 편이 안전합니다.

1
2
3
4
5
// app/shared-metadata.ts
export const sharedOpenGraph = {
  siteName: "Acme",
  images: ["/default-og.png"],
};
1
2
3
4
5
6
export const metadata = {
  openGraph: {
    ...sharedOpenGraph,
    title: "Blog",
  },
};

Next.js 파일 기반 metadata

App Router는 파일 이름으로 metadata를 생성할 수 있습니다.

app/
├─ favicon.ico
├─ icon.png
├─ apple-icon.png
├─ opengraph-image.png
├─ twitter-image.png
├─ robots.ts
├─ sitemap.ts
└─ layout.tsx

정적 OG 이미지는 opengraph-image.png로 둘 수 있습니다.

경로별 이미지를 만들려면 해당 route 디렉터리에 파일을 둡니다.

app/
├─ opengraph-image.png          # 사이트 기본 이미지
└─ blog/
   ├─ opengraph-image.png       # /blog 전용 이미지
   └─ [slug]/
      └─ opengraph-image.tsx    # 게시글별 동적 이미지

동적 이미지는 ImageResponse로 생성할 수 있습니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
// app/posts/[slug]/opengraph-image.tsx
import { ImageResponse } from "next/og";
import { getPost } from "@/lib/posts";

export const size = {
  width: 1200,
  height: 630,
};

export const contentType = "image/png";

export default async function Image({
  params,
}: {
  params: Promise<{ slug: string }>;
}) {
  const { slug } = await params;
  const post = await getPost(slug);

  return new ImageResponse(
    <div
      style={{
        width: "100%",
        height: "100%",
        display: "flex",
        alignItems: "center",
        justifyContent: "center",
        padding: "80px",
        color: "white",
        background: "#111827",
        fontSize: 64,
      }}
    >
      {post?.title ?? "게시글"}
    </div>,
    size,
  );
}

Next.js에서 robots와 sitemap 만들기

robots.ts를 사용하면 robots.txt를 생성할 수 있습니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
// app/robots.ts
import type { MetadataRoute } from "next";

export default function robots(): MetadataRoute.Robots {
  return {
    rules: {
      userAgent: "*",
      allow: "/",
      disallow: ["/admin/", "/private/"],
    },
    sitemap: "https://example.com/sitemap.xml",
  };
}

sitemap.ts에서는 검색엔진이 발견할 URL 목록을 제공합니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// app/sitemap.ts
import type { MetadataRoute } from "next";
import { getPosts } from "@/lib/posts";

export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
  const posts = await getPosts();

  return [
    {
      url: "https://example.com",
      lastModified: new Date(),
      priority: 1,
    },
    ...posts.map((post) => ({
      url: `https://example.com/posts/${post.slug}`,
      lastModified: new Date(post.updatedAt),
      priority: 0.7,
    })),
  ];
}

사이트맵은 페이지를 강제로 색인시키는 명령이 아니라 URL 발견을 돕는 자료입니다.


Next.js에서 JSON-LD 추가하기

JSON-LD는 페이지 컴포넌트에서 <script>로 렌더링할 수 있습니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
export default async function PostPage({ params }: PageProps) {
  const { slug } = await params;
  const post = await getPost(slug);

  if (!post) notFound();

  const jsonLd = {
    "@context": "https://schema.org",
    "@type": "BlogPosting",
    headline: post.title,
    description: post.summary,
    image: post.ogImage,
    datePublished: post.publishedAt,
    dateModified: post.updatedAt,
    author: {
      "@type": "Person",
      name: post.author.name,
    },
  };

  return (
    <>
      <script
        type="application/ld+json"
        dangerouslySetInnerHTML={{
          __html: JSON.stringify(jsonLd).replace(/</g, "\\u003c"),
        }}
      />

      <article>
        <h1>{post.title}</h1>
      </article>
    </>
  );
}

< 문자를 유니코드 이스케이프로 바꾸면 예상하지 않은 HTML 해석이나 script 종료 문자열이 들어오는 위험을 줄일 수 있습니다.

외부 데이터로 JSON-LD를 만들 때는 신뢰할 수 없는 값을 그대로 문자열 템플릿에 이어 붙이지 않는 것이 중요합니다.


작성한 metadata 검증하기

코드에 값을 작성했다고 끝난 것이 아닙니다. 실제 배포 결과를 확인해야 합니다.

1. 브라우저 개발자 도구

Elements 패널에서 현재 DOM의 <head>를 확인합니다.

1
2
3
4
5
6
7
document.title;

document.querySelector('meta[name="description"]')?.content;

document.querySelector('link[rel="canonical"]')?.href;

document.querySelector('meta[property="og:image"]')?.content;

2. 페이지 원본 보기

SPA에서는 개발자 도구에 metadata가 보여도 최초 HTML에는 없을 수 있습니다.

Elements
JavaScript 실행 이후의 현재 DOM

View Page Source 또는 curl
서버가 처음 보낸 HTML

터미널에서는 다음처럼 확인할 수 있습니다.

1
curl -L https://example.com/posts/metadata

3. 구조화 데이터 검사

Google Rich Results Test로 JSON-LD 문법과 지원되는 검색 기능을 확인합니다.

문법이 유효하더라도 실제 페이지 내용과 일치하는지 직접 검토해야 합니다.

4. 공유 미리보기 검사

플랫폼별 공유 디버거로 OG 제목, 설명, 이미지를 확인합니다.

공유 서비스는 metadata를 캐시할 수 있습니다. 코드를 수정해도 예전 이미지가 잠시 남을 수 있으므로 해당 플랫폼의 다시 수집 기능을 사용합니다.

5. URL 직접 검사

OG 이미지와 canonical URL을 브라우저에서 직접 열어 확인합니다.

200 상태 코드인가?
로그인 없이 접근 가능한가?
HTTPS URL인가?
이미지 Content-Type이 올바른가?
리다이렉트가 지나치게 많지 않은가?

자주 발생하는 실수

1. 모든 페이지에 같은 title과 description 사용

페이지별 차이를 검색엔진과 사용자에게 전달하지 못합니다.

홈, 상품, 게시글, 검색 결과 페이지
→ 각 페이지 내용을 설명하는 고유 metadata 작성

2. title과 본문 내용이 다름

클릭을 유도하려고 과장된 title을 쓰면 사용자의 기대와 콘텐츠가 어긋납니다. 검색엔진이 다른 제목을 선택할 가능성도 있습니다.

3. OG 이미지에 상대 URL 사용

외부 크롤러가 이미지를 찾지 못할 수 있습니다. 절대 URL을 사용합니다.

4. metadata를 Client Component에서만 생성

JavaScript를 실행하지 않는 공유 크롤러가 값을 못 볼 수 있습니다. 공개 페이지는 초기 HTML 결과를 확인합니다.

5. noindex를 배포 후 제거하지 않음

스테이징 설정이 운영으로 넘어가면 중요한 페이지가 검색 결과에서 제외될 수 있습니다.

6. robots.txt로 막고 noindex도 읽을 것이라 생각함

크롤러가 페이지를 가져오지 못하면 내부의 noindex를 발견하지 못할 수 있습니다.

7. canonical을 모든 페이지에서 홈으로 지정

서로 다른 페이지가 모두 홈의 중복 페이지라는 잘못된 신호를 줄 수 있습니다.

1
2
<!-- 모든 페이지에 무조건 사용하면 안 됨 -->
<link rel="canonical" href="https://example.com" />

8. JSON-LD에 화면에 없는 정보 작성

구조화 데이터는 사용자에게 실제로 제공되는 콘텐츠를 설명해야 합니다.

9. meta keywords를 많이 넣으면 SEO가 좋아진다고 생각함

Google Search는 이 태그를 검색 순위에 사용하지 않습니다.

10. Next.js 중첩 metadata가 깊게 병합된다고 생각함

하위 openGraph 객체가 상위 객체 전체를 대체할 수 있으므로 공통 필드를 명시적으로 재사용합니다.

11. metadata 변경 즉시 모든 미리보기가 바뀐다고 생각함

검색엔진과 SNS는 이전 결과를 캐시할 수 있습니다. 재크롤링과 캐시 갱신에는 시간이 걸릴 수 있습니다.

12. metadata에 비밀값을 넣음

metadata는 최종 HTML에 포함되어 누구나 볼 수 있습니다.

1
2
<!-- 절대 넣으면 안 되는 값 -->
<meta name="private-api-key" content="secret" />

페이지 종류별 체크리스트

일반 공개 페이지

[ ] 고유한 title
[ ] 페이지 내용을 요약하는 description
[ ] 올바른 canonical URL
[ ] Open Graph title, description, image, url
[ ] X Card metadata
[ ] favicon과 언어 설정
[ ] 모바일 viewport

블로그 게시글

[ ] og:type=article
[ ] 게시 날짜와 수정 날짜
[ ] 작성자 정보
[ ] 게시글별 대표 이미지
[ ] BlogPosting 또는 Article JSON-LD
[ ] 실제 본문과 metadata 내용 일치

상품 페이지

[ ] 상품명과 핵심 특징이 드러나는 title
[ ] 상품별 description
[ ] 상품 대표 이미지
[ ] 품절·삭제 상품의 상태 코드와 robots 정책
[ ] 화면 정보와 일치하는 Product 구조화 데이터

비공개 또는 임시 페이지

[ ] 인증과 권한 검사
[ ] 필요한 경우 noindex
[ ] sitemap에서 제외
[ ] 운영 환경에서 접근 정책 재확인

noindex는 접근 제어를 대신하지 않는다는 점을 다시 기억해야 합니다.


실무용 metadata 설계 순서

metadata를 페이지마다 즉흥적으로 작성하면 중복과 누락이 생깁니다.

다음 순서로 설계할 수 있습니다.

1. 사이트 공통값 정의
   사이트 이름, 기본 설명, 기본 OG 이미지, metadataBase

2. 페이지 유형 구분
   홈, 목록, 상세, 검색, 로그인, 오류 페이지

3. 각 유형의 index 정책 결정
   공개, noindex, canonical 대상

4. 페이지별 동적 값 연결
   title, summary, slug, image, publishedAt

5. 검색·공유·구조화 데이터 값 일치
   서로 다른 제목을 무분별하게 만들지 않기

6. 초기 HTML과 실제 URL 검증
   개발자 도구만 보지 않고 배포 결과 확인

재사용 가능한 생성 함수를 만들 수도 있습니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
import type { Metadata } from "next";

type PostMetadataInput = {
  title: string;
  summary: string;
  slug: string;
  image: string;
  publishedAt: string;
};

export function createPostMetadata({
  title,
  summary,
  slug,
  image,
  publishedAt,
}: PostMetadataInput): Metadata {
  const path = `/posts/${slug}`;

  return {
    title,
    description: summary,
    alternates: {
      canonical: path,
    },
    openGraph: {
      title,
      description: summary,
      type: "article",
      url: path,
      publishedTime: publishedAt,
      images: [{ url: image, alt: title }],
    },
    twitter: {
      card: "summary_large_image",
      title,
      description: summary,
      images: [image],
    },
  };
}

이렇게 하면 필드 누락을 줄이고 검색 결과와 공유 카드의 내용을 일관되게 유지할 수 있습니다.


전체 원리 다시 정리하기

페이지 콘텐츠
사용자가 읽는 실제 정보
        ↓
metadata로 페이지의 의미와 표시 정책 설명
        ↓
브라우저
탭 제목, viewport, icon 처리
        ↓
검색엔진
title, description, canonical, robots, JSON-LD 해석
        ↓
SNS와 메신저
Open Graph와 카드 metadata 해석

metadata는 모든 소비자에게 결과를 강제하는 명령서가 아닙니다.

title
검색 결과 제목의 중요한 후보

description
검색 결과 설명의 후보

canonical
대표 URL을 선택하기 위한 신호

Open Graph
공유 서비스가 링크 카드를 만드는 정보

JSON-LD
페이지 속 개체와 관계를 설명하는 구조화된 정보

각 소비자는 metadata와 실제 콘텐츠, HTTP 응답, 사이트 구조를 함께 보고 최종 결과를 결정합니다.


마무리

프론트엔드 metadata는 보이지 않는 부가 정보가 아니라 페이지가 웹 생태계와 대화하는 인터페이스입니다.

사용자는 본문을 읽는다.
브라우저는 문서 설정을 읽는다.
검색엔진은 페이지의 의미와 색인 정책을 읽는다.
SNS는 공유 카드 정보를 읽는다.

좋은 metadata를 만들기 위해 기억할 핵심은 다음과 같습니다.

페이지마다 고유하고 정확한 title과 description을 작성한다.

canonical, robots, sitemap의 역할을 구분한다.

Open Graph 이미지와 URL은 외부에서 접근 가능한 절대 URL로 제공한다.

구조화 데이터는 화면에 보이는 실제 정보와 일치시킨다.

공개 페이지는 metadata가 초기 HTML에도 존재하는지 확인한다.

Next.js 중첩 metadata의 얕은 병합을 주의한다.

개발자 도구뿐 아니라 원본 HTML과 배포 URL을 검증한다.

태그 이름만 외우기보다 누가 이 값을 읽고 어떤 결정을 내리는가를 생각하면 새로운 metadata를 만나도 올바르게 사용할 수 있습니다.


참고 자료

이 기사는 저작권자의 CC BY 4.0 라이센스를 따릅니다.

[환경 변수] .env 파일 완벽 이해하기: 종류, 우선순위, 보안 그리고 Next.js·Vite 활용법

-