초보자를 위한 Next.js SEO: 검색엔진 색인과 OG 이미지까지 직접 구현하기

작성일: 2026년 8월 11일 AM 09:00(마지막 수정: 2026년 8월 11일 AM 09:23)
조회수: 24

thumbnail

블로그 글을 하나 발행한 뒤 친구에게 링크를 보냈습니다. 그런데 검색 결과에는 글이 보이지 않았고, 카카오톡 공유 카드에는 글과 상관없는 이미지가 나타났습니다. 글의 내용은 멀쩡한데 검색엔진과 소셜 서비스가 글을 제대로 이해하지 못한 것입니다.

Next.js 프로젝트의 기능 구현이 끝났다면 이제 SEO를 시작할 차례입니다. 그런데 SEO라는 말을 처음 들으면 어디부터 손대야 할지 막막합니다. sitemap부터 만들어야 할까요? JSON-LD를 먼저 넣어야 할까요? 키워드를 많이 적어야 할까요?

이 글에서는 완성된 Next.js 프로젝트에 SEO를 처음 적용한다고 가정하고, 작업 순서를 처음부터 설명합니다. SEO라는 단어와 Next.js App Router가 아직 익숙하지 않은 사람도 따라올 수 있도록 검색엔진을 도서관에 비유해 보겠습니다.

중요한 약속이 하나 있습니다. SEO 설정을 잘했다고 검색 결과의 첫 번째 자리에 자동으로 올라가지는 않습니다. 우리가 할 일은 검색봇과 소셜 서비스가 페이지를 정확하게 읽을 수 있도록 좋은 안내판을 만드는 것입니다.

프로젝트가 끝났다면 SEO는 이 순서로 시작하세요

SEO는 한 번에 모든 기능을 추가하는 일이 아닙니다. 먼저 검색에 보여줄 페이지와 보여주지 않을 페이지를 정하고, 주소와 페이지 표지를 정리한 뒤, 검색엔진이 내용을 이해하도록 도와주는 파일을 추가합니다.

순서먼저 할 일완료 기준
1검색 대상 페이지와 제외 대상 페이지 분류어떤 URL을 색인할지 목록으로 설명할 수 있습니다.
2공식 사이트 URL과 canonical 설정모든 페이지가 같은 운영 주소를 사용합니다.
3title·description·robots 설정각 페이지에 읽을 수 있는 표지 정보가 있습니다.
4heading·본문·이미지 alt 정리사람이 읽어도 구조가 명확하고 이미지 설명이 있습니다.
5JSON-LD 추가게시글과 breadcrumb의 의미를 검색엔진에 전달합니다.
6sitemap·robots.txt·RSS 연결검색봇과 구독자가 공개 콘텐츠를 찾을 수 있습니다.
7OG 이미지와 fallback 구성썸네지가 없어도 공유 카드가 비어 보이지 않습니다.
8로컬 검증·배포·Search Console 확인실제 HTML과 운영 환경의 응답을 확인합니다.

처음 SEO를 시작한다면 1~4단계를 먼저 진행하세요. 이 단계들은 페이지가 어디에 있고, 어떤 내용이며, 검색엔진이 읽을 수 있는지를 정리하는 기본 작업입니다.

그다음 5~7단계에서 검색엔진의 이해와 소셜 공유 품질을 보완합니다. 마지막 8단계에서는 테스트와 배포 후 확인을 진행합니다.

SEO는 검색 순위를 조종하는 버튼이 아닙니다. 잘 만든 안내판을 세우고, 검색엔진이 그 안내판을 실제로 읽었는지 확인하는 작업에 가깝습니다.

3분 용어 사전

먼저 이 글에서 자주 사용하는 단어를 정리해 보겠습니다.

  • 웹사이트: 책이 여러 권 모여 있는 도서관입니다.
  • 페이지: 도서관 안의 책 한 권입니다.
  • URL: 그 책을 찾는 주소입니다.
  • SEO: Search Engine Optimization의 줄임말로, 검색엔진이 글을 찾고 이해하기 쉽게 만드는 작업입니다.
  • HTML: 브라우저가 읽는 페이지 설계도입니다.
  • Markdown: 제목과 코드처럼 글의 구조를 간단히 적는 문법입니다.
  • Next.js App Router: app 폴더의 파일 구조로 페이지 주소를 만드는 Next.js 방식입니다.
  • metadata: 책 표지에 적힌 제목과 짧은 소개처럼, 페이지를 설명하는 정보입니다.
  • JSON-LD: 검색엔진이 읽기 쉬운 정보 카드입니다. 정확히는 JavaScript Object Notation for Linked Data의 줄임말입니다.
  • Open Graph: 링크를 공유할 때 제목·설명·이미지를 정하는 규칙입니다.
  • Schema.org: BlogPosting 같은 데이터 종류와 필드 이름을 정해 둔 공통 사전입니다.
  • canonical: 여러 주소 중 공식 주소를 알려주는 정보입니다.
  • noindex: 해당 페이지를 검색 결과 목록에 넣지 말라는 지시입니다.
  • sitemap: 사이트의 공개 페이지 주소를 모아 둔 지도입니다.
  • robots.txt: 검색봇에게 어떤 경로를 살펴볼지 알려주는 안내문입니다.
  • RSS: 새 글이 발행되었을 때 구독자에게 알려주는 알림 통로입니다.
  • Supabase: 이 프로젝트에서 게시글과 썸네일 주소를 저장하는 데이터베이스 서비스입니다.
  • Vitest: JavaScript와 TypeScript 함수를 자동으로 검사하는 테스트 도구입니다.
  • smoke test: 서버를 실행하고 실제 URL 응답을 확인하는 간단한 검사입니다.
  • fallback: 첫 번째 방법이 안 될 때 사용하는 두 번째 방법입니다.

이 글에서 사용하는 프로젝트

이 글의 예시는 myblog라는 개인 기술 블로그 프로젝트를 기준으로 합니다. 하지만 독자가 이 저장소를 미리 알고 있을 필요는 없습니다.

myblog는 다음 기술을 사용하는 웹사이트입니다.

  • Next.js App Router: 화면과 URL을 만듭니다.
  • Supabase: 게시글, 작성일, 썸네일 URL을 저장합니다.
  • Markdown: 게시글 본문을 저장하고 화면에서 HTML로 보여줍니다.
  • React: 화면 컴포넌트를 구성합니다.

