[블로그 만들기 #10] 블로그에 태그(Tag)기반 카테고리 기능을 추가해보자 (2/2)
![Cover Image for [블로그 만들기 #10] 블로그에 태그(Tag)기반 카테고리 기능을 추가해보자 (2/2)](/_next/image?url=%2Fassets%2Fblog%2Fvercel-blog-10%2Fcover.webp&w=3840&q=75)
블로그에 태그(Tag) 기반 카테고리 기능을 추가해서 방문자들이 관련 주제들을 묶어서 볼 수 있게 만드는 방법을 알아봅시다.
들어가며
이전 포스팅에서는 md 파일에서 메타데이터로 관리하는 tags 요소들을 파싱한 다음
<a href="/tags/build-custom-blog">블로그 만들기 시리즈</a>
이렇게 label & slug 구조를 이용한 링크로 만들어서 태그를 시각화하는 것까지 만들었습니다.
이번엔 태그를 클릭하면 해당 태그로 필터링된 목록만 깔끔하게 보여지는, 진짜 ‘카테고리 기능’을 만드는 방법을 알아보겠습니다.
태그 필터링 함수
저희가 추천 콘텐츠 함수(이전 글, 다음 글) 구현을 위해 사용한 getRelatedPostsBySlug
함수 기억하시나요?
최신 날짜 기준으로 정렬된 포스팅 목록에서 slug 기준으로 앞, 뒤 콘텐츠를 필터링해서 가져오는게 핵심이였죠?
같은 원리로 포스팅 slug가 아닌, 태그 slug를 기준으로 필터링하는 getPostsByTag
함수를 만들어보도록 하겠습니다.
저희가 각종 api 모아놓은 /src/lib/api.ts
파일에
// src/lib/api.ts
import { ... } from "...";
import { Post } from "@/interfaces/post";
import { getAllPosts } from "@/lib/api";
/**
* ...기존 함수들
*/
export const getPostsByTag = (tagSlug: string): Post[] => {
const posts = getAllPosts();
return posts.filter((post) => {
// post 정보에서 tag slug 값만 추출
const tags = post.tags.map((tag) => tag.slug);
// 함수에 인자로 전달된 tagSlug 값이 포함되어 있는지 체크해서 true인 것들만 반환
return tags.includes(tagSlug);
});
};
테스트 한번 해보겠습니다.
const output = getPostsByTag("build-custom-blog");
// console.log(output);
[
{
slug: "vercel-blog-09",
title: "[블로그 만들기 #9] 블로그에 태그(Tag)기반 카테고리 기능을 추가해보자 (1/2)",
content: "",
...
tags: [
{ slug: "build-custom-blog", label: "블로그 만들기 시리즈" },
{ slug: "recommender-system", label: "추천 시스템" },
],
},
{
slug: "vercel-blog-08",
title: "[블로그 만들기 #8] 블로그에 이전 글, 다음 글 기능 추가해서 방문자 길게 붙잡아보자",
content: "",
...
tags: [
{ slug: "build-custom-blog", label: "블로그 만들기 시리즈" },
{ slug: "recommender-system", label: "추천 시스템" },
],
},
...
];
입력된 태그 기준으로 필터링 잘 되는 것을 보실 수 있습니다. 이제 방문자가
GET /tags/[slug]
경로로 접근했을 때 slug 값으로 필터링된 포스팅 목록들을 UI에 그려주면 태그 기반 카테고리 기능이 만들어지겠죠?
사전 지식
App Router
방문자들에게 어떻게 UI를 그려줄지 설계하기 위해서는, Next.js 13 이상부터 지원하는 App Router라는 기능을 알고가야 합니다.
App Router를 간단하게 정의하면 app이라는 디렉토리에서 작동하는 폴더, 파일 기반의 라우팅 시스템이라고 할 수 있는데요, 저희에게 필요한 부분만 가져오면
- 폴더: 경로를 정의하는 데 사용, 경로의 각 폴더는 경로 세그먼트를 나타냅니다.
- 파일:
- layout.tsx: 세그먼트와 자식에 대한 공유 UI
- page.tsx: 경로의 고유한 UI. 경로를 공개적으로 접근 가능하게 만들어줌
아직 조금 추상적이죠? 예시로 저희 블로그 구조를 가져와보겠습니다. 저희 블로그 포스팅 정보들에 접근하기 위해서는 /posts/slug-01, /posts/slug-02, ... 이런 경로로 접근해야 하는데요, App Router를 이용해 라우팅을 처리하려면
# 1. GET /posts/slug-01
# 2. GET /posts/slug-02
/app
├── posts # Segment
│ ├── layout.tsx # 페이지 공통 레이아웃
│ ├── slug-01 # Segment
│ │ └── page.tsx # 경로의 고유한 UI
│ └── slug-02 # Segment
│ └── page.tsx # 경로의 고유한 UI
이렇게 폴더 구조를 잡아줘야 합니다.
Dynamic Routes
하지만 위 폴더 구조에서 포스팅 1~2개 정도는 문제없지만, 데이터가 점점 쌓여나가면 관리 지옥이 펼쳐지겠죠? 비효율적인 반복을 혐오하는 개발자라면 이런 문제를 가만히 놔둘 수가 없습니다.
Next.js에서는 이 문제를 해결하기 위해서 동적 라우팅(Dynamic Routing)이라는 멋진 기능을 제공해주는데요, 이걸 이용해서 동적 세그먼트인 slug 데이터를 효율적으로 처리할 수 있습니다.
위에서처럼 개별 slug를 이용해 일일히 폴더를 생성하는게 아닌
/app
├── posts # /posts라는 공통 경로에서
│ ├── layout.tsx # 페이지 공통 레이아웃. 없으면 자동으로 상위 폴더의 layout.tsx 사용함
│ └── [slug] # 동적으로 변경되는 경로(동적 세그먼트)로 들어오면
│ └── page.tsx # 이 공통 컴포넌트로 페이지를 만들어준다
이렇게 단순히 폴더 이름을 대괄호로 감싸주기만 하면
// app/posts/[slug]/page.tsx
// 1. GET /posts/slug-01 => { params: { slug: "slug-01" } }
// 2. GET /posts/slug-02 => { params: { slug: "slug-02" } }
type Params = {
params: { slug: string };
};
export default function Page({ params }: Params) {
return <div>My Post: {params.slug}</div>;
}
Next.js가 내부적으로 동적 세그먼트 값을 공통 UI 컴포넌트인 page.tsx에게 params 값으로 전달해줍니다.
SSG(Static Site Generation)
이제 동적 세그먼트를 처리하기 위한 준비는 모두 끝났으니 동적 데이터를 처리해줄 서버가 필요합니다. 하지만 하루 한명 방문하는 블로그를 위해서 서버를 띄워놓는 금수저 개발자는 없습니다.
빵이 없으면 고기를 먹으면 되듯이, 서버가 없으면 빌드 시점에 동적인 데이터들을 미리 싹 다 불러와서 정적 파일들로 만들어버린 다음, CDN에 던져버리면 됩니다.(거의 공짜 + 빠른 속도는 덤입니다)
이렇게 동적인 요소들을 정적으로 일일히 바꿔주는 방법을 Static Site Generation, 멋있는 용어로 SSG라고 불러줍니다.
// app/posts/[slug]/page.tsx
export function generateStaticParams() {
return [
{ label: "블로그 만들기 시리즈", slug: "build-custom-blog" },
{ label: "추천 시스템", slug: "recommender-system" }
],
}
type Params = {
params: { slug: string };
};
export default function Page({ params }: Params) {
return <div>My Post: {params.slug}</div>;
}
이렇게 generateStaticParams
라는 함수와 위에서 설명드린 동적 라우팅(Dynamic Routing)을 함께 사용하면
- 요청 시점이 아닌 빌드 시점에
- page.tsx에게 params 값으로 세그먼트 데이터를 전달해서
- 정적으로 경로를 생성한다. (UI를 렌더링)
태그 기반 카테고리 UI
모든 태그 정보 가져오기
이렇게 어떤 폴더 구조로, 어떤 파일을 생성해야 하는지 알 수 있는데요, 각 태그마다 UI를 생성하려면 우선 모든 태그를 가져오는 함수가 필요합니다.
태그 정보 싹 다 가져오는김에 동일 태그 가진 게시글 카운팅 기능도 추가하면 인기 태그 기반 게시글 추천까지 확장 가능하지 않을까요? 이번 포스팅에서 다루지는 않겠지만 확장 가능성을 위해 카운팅 기능도 추가해서 Type 정의하고, 우아하게 정렬까지 해주는 getAllTags
함수 만들어보겠습니다.
// /src/app/interface/post.ts
export type TagCount = Tag & { count: number };
export type Tag = {
label: string;
slug: string;
};
// /src/lib/api.ts
import { ... } from "...";
import { TagCount } from "@/interfaces/post";
import { getAllPosts } from "@/lib/api";
// ...기존 함수들
export const getAllTags = (): TagCount[] => {
const posts = getAllPosts()
// 동일 slug 중복 방지를 위해 tagCountMap 객체 만들어주고
const tagCountMap: { [slug: string]: TagCount } = {};
for (const post of posts) {
post.tags.forEach((tag) => {
const { slug } = tag;
if (!tagCountMap[slug]) {
// 처음 나온 slug면 태그 정보 복사, 카운팅 1로 만들어주기
tagCountMap[slug] = { ...tag, count: 1 };
} else {
// 중복된 tag slug면 기존 태그 정보 재활용, 카운팅만 +1
tagCountMap[slug].count += 1;
}
});
}
// 카운팅 수 내림차순 정렬(가장 많은 게시글에서 태그된게 위쪽으로)
return Object.values(tagCountMap).sort((a, b) => b.count - a.count);
};
UI 렌더링
라우팅, 태그 필터링 기능이 완료되었으니 마지막으로 방문자가
GET /tags/[slug]
경로로 접근했을 때 필터링된 포스팅 목록들을 UI에 그려주기만 하면 끝입니다. App Router 구조에 맞춰서 src/app/tags/[tag]/page.tsx
파일 생성해주시고 UI 그려줄 TagPage
component 만들어 보겠습니다.
// src/app/tags/[tag]/page.tsx
import { ... } from "...";
import { Metadata } from "next";
import { getAllTags, getPostsByTag } from "@/lib/api";
type Params = {
params: { tag: string }
};
export function generateStaticParams() {
const tags = getAllTags();
return tags.map((tag) => ({
tag: tag.slug,
}));
}
export default async function TagPage({ params }: Params) {
const { tag } = params;
const posts = getPostsByTag(tag);
const titleElement = (
<h1 className="my-12 text-center text-3xl sm:text-5xl font-bold tracking-tighter">{`Tag: #${tag}`}</h1>
);
const listElement =
posts.length > 0 ? (
posts.map((post) => {
return (
<li className="mb-10" key={post.slug}>
<Link href={`/posts/${post.slug}`} className="cursor-pointer">
<h2 className="text-base sm:text-xl font-medium mb-2">{post.title}</h2>
<p className="text-xs sm:text-sm text-slategray mb-4">{post.excerpt}</p>
</Link>
</li>
);
})
) : (
<h2 className="text-center text-2xl">그런거 없습니다.</h2>
);
return (
<main>
{titleElement}
{listElement}
</main>
);
}
// SEO를 위한 메타에이터 생성
export async function generateMetadata({ params }: Params): Promise<Metadata> {
const { tag } = params;
const title = `Tag - ${tag} | day1swhan 블로그`;
const description = `tag: #${tag}`;
const canonicalUrl = `https://blog.day1swhan.com/tags/${tag}`;
return {
title,
description,
...
alternates: {
canonical: canonicalUrl,
},
};
}
빌드해서 테스트 해보면
이렇게 태그 기반 필터링 & UI 구현 모두 정상적으로 나오는 것 보이시죠? 존재하지 않는 태그들도 잘 처리되는지 확인해보면
깔끔하게 잘 나오는 것을 확인하실 수 있습니다.
마무리
이렇게 추천 콘텐츠의 알파이자 오메가인 이전 글 & 다음 글, 태그 기반 카테고리 기능을 하나씩 직접 구현해가며 멀쩡한 블로그 대열에 합류하게 되었습니다.
언젠가 이 태그 데이터 위에 기계 학습이 탑재되고, 유저 행동 기반 추천 알고리즘 기능도 적용되는 날이 오기를 꿈꾸며, 이번 포스팅은 여기서 마무리하겠습니다.