[블로그 만들기 #9] 블로그에 태그(Tag)기반 카테고리 기능을 추가해보자 (1/2)

Cover Image for [블로그 만들기 #9] 블로그에 태그(Tag)기반 카테고리 기능을 추가해보자 (1/2)
swhan
· 4 min read

블로그에 태그(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 키를 가진 객체들의 배열로 바꿔준 다음, 방문자가 개별 포스팅 페이지에 방문하였을 때

  1. label: 사용자에게 보여지는 부분 (블로그 만들기 시리즈)
  2. 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 함수를 통해 가져온 태그 정보들에

  1. resolvePostTags: 태그 정보가 없으면 기본 태그로 처리해주고 (slug: untagged, label: 태그 없음)
  2. 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>
  );
}

게시글에 태그 UI 추가 전

이렇게 밋밋한 게시글 헤더 부분이

게시글에 태그 UI 추가 후: 태그 기반 카테고리 조회 가능

버튼 모양의 태그 정보들이 추가되면서

  1. 방문자들에게 label 내용이 보여지고
  2. 태그 클릭하면 slug 기준으로

/tags/각종-태그-slug URL로 이동시켜 줌으로써, 방문자들에게 동일 태그를 가진 포스팅들을 묶어서 보여주기 위한 기초 작업이 완료되었습니다.

마무리

이제 md 파일 태그를 중심으로 관련 콘텐츠를 연결하는 첫 퍼즐 조각이 완성됐습니다.

다음 포스팅에서는 방문자가 실제로 태그 페이지(/tags/[slug])에 접근했을 때,

  1. 해당 태그(slug)를 가진 포스트만 필터링하고
  2. 리스트 형식으로 보기 좋게 보여주는 방법

이 두 가지를 구현해보겠습니다.