이 글에서 “현재 구현”이라고 말할 때는 이 프로젝트에 실제로 들어간 SEO 코드를 뜻합니다. 다른 Next.js 프로젝트에 적용할 때는 파일 이름과 데이터 조회 부분이 다를 수 있습니다. 하지만 “공식 사이트 주소를 하나로 관리한다”, “게시글 내용을 설명하는 metadata를 만든다”, “공유 이미지 fallback을 둔다”는 설계 원칙은 그대로 사용할 수 있습니다.

파일 경로는 저장소의 가장 바깥 폴더를 기준으로 읽습니다. 예를 들어 src/lib/site-config.ts는 “프로젝트 루트 아래의 srclib 폴더 안에 있는 site-config.ts 파일”이라는 뜻입니다.

코드를 직접 실행하지 않더라도 각 파일이 어떤 역할을 맡는지 중심으로 읽으면 됩니다. 직접 실행해 보고 싶다면 프로젝트 루트에서 npm install을 실행하고, .env.local을 설정한 뒤 npm run dev로 개발 서버를 시작하면 됩니다.

검색엔진은 도서관처럼 움직입니다

검색엔진을 거대한 도서관이라고 생각해 보겠습니다.

실제 개념도서관으로 비유하면하는 일
크롤링사서가 책을 찾아 방문하는 일페이지에 들어와 내용을 읽습니다.
색인책 제목과 내용을 목록에 적는 일나중에 검색할 수 있도록 저장합니다.
검색 순위어떤 책을 앞쪽 서가에 둘지 정하는 일검색어와 여러 품질 신호를 보고 순서를 정합니다.
sitemap도서관 지도중요한 페이지 주소를 알려줍니다.
robots.txt도서관 입구의 방문 안내문크롤러가 어디를 살펴볼지 안내합니다.
canonical복사본이 여러 서가에 있을 때 원본의 공식 주소대표 URL을 알려줍니다.
noindex목록에 넣지 말라는 스티커검색 결과에 표시하지 않도록 요청합니다.
OG 이미지친구에게 책을 소개할 때 쓰는 책 소개 카드링크 공유 카드에 제목과 이미지를 보여줍니다.

여기서 헷갈리기 쉬운 점은 크롤링, 색인, 검색 결과 노출이 같은 일이 아니라는 것입니다.

검색봇이 페이지를 방문했다고 반드시 색인되는 것은 아닙니다. 색인되었다고 반드시 높은 순위에 표시되는 것도 아닙니다. sitemap을 제출했다고 즉시 검색 결과에 나타나는 것도 아닙니다.

따라서 구현의 목표를 “검색 1등 만들기”가 아니라 “검색엔진이 읽을 수 있는 정확한 페이지 만들기”로 잡아야 합니다.

1단계: 검색 대상 페이지와 제외 대상 페이지를 나눕니다

SEO를 시작할 때 가장 먼저 할 일은 코드를 작성하는 것이 아닙니다. 내 사이트의 어떤 페이지를 검색 결과에 보여줄지 정하는 일입니다.

왜 필요한가요?

모든 URL을 검색 결과에 넣는 것이 좋은 것은 아닙니다.

예를 들어 다음 페이지를 생각해 보겠습니다.

  • 홈 페이지
  • 게시글 목록
  • 게시글 상세 페이지
  • 관리자 페이지
  • 로그인 페이지
  • 검색 결과 페이지
  • 정렬이나 필터가 적용된 목록 페이지

공개 게시글은 검색엔진이 찾아야 하지만, 관리자 페이지나 로그인 페이지는 검색 결과에 노출할 이유가 없습니다. 검색어에 따라 계속 바뀌는 검색 결과 페이지도 다른 페이지와 내용이 겹치기 쉽습니다.

일반적인 Next.js 프로젝트에서는 무엇을 확인하나요?

먼저 사이트의 URL 목록을 적어 봅니다.

text
/                       공개
/posts                  공개
/posts/123              공개
/search?q=next          제외
/admin                  제외
/login                  제외

그다음 각각의 페이지에 다음 중 하나를 정합니다.

  • index, follow: 검색 결과에 포함될 수 있고 페이지 안의 링크도 따라갑니다.
  • noindex, follow: 이 페이지는 검색 결과에서 제외하지만 페이지 안의 링크는 따라갑니다.
  • 인증 필요: 검색엔진이 접근하기 전에 로그인이나 권한 검사가 필요합니다.

robots.txtnoindex는 서로 다른 도구입니다.

  • robots.txt: 검색봇이 어디를 방문할지 안내합니다.
  • noindex: 페이지를 읽은 뒤 검색 목록에 넣지 말라고 요청합니다.

noindex를 읽게 하려면 robots.txt로 해당 페이지를 막아서는 안 됩니다. 방문을 막아 버리면 검색봇이 페이지의 noindex 지시를 읽을 수 없기 때문입니다.

robots.txt도 자물쇠가 아닙니다. 안내문을 무시하는 크롤러가 있을 수 있고, 비밀 정보를 숨기는 보안 장치로 사용할 수도 없습니다. 공개하면 안 되는 데이터는 서버 인증과 권한 검사로 보호해야 합니다.

myblog에서는 어떻게 연결되나요?

myblog의 /search에는 noindex, follow를 사용하고 sitemap에서도 제외합니다. 검색어가 바뀔 때마다 내용이 달라지고 다른 페이지와 겹치기 쉽기 때문입니다.

반면 다음 URL은 아직 별도의 noindex 정책으로 닫혀 있지 않습니다.

text
/posts?tag=1
/posts?sort=popular

이 목록 URL은 canonical을 /posts로 둘 수 있지만, canonical만으로 query URL의 모든 색인 문제가 자동으로 해결되는 것은 아닙니다. /search와 달리 부모 layout의 index, follow 정책을 물려받을 수 있고, Open Graph URL에는 query가 포함될 수 있습니다.

이 차이는 현재 구현의 한계이자 후속 정책 결정 대상입니다.

이 단계의 완료 기준

