[블로그 만들기 #9] 블로그에 태그(Tag)기반 카테고리 기능을 추가해보자 (1/2)
![Cover Image for [블로그 만들기 #9] 블로그에 태그(Tag)기반 카테고리 기능을 추가해보자 (1/2)](/assets/blog/vercel-blog-09/cover.webp)
블로그에 태그(Tag) 기반 카테고리 기능을 추가해서 방문자들이 관련 주제들을 묶어서 볼 수 있게 만드는 방법을 알아봅시다.
들어가며
지난 포스팅에서는 포스팅 작성 시간 기준으로 이전 글 & 다음 글 기능을 만들어서, 방문자들이 블로그에 더 오래 머물 수 있도록 하는 방법을 알아보았습니다.
이번에는 방문자들이 원하는 주제를 쉽게 찾아서 볼 수 있는, 태그(Tag) 기반 카테고리 기능을 구현해보려 합니다.
지금까지는 단순히 메타데이터로만 존재하던 태그들을 클릭 가능한 카테고리처럼 작동하게 만들면, 방문자들에게 더 많은 콘텐츠를 자연스럽게 소개할 수 있게 됩니다.
작동 방식
저희 블로그 본문을 작성하는 md 파일에서 tags 옵션 부분 기억하시나요?
---
title: "포스팅 제목"
excerpt: "포스팅 설명(요약본)"
slug: "post-slug-01"
....
tags: ["블로그 만들기 시리즈", "추천 시스템"]
---
포스팅 내용들~~
이렇게 기존 md 파일에서는 tags 부분을 단순 문자 배열로 작성중이였는데요
---
title: "포스팅 제목"
excerpt: "포스팅 설명(요약본)"
slug: "post-slug-01"
....
tags:
- label: "블로그 만들기 시리즈"
slug: "build-custom-blog"
- label: "추천 시스템"
slug: "recommender-system"
---
포스팅 내용들~~
이렇게 label, slug 키를 가진 객체들의 배열로 바꿔준 다음, 방문자가 개별 포스팅 페이지에 방문하였을 때
- label: 사용자에게 보여지는 부분 (블로그 만들기 시리즈)
- slug: url 요소 (build-custom-blog)
이렇게 동작하도록 link로 만들어주면
<a href="/tags/build-custom-blog">블로그 만들기 시리즈</a>
방문자가 링크 버튼을 클릭했을 때 전달받은 slug 값인 build-custom-blog를 기반으로 동일한 태그를 가진 포스팅 목록들을 필터링해서 보여주는게 태그 기반 카테고리 기능의 작동 방식입니다.
태그 파싱과 URL 라우팅 구조 만들기
태그 타입 정리 및 파싱 함수 구성
저희 프로젝트에서 포스팅 작성하는 md파일은 gray-matter 모듈을 이용하여 파싱되고 있는데요, md 파일에서 파싱된 Tag 정보들을 typescript에서 안전하게 작업할 수 있도록 인터페이스 먼저 수정해보겠습니다.
// /src/app/interface/post.ts
export type Tag = {
label: string;
slug: string;
};
export type Post = {
slug: string;
title: string;
date: string;
...
tags: Tag[];
};
인터페이스 파일에서 Tag type 추가해주시고
포스팅 본문, 메타데이터 내용을 파싱해주는 /src/lib/api.ts
파일도 수정해보겠습니다.
// /src/lib/api.ts
// ...기존 함수들
const normalizeTagSlug = (slug) => {
const slug = (slug || "")
.toLowerCase()
.normalize("NFD") // 발음 기호 제거용
.replace(/[\u0300-\u036f]/g, "") // 발음 기호 제거
.replace(/[^a-z0-9]+/g, "-") // 특수 문자, 공백 → 하이픈
.replace(/^-+|-+$/g, ""); // 앞뒤 하이픈 제거
return slug;
};
const resolvePostTags = (tags) => {
const default_tag_slug = "untagged";
const default_tag_label = "태그 없음";
const defaultTags = [{ slug: default_tag_slug, label: default_tag_label }];
if (Array.isArray(tags) && tags.length) {
return tags.map((tag) => {
return {
label: tag?.label || default_tag_label,
slug: normalizeTagSlug(tag?.slug) || default_tag_slug,
};
});
}
return defaultTags;
};
export function getPostBySlug(slug: string) {
const realSlug = slug.replace(/\.md$/, "");
const fullPath = join(postsDirectory, `${realSlug}.md`);
const fileContents = fs.readFileSync(fullPath, "utf8");
const { content, data } = matter(fileContents);
return {
slug: realSlug,
content,
...data,
tags: resolvePostTags(data.tags),
} as Post;
}
getPostBySlug 함수를 통해 가져온 태그 정보들에
- resolvePostTags: 태그 정보가 없으면 기본 태그로 처리해주고 (slug: untagged, label: 태그 없음)
- normalizeTagSlug: url에서 사용할 slug 값 normalize (대문자, 띄워쓰기, 특수문자 제거)
이렇게 두 기능이 적용되어서 안전하게 태그 기반으로 카테고리화가 가능해집니다.
이제 getPostBySlug 함수 호출해보면, 다음과 같은 형태로 파싱된 데이터를 반환합니다:
const post = getPostBySlug("post-slug-01.md");
// console.log(post)
{
slug: "post-slug-01",
title: "포스팅 제목",
excerpt: "포스팅 설명(요약본)",
...
tags: [
{ label: "블로그 만들기 시리즈", slug: "build-custom-blog" },
{ label: "추천 시스템", slug: "recommender-system" },
],
};
URL 라우팅 포스팅에 반영하기
파싱이 작동하는걸 확인했으니 포스팅 본문에 태그 정보들 넘겨서 라우팅 가능하도록 UI에 반영해보겠습니다.
이전 글, 다음 글 보여주기 기능 구현하기 포스팅에서는 블로그 본문 내용이 들어가는 PostBody component 아래에 이전 글, 다음 글 링크를 넣어주는 UI 추가했었죠?
저는 태그 정보가 담긴 UI를 PostHeader component에서 커버 이미지 아래에 넣어주려고 하는데요, UI 반영을 위해 src/app/_components/post-header.tsx
파일을 수정해보겠습니다.
// src/app/_components/post-header.tsx
import { ... } from "...";
import { Tag } from "@/interfaces/post";
import Link from "next/link";
type Props = {
title: string;
coverImage: string;
...
tags: Tag[]; // Tag type 추가
};
export function PostHeader({ title, coverImage, date, author, lastMod, tags }: Props) {
const tagBaseUrl = "/tags";
const tagElements = tags.map((tag) => (
<Link
key={tag.slug}
href={`${tagBaseUrl}/${tag.slug}`}
className="text-sm font-medium hover:underline border rounded px-3 py-1"
>
{`#${tag.label}`}
</Link>
));
return (
<>
{/* 설명을 위해 각종 불필요한 요소들은 생략했습니다. */}
<PostTitle>{title}</PostTitle>
<CoverImage title={title} src={coverImage} />
<Avatar name={author.name} picture={author.picture} />
...
{/* 태그 정보들 들어갈 element 추가 */}
<div className="flex flex-wrap gap-2 mb-6">{tagElements}</div>
</>
);
}
이제 PostHeader component에 태그 데이터를 props로 넘겨줄 수 있도록 src/app/posts/[slug]/page.tsx
파일을 수정해주면
// src/app/posts/[slug]/page.tsx
import { ... } from "기존 모듈들"
import { getPostBySlug, getRelatedPostsBySlug } from "@/lib/api";
import { PostRelated } from "@/app/_components/post-related";
export default async function Post(props: Params) {
const params = await props.params;
const post = getPostBySlug(params.slug);
const { previous, next } = getRelatedPostsBySlug(params.slug);
if (!post) {
return notFound();
}
const content = await markdownToHtml(post.content || "");
return (
<main>
<Container>
<Header />
<article className="mb-20">
<PostHeader
title={post.title}
coverImage={post.coverImage}
...
{/* 태그 정보들 props로 넘겨주기 */}
tags={post.tags}
/>
<PostBody content={content} />
<PostRelated previous={previous} next={next} />
</article>
</Container>
</main>
);
}
이렇게 밋밋한 게시글 헤더 부분이
버튼 모양의 태그 정보들이 추가되면서
- 방문자들에게 label 내용이 보여지고
- 태그 클릭하면 slug 기준으로
/tags/각종-태그-slug
URL로 이동시켜 줌으로써, 방문자들에게 동일 태그를 가진 포스팅들을 묶어서 보여주기 위한 기초 작업이 완료되었습니다.
마무리
이제 md 파일 태그를 중심으로 관련 콘텐츠를 연결하는 첫 퍼즐 조각이 완성됐습니다.
다음 포스팅에서는 방문자가 실제로 태그 페이지(/tags/[slug]
)에 접근했을 때,
- 해당 태그(slug)를 가진 포스트만 필터링하고
- 리스트 형식으로 보기 좋게 보여주는 방법
이 두 가지를 구현해보겠습니다.