Engineering

검색 엔진과 사람 모두를 위한 마감

잘 쓰인 글도 발견되지 않으면 소용이 없습니다. 마지막 편에서는 \ SEO 마감과, 그 글을 실제로 쓰는 관리자 경험을 다룹니다. 두 종류의 독자 블로그에는 사실 두 종류의 독자가 있습니다.

M
Mason
Software Engineer · · 8분

잘 쓰인 글도 발견되지 않으면 소용이 없습니다. 마지막 편에서는
SEO 마감과, 그 글을 실제로 쓰는 관리자 경험을 다룹니다.

두 종류의 독자

블로그에는 사실 두 종류의 독자가 있습니다. 글을 읽는 사람, 그리고 글을 색인하는 검색 엔진·SNS 봇입니다. masonlab은 둘 다를 1급 시민으로 대접합니다. 이번 편은 그 마감 작업과, 글을 쓰는 쪽의 경험까지 이야기합니다.

페이지마다 메타데이터를 만든다

App Router의 generateMetadata로 글마다 메타데이터를 생성합니다. canonical URL, OpenGraph(article 타입), Twitter 카드를 한 번에 채웁니다.

// src/app/(site)/posts/[slug]/page.tsx (발췌) \
export async function generateMetadata({ params }): Promise { \
const post = await getPostBySlug((await params).slug); \
if (!post) return {};
 
const title = post.seo_title ?? post.title; \
const description = post.seo_description ?? post.excerpt ?? siteConfig.description; \
const url = `/posts/${post.slug}`; \
const image = post.og_image_url ?? post.cover_image_url ?? undefined;
 
return { \
title, \
description, \
alternates: { canonical: url }, // 중복 색인 방지 \
openGraph: { \
type: "article", \
title, description, url, \
publishedTime: post.published_at ?? undefined, \
modifiedTime: post.updated_at, \
authors: post.author ? [post.author.name] : undefined, \
tags: post.tags.map((t) => t.name), \
images: image ? [{ url: image }] : undefined, \
}, \
twitter: { card: "summary_large_image", title, description, images: image ? [image] : undefined }, \
}; \
}
 

눈여겨볼 점은 폴백 사슬입니다. 제목은 seo_title → title, 설명은 seo_description → excerpt → 사이트 기본 설명, 이미지는 og_image_url → cover_image_url 순으로 떨어집니다. 글쓴이가 SEO 필드를 일일이 채우지 않아도 합리적인 기본값이 항상 채워집니다.

참고로 위 excerpt(요약)는 글쓴이가 비워 두면 본문에서 자동 생성되는데, 단어 중간이 아니라 문장·단어 경계에서 잘리도록 따로 다듬었습니다. 요약이 어색하게 끊기면 검색 결과 스니펫도 어색해지기 때문입니다.

구조화 데이터(JSON-LD)로 봇에게 직접 말하기

메타 태그만으로는 부족합니다. 검색 엔진이 "이건 블로그 글이고, 저자는 누구고, 언제 발행됐다"를 정확히 이해하도록 schema.org JSON-LD를 함께 심습니다.