다음 질문에 답할 수 있으면 1단계가 끝난 것입니다.

  • 검색 결과에 보여줄 페이지는 무엇인가요?
  • 검색 결과에서 제외할 페이지는 무엇인가요?
  • query parameter가 붙은 URL은 어떻게 처리하나요?
  • sitemap에 넣을 URL과 넣지 않을 URL을 구분했나요?
  • 관리자 페이지와 비공개 데이터가 서버 권한으로 보호되고 있나요?

2단계: 사이트 주소와 canonical을 고정합니다

검색엔진에게 두 번째로 필요한 정보는 “이 사이트의 공식 주소가 어디인가?”입니다.

왜 필요한가요?

개발 중인 주소와 운영 주소가 섞이면 다음 값들이 서로 달라질 수 있습니다.

  • canonical URL
  • Open Graph와 JSON-LD의 URL
  • sitemap의 URL
  • robots.txt가 가리키는 sitemap URL
  • RSS의 게시글 링크

검색엔진 입장에서는 서로 다른 주소를 가진 여러 사이트처럼 보일 수 있습니다.

canonical은 여러 주소 중 “이 페이지의 공식 주소는 이것입니다”라고 알려주는 정보입니다.

일반적인 Next.js 프로젝트에서는 무엇을 확인하나요?

운영 사이트의 공식 주소를 환경변수로 관리합니다.

env
# 로컬 개발
NEXT_PUBLIC_SITE_URL=http://localhost:3000

# 운영
NEXT_PUBLIC_SITE_URL=https://example.com

로컬에서는 http://localhost:3000을 사용해도 됩니다. 하지만 운영 환경에서는 반드시 실제 HTTPS 주소를 사용해야 합니다.

환경변수가 없을 때 임의로 localhost나 예전 배포 주소를 사용하게 만들면 문제가 늦게 발견됩니다. 운영 사이트가 잘못된 주소를 계속 검색엔진에 알려줄 수 있기 때문입니다.

myblog에서는 어떻게 연결되나요?

myblog에는 src/lib/site-config.ts가 있습니다. 이 파일의 getSiteUrl()은 사이트 URL을 한 곳에서 검사하고 정리합니다.

  • 환경변수가 없으면 오류를 냅니다.
  • 상대 경로가 아니라 절대 URL만 허용합니다.
  • 운영 환경에서는 https:만 허용합니다.
  • 주소 끝의 /를 제거해 형식을 통일합니다.

layout, 게시글 metadata, JSON-LD, RSS, sitemap, robots가 이 주소 정책을 공유합니다.

.env.local은 프로젝트 루트에 저장하고, 환경변수를 바꾼 뒤에는 개발 서버를 다시 시작합니다. 비밀키와 환경 설정이 들어갈 수 있으므로 Git에 올리지 않습니다.

이 단계의 완료 기준

  • NEXT_PUBLIC_SITE_URL이 로컬과 운영에 각각 설정되어 있습니다.
  • 운영 주소가 HTTPS입니다.
  • canonical, sitemap, robots, RSS, JSON-LD가 같은 사이트 주소를 사용합니다.
  • 주소 끝의 slash 처리 방식이 통일되어 있습니다.
  • 환경변수가 빠졌을 때 잘못된 주소로 조용히 동작하지 않습니다.

3단계: title·description·robots를 설정합니다

페이지에는 검색엔진이 읽을 수 있는 표지가 있어야 합니다.

왜 필요한가요?

사람은 화면을 보면 페이지가 어떤 내용인지 알 수 있습니다. 검색엔진은 HTML과 metadata를 읽어 페이지의 제목과 설명을 파악합니다.

title은 검색 결과의 제목이 될 수 있고, description은 검색 결과 아래에 보이는 설명 후보가 됩니다. 다만 검색엔진이 우리가 작성한 description을 항상 그대로 보여주는 것은 아닙니다.

일반적인 Next.js 프로젝트에서는 무엇을 확인하나요?

Next.js App Router에서는 공통 layout의 metadata와 페이지별 generateMetadata()를 사용합니다.

공통 layout에는 모든 페이지에 적용할 기본 정보를 둡니다.

  • 기본 title
  • 기본 description
  • robots 기본값
  • 사이트 이름

게시글 페이지에는 게시글마다 다른 정보를 만듭니다.

  • 게시글 title
  • 본문에서 추출한 description
  • 게시글 canonical
  • 작성일
  • 게시글 이미지
  • 게시글별 robots 정책

Markdown 글에서 description을 만들 때는 사람이 읽을 수 없는 부분을 제거해야 합니다.

  1. 이미지와 링크 문법을 제거합니다.
  2. 코드 블록과 HTML 태그를 제거합니다.
  3. 여러 줄의 공백을 한 칸으로 합칩니다.
  4. 너무 길면 문장이 중간에 잘리지 않도록 가능한 문장 경계에서 줄입니다.
  5. 내용이 비어 있으면 게시글 기본 설명을 사용합니다.

keywords를 많이 넣는 것보다 제목, 본문, heading, 링크가 실제 내용과 잘 맞는 것이 더 중요합니다.

title.template를 조심합니다

Next.js layout에서 다음처럼 제목 형식을 정할 수 있습니다.

ts
title: {
  template: '%s | MyBlog',
  default: 'MyBlog - 김영인의 기술 블로그',
}

이때 페이지의 generateMetadata()가 이미 내 글 | MyBlog를 반환하면 최종 제목에 suffix가 두 번 붙을 수 있습니다.

현재 myblog의 실제 HTML도 다음처럼 보일 수 있습니다.

text
글 제목 | MyBlog | MyBlog

title.template을 사용한다면 페이지에서는 보통 내 글처럼 논리적인 제목만 반환하는 편이 안전합니다.

metadata 객체만 보고 끝내지 말고 실제 HTML의 <title>을 확인해야 합니다. 설정한 값과 브라우저가 실제로 받은 값이 다를 수 있기 때문입니다.

myblog에서는 어떻게 연결되나요?

게시글 route의 generateMetadata()가 게시글 데이터를 읽고 title, description, canonical, Open Graph 정보를 만듭니다.

description은 Markdown 본문에서 추출합니다. 내용이 비어 있으면 기본 설명 fallback을 사용해 빈 description이 만들어지지 않게 합니다.

