블로그 만들기 07 - SEO, Open Graph 활용하기
2025-09-04
🧠 요약
- Open Graph를 이용해 블로그 본문의 링크 썸네일 만들기
- SEO 기본 세팅하기
0. 전제/환경
-
Next.js App Router
-
Vercel 배포
-
환경변수
NEXT_PUBLIC_SITE_URL=https://your.domain.url # 로컬에선 http://localhost:3001
0-1. Open Graph란?
오픈 그래프 프로토콜은 어떠한 인터넷 웹사이트의 HTML 문서에서 head -> meta 태그 중 "og
"가 있는 태그들을 찾아내어 보여주는 프로토콜이다.만약 웹사이트가 오픈 그래프 프로토콜을 지원한다면, 웹사이트에 들어가기도 전에 뭐하는 사이트인지 알 수 있다.
노션에서 구글을 북마크 할 경우 생성되는 북마크의 내용들이 오픈 그래프 프로토콜로 요약되어 보여준다.
1. 전역 SEO 기본값 (layout)
전역의 <head>는 app/layout.tsx의 Metadata API로 관리한다. metadataBase 만 제대로 잡으면, 페이지 단위로 설정하는 alternates.canonical, openGraph.url, openGraph.images의 상대 경로가 자동으로 절대 URL로 변환된다.
// app/layout.tsx import type { Metadata } from "next"; const siteUrl = process.env.NEXT_PUBLIC_SITE_URL || "http://localhost:3001"; export const metadata: Metadata = { metadataBase: new URL(siteUrl), title: { default: "블로그 이름", template: "%s | 블로그 이름" }, description: "블로그 한줄 소개", openGraph: { type: "website", siteName: "블로그 이름" }, robots: { index: true, follow: true }, images: ["imageUrl"] }; export default function RootLayout({ children }: { children: React.ReactNode }) { return <html lang="ko"><body>{children}</body></html>; }
2. 포스트 페이지 메타데이터 (generateMetadata)
페이지 라우트에서 generateMetadata({ params }) 를 사용해 글별 title/description/OG를 채운다.
// app/posts/[id]/page.tsx export async function generateMetadata({ params, }: { params: Promise<{ id: string }>; }) { const allPosts = getAllPosts(); const { id } = await params; const post = allPosts.find((p) => p.id === id); // 페이지는 아래에서 NotFound 처리하므로, 여기서는 안전한 기본값만 리턴 if (!post) { return { title: "글을 찾을 수 없습니다", description: "요청하신 글이 존재하지 않습니다.", robots: { index: false, follow: false }, }; } const canonical = `/posts/${post.id}`; // metadataBase가 layout.tsx에 있어야 절대 URL로 변환됨 const title = post.title ?? "제목 없음"; const description = post.description ?? ""; const ogImagePath = `${process.env.NEXT_PUBLIC_SITE_URL}/${post.og_image}`; return { title, description, alternates: { canonical }, openGraph: { type: "article", url: canonical, title, description, publishedTime: post.date?.toISOString?.(), authors: ["username"], images: [post.og_image ? ogImagePath : "/profile.png"], }, twitter: { card: "summary_large_image", title, description, images: [post.og_image ? ogImagePath : "/profile.png"], }, }; }
3. JSON-LD(구조화 데이터)
// app/posts/[id]/page.tsx export default async function PostPage({ params, }: { params: Promise<{ id: string }>; }) { const allPosts = getAllPosts(); const { id } = await params; const post = allPosts.find((p) => p.id === id); if (!post) { return <NotFound />; } const rawContent = post.content; const preprocessed = preprocessObsidianLinks(rawContent); const html = await unified() .use(remarkParse) .use(remarkGfm) .use(remarkRehype, { allowDangerousHtml: true }) .use(rehypeRaw) .use(rehypeSlug) .use(rehypeStringify, { allowDangerousHtml: true }) .process(preprocessed); const htmlString = html.toString(); const toc = extractTOCFromHTML(htmlString); const ogImagePath = `${process.env.NEXT_PUBLIC_SITE_URL}/${post.og_image}`; return ( <div className="relative flex justify-center"> <Tracker /> <Content post={post} postContent={preprocessed} /> {/* 사이드바 목차 */} <TOC items={toc} /> <Script id="ld-blogposting" type="application/ld+json" strategy="afterInteractive" dangerouslySetInnerHTML={{ __html: JSON.stringify({ "@context": "https://schema.org", "@type": "BlogPosting", headline: post.title, datePublished: post.date, dateModified: post.date, description: post.description ?? "", author: [{ "@type": "Person", name: "username" }], mainEntityOfPage: `/posts/${post.id}`, image: [post.og_image ? ogImagePath : "/profile.png"], }), }} /> </div> ); }
4. 프론트매터에서 og_image받기
블로그 만들기 02 – 옵시디언 연동하기에서 옵시디언을 사용하면서 함께 적용한 Templater에 og_image라는 속성을 추가한다.
--- id: <%* const id = tp.date.now("YYYYMMDDHHmmss") + '-' + Math.random().toString(36).substring(2, 10); tR += id %> title: <% tp.file.title %> date: <% tp.date.now("YYYY-MM-DDTHH:mm:ss") %> original_date: <% tp.date.now("YYYY-MM-DDTHH:mm:ss") %> description: 이 글은 무엇에 대한 글인지 간단히 설명합니다. tags: [] og_image: <%* tR += '/og?title=' + encodeURIComponent(tp.file.title) %> ---
현재 이런식으로 블로그 게시글 템플릿을 적용하고 있는데, og_image 경로는 프로젝트에 맞도록 적절하게 수정하면 된다.
5. "링크 카드(LinkCard)" 파이프라인
5-1. 링크 메타 수집 API (OG 스크래핑)
외부 페이지의 <meta property="og:*">를 읽어오는 Route Handler.
// app/api/link-preview/route.ts import { NextResponse } from "next/server"; import * as cheerio from "cheerio"; export const runtime = "nodejs"; export const dynamic = "force-dynamic"; export async function GET(req: Request) { const target = new URL(req.url).searchParams.get("url"); if (!target) return NextResponse.json({ error: "Invalid URL" }, { status: 400 }); const res = await fetch(target, { headers: { "User-Agent": "Mozilla/5.0 (LinkPreviewBot)" }, cache: "no-store" }); const html = await res.text(); const $ = cheerio.load(html); const pickLast = (names: string[]) => { for (const name of names) { const els = $(`meta[property="${name}"], meta[name="${name}"]`).toArray(); const last = els.at(-1); if (last) return $(last).attr("content"); } return undefined; }; const siteName = pickLast(["og:site_name"]) || new URL(target).hostname; let image = pickLast(["og:image", "twitter:image"]); if (image && !/^https?:\/\//.test(image)) image = new URL(image, target).toString(); return NextResponse.json({ url: target, title: pickLast(["og:title"]) || $("title").first().text() || undefined, description: pickLast(["og:description", "description"]), image, siteName, }, { headers: { "Cache-Control": "public, s-maxage=86400, stale-while-revalidate=3600" }, }); }
5-2. 외부 이미지 CORS 우회 “프록시 API”
페이지에서 외부 이미지를 직접
로 띄우면 CORS/COEP/CORP에 막힐 수 있다.
프록시 라우트로 한 번 받아서 같은 출처로 재서빙하면 해결된다.
// app/api/img/route.ts import { NextResponse } from "next/server"; export const runtime = "nodejs"; export const dynamic = "force-dynamic"; export async function GET(req: Request) { const url = new URL(req.url).searchParams.get("url"); if (!url) return NextResponse.json({ error: "no url" }, { status: 400 }); const upstream = await fetch(url, { headers: { "user-agent": "Mozilla/5.0 (+LinkCard)" } }); if (!upstream.ok) return NextResponse.json({ error: "upstream failed" }, { status: 502 }); const type = upstream.headers.get("content-type") || ""; if (!type.startsWith("image/")) return NextResponse.json({ error: "not image" }, { status: 415 }); const headers = new Headers(upstream.headers); headers.set("Cache-Control", "public, s-maxage=86400, stale-while-revalidate=3600"); headers.set("Access-Control-Allow-Origin", "*"); // 캔버스 등에 쓸 경우 허용 return new NextResponse(upstream.body, { headers }); }
5-3. LinkCard 컴포넌트
-
/api/link-preview로 OG 메타를 불러오고,
-
이미지가 외부 URL이면 무조건 /api/img?url=... 프록시로 감싼 뒤,
-
useImageProbe로 “실제 로드 성공한 이미지”만 렌더링.
// app/_components/link-card.tsx "use client"; import { useEffect, useMemo, useState } from "react"; type Preview = { url: string; title?: string; description?: string; image?: string; siteName?: string; }; function normalizeUrl(u: string) { try { if (/^https?:\/\//i.test(u)) return u; if (u.startsWith("//")) return `https:${u}`; if (typeof window !== "undefined") return new URL(u, window.location.origin).toString(); const base = process.env.NEXT_PUBLIC_SITE_URL || ""; return base ? new URL(u, base).toString() : u; } catch { return u; } } function toProxy(src?: string) { if (!src) return undefined; const abs = /^https?:\/\//i.test(src) ? src : normalizeUrl(src); return /^https?:\/\//i.test(abs) ? `/api/img?url=${encodeURIComponent(abs)}` : abs; } function useImageProbe(src?: string, ms = 6000) { const [status, setStatus] = useState<"idle"|"loading"|"ok"|"bad">("idle"); const [finalSrc, setFinalSrc] = useState<string>(); useEffect(() => { if (!src) { setStatus("bad"); setFinalSrc(undefined); return; } let off = false; setStatus("loading"); const img = new Image(); img.referrerPolicy = "no-referrer"; const t = setTimeout(() => { if (!off){ setStatus("bad"); setFinalSrc(undefined);} }, ms); img.onload = () => { if (!off){ clearTimeout(t); setFinalSrc(src); setStatus("ok"); } }; img.onerror = () => { if (!off){ clearTimeout(t); setFinalSrc(undefined); setStatus("bad"); } }; img.src = src; return () => { off = true; clearTimeout(t); }; }, [src, ms]); return { status, finalSrc }; } export default function LinkCard({ url, fallbackTitle }: { url: string; fallbackTitle?: string }) { const absUrl = useMemo(() => normalizeUrl(url), [url]); const [data, setData] = useState<Preview | null>(null); const [loading, setLoading] = useState(true); useEffect(() => { let alive = true; (async () => { try { const res = await fetch(`/api/link-preview?url=${encodeURIComponent(absUrl)}`, { cache: "force-cache" }); const json = await res.json(); if (alive) setData(json); } finally { if (alive) setLoading(false); } })(); return () => { alive = false; }; }, [absUrl]); const title = data?.title || fallbackTitle || absUrl; const desc = data?.description; const proxiedImg = useMemo(() => toProxy(data?.image), [data?.image]); const { status: imgStatus, finalSrc } = useImageProbe(proxiedImg, 6000); return ( <a href={absUrl} target="_blank" rel="noopener noreferrer" className="block w-full group"> <div className="rounded-2xl border border-border overflow-hidden hover:shadow-md transition-shadow"> {loading ? ( <div className="w-full aspect-[1.91/1] bg-muted flex items-center justify-center"> <div className="w-11/12 h-4/6 animate-pulse rounded-lg bg-gray-300" /> </div> ) : imgStatus === "ok" && finalSrc ? ( <div className="w-full aspect-[1.91/1] bg-muted"> {/* eslint-disable-next-line @next/next/no-img-element */} <img src={finalSrc} alt={title} className="w-full h-full object-cover" loading="lazy" decoding="async" /> </div> ) : null} <div className="p-4"> <div className="text-sm text-muted-foreground truncate">{data?.siteName}</div> <div className="font-semibold mt-1 line-clamp-2">{title}</div> {desc && <div className="text-sm text-muted-foreground mt-1 line-clamp-2">{desc}</div>} </div> </div> </a> ); }
5-4. “문단 안의 링크”를 자동으로 LinkCard로
react-markdown의 components.p에서 문단 내부의 <a>를 수집해, 문단을 그대로 렌더한 뒤 아래에 LinkCard들을 붙인다.
"use client"; import Markdown from "react-markdown"; import remarkGfm from "remark-gfm"; import remarkDirective from "remark-directive"; import remarkFrontmatter from "remark-frontmatter"; import rehypeFormat from "rehype-format"; import rehypeSanitize from "rehype-sanitize"; import rehypeSlug from "rehype-slug"; import rehypeRaw from "rehype-raw"; import { Prism as SyntaxHighlighter } from "react-syntax-highlighter"; import { oneDark } from "react-syntax-highlighter/dist/cjs/styles/prism"; import LinkCard from "../link-card"; interface ContentProps { postContent: string; } function extractTextFromHastChildren(children: any[]): string { const walk = (nodes: any[]): string => nodes .map((n) => n.type === "text" ? n.value : n.children ? walk(n.children) : "", ) .join(""); return walk(children || []); } const ContentMarkdown = ({ postContent }: ContentProps) => { return ( <Markdown remarkPlugins={[remarkGfm, remarkDirective, remarkFrontmatter]} rehypePlugins={[rehypeRaw, rehypeSlug, rehypeFormat, rehypeSanitize]} components={{ // 다른 요소들은 생략 p: ({ node, children, ...props }) => { const hastChildren: any[] = (node as any)?.children ?? []; // p 내부의 <a> 요소 전부 수집 const anchors = hastChildren .filter( (c) => c?.type === "element" && c?.tagName === "a" && typeof c?.properties?.href === "string", ) .map((a) => ({ href: a.properties.href as string, text: extractTextFromHastChildren(a.children || []), })) // mailto, tel, 해시 링크 제외 .filter(({ href }) => !/^(mailto:|tel:|#)/i.test(href)); // href 기준으로 중복 제거 const uniqueAnchors = Array.from( new Map(anchors.map((a) => [a.href, a])).values(), ); // 문단 본문은 그대로 + 아래에 카드(들) 추가 return ( <> <p className="leading-relaxed mb-4" {...props}> {children} </p> {uniqueAnchors.map((a, i) => ( <div key={`${a.href}-${i}`} className="my-3"> <LinkCard url={a.href} fallbackTitle={a.text} /> </div> ))} </> ); }, }} > {postContent} </Markdown> ); }; export default ContentMarkdown;
적용 후
🔄 회고 및 인사이트
새로 알게 된 점
-
CORS/COEP/CORP의 실체: 외부 이미지를 직접
로 불러오면 혼합콘텐츠·리퍼러·핫링크·정책 헤더에 막힌다. 프록시 API로 같은 출처에서 재서빙하면 대부분의 문제가 사라진다.
-
링크 카드 자동화 패턴: Markdown 문단 내부 <a>를 수집해 문단 아래에 LinkCard를 붙이는 방식이 UX와 가독성 모두에 좋았다. 또한 useImageProbe로 “실제로 로드 가능한 이미지”만 보여주니 깨진 썸네일이 줄어들었다.
관점의 변화
-
“기능이 아니라 파이프라인” 관점: OG 세팅, 미리보기 스크래핑, 이미지 프록시, 카드 렌더링은 따로가 아니라 하나의 파이프라인이다. 한 구간이 어긋나면 전체 경험이 무너진다. (예: params 타입 실수 → 페이지 OG 실패 → 프리뷰/카드 품질 저하)
-
정적/동적의 경계 재설정: Page/SEO는 정적으로, 외부 HTML/이미지는 동적으로. Route Handler에서 revalidate 대신 응답 캐시 헤더로 제어하는 것이 예측 가능했다.
추후 확장 아이디어
-
동적 OG 이미지 생성기: app/og/route.tsx + ImageResponse로 제목/태그 기반 커버를 자동 생성 → 프런트매터의 og_image가 비어있을 때 폴백으로 사용.
-
SWR/캐시 전략 고도화: /api/link-preview 응답을 클라이언트에서 SWR로 캐시하고, 서버에선 CDN 캐시( s-maxage + stale-while-revalidate)를 최적화.
-
도메인 화이트리스트/보안: 프록시 대상 URL을 허용 도메인으로 제한하고, URL 정규화/검증 강화(리다이렉트/데이터 URL 차단).
-
지표 수집: 링크 카드 클릭률(CTR), 프리뷰 성공률(이미지 probe OK 비율), 프리뷰 지연 시간, OG 미스매치(레이아웃 OG로 떨어진 비율) 등을 추적해 주기적으로 Tuning.
-
SSR 프리뷰 옵션: SEO가 중요한 문맥에는 LinkCard를 서버 컴포넌트로 분리해 서버에서 메타를 가져와 렌더(필요 시 캐싱). 현재 CSR 방식은 UX에 유리하지만 SEO 영향은 제한적일 수 있음.
-
이미지 최적화: 프록시 대신 next/image(remotePatterns)로 대체하거나, 프록시에서 리사이즈/포맷(AVIF/WebP) 변환을 고려.