DevMDXNext.js

Contentlayer 없이 MDX 블로그 만들기: 직접 파서 짠 후기

2026.04.21

Contentlayer가 사라진 자리

이 블로그를 처음 만들 때 Contentlayer를 쓸까 했다. MDX 파싱, frontmatter 파싱, 타입 생성을 한 번에 해주는 도구다. 검색하면 다 Contentlayer를 쓴다.

문제는 Contentlayer가 메인테넌스 중단 상태라는 거다. Next.js 15 호환에 이슈가 있고, App Router 이슈도 적극적으로 안 고쳐지고 있다. Contentlayer 2가 나온다고 했지만 일정이 명확하지 않다.

대안은 두 가지다. fork된 community 버전을 쓰거나, 직접 짠다. 내가 고른 건 후자다. 결과적으로 100줄 안쪽의 자체 콘텐츠 레이어가 됐고, 더 자유롭고 더 빠르다.

Contentlayer가 해주던 일

직접 짜기 전에 Contentlayer가 정확히 어떤 일을 해주는지 정리.

  1. content/ 디렉토리의 MDX 파일을 스캔
  2. 각 파일의 frontmatter를 파싱
  3. 본문을 미리 컴파일된 React 컴포넌트로 변환
  4. 모든 파일을 모은 인덱스를 빌드 타임에 생성
  5. TypeScript 타입을 자동 생성

이 다섯 가지를 직접 하면 된다.

짠 코드

핵심 파일은 lib/posts.ts 하나다.

import fs from "node:fs";
import path from "node:path";
import matter from "gray-matter";
 
const CONTENT_DIR = path.join(process.cwd(), "content");
 
export type Post = {
  slug: string;
  title: string;
  description: string;
  date: string;
  tags: string[];
  content: string;
};
 
export function getAllPosts(): Post[] {
  const dirs = fs.readdirSync(CONTENT_DIR);
  const posts: Post[] = [];
 
  for (const dir of dirs) {
    const dirPath = path.join(CONTENT_DIR, dir);
    if (!fs.statSync(dirPath).isDirectory()) continue;
 
    const files = fs.readdirSync(dirPath).filter((f) => f.endsWith(".mdx"));
 
    for (const file of files) {
      const raw = fs.readFileSync(path.join(dirPath, file), "utf-8");
      const { data, content } = matter(raw);
      posts.push({
        slug: file.replace(/\.mdx$/, ""),
        title: data.title,
        description: data.description,
        date: data.date,
        tags: data.tags || [],
        content,
      });
    }
  }
 
  return posts.sort((a, b) => b.date.localeCompare(a.date));
}
 
export function getPostBySlug(slug: string): Post | null {
  return getAllPosts().find((p) => p.slug === slug) || null;
}
 
export function getPostsByCategory(category: string): Post[] {
  return getAllPosts().filter((p) => p.slug.includes(category));
}

50줄도 안 된다. 이걸로 콘텐츠 인덱스 1번~5번을 다 한다.

페이지에서 쓰는 법

블로그 목록 페이지.

// app/blog/page.tsx
import { getAllPosts } from "@/lib/posts";
 
export default function BlogIndex() {
  const posts = getAllPosts();
  return (
    <ul>
      {posts.map((p) => (
        <li key={p.slug}>
          <a href={`/blog/${p.slug}`}>{p.title}</a>
          <time>{p.date}</time>
        </li>
      ))}
    </ul>
  );
}

개별 글 페이지.

// app/blog/[slug]/page.tsx
import { MDXRemote } from "next-mdx-remote/rsc";
import { getAllPosts, getPostBySlug } from "@/lib/posts";
 
export async function generateStaticParams() {
  return getAllPosts().map((p) => ({ slug: p.slug }));
}
 
export default async function PostPage({ params }) {
  const post = getPostBySlug(params.slug);
  if (!post) return null;
  return (
    <article>
      <h1>{post.title}</h1>
      <MDXRemote source={post.content} />
    </article>
  );
}

App Router MDX 글에서 다룬 패턴 그대로.

Contentlayer와 비교

직접 짠 후 한 달 이상 운영해본 결과.

장점

1. 빌드 속도

Contentlayer는 빌드 시 .contentlayer/ 디렉토리를 만들면서 모든 글을 한 번 컴파일한다. 글이 100개 넘어가면 무거워진다. 직접 짠 버전은 그런 사전 컴파일 단계가 없다. 페이지가 SSG될 때 한 번만 처리한다.

2. 자유도

frontmatter 형식, 디렉토리 구조, slug 규칙을 다 마음대로 정한다. Contentlayer는 설정으로만 바꿀 수 있다.