검색 페이지는 noindex, follow를 유지합니다. 반면 /posts의 query URL은 아직 별도 noindex 정책이 명시되어 있지 않다는 점을 기억해야 합니다.

이 단계의 완료 기준

  • 모든 공개 페이지에 읽을 수 있는 title이 있습니다.
  • 게시글 description이 본문 내용과 관련되어 있습니다.
  • description이 비어 있는 게시글에는 기본 설명이 있습니다.
  • 검색하지 않을 페이지에는 적절한 robots 정책이 있습니다.
  • 실제 HTML에서 <title>, description, canonical을 확인했습니다.
  • title.template 때문에 제목이 중복되지 않는지 확인했습니다.

4단계: heading·본문·이미지 alt를 정리합니다

SEO는 metadata만의 일이 아닙니다. 사람이 읽는 본문 자체가 명확해야 합니다.

왜 필요한가요?

검색엔진은 페이지의 제목과 설명만 보는 것이 아닙니다. 실제 HTML 본문이 어떤 구조인지, 제목과 내용이 서로 맞는지, 이미지에 설명이 있는지도 확인합니다.

heading은 단순히 글자를 크게 보이게 하는 장식이 아닙니다. 글의 목차이자 구조입니다.

일반적인 Next.js 프로젝트에서는 무엇을 확인하나요?

일반적으로 페이지는 다음과 같은 구조로 시작합니다.

html
<h1>페이지의 가장 큰 제목</h1>
<h2>첫 번째 큰 주제</h2>
<h3>첫 번째 주제의 세부 내용</h3>
<h2>두 번째 큰 주제</h2>

보통 한 페이지에 핵심 <h1>이 하나 있고, 그 아래에 <h2>, 더 자세한 항목에는 <h3>를 사용합니다.

Markdown의 일반적인 변환은 다음과 같습니다.

markdown
# 큰 제목
## 중간 제목
### 작은 제목
html
<h1>큰 제목</h1>
<h2>중간 제목</h2>
<h3>작은 제목</h3>

이미지에는 alt를 넣습니다. alt는 검색엔진을 위한 키워드 창고가 아니라, 이미지를 볼 수 없는 사람에게 전달하는 짧은 설명입니다.

markdown
![노트북 화면에 표시된 SEO 안내문](image-url)

이미지의 의미가 중요하지 않은 장식이라면 빈 alt가 더 적절할 수도 있습니다. 중요한 것은 이미지마다 실제 목적에 맞는 설명을 제공하는 것입니다.

myblog에서는 어떻게 연결되나요?

myblog 게시글 화면에는 게시글 제목을 위한 <h1>이 이미 있습니다. 그래서 MarkdownRenderer는 본문 Markdown heading을 한 단계씩 낮춥니다.

이 프로젝트에서는 다음과 같이 변환됩니다.

markdown
# 검색엔진은 도서관처럼 움직입니다
## 크롤링과 색인의 차이
html
<h2>검색엔진은 도서관처럼 움직입니다</h2>
<h3>크롤링과 색인의 차이</h3>

이 동작을 모르면 일반 Markdown 규칙과 프로젝트의 특별한 변환을 혼동할 수 있습니다. Markdown 원문뿐 아니라 실제 HTML도 확인해야 하는 이유입니다.

현재 blog publish 흐름이 자동으로 넣는 썸네일의 alt는 thumbnail입니다. 이 값은 “이 이미지가 무엇을 보여주는가?”를 설명하지 못하고 description 추출 결과에 단어 하나로 남을 수 있습니다. 게시 자동화와 접근성 품질을 함께 높이려면 제목과 이미지 내용을 반영한 alt를 받도록 개선하는 것이 좋습니다.

Mermaid 다이어그램은 화면에서 그림으로 보이지만, 서버 HTML에는 원본 코드도 남깁니다. JavaScript가 실패해도 원본을 보고 복사할 수 있게 하는 fallback입니다.

다만 Mermaid 코드가 있다는 것만으로 검색 노출이나 접근성이 자동으로 완성되는 것은 아닙니다. 중요한 다이어그램에는 다음처럼 사람이 읽을 수 있는 요약도 함께 제공해야 합니다.

text
요청이 들어오면 서버가 게시글 데이터를 읽고 HTML을 만든 뒤 브라우저에 전달합니다.

이 단계의 완료 기준

  • 페이지의 heading 순서가 자연스럽습니다.
  • 본문 제목과 실제 내용이 일치합니다.
  • 이미지마다 의미에 맞는 alt가 있습니다.
  • Markdown 원문과 실제 HTML의 heading 구조를 확인했습니다.
  • 다이어그램이나 JavaScript가 실패해도 핵심 내용을 이해할 수 있습니다.

5단계: JSON-LD로 게시글의 종류를 알려줍니다

사람은 화면을 보고 “이건 블로그 글이고, 이 숫자는 작성일이며, 이 링크는 글 목록으로 돌아가는 링크구나”라고 추측할 수 있습니다.

검색엔진이 더 쉽게 이해하도록 JSON-LD라는 정보 카드를 HTML에 추가할 수 있습니다.

JSON-LD를 한 문장으로 이해하기

JSON-LDJavaScript Object Notation for Linked Data의 줄임말입니다.

쉽게 말하면 “JSON 모양으로 작성한, 서로 연결된 정보 카드”입니다.

이름에 JavaScript가 들어가지만 화면에서 실행할 프로그램을 작성하는 것은 아닙니다. HTML의 <head>나 본문에 다음처럼 넣어 검색엔진이 읽도록 합니다.

html
<script type="application/ld+json">
{
  "@context": "https://schema.org",
  "@type": "BlogPosting",
  "headline": "초보자를 위한 Next.js SEO",
  "author": {
    "@type": "Person",
    "name": "김영인"
  },
  "datePublished": "2026-08-11",
  "url": "https://example.com/posts/123"
}
</script>

여기서 각 필드는 다음 뜻을 가집니다.

  • @context: 어떤 공통 사전을 사용할지 알려줍니다.
  • @type: 이 정보가 무엇인지 알려줍니다.
  • BlogPosting: 블로그 게시글이라는 뜻입니다.
  • headline: 게시글 제목입니다.
  • author: 작성자 정보입니다.
  • datePublished: 게시된 날짜입니다.
  • url: 게시글의 공식 주소입니다.

