raonrabbit.dev
Next.jsMDX블로그튜토리얼

이 블로그는 어떻게 만들어졌을까 — MDX 블로그 제작기

Next.js App Router와 MDX로 블로그를 처음부터 만드는 과정을 단계별로 설명합니다. 이 글 자체가 MDX로 쓰여 있고, 글 안에서 React 컴포넌트가 동작합니다.

지금 보고 있는 이 글은 .mdx 파일입니다. 단순한 텍스트처럼 보이지만, 아래 버튼을 한번 눌러보세요.

이 버튼을 눌러보세요 →
0

방금 React 컴포넌트가 동작했습니다. 이것이 MDX의 핵심입니다. Markdown 안에 React를 넣을 수 있다는 것.

이 글에서는 이 블로그가 어떻게 만들어졌는지, MDX로 무엇을 할 수 있는지, 왜 이런 선택을 했는지를 처음부터 단계별로 설명합니다. 블로그를 처음 만들어보는 분도 따라올 수 있도록 썼습니다.

Markdown과 MDX — 뭐가 다를까요?

블로그를 만들 때 가장 먼저 결정할 것은 글을 어떤 포맷으로 쓸 것인가입니다.

Markdown이란?

Markdown은 텍스트를 HTML로 변환해주는 포맷입니다. #으로 제목을 쓰고, **굵게**로 강조합니다. GitHub README 파일이 바로 Markdown으로 쓰여 있습니다.

# 제목
 
**굵은 텍스트**, _기울임_, [링크](https://example.com)
 
- 리스트 항목 1
- 리스트 항목 2
 
` ``js
console.log("코드 블록"); ` ``

단순하고 어디서나 동작하지만, HTML 태그나 React 컴포넌트를 넣을 수 없습니다.

MDX란?

MDX는 Markdown에 JSX(React 문법)를 추가한 포맷입니다. 일반 Markdown 문법은 그대로 쓰면서, 필요한 곳에 React 컴포넌트를 끼워 넣을 수 있습니다.

# 제목
 
일반 Markdown은 그대로 작동합니다.
 
<Counter />
 
<Callout type="tip" title="이런 것도 됩니다">
  컴포넌트 안에 **Markdown**을 넣을 수도 있습니다.
</Callout>

둘의 차이 한눈에 보기

기능MarkdownMDX
제목, 리스트, 인용
코드 블록, 테이블
React 컴포넌트 삽입
인터랙티브 UI
Props로 동작 제어
TIP언제 MDX를 선택할까요?

글 안에 인터랙티브 요소(차트, 데모, 커스텀 UI)가 필요하거나, 이 글처럼 Callout 박스 같은 커스텀 UI를 쓰고 싶다면 MDX가 적합합니다. 텍스트 위주의 단순한 블로그라면 .md로도 충분합니다.

글 저장소 — 외부 서비스 vs 파일

글을 어디에 저장할지도 선택해야 합니다. 선택지를 비교해 봤습니다.

Notion + API 연동 글쓰기 UX가 매우 좋습니다. 하지만 페이지를 불러올 때마다 Notion API를 호출해야 하고, rate limit(분당 요청 제한)에 걸릴 수 있습니다. 외부 서비스에 의존하는 것도 불안합니다.

Contentful, Sanity 같은 Headless CMS 강력한 콘텐츠 관리 기능을 제공합니다. 하지만 스키마 정의, 대시보드 설정, API 키 관리까지 해야 합니다. 개인 블로그 하나 만들려는데 오버엔지니어링입니다.

파일 시스템 (MDX 파일) 파일이 코드와 함께 git으로 관리됩니다. 외부 의존성이 없고 빌드가 빠릅니다. VS Code에서 바로 글을 씁니다. git push 하나로 배포됩니다.

INFO이 블로그의 선택

content/posts/ 폴더에 .mdx 파일을 저장합니다. 파일 이름이 곧 URL slug가 됩니다.

building-blog-with-mdx.mdx/blog/building-blog-with-mdx

파일 하나 추가하면 새 글이 생깁니다.

글 파일 읽기

shared/lib/posts.ts에 파일 시스템을 읽는 함수를 모아놨습니다. Next.js App Router 덕분에 fs(파일 시스템) 모듈을 서버에서만 실행되는 코드에서 자유롭게 쓸 수 있습니다.

import fs from "fs";
import path from "path";
import matter from "gray-matter";
 
const POSTS_DIR = path.join(process.cwd(), "content/posts");
 
export function getAllPosts(): PostMeta[] {
  return fs
    .readdirSync(POSTS_DIR) // 폴더 안 파일 목록
    .filter((f) => f.endsWith(".mdx")) // .mdx 파일만
    .map((file) => {
      const slug = file.replace(/\.mdx$/, "");
      const raw = fs.readFileSync(path.join(POSTS_DIR, file), "utf-8");
      const { data } = matter(raw); // frontmatter 파싱
      return { slug, ...data } satisfies PostMeta;
    })
    .filter((p) => !p.draft) // draft: true 글 제외
    .sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime()); // 날짜 내림차순 정렬
}
INFOfrontmatter란?

