Contentlayer 없이 MDX 블로그 만들기: 직접 파서 짠 후기
2026.04.21
Contentlayer가 사라진 자리
이 블로그를 처음 만들 때 Contentlayer를 쓸까 했다. MDX 파싱, frontmatter 파싱, 타입 생성을 한 번에 해주는 도구다. 검색하면 다 Contentlayer를 쓴다.
문제는 Contentlayer가 메인테넌스 중단 상태라는 거다. Next.js 15 호환에 이슈가 있고, App Router 이슈도 적극적으로 안 고쳐지고 있다. Contentlayer 2가 나온다고 했지만 일정이 명확하지 않다.
대안은 두 가지다. fork된 community 버전을 쓰거나, 직접 짠다. 내가 고른 건 후자다. 결과적으로 100줄 안쪽의 자체 콘텐츠 레이어가 됐고, 더 자유롭고 더 빠르다.
Contentlayer가 해주던 일
직접 짜기 전에 Contentlayer가 정확히 어떤 일을 해주는지 정리.
content/디렉토리의 MDX 파일을 스캔- 각 파일의 frontmatter를 파싱
- 본문을 미리 컴파일된 React 컴포넌트로 변환
- 모든 파일을 모은 인덱스를 빌드 타임에 생성
- 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 /> 같은 컴포넌트를 쓰려면 MDXRemote에 components prop으로 전달해야 한다.
import GitCommandCard from "@/components/GitCommandCard";
<MDXRemote
source={post.content}
components={{ GitCommandCard }}
/>이걸 빠뜨리면 MDX 안의 컴포넌트가 그대로 텍스트로 렌더된다.
함정 3: 정렬
localeCompare로 정렬하면 한국어 제목도 자연스럽게 정렬된다. 단순 비교(a > b)는 유니코드 코드포인트 기준이라 한국어 정렬이 어색해진다.
직접 겪은 일: 태그가 자유 문자열이라 갈렸다
frontmatter 설계에서 가장 늦게 후회한 건 태그였다.
tags:
- ClaudeCode문자열 배열이라 아무 값이나 넣을 수 있다. 편한 만큼 갈린다. 한참 뒤에 세어보니 이랬다.
ClaudeCode— 14편Claude Code— 4편
같은 도구인데 다른 태그로 잡혀 있었다. 필터 줄에 거의 같은 이름이 나란히 뜨고, 어느 쪽을 눌러도 글의 일부만 나왔다. 태그가 겹치는 글끼리 이어주는 기능을 붙일 때가 되어서야 이게 드러났다.
스크립트로 한 번에 합쳤다. 고치는 데 오래 걸리진 않았지만, 안 겪어도 될 일이었다.
타입으로 좁혀두면 애초에 안 생길 문제다. 태그 목록을 상수 배열로 두고 frontmatter를 그 유니온 타입으로 검사하면, 오타나 표기 흔들림은 빌드에서 걸린다. contentlayer를 안 쓰기로 했다면 이 검증만큼은 직접 넣어두는 게 좋다. 글이 쌓인 뒤에 고치는 것보다 훨씬 싸다.
정리
Contentlayer 없이 MDX 블로그를 만드는 건 어렵지 않다. gray-matter + 직접 짠 50줄짜리 인덱스 함수가 본질이다.
직접 짜면 의존성이 가벼워지고, 자유도가 높아지고, 디버깅이 쉽다. 단점인 타입 자동 생성과 검증은 zod로 보완할 수 있다.
내 결론은 단순하다. 콘텐츠가 100개 미만이고, 형식이 자주 변하지 않는 개인 블로그라면 직접 짜는 게 답이다. Contentlayer가 살아 돌아오면 그때 가서 다시 고민해도 늦지 않다. 그때까지는 50줄짜리 코드가 충분히 잘 동작한다.
같이 읽으면 좋은 글
MDX에서 표 대신 React 컴포넌트로 시각화하기: 설계와 패턴
마크다운 표는 모바일에서 깨진다. MDX의 진짜 가치는 표를 시각화 컴포넌트로 대체하는 데 있다. CompareCard, PlanCard, FlowCard 같은 시각화 컴포넌트의 설계 원칙을 정리한다.
MDX디자인React2026.04.22Cloudflare Pages 배포 시 빌드 캐시 이슈와 해결기
Cloudflare Pages 빌드가 매번 처음부터 도는 문제, 캐시가 적용 안 되는 이유, _next 캐싱 충돌, 무효화 시점까지 — 실제로 부딪힌 빌드 캐시 이슈와 해결법을 정리한다.
Cloudflare배포Next.js2026.04.20