@context의 Schema.org는 여러 사이트가 같은 필드 이름을 같은 의미로 해석할 수 있게 만든 공통 사전입니다.

사람에게 보이는 글 제목과 JSON-LD의 headline은 같은 글 제목이어야 합니다. 화면에는 “초보자를 위한 Next.js SEO”라고 써 놓고 JSON-LD에는 전혀 다른 제목을 넣으면 검색엔진을 헷갈리게 할 수 있습니다.

metadata·JSON-LD·Open Graph는 어떻게 다른가요?

정보주로 읽는 대상알려주는 내용
HTML 제목·본문사람과 검색봇실제로 읽는 글
metadata검색엔진과 브라우저페이지 제목, 설명, canonical
JSON-LD검색엔진이 페이지가 어떤 종류의 정보인지
Open Graph카카오톡·Slack·Facebook 등 공유 서비스공유 카드의 제목, 설명, 이미지

Open Graph도 넓은 의미에서는 metadata의 한 종류입니다. Next.js Metadata 설정 안에서 검색엔진용 제목·설명과 링크 공유용 Open Graph 정보를 함께 설정한다고 이해하면 됩니다.

Twitter/X는 Open Graph와 비슷한 목적을 갖지만 별도의 카드 규칙도 사용합니다.

myblog에서는 어떻게 연결되나요?

myblog 게시글에는 다음 구조를 사용합니다.

  • BlogPosting: 제목, 설명, 작성자, 작성일, URL, 이미지
  • BreadcrumbList: 홈 → 글 목록 → 현재 게시글이라는 이동 경로

홈 페이지에는 WebSite, SearchAction, 최신 게시글 정보를 표현하는 구조화 데이터도 사용합니다.

게시글 URL과 breadcrumb URL은 화면의 링크와 같은 NEXT_PUBLIC_SITE_URL 정책을 사용해야 합니다. 대표 이미지는 사이트 안의 생성 카드일 수도 있고, HTTPS 형식 검사를 통과한 외부 URL일 수도 있습니다.

게시글 description이 비어 있지 않도록 본문에서 추출한 설명과 기본 설명 fallback을 함께 둡니다.

JSON-LD를 추가했다고 검색 결과의 특별한 카드가 반드시 나타나는 것은 아닙니다. 구조화된 데이터는 검색엔진이 사용할 수 있는 정보 카드이지, 검색 결과 모양을 명령하는 기능은 아닙니다.

또한 실제 화면에 없는 내용을 JSON-LD에만 넣어서는 안 됩니다. 구조화된 데이터의 내용은 실제 페이지의 내용과 일치해야 합니다.

이 단계의 완료 기준

  • 게시글 유형이 BlogPosting으로 표현되어 있습니다.
  • 이동 경로가 BreadcrumbList로 표현되어 있습니다.
  • JSON-LD의 제목·작성자·날짜·URL이 실제 페이지와 일치합니다.
  • JSON-LD의 URL과 이미지가 사이트 URL 정책을 따릅니다.
  • JSON-LD가 있다고 rich result가 보장되는 것은 아니라는 점을 이해합니다.

6단계: sitemap·robots.txt·RSS를 연결합니다

페이지 metadata가 책의 표지라면 sitemap과 robots.txt는 도서관의 지도와 입구 안내문입니다.

RSS는 검색 순위를 올리는 버튼이 아니라, 구독자에게 새 글을 알려주는 배포 통로입니다.

왜 필요한가요?

검색봇이 사이트의 공개 페이지를 발견하는 방법은 여러 가지입니다.

  • 다른 페이지의 링크를 따라갑니다.
  • sitemap에서 URL 목록을 읽습니다.
  • robots.txt에서 사이트의 크롤링 규칙과 sitemap 주소를 확인합니다.

RSS는 검색엔진뿐 아니라 feed reader 같은 구독 앱이 새 글을 발견하도록 도와줍니다.

일반적인 Next.js 프로젝트에서는 무엇을 확인하나요?

Next.js App Router에서는 다음과 같은 파일 기반 route를 사용할 수 있습니다.

text
src/app/sitemap.ts
src/app/robots.ts
src/app/feed.xml/route.ts

각 파일은 다음 URL을 만듭니다.

text
/sitemap.xml
/robots.txt
/feed.xml

sitemap에는 공개하고 싶은 페이지를 넣습니다. 관리자 페이지, 검색 결과 페이지처럼 검색하지 않을 페이지는 제외합니다.

robots.txt에는 sitemap 주소를 적고, 필요하다면 크롤링하지 않을 경로를 안내합니다. 다만 robots.txt만으로 비공개 데이터를 보호해서는 안 됩니다.

RSS XML을 직접 만들 때는 제목·설명·URL 안의 특수문자를 XML 규칙에 맞게 escape해야 합니다.

myblog에서는 어떻게 연결되나요?

myblog는 다음 파일을 사용합니다.

파일 또는 route역할
src/app/sitemap.ts홈, 글 목록, 게시글의 공개 URL을 제공합니다.
src/app/robots.ts크롤링 규칙과 sitemap 주소를 제공합니다.
src/app/feed.xml/route.tsRSS XML을 만들고 XML 특수문자를 escape합니다.
src/app/manifest.ts웹 앱 이름, 아이콘, 표시 방식을 제공합니다.
src/app/icon.png, src/app/apple-icon.png브라우저와 모바일 기기 아이콘입니다.

현재 RSS는 최신 30개 글을 포함하고 1시간 단위로 재검증합니다. stale-while-revalidate 설정 때문에 오래된 feed가 최대 24시간 더 제공될 수도 있습니다.

DB 오류가 나도 200 응답의 빈 feed가 나올 수 있으므로, 운영에서는 응답 상태뿐 아니라 실제 XML 내용과 캐시 시간도 확인해야 합니다.

게시글 sitemap은 수정일을 사용합니다. 홈과 목록의 new Date() 값은 파일이 만들어진 시각이 아니라 route가 실행되거나 캐시가 생성된 시각입니다.

