정적 블로그 SEO 마감하기 — OG 이미지 · RSS · JSON-LD (마감 편)
2편에서 검색 노출까지 했으니 이번엔 마감 — OG 이미지 자동 생성으로 공유 링크 살리고, /rss.xml로 구독자를 받고, Article JSON-LD로 검색 결과를 풍부하게.
Series
블로그 만들기- 1정적 블로그에 댓글 시스템 도입하기 (Giscus 편)
- 2GitHub Pages 블로그를 구글 검색에 노출시키기 (Search Console 편)
- 3정적 블로그 SEO 마감하기 — OG 이미지 · RSS · JSON-LD (마감 편)
이어서 — 이번엔 SEO 마감
1편(Giscus)에서 댓글을 붙였고, 2편(Search Console)에서 구글에 색인까지 시켰다. 그런데 두 편이 끝나도 블로그는 "검색은 되는" 상태일 뿐, 공유될 때 보기 좋은 상태는 아직 아니다.
- 카카오톡·디스코드·트위터에 링크를 붙이면 텍스트만 덩그러니. 카드 이미지가 안 뜬다.
- RSS 리더(Feedly 등)로 구독하려는 독자가 피드를 찾기 어렵다 — 헤더에 광고가 없으면 리더가 못 잡는다.
- 구글 검색 결과에서 "작성자 / 발행일 / 시리즈" 같은 풍부한 정보(rich snippet)는 안 뜬다.
이번 편에서 이 3가지를 한 번에 닫는다 — OG 이미지 자동 생성, RSS 점검, JSON-LD Article 스키마. 끝나면 "검색·공유·구독" 셋 다 챙긴 블로그가 된다.
왜 "마감"인가 — 색인만으론 부족하다
색인은 발견되는 문제다. 그런데 발견 다음엔 항상 다음 단계가 있다 — 어떻게 보이는가, 어떻게 다시 찾아오는가.
| 영역 | 2편까지 | 이번 편에서 |
|---|---|---|
| 검색 색인 | ✅ sitemap.xml + Search Console | (유지) |
| SNS 공유 카드 | ❌ 기본 텍스트만 | ✅ opengraph-image.tsx |
| RSS 구독 발견 | ⚠️ rss.xml 존재 / 광고는 안 함 | ✅ <link rel="alternate"> 까지 |
| Rich Snippet | ❌ Article 스키마 없음 | ✅ JSON-LD Article + BreadcrumbList |
3가지 다 작은 코드 변경으로 끝난다 — 한 시간이면 충분.
OG 이미지 자동 생성 — opengraph-image.tsx
Next.js App Router 의 파일 컨벤션을 쓰면 글마다 OG 이미지를 빌드 타임에 자동 생성할 수 있다. app/blog/[slug]/opengraph-image.tsx 파일 하나만 두면 끝이다.
import { ImageResponse } from "next/og";
import { getPostBySlug, getAllPosts } from "@/lib/posts";
import { SITE_NAME } from "@/lib/site";
export const size = { width: 1200, height: 630 };
export const contentType = "image/png";
export const generateStaticParams = () =>
getAllPosts().map((post) => ({ slug: post.slug }));
const OGImage = async ({ params }: { params: { slug: string } }) => {
const post = getPostBySlug(params.slug);
if (!post) return new Response(null, { status: 404 });
const fontUrl =
"https://cdn.jsdelivr.net/gh/orioncactus/pretendard@v1.3.9/packages/pretendard/dist/web/static/Pretendard-Bold.otf";
const pretendard = await fetch(fontUrl).then((r) => r.arrayBuffer());
return new ImageResponse(
(
<div
style={{
width: "100%",
height: "100%",
padding: 72,
background: "linear-gradient(135deg, #0b1020 0%, #1a2240 100%)",
color: "#fff",
display: "flex",
flexDirection: "column",
justifyContent: "space-between",
}}
>
<div style={{ fontSize: 28, opacity: 0.6 }}>
{SITE_NAME} · {post.category}
</div>
<div style={{ fontSize: 60, fontWeight: 700, lineHeight: 1.25 }}>
{post.title}
</div>
<div style={{ fontSize: 24, opacity: 0.7 }}>
{post.tags.slice(0, 4).map((t) => `#${t}`).join(" ")}
</div>
</div>
),
{
...size,
fonts: [
{ name: "Pretendard", data: pretendard, style: "normal", weight: 700 },
],
},
);
};
export default OGImage;핵심 포인트 3가지:
size는 1200×630 — Open Graph 권장 비율(1.91:1). Twitter·Facebook·Slack·카카오톡 다 이 한 사이즈로 커버된다.generateStaticParams필수 —output: "export"(정적 빌드, GitHub Pages 모드) 에서 반드시 있어야 PNG 가 미리 생성된다. 안 넣으면 빌드 산출물에 OG 이미지가 빠져버린다.- 한글 폰트 fetch — Pretendard 같은 한글 폰트를
arrayBuffer()로 가져와fonts에 등록. 안 하면 한글이 두부(□)로 나온다.
폰트 CDN 의존이 신경 쓰이면 public/fonts/ 에 .otf 를 두고 fs.readFile 로 읽어도 된다. 정적 export 모드에선 빌드 머신만 영향받으니 보통 CDN 으로 충분.
빌드 후 out/blog/<slug>/opengraph-image.png 가 떨어졌으면 OK. <meta property="og:image"> 는 Next.js 가 알아서 채워준다 — 별도 작업 없음.
/rss.xml 점검 — 구독자가 보는 첫 인상
이 블로그는 빌드 직전에 scripts/generate-rss.mjs 로 public/rss.xml 을 생성한다. package.json 의 prebuild 훅 한 줄이 트리거다:
{
"scripts": {
"prebuild": "node scripts/generate-rss.mjs",
"build": "next build"
}
}npm run build → prebuild 가 먼저 돌고 → public/rss.xml 갱신 → next build 가 그 파일을 정적 자산으로 배포. 새 글 push 만 하면 RSS 가 알아서 갱신된다.
피드의 핵심 5필드
| 필드 | 의미 | 흔한 실수 |
|---|---|---|
<title> | 글 제목 | &·<·> escape 안 하면 파서 실패 |
<link> / <guid isPermaLink="true"> | 영구 URL | 둘이 같은 URL이면 OK. trailing / 일관 |
<description> | summary frontmatter | HTML 들어가면 CDATA로 감싸야 깔끔 |
<pubDate> | Date.toUTCString() (RFC 822) | ISO 형식이면 일부 리더가 못 읽음 |
<atom:link rel="self"> | 채널 자기 자신을 가리키는 atom 링크 | RSS 2.0 권장 — 없어도 동작은 함 |
발견되게 만들기 — <head> 안의 alternate 링크
피드가 있어도 리더가 못 찾으면 의미 없다. layout.tsx metadata 에 다음 한 줄이면 끝:
export const metadata: Metadata = {
// ...
alternates: {
canonical: "/",
types: { "application/rss+xml": "/rss.xml" },
},
};렌더된 HTML 에 다음이 박힌다:
<link rel="alternate" type="application/rss+xml" href="/rss.xml" />Feedly·Inoreader 같은 리더는 도메인만 줘도 이 링크를 보고 피드를 잡아낸다. 이게 RSS 발견율을 가장 크게 올린다.
검증 2단계
- W3C Feed Validator 에
https://<your-site>/rss.xml통과 — "Congratulations!" 메시지. - Feedly 에 도메인 입력 → 신규 글이 카드로 뜨는지.
JSON-LD Article — 검색 결과를 풍부하게
Next.js Metadata API 의 openGraph / twitter 는 SNS 공유 카드용이다. 구글 검색 결과의 rich snippet 은 별도 채널 — schema.org 의 Article JSON-LD 를 페이지 자체에 박아야 뜬다.
import { SITE_URL, SITE_NAME } from "@/lib/site";
import type { Post } from "@/types/post";
const buildArticleJsonLd = (post: Post) => ({
"@context": "https://schema.org",
"@type": "Article",
headline: post.title,
description: post.summary,
image: `${SITE_URL}/blog/${post.slug}/opengraph-image.png`,
datePublished: new Date(post.date).toISOString(),
dateModified: new Date(post.updated ?? post.date).toISOString(),
author: { "@type": "Person", name: SITE_NAME },
publisher: { "@type": "Organization", name: SITE_NAME },
mainEntityOfPage: `${SITE_URL}/blog/${post.slug}/`,
keywords: post.tags.join(", "),
});
// PostPage 컴포넌트 return 안
<script
type="application/ld+json"
dangerouslySetInnerHTML={{
__html: JSON.stringify(buildArticleJsonLd(post)),
}}
/>;시리즈 글은 BreadcrumbList 까지 하나 더 — 검색 결과에 "Home > 블로그 만들기 > SEO 마감" 같은 경로가 뜬다.
const buildBreadcrumbJsonLd = (post: Post) => ({
"@context": "https://schema.org",
"@type": "BreadcrumbList",
itemListElement: [
{ "@type": "ListItem", position: 1, name: "Home", item: SITE_URL },
...(post.series
? [{
"@type": "ListItem",
position: 2,
name: post.series,
item: `${SITE_URL}/series/${encodeURIComponent(post.series)}`,
}]
: []),
{
"@type": "ListItem",
position: post.series ? 3 : 2,
name: post.title,
item: `${SITE_URL}/blog/${post.slug}/`,
},
],
});dangerouslySetInnerHTML 이 신경 쓰일 수 있지만, 여기 들어가는 값은 전부 우리 frontmatter — XSS 벡터가 없다. 외부 입력을 그대로 JSON-LD 에 박을 때만 escape 를 신경 쓰면 된다.
발행 후 검증 — 3가지 도구
이 3개만 통과하면 "마감 완료" 사인을 받은 거다.
| 영역 | 도구 | 통과 기준 |
|---|---|---|
| OG | opengraph.xyz · Facebook Debugger · Twitter Card Validator · 카톡 실제 링크 | 1200×630 카드가 뜨고 제목·설명이 frontmatter 와 일치 |
| RSS | W3C Feed Validator + Feedly 구독 | "Valid RSS feed" + 신규 글이 Feedly 에 뜸 |
| JSON-LD | Google Rich Results Test | Article + BreadcrumbList 둘 다 "detected" |
Search Console 에서도 며칠 뒤 강화 → Article 보고서가 채워진다. 색인된 글 수만큼 카운트되는지 점검하면 운영 시점에서 잘 동작하는지 확인된다.
정리 + 다음 편 예고
여기까지로 블로그 만들기 시리즈 1·2·3편이 "기본 → 검색 노출 → SEO 마감" 의 한 사이클을 닫았다. 1편에서 댓글로 사람의 흔적을, 2편에서 색인으로 발견을, 이번 편에서 OG·RSS·JSON-LD 로 공유·구독·요약 을 챙겼다.
옵션 4편(배포 자동화 — 빌드 캐시 / PR 프리뷰)은 GitHub Actions 기초가 Git 시리즈 7편 에서 따로 다뤄지므로 그 위에 얹는 운영 팁으로 잡을 예정.
당장 다음 글은 시리즈를 갈아탄다 — Git 시리즈 4편 — merge 냐 rebase 냐. merge commit 의 의미와 rebase 의 위험성, 그리고 둘을 섞는 현실적인 워크플로를 풀어볼 차례다.