블로그 만들기 07 - SEO, Open Graph 활용하기

2025-09-04

#블로그#Nextjs#Vercel#SEO#Open-Graph

🧠 요약

  • Open Graph를 이용해 블로그 본문의 링크 썸네일 만들기
  • SEO 기본 세팅하기

0. 전제/환경

  • Next.js App Router

  • Vercel 배포

  • 환경변수

NEXT_PUBLIC_SITE_URL=https://your.domain.url  # 로컬에선 http://localhost:3001

0-1. Open Graph란?

The Open Graph protocol

오픈 그래프 프로토콜은 어떠한 인터넷 웹사이트의 HTML 문서에서 head -> meta 태그 중 "og

"가 있는 태그들을 찾아내어 보여주는 프로토콜이다.

만약 웹사이트가 오픈 그래프 프로토콜을 지원한다면, 웹사이트에 들어가기도 전에 뭐하는 사이트인지 알 수 있다.

노션에서 구글을 북마크 할 경우 생성되는 북마크의 내용들이 오픈 그래프 프로토콜로 요약되어 보여준다. 스크린샷 2025-09-04 16.04.32.png

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;

적용 후 스크린샷 2025-09-04 15.52.34.png


🔄 회고 및 인사이트

새로 알게 된 점

  • 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) 변환을 고려.