현재 구현은 한 번에 10개씩 최대 2,000페이지를 조회해 게시글 최대 10,000개를 담습니다. 별도 재검증 설정이 없으므로 Next.js의 기본 캐시 동작과 DB 오류 시 반환한 부분 결과가 운영상 주의 대상입니다.

sitemap의 prioritychangeFrequency도 검색 순위를 조종하는 레버가 아니라 검색엔진에 주는 참고 정보로 이해해야 합니다.

이 단계의 완료 기준

  • /sitemap.xml이 공개 페이지 목록을 반환합니다.
  • /search와 관리자 페이지가 sitemap에서 빠져 있습니다.
  • /robots.txt가 canonical 사이트의 sitemap URL을 사용합니다.
  • /feed.xml이 깨지지 않은 RSS XML을 반환합니다.
  • RSS XML 특수문자가 정상적으로 escape됩니다.
  • manifest와 아이콘 경로가 실제 파일과 일치합니다.

7단계: OG 이미지와 fallback을 구성합니다

링크를 카카오톡이나 Slack에 붙여넣었을 때 제목과 이미지가 함께 보이는 것을 링크 공유 카드라고 합니다.

그 카드의 제목·설명·이미지를 정하는 대표적인 규칙이 Open Graph입니다.

왜 필요한가요?

본문이 아무리 좋아도 공유 카드에 엉뚱한 이미지가 나오면 글을 열어 보기 전에 신뢰를 잃을 수 있습니다.

게시글마다 썸네일이 있다면 그 이미지를 우선 사용합니다. 썸네일이 없다면 사이트의 브랜드 카드나 제목 기반 자동 생성 카드를 사용합니다.

일반적인 Next.js 프로젝트에서는 무엇을 확인하나요?

썸네지 선택 순서는 다음처럼 정할 수 있습니다.

text
유효한 HTTPS thumbnail_url
  → 원본 이미지를 Open Graph와 Twitter image에 사용

썸네일 없음 또는 잘못된 형식
  → 파일 기반 opengraph-image fallback 사용

썸네지를 확인하기 위해 매 요청마다 원격 이미지에 HEAD 요청을 보낼 필요는 없습니다. 우선 안전한 절대 https: URL 형식만 확인하는 방법이 단순하고 빠릅니다.

다만 URL 형식이 맞는다고 실제 이미지가 정상이라는 뜻은 아닙니다. 404 응답, HTML 응답, 잘못된 MIME 타입까지 확인하는 것은 별도의 검증 문제입니다.

fallback에서는 왜 images 속성을 생략하나요?

썸네일이 없는 경우 metadata에 다음처럼 넣고 싶을 수 있습니다.

ts
images: undefined

하지만 Next.js의 파일 기반 metadata convention이 opengraph-image.tsx를 fallback으로 사용할 기회를 주려면 images 속성 자체를 생략하는 편이 안전합니다.

ts
// 썸네일이 있을 때만 images를 포함
{
  openGraph: {
    images: [thumbnailUrl],
  },
  twitter: {
    images: [thumbnailUrl],
  },
}

// fallback이 필요할 때는 images 속성을 넣지 않음
{
  openGraph: {
    title,
    description,
  },
  twitter: {
    card: 'summary_large_image',
  },
}

images를 빈 값으로 명시하면 “이미지는 없다”는 의미가 되어 자동 fallback을 방해할 수 있습니다.

myblog에서는 어떻게 연결되나요?

현재 저장소에는 다음 파일이 있습니다.

text
src/app/opengraph-image.tsx
src/app/posts/[id]/opengraph-image.tsx

썸네지가 있는 게시글은 원본 thumbnail_url을 사용합니다. 썸네지가 없거나 안전한 HTTPS URL 형식이 아니면 metadata의 images 속성을 생략하고 게시글용 자동 생성 카드를 사용합니다.

생성 카드가 서버에서 만들어질 때 외부 Google Fonts를 다시 요청하면 네트워크가 막힌 환경에서 한글이 네모로 보일 수 있습니다. 그래서 src/assets/fonts/에 Noto Sans KR Regular와 Bold 파일을 넣고 next/og가 로컬 파일을 읽도록 했습니다.

폰트는 반복해서 읽지 않도록 모듈 단위로 캐시합니다. 폰트의 출처와 SIL Open Font License 정보는 src/assets/fonts/README.md에 기록했습니다.

다만 별도 twitter-image.tsx는 없습니다. Open Graph fallback 카드가 X/Twitter에서도 항상 독립적으로 사용된다고 단정할 수는 없습니다. Open Graph와 Twitter/X 카드는 비슷한 목적을 갖지만 완전히 같은 규칙은 아니기 때문입니다.

또한 OG route 자체는 로컬 폰트를 사용하지만 layout에는 아직 next/font/google이 있습니다. 따라서 “전체 Next.js build가 완전히 오프라인에서도 동작한다”고 말할 수는 없습니다. 생성 카드의 네트워크 의존성과 전체 build의 네트워크 의존성을 구분해야 합니다.

이 단계의 완료 기준

  • 썸네지가 있으면 원본 썸네지가 공유 카드에 사용됩니다.
  • 썸네지가 없으면 자동 생성 카드가 사용됩니다.
  • fallback metadata에서 images 속성을 불필요하게 지정하지 않습니다.
  • 생성 OG 카드에서 한글이 깨지지 않습니다.
  • 로컬 폰트가 해결하는 범위와 전역 Google Font가 남기는 범위를 구분합니다.
  • X/Twitter fallback은 별도 route가 없다는 한계를 알고 있습니다.

myblog의 실제 파일 흐름

지금까지 설명한 일반적인 SEO 작업을 myblog의 파일에 연결해 보겠습니다.

모든 파일을 외울 필요는 없습니다. 다음 세 종류로 나누어 보면 됩니다.

  • 주소를 정하는 파일
  • 게시글의 표지 정보를 만드는 파일
  • 검색봇과 구독자에게 공개 목록을 주는 파일
