도구

Next.js에서 MDX 블로그 시스템 구축하기

2026.04.04

MDX 파일이 파서를 거쳐 웹 페이지로 바뀌는 과정을 나타낸 일러스트

MDX란?

MDX는 마크다운에 JSX를 삽입할 수 있는 포맷이다. 일반 마크다운으로 글을 쓰다가, 필요한 순간에 React 컴포넌트를 넣을 수 있다.

# 일반 마크다운 제목
 
일반 문단입니다.
 
<InteractiveChart data={chartData} />
 
다시 일반 문단으로 돌아옵니다.

개발 블로그에서 인터랙티브 데모나 커스텀 UI를 글 안에 넣고 싶을 때 유용하다.

파일 기반 콘텐츠 관리

CMS를 쓰지 않고, content/blog/ 디렉토리에 .mdx 파일을 직접 관리한다.

content/
└── blog/
    ├── hello-world.mdx
    ├── why-i-built-my-own-blog.mdx
    └── choosing-tech-stack.mdx

각 파일 상단에는 frontmatter로 메타데이터를 정의한다.

---
title: "글 제목"
description: "글 설명"
date: "2026-04-01"
tags: ["Dev", "Blog"]
---

이 방식의 장점은 Git으로 버전 관리가 되고, 에디터에서 바로 수정할 수 있다는 것이다.

MDX 파싱 유틸리티

핵심은 src/lib/mdx.ts 파일이다. 두 가지 라이브러리를 사용한다.

  • gray-matter: frontmatter(YAML 헤더)를 파싱
  • next-mdx-remote: MDX를 React 컴포넌트로 변환
import fs from "fs";
import path from "path";
import matter from "gray-matter";
 
const BLOG_DIR = path.join(process.cwd(), "content/blog");
 
export function getAllPosts() {
  const files = fs.readdirSync(BLOG_DIR).filter(f => f.endsWith(".mdx"));
 
  return files.map(filename => {
    const { data } = matter(
      fs.readFileSync(path.join(BLOG_DIR, filename), "utf-8")
    );
    return {
      slug: filename.replace(/\.mdx$/, ""),
      title: data.title,
      date: data.date,
      tags: data.tags,
    };
  }).sort((a, b) => (a.date > b.date ? -1 : 1));
}

getAllPosts()는 모든 글의 메타데이터를 날짜 역순으로 반환한다. getPostBySlug()는 특정 글의 본문까지 포함해서 반환한다.

코드 하이라이팅

개발 블로그에서 코드 하이라이팅은 필수다. rehype-pretty-code와 shiki를 조합했다.

<MDXRemote
  source={post.content}
  options={{
    mdxOptions: {
      rehypePlugins: [
        [rehypePrettyCode, { theme: "github-light" }],
      ],
    },
  }}
/>

shiki는 VS Code와 동일한 TextMate 문법을 사용해서, VS Code에서 보는 것과 똑같은 코드 하이라이팅을 웹에서 볼 수 있다.

태그 필터링

블로그 목록 페이지에서 태그로 글을 필터링할 수 있다. 구현 방식은 간단하다.

서버 컴포넌트에서 모든 글과 태그 목록을 가져오고, 클라이언트 컴포넌트에서 필터 상태를 관리한다.

// 서버 컴포넌트 (page.tsx)
const posts = getAllPosts();
const tags = getAllTags();
return <BlogList posts={posts} tags={tags} />;
 
// 클라이언트 컴포넌트 (blog-list.tsx)
const [selectedTag, setSelectedTag] = useState(null);
const filtered = selectedTag
  ? posts.filter(p => p.tags.includes(selectedTag))
  : posts;

API route를 만들 필요 없이, 서버/클라이언트 컴포넌트 패턴으로 깔끔하게 해결했다.

직접 겪은 일: 파일명으로 주소를 만들면 나중에 발목을 잡는다

여기서 정한 규칙 하나가 몇 달 뒤에 비용으로 돌아왔다.

파일명 앞의 숫자를 잘라 주소로 쓰기로 했다. 2026040103-building-mdx-blog-system.mdx면 주소는 /blog/2026040103이 되는 식이다. 짧고 안 겹쳐서 좋아 보였다.

문제는 나중에 글을 dev와 life 두 갈래로 나누면서 생겼다. 주소가 /blog/<번호>에서 /blog/dev/<번호>로 바뀌었고, 예전 주소로 색인돼 있던 페이지들이 한꺼번에 404가 됐다. 서치 콘솔에서 404 열한 개를 확인하고 나서야 알았다.

되돌리는 방법은 옛 주소를 새 주소로 잇는 것뿐이었다. 지금 next.config.ts가 77줄인데 그중 상당 부분이 이 리디렉션을 만드는 코드다. 손으로 적지 않고 content/ 폴더를 읽어서 만들게 했다 — 어느 글이 dev고 어느 글이 life인지는 폴더가 알고 있으니까.

교훈은 이거다. 주소 규칙은 콘텐츠 구조가 바뀌지 않는다는 가정 위에 세워진다. 그 가정은 대체로 틀린다.

글 추가하는 법

새 글을 쓰려면 content/blog/ 에 .mdx 파일을 추가하고 Git push하면 끝이다.

# 새 글 작성
vi content/blog/new-post.mdx
 
# 배포
git add . && git commit -m "post: 새 글" && git push

Cloudflare Pages가 자동으로 빌드하고 배포한다. CMS 없이도 충분히 편하다.

같이 읽으면 좋은 글