```ts\ // src/lib/seo.ts (발췌)\ export function articleJsonLd(post: PostWithRelations) {\ return {\ "@context": "https://schema.org",`\ "@type": "BlogPosting",
headline: post.title,
description: post.excerpt ?? siteConfig.description,
datePublished: post.published_at ?? undefined,
dateModified: post.updated_at,
inLanguage: "ko-KR",
author: post.author
? { "@type": "Person", name: post.author.name }
: { "@type": "Organization", name: siteConfig.organization },
image: post.cover_image_url ? [post.cover_image_url] : [absoluteUrl(siteConfig.ogImage)],
url: absoluteUrl(/posts/${post.slug}`),
keywords: post.tags.map((t) => t.name).join(", ") || undefined,
articleSection: post.category?.name,
};
}


여기에 더해 `BreadcrumbList`(홈 → 전체 글 → 현재 글)도 심어, 검색 결과에 경로가 함께 노출되도록 했습니다. 글 페이지에서는 두 JSON-LD가 함께 출력됩니다.

```tsx`\
` <JsonLd data={articleJsonLd(post)} />`\
` <JsonLd data={breadcrumbJsonLd([`\
` { name: "홈", url: absoluteUrl("/") },`\
` { name: "전체 글", url: absoluteUrl("/posts") },`\
` { name: post.title, url: absoluteUrl(`/posts/${post.slug}`) }, \
])} />

RSS: 봇이 아니라 사람을 위한 구독

SEO와 별개로, 글을 꾸준히 구독하려는 독자를 위해 RSS 피드도 제공합니다. App Router의 라우트 핸들러로 XML을 직접 만듭니다.

// src/app/rss.xml/route.ts (발췌) \
export const revalidate = 3600; // 피드도 캐시, 발행 시 웹훅이 갱신
 
export async function GET() { \
const posts = await getPublishedPosts({ limit: 50 }); \
const items = posts.map((p) => `<item>`\
` <title>${escapeXml(p.title)}</title>`\
` <link>${absoluteUrl(`/posts/${p.slug}`)}</link>`\
` ...`\
` </item>`).join("\n"); \
return new Response(xml, { headers: { "Content-Type": "application/xml; charset=utf-8" } }); \
}
 

여기서도 2편의 캐싱 전략이 그대로 이어집니다. RSS는 revalidate = 3600으로 캐시되고, 글을 발행하면 웹훅이 /rss.xml 경로를 함께 갱신합니다. 사이트맵·RSS까지 같은 갱신 흐름 안에 들어가 있는 것입니다.

글을 쓰는 쪽: 노션 같은 에디터, 저장은 Markdown

마지막은 글을 쓰는 경험입니다. 관리자 스튜디오에서는 BlockNote 기반의 블록 에디터를 씁니다. 슬래시 메뉴(/), 드래그 핸들, 인라인 이미지까지 노션과 비슷한 감각으로 글을 씁니다.

중요한 설계는 에디터는 어디까지나 작성 표면일 뿐, 저장은 Markdown이라는 점입니다.

// src/components/studio/block-editor.tsx (발췌) \
const editor = useCreateBlockNote({ \
uploadFile: (file: File) => uploadToStorage(file), // 이미지는 Supabase Storage로 \
});
 
// 1) 기존 글의 Markdown을 블록으로 파싱해 불러오기 (최초 1회) \
const blocks = await editor.tryParseMarkdownToBlocks(initialMarkdown); \
editor.replaceBlocks(editor.document, blocks);
 
// 2) 편집할 때마다 다시 Markdown으로 직렬화 (디바운스) \
const md = await editor.blocksToMarkdownLossy(editor.document); \
onChange(md);
 

이 선택의 이점이 큽니다. 글은 결국 Markdown 텍스트로 DB에 들어가므로, 3편에서 만든 공개 렌더링 파이프라인(remark/rehype + Shiki + 목차)을 그대로 재사용할 수 있습니다. 에디터를 바꾸더라도 저장 포맷과 렌더링은 흔들리지 않습니다. 작성 표면과 렌더링 파이프라인을 Markdown이라는 공통 포맷으로 분리해 둔 셈입니다.

이미지는 에디터에서 드롭/붙여넣기하면 Supabase Storage로 업로드되고, 본문에는 그 URL이 ![캡션](url) 형태로 들어갑니다. 그리고 3편의 커스텀 플러그인이 이를 <figure>로 렌더링합니다. 작성부터 표시까지 한 줄로 이어지는 것입니다.

시리즈를 마치며

네 편에 걸쳐 masonlab 블로그의 기술적 결정을 정리했습니다. 관통하는 원칙은 하나였습니다.

읽기는 비용이 0에 수렴하도록, 쓰기는 즉시 반영되도록.

  • 1편 — 읽기 중심 서비스라는 본질에서 출발해 "전부 굽고 바뀐 곳만 도려내기" 구조를 잡았습니다.

  • 2편 — SSG + ISR + 태그 기반 캐싱으로 정적 속도와 즉시 발행을 동시에 얻었습니다.

  • 3편 — Markdown을 빌드 타임에 안전한 HTML로 바꾸는 unified 파이프라인을 해부했습니다.

  • 4편 — 메타데이터·JSON-LD·RSS로 발견 가능성을 마감하고, Markdown을 공통 포맷으로 둔 에디터까지 이었습니다.

화려한 기능보다, 블로그라는 서비스의 성격에 맞는 결정을 하나씩 쌓는 일이 더 중요했습니다. 같은 고민을 하는 분께 이 기록이 작은 참고가 되면 좋겠습니다.


시리즈 끝. 읽어 주셔서 감사합니다.

이 글이 어떠셨나요? 이모지로 반응을 남겨주세요
M
Mason
Software Engineer · masonlab

코드와 비즈니스, 그 사이에서 배운 것들을 기록하고 공유합니다.

댓글 0

아직 댓글이 없어요. 첫 댓글을 남겨보세요.

이런 글은 어때요?