3. 의존성이 줄어든다

gray-matter 한 개만 추가된다. Contentlayer는 자체적으로 여러 의존성을 가진다.

4. 디버깅이 쉽다

문제가 생기면 내 코드를 본다. Contentlayer는 black-box라서 빌드 에러가 나면 lib 내부를 까봐야 한다.

단점

1. 타입 자동 생성이 없다

Contentlayer는 frontmatter 스키마에서 TypeScript 타입을 자동 생성한다. 직접 짠 버전은 type Post를 수동으로 적어야 한다. 다만 이건 한 번만 적으면 끝이다.

2. 검증이 약하다

Contentlayer는 frontmatter에 필수 필드가 빠지면 빌드 에러를 낸다. 직접 짠 버전은 런타임에 undefined를 만난다. zod로 검증층을 추가하면 비슷하게 만들 수 있다.

import { z } from "zod";
 
const PostSchema = z.object({
  title: z.string(),
  description: z.string(),
  date: z.string(),
  tags: z.array(z.string()).optional(),
});
 
export function getAllPosts(): Post[] {
  // ...
  for (const file of files) {
    const { data, content } = matter(raw);
    const parsed = PostSchema.parse(data);  // 검증 + 타입 추론
    posts.push({ ...parsed, content, slug: ... });
  }
}

이걸 추가하면 frontmatter 누락 시 빌드 에러로 잡힌다.

빌드 캐시 추가

콘텐츠가 많아지면 빌드 시 매번 fs.readFileSync 수십 번이 일어난다. 빌드 캐시를 붙이면 변경된 파일만 다시 파싱한다.

// lib/posts.ts에 추가
let cache: Post[] | null = null;
let cacheKey: string | null = null;
 
export function getAllPosts(): Post[] {
  const dirs = fs.readdirSync(CONTENT_DIR);
  const key = dirs.map((d) => fs.statSync(path.join(CONTENT_DIR, d)).mtimeMs).join("-");
 
  if (cache && cacheKey === key) return cache;
 
  const posts = computePosts();  // 위의 원래 로직
  cache = posts;
  cacheKey = key;
  return posts;
}

빌드 한 번 안에서 같은 글을 여러 페이지가 부르면 한 번만 파싱한다. 빌드 시간이 절반쯤 줄었다. Cloudflare 빌드 캐시 글에서 더 다뤘다.

카테고리 분리

이 블로그는 content/dev/content/life/ 두 카테고리로 분리되어 있다. 직접 짠 코드는 폴더 구조를 자유롭게 받는다.

export function getAllPosts(): Post[] {
  // ...
}
 
export function getDevPosts(): Post[] {
  return getAllPosts().filter((p) => p.category === "dev");
}
 
export function getLifePosts(): Post[] {
  return getAllPosts().filter((p) => p.category === "life");
}

폴더 이름을 카테고리로 쓰니까 추가 메타데이터 없이 분리된다.

흔한 함정

함정 1: frontmatter 타입 안전성

data.title은 자동으로 any다. zod 검증을 안 하면 빌드는 통과해도 런타임에 깨진다. 처음에는 zod 없이 시작하더라도, 글이 10개 넘으면 추가하는 게 안전하다.

함정 2: MDX 컴포넌트 import

MDX 안에서 <GitCommandCard /> 같은 컴포넌트를 쓰려면 MDXRemotecomponents prop으로 전달해야 한다.

import GitCommandCard from "@/components/GitCommandCard";
 
<MDXRemote
  source={post.content}
  components={{ GitCommandCard }}
/>

이걸 빠뜨리면 MDX 안의 컴포넌트가 그대로 텍스트로 렌더된다.

함정 3: 정렬

localeCompare로 정렬하면 한국어 제목도 자연스럽게 정렬된다. 단순 비교(a > b)는 유니코드 코드포인트 기준이라 한국어 정렬이 어색해진다.

정리

Contentlayer 없이 MDX 블로그를 만드는 건 어렵지 않다. gray-matter + 직접 짠 50줄짜리 인덱스 함수가 본질이다.

직접 짜면 의존성이 가벼워지고, 자유도가 높아지고, 디버깅이 쉽다. 단점인 타입 자동 생성과 검증은 zod로 보완할 수 있다.

내 결론은 단순하다. 콘텐츠가 100개 미만이고, 형식이 자주 변하지 않는 개인 블로그라면 직접 짜는 게 답이다. Contentlayer가 살아 돌아오면 그때 가서 다시 고민해도 늦지 않다. 그때까지는 50줄짜리 코드가 충분히 잘 동작한다.