MDX 파일 맨 위 --- 사이에 있는 구역입니다. 제목, 날짜, 태그 같은 메타데이터를 YAML 형식으로 씁니다.

---
title: "글 제목"
date: "2026-06-05"
tags: ["Next.js", "MDX"]
draft: false
---
 
여기서부터 본문이 시작됩니다.

gray-matter 패키지가 이 부분을 JavaScript 객체로 파싱해줍니다.

draft: true로 표시한 글은 목록에 나타나지 않습니다. 아직 작성 중인 글을 저장할 때 유용합니다.

MDX 렌더링하기

파일을 읽었으면 HTML로 변환해서 보여줘야 합니다. next-mdx-remote/rsc가 이 역할을 담당합니다.

import { MDXRemote } from "next-mdx-remote/rsc";
import rehypePrettyCode from "rehype-pretty-code";
import rehypeSlug from "rehype-slug";
import remarkGfm from "remark-gfm";
 
export function MdxContent({ source }: { source: string }) {
  return (
    <MDXRemote
      source={source}
      options={{
        mdxOptions: {
          remarkPlugins: [remarkGfm],
          rehypePlugins: [
            rehypeSlug,
            [
              rehypePrettyCode,
              {
                theme: { dark: "github-dark", light: "github-light-default" },
                keepBackground: false,
              },
            ],
          ],
        },
      }}
      components={mdxComponents}
    />
  );
}

플러그인이 세 개 붙어 있습니다. 각각 하는 일을 정리하면:

플러그인하는 일
remark-gfm표(table), 취소선(~~텍스트~~), 체크박스 리스트 지원
rehype-slug## 제목 헤딩에 id="제목" 속성 자동 부여 (목차 링크에 필요)
rehype-pretty-code코드 블록 신택스 하이라이팅

코드 하이라이팅

rehype-pretty-code는 코드 블록의 각 토큰(변수명, 키워드, 문자열 등)에 색상 정보를 적용합니다. keepBackground: false 옵션을 쓰면 배경색을 인라인 스타일로 주입하는 대신 CSS 변수를 사용합니다.

/* 라이트 모드에서는 --shiki-light 변수 사용 */
[data-rehype-pretty-code-figure] code {
  color: var(--shiki-light);
}
 
/* 다크 모드에서는 --shiki-dark 변수 사용 */
.dark [data-rehype-pretty-code-figure] code {
  color: var(--shiki-dark);
}

themedarklight를 둘 다 지정하면 rehype-pretty-code가 두 테마에 맞는 CSS 변수를 모두 내보냅니다. 다크모드 전환 시 클래스만 바꾸면 색상이 자동으로 따라옵니다.

TIP코드 블록에 파일명 표시하기

코드 블록 시작 부분에 파일명을 쓰면 상단에 레이블이 붙습니다.

```ts title="posts.ts"
export function getAllPosts() { ... }
```

이 기능도 rehype-pretty-code가 제공합니다.

이 블로그에서 가능한 것들

MDX의 가장 강력한 기능은 직접 만든 React 컴포넌트를 글 안에서 쓸 수 있다는 점입니다. 지금 이 글에서 실제로 동작하는 컴포넌트들을 보여드리겠습니다.

Callout 박스

지금 이 글에서 계속 보이는 색상 박스가 Callout 컴포넌트입니다. 네 가지 타입이 있습니다.

INFOinfo — 정보 전달

독자에게 배경 지식이나 참고 정보를 알릴 때 씁니다.

TIPtip — 유용한 팁

더 잘 할 수 있는 방법이나 놓치기 쉬운 팁을 알릴 때 씁니다.

WARNINGwarning — 주의 사항

실수하기 쉬운 부분이나 주의해야 할 점을 강조할 때 씁니다.

IMPORTANTimportant — 핵심 강조

반드시 기억해야 할 중요한 내용을 강조할 때 씁니다.

MDX 파일에서는 이렇게 씁니다.

<Callout type="tip" title="팁 제목">
  **Markdown**도 안에서 그대로 동작합니다. - 리스트도 됩니다 - `인라인 코드`
  됩니다
</Callout>

인터랙티브 컴포넌트

글 상단에서 이미 동작을 확인했지만, 다시 한번 보여드립니다.

이 버튼을 눌러보세요 →
0

이것은 useState를 쓰는 React 클라이언트 컴포넌트입니다. "use client" 선언이 있어서 브라우저에서 상태를 관리합니다. 서버에서 렌더링되는 MDX 안에서도 클라이언트 컴포넌트는 정상적으로 동작합니다.

MDX 파일에서는 이렇게 한 줄로 씁니다.

<Counter />

컴포넌트를 등록하는 방법

