sy/dev
Tutorial
10 min read

정적 블로그 SEO 마감하기 — OG 이미지 · RSS · JSON-LD (마감 편)

2편에서 검색 노출까지 했으니 이번엔 마감 — OG 이미지 자동 생성으로 공유 링크 살리고, /rss.xml로 구독자를 받고, Article 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 SnippetArticle 스키마 없음✅ JSON-LD Article + BreadcrumbList

3가지 다 작은 코드 변경으로 끝난다 — 한 시간이면 충분.

OG 이미지 자동 생성 — opengraph-image.tsx

Next.js App Router 의 파일 컨벤션을 쓰면 글마다 OG 이미지를 빌드 타임에 자동 생성할 수 있다. app/blog/[slug]/opengraph-image.tsx 파일 하나만 두면 끝이다.

src/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.mjspublic/rss.xml 을 생성한다. package.jsonprebuild 훅 한 줄이 트리거다:

package.json
{
  "scripts": {
    "prebuild": "node scripts/generate-rss.mjs",
    "build": "next build"
  }
}

npm run buildprebuild 가 먼저 돌고 → public/rss.xml 갱신 → next build 가 그 파일을 정적 자산으로 배포. 새 글 push 만 하면 RSS 가 알아서 갱신된다.

피드의 핵심 5필드

필드의미흔한 실수
<title>글 제목&·<·> escape 안 하면 파서 실패
<link> / <guid isPermaLink="true">영구 URL둘이 같은 URL이면 OK. trailing / 일관
<description>summary frontmatterHTML 들어가면 CDATA로 감싸야 깔끔
<pubDate>Date.toUTCString() (RFC 822)ISO 형식이면 일부 리더가 못 읽음
<atom:link rel="self">채널 자기 자신을 가리키는 atom 링크RSS 2.0 권장 — 없어도 동작은 함

발견되게 만들기 — <head> 안의 alternate 링크

피드가 있어도 리더가 못 찾으면 의미 없다. layout.tsx metadata 에 다음 한 줄이면 끝:

src/app/layout.tsx
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단계

  1. W3C Feed Validatorhttps://<your-site>/rss.xml 통과 — "Congratulations!" 메시지.
  2. Feedly 에 도메인 입력 → 신규 글이 카드로 뜨는지.

JSON-LD Article — 검색 결과를 풍부하게

Next.js Metadata API 의 openGraph / twitter 는 SNS 공유 카드용이다. 구글 검색 결과의 rich snippet 은 별도 채널 — schema.org 의 Article JSON-LD 를 페이지 자체에 박아야 뜬다.

src/app/blog/[slug]/page.tsx (snippet)
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개만 통과하면 "마감 완료" 사인을 받은 거다.

영역도구통과 기준
OGopengraph.xyz · Facebook Debugger · Twitter Card Validator · 카톡 실제 링크1200×630 카드가 뜨고 제목·설명이 frontmatter 와 일치
RSSW3C Feed Validator + Feedly 구독"Valid RSS feed" + 신규 글이 Feedly 에 뜸
JSON-LDGoogle Rich Results TestArticle + 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 의 위험성, 그리고 둘을 섞는 현실적인 워크플로를 풀어볼 차례다.

Comments