Next.js Git Blog (5): MDX 도입과 데이터 독립성에 대한 고찰

목차

소개#

현재 이 블로그는 마크다운 파일을 불러와 해석하고 렌더링하는 방식을 취하고 있다.
그래서 본문은 마크다운이 약속한 표준 태그들로만 구성되어 있다.

그러던 중 MDX(Markdown + JSX) 라는 재밌는 개념을 접했다. 마크다운 본문 중간에 React 컴포넌트를 직접 삽입하여 글을 다채롭게 확장할 수 있다고 한다. 하지만 "이게 정말 좋은 방향일까?" 라는 의문이 든다.🤔

데이터와 UI의 독립성 훼손

마크다운 파일로 본문을 다루는 점에 있어 가장 매력적이라고 느꼈던 부분은 데이터(글)와 UI의 완전한 독립성이었다.
서로 영향없이 오로지 각자의 역할을 가지는 점이 좋았는데, 마크다운 내부에 React 컴포넌트가 들어오는 순간 이 독립성이 훼손된다는 느낌이 들었다. 순수한 글 데이터가 특정 기술 스택에 의존적이게 되는 것이 걸렸다.

목적에 따라 선택적 사용

그럼에도 불구하고 MDX를 도입하기로 했다. 다만 마크다운 파일을 기본으로 하고, 특정 목적을 가진 글만 mdx로 관리하고자 한다.

  • 작성 방식 병행: 모든 글을 .mdx로 바꿀 필요가 없다. 일상의 기록은 순수 .md로 작성하고, 인터랙티브 요소가 필요한 특정 글들은 .mdx로 작성하면 좋을 것 같았다.
  • 렌더링 엔진 통합: Next.js 환경에서 MDX 엔진을 구축해두면, .md 파일 또한 똑같이 최적화된 방식으로 렌더링할 수 있다고 한다.

MDX로 전환하기#

전환하는 과정은 생각보다 간단했다. 기존 라이브러리를 교체하고 데이터 페칭 로직을 수정하는 작업을 진행했다.

1. 라이브러리 설치

일반 마크다운 파서(remark) 대신 MDX 전용 파서(next-mdx-remote)로 다시 설치해야 한다.
그리고 더이상 필요가 없어진 라이브러리도 함께 삭제하자.

📌 gray-matter 라이브러리는 빠른 글 목록 조회를 위해 삭제하지 않았다.

# 새로운 MDX 파서 설치
pnpm install next-mdx-remote
 
# 기존 마크다운 라이브러리 삭제
pnpm uninstall remark-html remark

2. 데이터 페칭 및 렌더링 로직 수정

기존에 fs로 읽어온 뒤 remark를 거쳐 dangerouslySetInnerHTML로 뿌려주던 방식을, MDX 파서가 제공하는 컴포넌트 방식으로 바꿔야 한다.

// markdown-utils.ts
 
export const getAllSlugs = (section: string): string[] => {
	const sectionPath = path.join(contentRoot, section);
	if (!fs.existsSync(sectionPath)) return [];
 
	return (
		fs
			.readdirSync(sectionPath)
			// 1. .md 또는 .mdx로 끝나는 파일만 필터링
			.filter((file) => /\.mdx?$/.test(file))
			.sort((a, b) => b.localeCompare(a))
			// 2. 확장자 부분을 제거 (.md 또는 .mdx)
			.map((file) => file.replace(/\.mdx?$/, ''))
	);
};
 
export const getMarkDown = async (section: string, slug: string) => {
	// 후보 파일 경로 생성
	const mdxPath = path.join(contentRoot, section, `${slug}.mdx`);
	const mdPath = path.join(contentRoot, section, `${slug}.md`);
 
	// 실제 존재하는 파일 경로로 선택
	const fullPath = fs.existsSync(mdxPath) ? mdxPath : mdPath;
 
	const fileContents = fs.readFileSync(fullPath, 'utf8');
 
	// 마크다운의 메타데이터도 함께 포함됨
	const mdxSource = await serialize(fileContents, {
		parseFrontmatter: true, // 이 옵션이 gray-matter 역할을 대신함
		mdxOptions: {
			// 확장자에 따라 포맷을 유연하게 지정
			format: fullPath.endsWith('.mdx') ? 'mdx' : 'md',
		},
	});
 
	return {
		section,
		slug,
		mdxSource,
	};
};
// [slug].tsx
 
export const getStaticProps: GetStaticProps = async ({ params }) => {
	const { section, slug } = params as { section: string; slug: string };
 
	try {
		const post = await getMarkDown(section, slug);
		return { props: { ...post, section } };
	} catch (err) {
		return { notFound: true };
	}
};
 
const SlugPage = ({ mdxSource }: SlugPageProps) => {
	return (
		<article>
			<h1>{mdxSource?.frontmatter?.title || ''}</h1>
			<time>{mdxSource?.frontmatter?.date || ''}</time>
			<hr />
			{/* 파싱된 HTML을 렌더링 */}
			<div>{mdxSource && <MDXRemote {...mdxSource} />}</div>
		</article>
	);
};

MDX 동작 테스트#

설정을 마친 후, 기존의 .md 파일들은 이전과 다름없이 깔끔하게 출력되는 것을 확인했다. 이제 .mdx 파일 내에서 React 컴포넌트가 작동하는지 테스트해보자.

---
title: 'MDX 연습'
date: '2025-07-25'
---
 
마크다운 본문 안에서 바로 버튼 컴포넌트를 만들고 이벤트를 할당해 보도록하자.
 
<div className='p-4 bg-gray-100 rounded-lg text-center w-[50%]'>
	<button
		className='bg-black text-white rounded-full py-2 px-6 hover:bg-gray-800'
		onClick={() => alert('MDX 내에서 클릭 이벤트가 발생했습니다')}
	>
		클릭
	</button>
</div>
 
위 버튼은 React 컴포넌트로 렌더링된 결과물이다.

alt 그림 1. 마크다운 본문 내에서 버튼 컴포넌트와 클릭 이벤트가 정상 작동하는 모습

마무리#

사실 일반적인 블로그 글을 매번 .mdx 로 작성할 계획은 없다. 다만, 특수한 기능을 포함한 단일 페이지를 만들거나 한다면 유용하게 사용할 수 있지 않을까 기대해볼 수 있을 것 같다.

여담으로, 현재 블로그 개발을 위해 VSCode를 사용중인데 .mdx 파일 안에서 React 컴포넌트를 작성하는 것이 몹시 불편하다. 자동 완성이나 하이라이팅 등의 익스텐션이 있는지 확인해보고 적용하는 것이 좋을 것 같다.

📌 그리고 아니나 다를까 MDX 라는 익스텐션이 존재하더라. 사용하도록 하자.