[블로그 만들기 #8] 블로그에 이전 글, 다음 글 기능 추가해서 방문자 길게 붙잡아보자

Cover Image for [블로그 만들기 #8] 블로그에 이전 글, 다음 글 기능 추가해서 방문자 길게 붙잡아보자
swhan
· 5 min read

AI 추천 기술 시대에 맞서, 첨단 인간 지능으로 만든 고전 추천 시스템, 이전 글과 다음 글 기능을 블로그에 적용하는 방법을 알아봅시다.

들어가며

이전 포스팅에서는 블로그에 스타일링을 적용해서(코드 하이라이팅, 표, 글자 크기, 간격, 굵기 등), 메모장이 아닌 에디터 느낌 나도록 만드는 방법을 알아보았습니다.

이번 포스팅에서는 화려한 AI 추천 기술 시대에 맞서, 인간 지능을 이용한 날짜 기준 추천 방식으로 "이전 글"과 "다음 글" 기능을 블로그에 적용해보는 방법을 알아보겠습니다.

어떻게 만들지?

일단 저희 블로그 구조 파악을 위해서, 개별 포스팅 작성한 _post/post-slug.md 파일 확인해보면

---
title: "[블로그 만들기 #12345] 포스팅 제목"
excerpt: "포스팅 설명(요약본)"
date: "2025-XX-XXT12:00:00.000Z"
author:
  name: "작성자"
  picture: "/assets/blog/authors/author.jpg"
coverImage: "/assets/blog/post-slug/cover.jpg"
ogImage:
  url: "/assets/blog/post-slug/cover.jpg"
tags: ["각종", "태그들"]
slug: "post-slug"
---

## 제목1

내용1

## 제목2

내용2

이런 구조겠죠?

  1. Markdown 파일에서 title, excerpt, date, author 등 메타데이터 관리중이고
  2. gray-matter 모듈을 이용해서 메타데이터, 본문 내용 파싱

이렇게 블로그 포스팅 정보 관리하는 큰 구조는 딱 2개입니다.

블로그 멋있어 보이기 위해서 카테고리로 포스트 나누고, 최신글, 인기글 순으로 정렬 & 추천 등 여러 기능을 넣고 싶지만, 무슨 일이든 처음부터 복잡하게 시작하면 시작도 하기전에 지쳐서 포기하게 됩니다.

빠르게 변하는 시대에는 오히려 변하지 않는 본질에 집중하는 것도 좋은 방법이죠.

저희는 가장 본질적이면서 변하지 않는 추천 방식인, 날짜 기준으로 이전글, 다음글 연결해주는 기능부터 구현해보도록 하겠습니다.

포스팅 데이터 가져오기

저희가 사용하는 blog-starter-kit에서는 Markdown 파일들을 어떻게 처리중인지 확인해보겠습니다.

src/lib/api.ts 함수 확인해보시면 이렇게 나오는데요, 하나씩 차근차근 뜯어봅시다.

// src/lib/api.ts
import fs from "fs";
import { join } from "path";
import matter from "gray-matter"; // Markdown 파일 파싱해주는 친구
import { Post } from "@/interfaces/post"; // Post 인터페이스

// 블로그 컨텐츠 저장 경로 >> /프로젝트 폴더 절대경로/_posts
const postsDirectory = join(process.cwd(), "_posts");

// _posts 폴더 내부 포스팅 파일들
export function getPostSlugs() {
  // ['slug-01.md','slug-02.md']
  return fs.readdirSync(postsDirectory);
}

export function getPostBySlug(slug: string) {
  // 포스팅 파일명 .md 확장자 제거
  const realSlug = slug.replace(/\.md$/, "");

  // 파일 읽기 위해서 절대 경로로 바꿔주고
  const fullPath = join(postsDirectory, `${realSlug}.md`);

  // 파일 읽어서
  const fileContents = fs.readFileSync(fullPath, "utf8");

  // gray-matter 모듈에게 분석하라고 시키기
  // content: 본문 내용, data: md 파일에서 가져온 메타데이터
  const { content, data } = matter(fileContents);

  // slug 확장자 제거된 순수 slug
  return { slug: realSlug, content, ...data } as Post;
}
// {
//   slug: 'slug-01',
//   title: '포스팅 제목 1',
//   content: '포스팅 내용 1',
//   date: '2025-01-01T12:00:00.000Z', // utc 포맷
//   ...
// }

export function getAllPosts(): Post[] {
  const slugs = getPostSlugs();
  const posts = slugs
    .map((slug) => getPostBySlug(slug))
    // 내림차순 정렬(최신글이 위쪽으로)
    .sort((post1, post2) => (post1.date > post2.date ? -1 : 1));
  return posts;
}
// [
//   {
//     slug: 'slug-02',
//     title: '포스팅 제목 2',
//     content: "포스팅 내용 2",
//     date: '2025-02-01T12:00:00.000Z',
//     ...
//   },
//   {
//     slug: 'slug-01',
//     title: '포스팅 제목 1',
//     content: "포스팅 내용 1",
//     date: '2025-01-01T12:00:00.000Z',
//     ...
//   },
// ]
  1. getPostSlugs: 저희가 포스팅 저장하는 _posts 폴더에서 파일 목록들 확장자 포함해서 가져오는 함수
  2. getPostBySlug: 포스팅 파일 이름 입력받아서 파일 읽기, gray-matter 모듈 이용해서 메타데이터, 본문 파싱 후 slug명 포함해서 객체로 반환
  3. getAllPosts: md파일 파싱된 정보(메타데이터, 본문, slug)를 날짜 기준 내림차순(최신글 먼저) 정렬해서 배열로 반환

이런식으로 포스팅 데이터들을 관리하기 위해서, 각각의 함수가 어떻게 작동하는지 알 수 있습니다.

추천 콘텐츠 함수 만들기

추천 콘텐츠(이전글, 다음글) 정보를 가져오기 위해서 저희는 새로운 함수를 하나 만들어 볼건데요,

slug 정보를 인자로 받는 getRelatedPostsBySlug 함수 하나 만들어준 다음에

  1. getAllPosts 함수 호출해서 가져온 전체 포스팅 정보들에서 (최신글이 위쪽으로 정렬된 상태)
  2. getRelatedPostsBySlug 함수에서 인자로 전달받은 slug 위치(index) 기준으로
  3. 앞, 뒤 날짜의 포스팅 정보를 반환한다. (이전글: index + 1, 다음글: index - 1 >> 최신글이 위쪽으로 가도록 정렬 되어있으니까)

이렇게 작동하는 getRelatedPostsBySlug 함수의 결과 값을 포스팅 본문 렌더링 담당하는 component(src/app/posts/[slug]/page.tsx)에 전달해주면 되지 않을까요??

일단 추천 콘텐츠 가져올 함수가 모든 포스팅 정보에 효율적으로 접근할 수 있도록, getAllPosts 함수부터 최적화 해보겠습니다.

포스팅 데이터 캐싱하기

포스팅 데이터들을 가져오는 getAllPosts 함수는 호출될 때마다 모든 md 파일들을 읽어서 파싱하고 있는데요

// src/lib/api.ts
// getAllPosts 함수 수정

let AllPostsCache: Post[] | undefined = undefined;

export function getAllPosts(): Post[] {
  if (AllPostsCache) {
    return AllPostsCache;
  }

  const slugs = getPostSlugs();
  const posts = slugs
    .map((slug) => getPostBySlug(slug))
    // sort posts by date in descending order
    .sort((post1, post2) => (post1.date > post2.date ? -1 : 1));

  AllPostsCache = posts;
  return AllPostsCache;
}

추천 콘텐츠를 찾기 위해 전체 콘텐츠 정보를 반복해서 조회할 경우에도 효율적으로 작동하도록, AllPostsCache라는 변수에 캐싱 해주겠습니다.

GetRelatedPostsBySlug 기능 구현하기

단순 index 기준으로 앞, 뒤 콘텐츠 정보 가져오는 함수이지만, 마음만은 첨단 추천 알고리즘이 적용된 마이크로서비스라 생각하고 함수 만들어보겠습니다.

// src/lib/api.ts

/**
 * ....
 * 기존 함수들
 */

// RelatedPosts type 추가해주고
export type RelatedPosts = {
  previous?: { slug: string; title: string };
  next?: { slug: string; title: string };
};

// 추천 콘텐츠 제공하는 함수 추가해주기
export function getRelatedPostsBySlug(currentSlug: string): RelatedPosts {
  // 캐싱된 전체 포스팅 정보 가져와서(최신 날짜 기준으로 정렬됨)
  const posts = getAllPosts();

  // 현재 포스팅의 slug 기준으로 index 값 구해서
  const currentIndex = posts.findIndex((post) => post.slug === currentSlug);

  // 현재 포스팅의 slug 기준으로 이전글, 다음글 index 이용해서 개별 포스팅 정보 가져오기
  const previousPost = posts[currentIndex + 1] || undefined;
  const nextPost = posts[currentIndex - 1] || undefined;

  return {
    ...(previousPost && { previous: { slug: previousPost.slug, title: previousPost.title } }),
    ...(nextPost && { next: { slug: nextPost.slug, title: nextPost.title } }),
  };
}

이렇게 getRelatedPostsBySlug 함수, RelatedPosts type 추가해주고 테스트 한번 돌려보면

// src/lib/test.ts
import { getRelatedPostsBySlug } from "@/lib/api";

// 포스팅 목록에 "vercel-blog-01", "vercel-blog-02", "vercel-blog-03" 이렇게 3개 있을 때
const relatedPosts = getRelatedPostsBySlug("vercel-blog-02");

console.log(relatedPosts);
{
  "previous": {
    "slug": "vercel-blog-01",
    "title": "[블로그 만들기 #1] 백엔드 개발자의 Next.js 블로그 30분 구축기 (feat. Vercel)"
  },
  "next": {
    "slug": "vercel-blog-03",
    "title": "[블로그 만들기 #3] 구글 서치 콘솔에 블로그 주소를 등록해보자 (feat. sitemap.xml)"
  }
}

이런식으로 입력받은 slug 값 기준으로 앞, 뒤 게시글 제목과 slug 값을 잘 가져오는 걸 확인할 수 있습니다.

추천 콘텐츠 함수 적용하기

이제 우리 최첨단 인간지능 추천 알고리즘을 호출할 수 있는 함수(api)가 완성되었으니, 게시글에 반영해서 방문자들의 클릭률을 높여봅시다.

우선 이전글, 다음글 보여주는 post-related.tsx component 만들어주고

// src/app/_components/post-related.tsx
import { RelatedPosts } from "@/lib/api";
import Link from "next/link";

export function PostRelated({ previous, next }: RelatedPosts) {
  return (
    <div className="max-w-2xl mx-auto space-y-4 mt-20">
      {previous && (
        <Link
          href={`/posts/${previous.slug}`}
          className="block border rounded-xl p-4 hover:shadow transition duration-200"
        >
          <div className="text-xs  mb-1">← 이전 글</div>
          <div className="text-lg font-medium hover:underline">{previous.title}</div>
        </Link>
      )}
      {next && (
        <Link href={`/posts/${next.slug}`} className="block border rounded-xl p-4 hover:shadow transition duration-200">
          <div className="text-xs  mb-1">다음 글 →</div>
          <div className="text-lg font-medium hover:underline">{next.title}</div>
        </Link>
      )}
    </div>
  );
}

포스팅 본문 렌더링 담당하는 src/app/posts/[slug]/page.tsx 파일에 추천 콘텐츠 component 추가해주면

// src/app/posts/[slug]/page.tsx

import { ... } from "기존 모듈들"
// 추천 콘텐츠 함수 구현한 getRelatedPostsBySlug 추가해주고
import { getAllPosts, getPostBySlug, getRelatedPostsBySlug } from "@/lib/api";
// 방금 만든 추천글 보여줄 component 가져오기
import { PostRelated } from "@/app/_components/post-related";

export default async function Post(props: Params) {
  const params = await props.params;
  const post = getPostBySlug(params.slug);

  // 추천글 가져오기 위해 getRelatedPostsBySlug 함수 호출하고
  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}
            date={post.date}
            author={post.author}
            lastMod={post.lastMod}
          />
          <PostBody content={content} />
          // 추천글 보여줄 component 삽입
          <PostRelated previous={previous} next={next} />
        </article>
      </Container>
    </main>
  );
}

게시글에 추천 UI 추가 전: 본문만 있는 화면

기존처럼 꼴랑 포스팅 내용만 있고 끝나는 화면이

게시글에 추천 UI 추가 후: 이전글/다음글 박스가 추가됨

이렇게 추천글(이전글, 다음글) 보여줄 수 있게 변신합니다.

마무리

이제 저희가 구현한 심플한 추천 시스템 덕분에 방문자들이 글 하나만 읽고 도망가기는 어려워졌습니다.(SEO 최적화는 덤입니다)

다음 포스팅에서는 방문자들이 관심 있는 주제에 더 쉽게 접근할 수 있도록(클릭률 더 올리고, 방문자들을 더 오래 붙잡아둘 수 있는) 태그 기반 카테고리 시스템을 만들어보겠습니다.