파일초보자용 설명
src/lib/site-config.ts블로그의 공식 주소를 검사하고 정리합니다.
src/lib/seo-metadata.ts썸네지가 있는지 보고 공유 이미지 선택 결과를 만듭니다.
src/app/layout.tsx모든 페이지가 공유하는 기본 metadata와 제목 형식을 정합니다.
src/app/page.tsx홈의 WebSite, SearchAction, 최신 글 구조화 데이터를 만듭니다.
src/app/posts/[id]/page.tsx게시글의 제목·설명·canonical·JSON-LD를 만듭니다.
src/app/opengraph-image.tsx이미지가 없는 페이지의 기본 공유 카드를 만듭니다.
src/app/posts/[id]/opengraph-image.tsx이미지가 없는 게시글의 제목 기반 공유 카드를 만듭니다.
src/app/sitemap.ts, src/app/robots.ts, src/app/feed.xml/route.ts검색봇과 구독자가 사용할 공개 URL·규칙·피드를 제공합니다.

파일 이름과 URL의 관계도 함께 보세요. App Router에서는 폴더와 파일 이름이 URL을 만드는 규칙이 됩니다.

파일실제 URL 예시
src/app/page.tsx/
src/app/posts/[id]/page.tsx/posts/123에서 [id]는 실제 게시글 숫자 식별자로 바뀝니다.
src/app/robots.ts/robots.txt
src/app/feed.xml/route.ts/feed.xml

데이터 흐름은 다음과 같습니다.

mermaid
다이어그램 정의(mermaid):
flowchart LR
    env["사이트 주소 환경변수"] --> config["주소 검사와 정규화"]
    config --> meta["페이지 표지 정보와 JSON-LD"]
    config --> routes["sitemap · robots · RSS"]
    post["게시글 데이터"] --> desc["게시글 설명"]
    post --> chooser{"썸네일 검사"}
    desc --> meta
    chooser -->|"예: 원본 사용"| original["원본 공유 이미지"]
    chooser -->|"아니오: 자동 생성"| fallback["자동 생성 파일"]
    original --> social["공유 이미지 metadata"]
    fallback --> fonts["저장소의 Noto Sans KR"]
    fonts --> social
    social --> meta

왼쪽에서 오른쪽으로 읽으면 됩니다.

사이트 주소는 여러 SEO 파일의 공통 출발점입니다. 게시글 데이터는 설명과 공유 이미지로 나뉘어 페이지 표지 정보를 채웁니다. 썸네지가 있으면 원본을 사용하고, 없으면 로컬 폰트를 사용하는 자동 생성 카드로 이동합니다.

myblog에 실제로 적용한 기준

1. 사이트 URL을 한 곳에서 관리했습니다

site-config.ts가 주소를 검증하고 정규화합니다.

layout, 게시글 metadata, JSON-LD, RSS, sitemap, robots가 각자 다른 fallback을 갖지 않도록 같은 helper를 사용합니다.

2. 게시글 썸네지를 먼저 사용합니다

게시글에 thumbnail_url이 있고 안전한 절대 https: URL이면 원본을 사용합니다.

text
유효한 HTTPS thumbnail_url
  → openGraph.images에 원본 URL
  → twitter.images에도 원본 URL

썸네지가 없거나 HTTP, 잘못된 형식이면 원격 서버에 매 요청마다 HEAD 요청을 보내지 않고 URL 형식만 검사합니다.

이 경우 metadata에 images: undefined를 넣지 않고 images 속성 자체를 생략합니다. 그래야 파일 기반 opengraph-image fallback이 동작할 기회를 유지할 수 있습니다.

3. 생성 카드에 한글 폰트를 저장소에 넣었습니다

OG 이미지 생성에 외부 Google Fonts를 사용하면 네트워크 차단 환경에서 한글이 깨질 수 있습니다.

그래서 Noto Sans KR Regular와 Bold 파일을 저장소에 넣고, next/og가 로컬 폰트를 사용하도록 했습니다. 폰트 라이선스와 출처도 문서화했습니다.

4. 검색엔진용 파일을 연결했습니다

sitemap, robots.txt, RSS, manifest, icon route를 연결했습니다.

검색엔진이 읽을 수 있는 URL 목록과 크롤링 안내문을 제공하고, 구독자에게는 RSS로 새 글을 전달합니다.

완료된 것과 현재 구현의 한계

완료된 작업만 나열하면 구현을 과대평가하게 됩니다. 현재 상태를 완료와 주의사항으로 나눠 보겠습니다.

상태내용지금 이해해야 할 점
완료필수 NEXT_PUBLIC_SITE_URL, HTTPS 운영 검증, trailing slash 정리잘못된 주소로 조용히 배포하지 않습니다.
완료HTTPS 썸네지 우선 및 Open Graph 브랜드 카드 fallback이미지가 없을 때도 Open Graph용 카드 경로가 있습니다.
주의썸네지는 실제 파일 응답이 아니라 HTTPS URL 형식만 검사404나 잘못된 응답까지 보장하지는 않습니다.
완료Markdown 설명 추출과 단어 수 계산helper의 규칙을 단위 테스트로 확인합니다.
완료JSON-LD의 BlogPosting, BreadcrumbList실제 HTML의 JSON-LD도 route 검사로 확인해야 합니다.
완료sitemap, robots, manifest, icon, 보안 헤더공개 URL과 크롤러 안내 파일을 확인할 수 있습니다.
주의별도 twitter-image route 없음X/Twitter fallback 카드는 별도 검증이나 후속 구현이 필요합니다.
주의/posts의 sort/tag query URL/search와 달리 query URL의 noindex 정책은 아직 명시적이지 않습니다.
주의sitemap 최대 10,000개·최대 2,000페이지 반복 및 DB 오류 시 부분 결과게시글 수가 커지거나 DB가 실패하면 운영 모니터링이 필요합니다.
주의RSS는 최신 30개와 1시간 재검증 기준stale-while-revalidate 때문에 오래된 응답이 더 오래 제공될 수 있습니다.
주의OG route 폰트와 전역 Google Font의 차이생성 카드의 네트워크 의존성은 줄였지만 전체 build와는 범위가 다릅니다.
주의MarkdownRenderer의 heading 강등과 자동 thumbnail altMarkdown 원문과 실제 HTML의 heading·description을 함께 확인해야 합니다.
주의게시글 ID의 느슨한 파싱과 하위 경로 URL/posts/1abc/posts/1처럼 처리되거나 URL 정책이 달라질 수 있습니다.
주의화면 이미지 검증과 metadata 이미지 검증의 차이metadata가 허용한 URL이 화면에서 실제 이미지로 동작하는지는 별도 문제입니다.