MDX 파일 안에서 <Callout />이나 <Counter />를 쓰려면, mdxComponents 객체에 등록해야 합니다. 이 객체가 MDXRemotecomponents prop으로 전달됩니다.

1. 컴포넌트를 만듭니다

// Callout.tsx (서버 컴포넌트 — "use client" 없음)
export function Callout({ type, title, children }) {
  return (
    <div className={...}>
      {title && <div>{title}</div>}
      {children}
    </div>
  );
}
 
// Counter.tsx (클라이언트 컴포넌트 — 상태가 필요함)
"use client";
export function Counter() {
  const [count, setCount] = useState(0);
  return ( ... );
}

2. mdxComponents.tsx에 등록합니다

import { Callout } from "./Callout";
import { Counter } from "./Counter";
 
export const mdxComponents = {
  // 기존 HTML 태그 오버라이드 (h1, p, a, code, ...)
  h1: ({ children, id }) => (
    <h1 id={id} className="...">
      {" "}
      {children}{" "}
    </h1>
  ),
 
  // 커스텀 컴포넌트 추가
  Callout,
  Counter,
};

3. MDX 파일에서 바로 씁니다

<Callout type="warning" title="주의">
  이 방법은 개발 환경에서만 동작합니다.
</Callout>

import 구문 없이 컴포넌트 이름만 쓰면 됩니다. MDXRemotecomponents 맵에서 자동으로 찾아 렌더링합니다.

WARNING주의: 서버/클라이언트 컴포넌트 구분

MDX를 렌더링하는 MdxContent는 서버 컴포넌트입니다. 여기서 클라이언트 컴포넌트("use client")를 components로 넘기는 것은 괜찮습니다. React가 서버/클라이언트 경계를 알아서 처리합니다.

하지만 클라이언트 컴포넌트가 서버 전용 코드(fs, path 등)를 직접 사용하는 것은 안 됩니다.

목차(TOC) 만들기

블로그 상세 페이지 오른쪽에 보이는 목차는 두 단계로 동작합니다.

헤딩 목록 추출

서버에서 MDX 본문을 정규식으로 스캔해서 ##, ### 헤딩만 뽑아냅니다. DOM을 사용하지 않으므로 서버에서 실행됩니다.

export function extractHeadings(content: string) {
  const headings = [];
  for (const line of content.split("\n")) {
    const match = line.match(/^(#{2,3})\s+(.+)$/);
    if (!match) continue;
 
    const text = match[2]
      .replace(/\*\*(.+?)\*\*/g, "$1") // **굵게** 제거
      .replace(/`(.+?)`/g, "$1"); // `코드` 제거
 
    headings.push({
      level: match[1].length, // 2 = ##, 3 = ###
      text,
      id: text
        .toLowerCase()
        .replace(/\s+/g, "-")
        .replace(/[^\p{L}\p{N}-]/gu, ""),
    });
  }
  return headings;
}

현재 위치 추적

추출한 헤딩 목록을 클라이언트에서 IntersectionObserver로 감시합니다. 헤딩이 화면에 보이면 TOC에서 해당 항목을 active 상태로 표시합니다.

const observer = new IntersectionObserver(
  (entries) => {
    for (const entry of entries) {
      if (entry.isIntersecting) {
        setActiveId(entry.target.id);
        break;
      }
    }
  },
  { rootMargin: "-80px 0px -60% 0px", threshold: 0 },
);

rootMargin-80px는 고정 헤더 높이만큼 위쪽을 잘라냅니다. -60%는 화면 상단 40% 영역에 들어온 헤딩만 active로 처리합니다. 이 수치를 조정하면 TOC가 넘어가는 타이밍을 바꿀 수 있습니다.

기술 스택 요약

지금까지 설명한 내용을 정리합니다.

기능사용 기술선택 이유
MDX 렌더링next-mdx-remote/rscRSC에서 바로 렌더링, 클라이언트 번들 없음
코드 하이라이팅rehype-pretty-code다크모드 CSS 변수 지원, Shiki 기반
GitHub 문법remark-gfm표, 취소선, 체크박스
헤딩 idrehype-slug목차 앵커 링크에 필요
Frontmatter 파싱gray-matter가볍고 단순한 YAML 파서
페이지 전환framer-motion + template.tsx라우트 이동마다 fade 애니메이션
커스텀 컴포넌트mdxComponents 오브젝트import 없이 MDX에서 바로 사용

MDX 블로그 자체는 복잡하지 않습니다. 파일을 읽고, frontmatter를 파싱하고, MDX를 HTML로 렌더링하는 것이 전부입니다. 각 라이브러리가 어떤 역할을 하는지 이해하면 코드가 자연스럽게 읽히고, 새 컴포넌트를 추가하거나 기능을 확장하기도 쉬워집니다.

다음 글에서는 GNB 헤더의 active 섹션 추적 구조를 다뤄볼 생각입니다.