왜 나는 Sanity CMS를 포기하고 MDX를 사용했는지 (다시는 돌아보지 않음)
I’m happy to translate the article for you, but I need the full text you’d like translated. Could you please paste the content (or the portions you want translated) here? I’ll keep the source link exactly as you provided and preserve all formatting, markdown, and technical terms.
설정: 왜 처음에 Sanity를 선택했는가
포트폴리오를 처음 만들었을 때, Sanity CMS는 블로그 콘텐츠 관리를 위한 명백한 선택처럼 보였습니다:
- Visual Editor – 글쓰기를 위한 깔끔한 WYSIWYG 인터페이스
- Structured Content – 스키마 기반 콘텐츠 모델링
- Real‑time Collaboration – 비록 제가 유일한 저자였지만 😅
- CDN‑hosted Images – 자동 이미지 최적화
- Webhook Revalidation – 콘텐츠가 변경될 때 온‑디맨드 ISR
잘 작동했습니다. 하지만 시간이 지나면서 균열이 보이기 시작했습니다.
Source: …
한계점: 내가 떠나기로 결심한 이유
1. 단일 저자를 위한 과도한 오버헤드
나는 나 자신만을 위해 전체 CMS 인프라를 운영하고 있었다. Sanity Studio는 라우트, 의존성, 복잡성을 추가했으며, 이는 점점 불필요하게 느껴졌다:
src/sanity/
├── env.ts
├── lib/
│ ├── client.ts
│ ├── image.ts
│ └── queries.ts
├── schemaTypes/
│ ├── authorType.ts
│ ├── blockContentType.ts
│ ├── categoryType.ts
│ └── postType.ts
└── structure.ts
이 모든 인프라가 단순한 마크다운 파일 하나로도 충분했을 텐데.
2. 외부 의존성 문제
글을 쓰고 싶을 때마다 나는 다음을 해야 했다:
- 내 사이트를 열고
/studio로 이동하고- Sanity Studio가 로드될 때까지 기다리고
- 그들의 에디터에서 글을 쓰고
- 웹훅이 재검증을 제대로 호출했는지 기대한다
내 콘텐츠는 다른 사람의 서버에 존재했다. Sanity가 가격을 바꾸거나, 장애가 발생하거나, 기능을 종료하면 나는 급히 대처해야 했다.
3. 코드 블록이 고통이었다
기술 콘텐츠를 작성하는 개발자에게 코드 블록은 필수다. Sanity의 Portable Text 포맷은 커스텀 직렬화가 필요했고, 구문 강조를 제대로 맞추는 것은 언제나 전쟁이었다:
// Old Sanity code block serializer – verbose and fragile
const CodeBlock = ({ value }: { value: CodeBlockValue }) => {
return (
<pre>
<code>{value.code}</code>
</pre>
);
};
MDX를 사용하면 그냥… 마크다운이다:
```typescript
const greeting = "Hello, World!";
```
4. 버전 관리? 버전 관리가 뭐죠?
내 코드는 Git에 있었다. 내 콘텐츠는 Sanity에 있었다. 두 개의 진실 소스, 통합된 히스토리는 전무했다. 나는 쉽게 할 수 없었다:
- PR에서 콘텐츠 변경을 검토하기
- 게시물을 이전 버전으로 롤백하기
- 코드 변경과 함께 무엇이 바뀌었는지 보기
Source: …
솔루션: Next.js와 MDX
MDX는 마크다운의 간단함과 React의 강력함을 동시에 제공합니다. 설정 방법은 다음과 같습니다.
Step 1 – 의존성 설치
pnpm add @next/mdx @mdx-js/loader @mdx-js/react
pnpm add remark-gfm rehype-slug rehype-prism-plus
- @next/mdx – 공식 Next.js MDX 통합
- remark-gfm – GitHub‑flavored Markdown(표, 취소선 등)
- rehype-slug – 헤딩에 자동으로 ID를 생성(목차용)
- rehype-prism-plus – Prism.js를 이용한 구문 강조
Step 2 – Next.js 설정
// next.config.mjs
import createMDX from '@next/mdx';
import remarkGfm from 'remark-gfm';
import rehypeSlug from 'rehype-slug';
import rehypePrismPlus from 'rehype-prism-plus';
const nextConfig = {
pageExtensions: ['js', 'jsx', 'md', 'mdx', 'ts', 'tsx'],
// …other config
};
const withMDX = createMDX({
options: {
remarkPlugins: [remarkGfm],
rehypePlugins: [rehypeSlug, [rehypePrismPlus, { ignoreMissing: true }]],
},
});
export default withMDX(nextConfig);
Step 3 – MDX 컴포넌트 만들기
프로젝트 루트에 있는 mdx-components.tsx 파일은 MDX 요소가 어떻게 렌더링될지 커스터마이징합니다:
// mdx-components.tsx
import type { MDXComponents } from 'mdx/types';
import Image from 'next/image';
import Link from 'next/link';
export function useMDXComponents(components: MDXComponents): MDXComponents {
return {
// 자동 생성된 ID를 가진 커스텀 헤딩
h2: ({ children, id }) => (
<h2 id={id}>## {children}</h2>
),
// 스마트 링크: 내부 vs 외부
a: ({ href, children }) => {
if (href?.startsWith('/')) {
return <Link href={href}>{children}</Link>;
}
return (
<a href={href} target="_blank" rel="noopener noreferrer">
{children}
</a>
);
},
// 접근성 높은 코드 블록
pre: ({ children }) => (
<pre className="code-block">
{children}
</pre>
),
...components,
};
}
Step 4 – 콘텐츠 구조화
각 블로그 포스트는 이제 메타데이터를 내보내는 간단한 .mdx 파일이 됩니다:
content/
└── blog/
├── my-first-post.mdx
├── another-post.mdx
└── this-post.mdx
전형적인 파일 예시:
export const metadata = {
title: 'My Blog Post',
slug: 'my-blog-post',
publishedAt: '2025-01-01',
categories: ['nextjs', 'react'],
coverImage: '/images/blog/my-post.avif',
author: {
name: 'Akshay Gupta',
avatar: '/images/blog-author.png',
},
excerpt: 'A short description of the post.',
};
TL;DR
단일 저자를 위한 전체 CMS 운영은 불필요한 복잡성을 추가했다.
MDX로 전환하면서 얻은 것:
- 외부 의존성 제로 – 모든 것이 레포에 존재
- 네이티브 Git 히스토리 – 콘텐츠 변경이 PR의 일부
- 코드가 단순해짐 – 코드 블록을 위한 커스텀 직렬화가 필요 없음
- 빠르고 안정적인 빌드 – 원격 API에서 런타임 fetch 없음
개인 블로그나 소규모 사이트를 운영한다면 무거운 CMS를 버리고 MDX에 맡겨 보세요. 단순함이 진정한 최고의 세련됨입니다.
소개
여기에 마크다운 콘텐츠를 입력하세요…
Step 5: MDX 유틸리티 구축
블로그 작업을 처리하기 위해 작은 유틸리티 라이브러리를 만들었습니다:
// src/lib/mdx/index.ts
import fs from 'fs';
import path from 'path';
const CONTENT_DIR = path.join(process.cwd(), 'content', 'blog');
export function getBlogSlugs(): string[] {
const files = fs.readdirSync(CONTENT_DIR);
return files
.filter((file) => file.endsWith('.mdx'))
.map((file) => file.replace(/\.mdx$/, ''));
}
export async function getBlogBySlug(slug: string) {
const { metadata } = await import(`@/content/blog/${slug}.mdx`);
const rawContent = fs.readFileSync(
path.join(CONTENT_DIR, `${slug}.mdx`),
'utf-8'
);
const readingTime = calculateReadingTime(rawContent);
return { metadata, slug, readingTime };
}
Step 6: 블로그 페이지 렌더링
동적 라우트가 MDX를 직접 가져와서 렌더링합니다:
// src/app/blog/[slug]/page.tsx
export default async function BlogPost({ params }: Props) {
const { slug } = await params;
const post = await getBlogBySlug(slug);
// MDX 콘텐츠의 동적 import
const { default: MDXContent } = await import(
`@/content/blog/${slug}.mdx`
);
return (
<article>
<h1>{post.metadata.title}</h1>
<MDXContent />
</article>
);
}
export async function generateStaticParams() {
const slugs = getBlogSlugs();
return slugs.map((slug) => ({ slug }));
}
마이그레이션: 변경 사항
제거된 항목 (~12 000줄)
- 전체
src/sanity/디렉터리 - Sanity Studio 라우트 (
/studio) - 웹훅 재검증 엔드포인트
- Portable Text 직렬화기
- Sanity 관련 npm 패키지 8개
추가된 항목 (~7 500줄)
content/blog/에 있는 MDX 블로그 포스트 7개src/lib/mdx/의 MDX 유틸리티- 맞춤형 MDX 컴포넌트
- Prism.js 구문 강조 테마
public/images/blog/의 커버 이미지
결과: 코드 라인이 약 4 700줄 감소 – 코드가 적어지고, 버그가 줄며, 유지보수가 간단해짐.
지금 내가 누리고 있는 혜택
-
어디서든 작성 – 내가 가장 좋아하는 마크다운 편집기인 VS Code, Obsidian, 혹은 급할 때는
neovim까지 사용할 수 있습니다. 브라우저가 필요 없습니다. -
Git‑네이티브 콘텐츠 – 모든 포스트가 버전 관리됩니다. 전체 히스토리를 확인하고, 초안용 브랜치를 만들며, 코드와 함께 PR에서 콘텐츠 변경을 검토할 수 있습니다.
-
번개 같은 빠른 빌드 – 빌드 중에 API 호출이 없습니다. 모든 것이 로컬 파일 시스템 읽기이므로 빌드 속도가 눈에 띄게 빨라집니다.
-
진정한 소유권 – 콘텐츠가 내 레포에 존재합니다. 벤더 락인도 없고, 예상치 못한 가격 변동도 없으며, 외부 종속성도 없습니다.
-
향상된 코드 블록 – Dracula 테마를 적용한 Prism.js, 자동 언어 감지, 키보드 접근 가능한 코드 영역을 제공합니다. 바로 작동합니다:
// Look ma, beautiful syntax highlighting! const sum = (a: number, b: number): number => a + b; -
마크다운에서 React 컴포넌트 – 커스텀 콜아웃이 필요하거나 인터랙티브 데모가 필요하신가요? 그냥 가져와서 사용하면 됩니다:
import { InteractiveDemo } from '@/components/Demo'; {/* Here’s a live demo: */} <InteractiveDemo />
주의사항 및 해결책
1. OpenGraph 이미지에 Node.js 런타임 필요
OG 이미지 생성기는 fs를 사용해 MDX 파일을 읽지만, Next.js 이미지 라우트는 기본적으로 Edge 런타임을 사용합니다. Node.js 런타임을 강제 지정하여 해결합니다:
// src/app/blog/[slug]/opengraph-image.tsx
export const runtime = 'nodejs';
2. 읽는 시간 계산
Sanity에서는 계산된 필드를 쿼리할 수 있었지만, MDX에서는 원시 콘텐츠에서 직접 계산합니다:
export function calculateReadingTime(content: string) {
const text = content
.replace(/```[\s\S]*?```/g, '') // 코드 블록 제거
.replace(/`[^`]*`/g, '') // 인라인 코드 제거
.replace(/]*>/g, ''); // HTML 제거
const words = text.split(/\s+/).filter(Boolean).length;
const minutes = Math.ceil(words / 200);
return { text: `${minutes} min read`, minutes, words };
}
3. 목차
Sanity에서 구조화된 AST를 제공받지 못하므로, 간단한 정규식을 사용해 헤딩을 추출합니다:
export function extractHeadings(content: string) {
const headingRegex = /^(#{1,4})\s+(.+)$/gm;
const headings: { id: string; text: string; level: number }[] = [];
let match;
while ((match = headingRegex.exec(content)) !== null) {
const level = match[1].length;
const text = match[2].trim();
const id = text.toLowerCase().replace(/\s+/g, '-');
headings.push({ id, text, level });
}
return headings;
}
전환을 고려해야 할까요?
MDX가 적합한 경우:
- 단독 저자이거나 소규모 팀인 경우
- 코드 블록이 포함된 기술 콘텐츠를 작성하는 경우
- 콘텐츠를 버전 관리하고 싶은 경우
- 기능 풍부함보다 단순함을 중시하는 경우
- 마크다운에 익숙한 경우
CMS를 고수해야 하는 경우:
- 비기술적인 콘텐츠 편집자가 있는 경우
- 복잡한 워크플로와 승인 절차가 필요한 경우
- 실시간 협업이 필요한 경우
- 시각적 편집 경험을 원하는 경우
결론
Sanity에서 MDX로 옮기는 것은 어수선한 옷장을 정리하는 것과 같았습니다. 즉각적인 이점은 명확합니다: 물건이 줄어들고, 공간이 늘어나며, 찾기가 쉬워집니다. 진정한 즐거움은 단순히… 글을 쓰는 일상의 경험에서 옵니다.
대시보드도 없고, 로딩 스피너도 없으며, “콘텐츠 동기화”도 없습니다. 오직 나와 내 에디터, 그리고 마크다운—블로깅이 그래야 하는 방식입니다.
이 전체 블로그 시스템의 코드는 오픈 소스로 제공됩니다:
github.com/gupta-akshay/portfolio-v2. 자유롭게 사용하세요. 🚀
“완벽함은 더 이상 추가할 것이 없을 때가 아니라, 더 이상 뺄 것이 없을 때 달성된다.” – Antoine de Saint‑Exupéry