이 표는 “구현이 실패했다”는 뜻이 아닙니다. 어디까지 확인했고 어디부터 별도 작업인지 알려주는 경계선입니다.

직접 검증하는 방법

SEO 설정은 코드만 보고 끝내면 안 됩니다. 실제 HTML, XML, PNG 응답을 확인해야 합니다.

코드 품질 검사

bash
npm run test
npx tsc --noEmit
npm run lint
npm run build
git diff --check

단위 테스트에서는 다음 순수 함수를 확인합니다.

  • Markdown에서 링크, 이미지, 코드, HTML을 제거해 description을 만드는가?
  • 공백을 정리하고 문장 경계에서 길이를 제한하는가?
  • 단어 수를 계산하는가?
  • 사이트 URL이 없거나 잘못되었을 때 실패하는가?
  • 운영 환경에서 HTTP 주소를 거부하는가?
  • trailing slash를 정리하는가?
  • 유효한 thumbnail이면 원본 이미지를 선택하는가?
  • fallback이면 openGraph.imagestwitter.images를 아예 생략하는가?

현재 Vitest 16개는 이런 순수 helper 규칙을 검사합니다. 게시글·홈 JSON-LD와 RSS route 출력 자체를 단위 테스트하는 것은 아닙니다. 실제 브라우저 HTML, XML, PNG가 올바른지는 route smoke test도 필요합니다.

로컬 route 확인

개발 서버 또는 production server를 실행한 뒤 다음 URL을 확인합니다.

OG ImageResponse와 로컬 폰트는 npm run buildnpx next start -p 3002로 확인하는 편이 안전합니다. 이 환경에서는 next dev --turbopack과 production bundle의 파일 URL 처리 결과가 달랐습니다.

URL기대 결과
/feed.xml200, 깨지지 않은 RSS XML, 특수문자 escape
/manifest.webmanifest200, manifest 안의 아이콘 경로가 실제 파일과 일치
/robots.txt설정한 사이트 URL의 sitemap 주소 사용
/sitemap.xmllocalhost와 /search가 없음
/opengraph-image200, PNG, 한글 제목이 깨지지 않음
썸네지가 있는 게시글canonical과 og:image가 원본 썸네지 URL
썸네지가 없는 게시글생성된 Open Graph 카드 사용

게시글 HTML에서는 다음도 확인합니다.

  • <link rel="canonical">
  • og:title
  • og:description
  • og:image
  • twitter:card
  • application/ld+json
  • BlogPosting
  • BreadcrumbList
  • content-language

응답 보안 헤더도 함께 봅니다. HSTS는 브라우저에게 앞으로 HTTPS만 사용하라고 기억시키는 규칙입니다. HTTPS 운영에서만 의미가 있으므로 localhost에서 무조건 강제하는 설정은 피해야 합니다.

현재 프로젝트에는 Mermaid 관련 dynamic require 빌드 경고가 기존에 있습니다. 이 경고는 기존 동작으로 기록하고, SEO 작업에서 새로운 경고를 추가하지 않는 것을 기준으로 삼습니다.

배포 후 확인

로컬에서 200이 나왔다고 실제 검색엔진 색인까지 끝난 것은 아닙니다.

운영 주소가 배포된 다음에는 다음 순서로 확인합니다.

  1. 운영 사이트가 실제로 열리는지 확인합니다.
  2. Search Console에 사이트를 등록합니다.
  3. sitemap을 제출합니다.
  4. URL 검사에서 대표 게시글을 확인합니다.
  5. 필요하면 색인 요청을 합니다.
  6. 소셜 공유 미리보기를 확인합니다.

이번 작업에서는 배포, Search Console 등록, 실제 색인 요청을 수행하지 않습니다.

소셜 플랫폼은 URL과 이미지를 캐시합니다. metadata를 고쳐도 공유 미리보기가 바로 바뀌지 않을 수 있습니다. 변경 후에도 이전 이미지가 보인다면 캐시가 갱신되는 시간을 고려해야 합니다.

커밋 전에 스스로 설명해보기

다음 질문에 자기 말로 답할 수 있다면 구현의 큰 흐름을 이해한 것입니다.

  1. SEO를 시작할 때 sitemap이나 JSON-LD보다 먼저 검색 대상 페이지를 분류해야 하는 이유는 무엇인가요?
  2. 왜 사이트 URL을 localhost나 오래된 배포 주소로 자동 대체하지 않나요?
  3. canonical은 어떤 문제를 해결하고, 어떤 문제까지 자동으로 해결하지는 못하나요?
  4. 크롤링, 색인, 검색 결과의 순서는 각각 무엇인가요?
  5. robots.txtnoindex, follow는 각각 어느 단계에서 작동하나요?
  6. metadata, JSON-LD, Open Graph는 각각 누가 읽고 어떤 정보를 전달하나요?
  7. 썸네지 fallback 때 images 속성을 비워 넣지 않고 생략하는 이유는 무엇인가요?
  8. 로컬 Noto Sans KR 폰트는 어떤 문제를 해결하고, next/font/google 때문에 어떤 문제는 남아 있나요?
  9. Vitest와 route smoke test가 각각 증명하는 것은 무엇인가요?
  10. 실제 검색엔진의 색인과 검색 순위는 왜 Search Console과 운영 환경에서 별도로 확인해야 하나요?

SEO는 검색엔진을 속이는 기술이 아니라, 글의 주소와 구조와 의미를 정직하게 전달하는 기술에 가깝습니다.

프로젝트 구현이 끝났다면 먼저 검색에 보여줄 페이지를 정하고, 공식 주소를 고정하고, 페이지의 제목과 내용을 정리하세요. 그다음 검색엔진용 구조화 데이터와 sitemap을 연결하고, 사람들이 링크를 공유했을 때도 글의 내용이 정확히 보이도록 OG 이미지를 구성하면 됩니다.

공식 참고 링크

0개의 댓글
💬

아직 댓글이 없습니다

첫 번째 댓글을 작성해보세요!