웹 프로그래밍
MDX를 활용한 정적 블로그 구축
이 실습 문제는 Next.js를 사용하여 MDX를 활용한 정적 블로그를 단계별로 구성하는 실습 문제입니다. 완성된 페이지는 practice-nextjs-mdx 를 참고하세요.
0. 정적 블로그
정적 블로그는 개인의 전문성 증명과 꾸준한 자기 계발의 기록 공간으로서, 팀 합류에 중요한 역할을 합니다. 개발자가 자신이 공부한 내용, 프로젝트 경험, 문제 해결 과정을 글로 정리하여 보여주면, 팀 합류를 담당하는 사람이 지원자의 깊은 이해도와 지속적인 성장 의지를 직접 확인할 수 있습니다.
무엇보다도 Github Pages를 활용하는 정적 블로그는 팀 합류 시 큰 다음과 같은 이점이 있습니다.
- 무료 호스팅으로 비용 부담 없이 나만의 포트폴리오 및 기술 블로그를 운영할 수 있음
- Git과 연동되어 버전관리 및 코드 배포 경험을 동시에 보여줄 수 있어 기술 역량을 간접적으로 어필하는 데 유리
- HTML, CSS, JavaScript 등을 활용해 블로그를 자유롭게 커스터마이즈할 수 있어서 개발자로서의 실무 능력도 시각적으로 표현 가능
- 외부에 손쉽게 URL 링크를 공유하며 실무나 이직 시 자신의 역량을 간편하게 전달할 수 있음
정적 블로그를 잘 운영하면 단순히 이력서나 면접 답변보다 강력한 개인 브랜딩 도구가 되며, 전문 분야에서 차별화된 경쟁력을 갖추는 데 도움을 줍니다. 취업 준비 과정에서 자신의 공부, 프로젝트, 경험을 꾸준히 기록해나가는 것이 취업 성공률을 높이는데 특히 효과적이라는 점도 알려져 있습니다12.
따라서 정적 블로그와 Github Pages는 팀 합류시 자신의 전문성과 꾸준함을 증명하고, 문제 정의 능력과 해결 능력을 시각화하여 효과적으로 제안할 수 있는 유용한 도구입니다. 개발직군에서는 직접 만든 Github Pages 블로그가 기술력을 보여줄 수 있는 좋은 포트폴리오로 작용할 수 있습니다3.
실습 개요
- Next.js 16과 MDX를 활용한 정적 블로그 구축
- GitHub Pages를 통한 무료 호스팅
주요 특징
- 최신 Next.js의 주요 기능을 최대한 활용
- 정적 사이트 생성(SSG)
- 모든 페이지를 빌드 타임에 한 번에 생성
- 서버 부하 없이 빠르고 최적의 퍼포먼스 제공
- 모든 페이지를 빌드 타임에 한 번에 생성
- MDX 지원
- 마크다운 + React 컴포넌트 삽입 가능
- 실습 코드, 알림박스, 차트 등 다양한 콘텐츠 구현
- 마크다운 + React 컴포넌트 삽입 가능
- 반응형 디자인
- 모바일~데스크탑까지 최적의 읽기 경험 제공
- 모바일~데스크탑까지 최적의 읽기 경험 제공
- SEO(검색엔진최적화) 극대화
- 메타데이터 자동 설정
- 검색엔진 친화적 구조로 상위 노출 용이
- 메타데이터 자동 설정
- 빠른 로딩 속도
- 정적 렌더링, 불필요한 서버 요청 없음
- 방문자가 쾌적하게 자료 열람 가능
- 정적 렌더링, 불필요한 서버 요청 없음
기술 스택
- React 기반 프레임워크 Next.js(16버전, App Router 구조)로 정적 블로그 개발
- 프로젝트 구조/라우팅/SSG(정적생성)/SSR 등 현대적 웹 개발 기능을 간편하게 제공
- 주요 라이브러리 및 역할
@next/mdx,next-mdx-remote: Next.js에서 MDX(Markdown+JSX) 자유롭게 활용- 마크다운 안에 React 컴포넌트 동적 삽입 가능
remark: 마크다운 \(\to\) JS 객체 파싱remark-gfm: 표, 체크박스 등 GitHub Flavored Markdown(GFM) 문법 지원remark-math,rehype-katex:- 마크다운의 수학 수식(Markdown Math) 파싱
- KaTeX로 HTML 수식 렌더링
- 마크다운의 수학 수식(Markdown Math) 파싱
rehype-highlight: 코드블록 구문 하이라이트gray-matter: 포스트 상단 메타데이터(제목, 태그, 날짜 등) 파싱github-markdown-css: 최종 마크다운 렌더링 시 GitHub 스타일 적용
- 완성된 사이트는 GitHub Pages로 무료 퍼블리싱
- GitHub Actions로 자동 빌드,배포 연동(지속적 업데이트)
프로젝트 구조
practice-nextjs-mdx/
├── app/ # Next.js App Router
│ ├── layout.tsx # 루트 레이아웃
│ ├── page.tsx # 홈 페이지
│ ├── blog/ # 블로그 섹션
│ │ ├── page.tsx # 블로그 목록 페이지
│ │ └── [slug]/ # 동적 라우트
│ └── page.tsx # 개별 포스트 페이지
│ └── globals.css # 전역 스타일
├── content/ # MDX 파일 저장소
│ └── posts/ # 블로그 포스트
│ ├── first-post.mdx
│ ├── second-post.mdx
│ └── ...
├── components/ # React 컴포넌트
│ ├── BlogPost.tsx # 포스트 레이아웃
│ ├── PostCard.tsx # 포스트 카드
│ ├── Navigation.tsx # 네비게이션
│ └── Footer.tsx # 푸터
├── lib/ # 유틸리티 함수
│ ├── mdx.ts # MDX 처리 함수
│ └── posts.ts # 포스트 관련 함수
├── public/ # 정적 파일
│ ├── images/ # 이미지 파일
│ └── favicon.ico
├── next.config.ts # Next.js 설정
├── mdx-components.tsx # MDX 컴포넌트 매핑
├── package.json
├── tsconfig.json
└── .github/
└── workflows/
└── nextjs.yml # GitHub Actions 워크플로우
주요 기능
- MDX 파일로 포스트 작성
- Frontmatter를 통한 메타데이터 관리 (제목, 날짜, 태그 등)
- 자동 포스트 목록 생성
- 마크다운 문법 지원 (GitHub Flavored Markdown)
- 코드 하이라이팅 (rehype-highlight)
- 수학 수식 렌더링 (KaTeX)
- GitHub 스타일 마크다운 CSS
- React 컴포넌트 삽입 가능
부가적인 기능
- 태그/카테고리 필터링 (도전과제)
- 다크 모드 지원 (도전과제)
- 검색 기능 (도전과제)
- 페이지네이션 (도전과제)
1. 프로젝트 구현 설계
- 포스트 읽기 함수 구현 (
lib/posts.ts)
- MDX 파일 읽기
- Frontmatter 파싱
- 포스트 목록 생성
- 포스트 정렬 (날짜 기준)
- 블로그 목록 페이지 (
app/blog/page.tsx)
- 모든 포스트 목록 표시
- 포스트 카드 컴포넌트 사용
- 개별 포스트 페이지 (
app/blog/[slug]/page.tsx)
- 동적 라우팅으로 포스트 표시
- MDX 콘텐츠 렌더링
- 레이아웃 컴포넌트 (
components/BlogPost.tsx)
- 포스트 레이아웃 구성
- 제목, 날짜, 태그 표시
2. 상세 구현
2-1단계: 프로젝트 초기화 및 환경 설정
npx create-next-app@latest practice-nextjs-mdx --yes2-2단계: 필수 패키지 설치
npm install @next/mdx @mdx-js/loader @mdx-js/react remark remark-gfm rehype-highlight gray-matter next-mdx-remote remark-math rehype-katex github-markdown-cssnext-mdx-remote: App Router에서 동적 MDX 렌더링을 위해 필요remark-math,rehype-katex: 수학 수식 지원github-markdown-css: GitHub 스타일 마크다운 CSS@next/mdx: Next.js용 MDX 통합 플러그인next-mdx-remote: 동적 MDX 렌더링 지원@mdx-js/loader: MDX 에서 JSX로 변환하는 Webpack 로더@mdx-js/react: MDX용 React 컴포넌트 매핑remark: 마크다운 파서(확장용)remark-gfm: GFM(GitHub 마크다운) 지원remark-math: 마크다운 수식 파서rehype-highlight: 코드 블록 하이라이트rehype-katex: 수식(KaTeX) HTML 렌더링github-markdown-css: GitHub 마크다운 스타일 CSS
2-3단계: 기본 설정 (MDX & Next.js)
MDX를 지원하도록 Next.js 설정을 변경합니다. withMDX 관련 설정이 중요합니다. Next.js 프로젝트 내에서 MDX 기반의 블로그, 문서, 위키 등 다양한 정적 콘텐츠를 React 컴포넌트로 쉽게 취급할 수 있음, 블로그와 같이 마크다운과 리액트 컴포넌트를 혼합해 콘텐츠를 작성해야 할 때 필수적인 설정입니다.
withMDX는 Next.js에서 MDX 파일을 페이지로 사용할 수 있게 해주려면, MDX 파일을 일반적인 React 컴포넌트처럼 인식하고 처리할 수 있어야 함, Next.js의 설정 객체(next.config.js 또는 next.config.ts)에 MDX 관련 처리를 통합하는 함수.mdx파일을 import 하거나 페이지로 직접 쓸 수 있게 Next.js의 파일 확장자를 확장하고, 빌드 과정에서 MDX를 React 컴포넌트로 변환하는 로더를 자동으로 설정하며, 필요한 경우 remark/rehype 등 다양한 플러그인으로 MDX의 기능을 확장할 수 있게 해줌
// next.config.ts
import createMDX from '@next/mdx'
import type { NextConfig } from 'next'
const withMDX = createMDX({
options: {
// Plugins are handled in MDXRemote for dynamic content
// remarkPlugins: [],
// rehypePlugins: [],
},
})
const nextConfig: NextConfig = {
pageExtensions: ['js', 'jsx', 'md', 'mdx', 'ts', 'tsx'],
}
export default withMDX(nextConfig)2-4단계: mdx-components.tsx 생성
기본 컴포넌트 매핑은 필요에 따라 추가할 수 있습니다. 현재는 next-mdx-remote를 사용하여 동적으로 렌더링하므로 기본 매핑만 사용합니다. MDX(Markdown + JSX) 파일에서 사용되는 기본 HTML 요소(h1, h2, p 등)를 확장하거나 커스터마이징하기 위해 사용됩니다. Next.js에서 MDX 파일을 렌더링할 때 각각의 태그가 어떻게 보일지 사용자 정의 컴포넌트로 대체할 수 있는데, 이 파일에서는 h1, h2, p 태그를 Tailwind CSS 등으로 스타일링한 React 컴포넌트로 매핑합니다. 이를 통해 블로그 글 전체에 일관된 스타일과 UI/UX를 적용할 수 있고, 필요하다면 코드블록, 이미지 등 다양한 HTML 태그에 대해서도 자유롭게 컴포넌트를 확장하여 사용할 수 있습니다. 현재는 github-markdown-css 스타일을 사용하기 때문에 별도의 설정은 하지 않았습니다.
// mdx-components.tsx (루트 디렉토리)
import type { MDXComponents } from 'mdx/types'
export function useMDXComponents(components: MDXComponents): MDXComponents {
return components;
}2-5단계: 디렉토리 구조 및 유틸리티 구현
블로그 포스트를 저장할 공간과 이를 읽어올 함수를 작성합니다.
- 디렉토리 생성
content/posts: MDX 파일 저장소lib: 유틸리티 함수components: React 컴포넌트
lib/posts.ts구현
- MDX 파일을 읽고 파싱하는 핵심 로직
// lib/posts.ts
import fs from 'fs'
import path from 'path'
import matter from 'gray-matter'
const postsDirectory = path.join(process.cwd(), 'content/posts')
export interface Post {
slug: string
title: string
date: string
description?: string
tags?: string[]
content: string
}
export function getSortedPostsData(): Omit<Post, 'content'>[] {
if (!fs.existsSync(postsDirectory)) return []
const fileNames = fs.readdirSync(postsDirectory)
const allPostsData = fileNames.map((fileName) => {
const slug = fileName.replace(/\.mdx$/, '')
const fullPath = path.join(postsDirectory, fileName)
const fileContents = fs.readFileSync(fullPath, 'utf8')
const { data } = matter(fileContents)
return {
slug,
...(data as any),
}
})
return allPostsData.sort((a, b) => (a.date < b.date ? 1 : -1))
}
export function getPostData(slug: string): Post {
const fullPath = path.join(postsDirectory, `${slug}.mdx`)
const fileContents = fs.readFileSync(fullPath, 'utf8')
const { data, content } = matter(fileContents)
return {
slug,
content,
...(data as any),
}
}2-6단계: components/BlogPost.tsx 생성
// components/BlogPost.tsx
import React from 'react'
interface BlogPostProps {
title: string
date: string
children: React.ReactNode
}
export default function BlogPost({ title, date, children }: BlogPostProps) {
return (
<article className="max-w-2xl mx-auto py-8 px-4">
<header className="mb-8">
<h1 className="text-3xl font-bold mb-2">{title}</h1>
<time className="text-gray-500">{date}</time>
</header>
<div className="prose dark:prose-invert">
{children}
</div>
</article>
)
}2-7단계: app/blog/page.tsx
전체 글 목록과 개별 글 상세 페이지를 구현합니다.
import Link from 'next/link'
import { getSortedPostsData } from '@/lib/posts'
export default function BlogIndex() {
const allPostsData = getSortedPostsData()
return (
<div className="max-w-2xl mx-auto py-8 px-4">
<h1 className="text-4xl font-bold mb-8">Blog</h1>
<ul className="space-y-4">
{allPostsData.map(({ slug, date, title, description }) => (
<li key={slug} className="border p-4 rounded-lg hover:shadow-md transition-shadow">
<Link href={`/blog/${slug}`}>
<h2 className="text-2xl font-bold">{title}</h2>
<div className="text-gray-500 text-sm mb-2">{date}</div>
{description && <p className="text-gray-700">{description}</p>}
</Link>
</li>
))}
</ul>
</div>
)
}2-8단계: app/blog/[slug]/page.tsx
// `app/blog/[slug]/page.tsx`
import { getPostData, getSortedPostsData } from '@/lib/posts'
import BlogPost from '@/components/BlogPost'
import { MDXRemote } from 'next-mdx-remote/rsc'
import remarkGfm from 'remark-gfm'
import remarkMath from 'remark-math'
import rehypeHighlight from 'rehype-highlight'
import rehypeKatex from 'rehype-katex'
export async function generateStaticParams() {
const posts = getSortedPostsData()
return posts.map((post) => ({
slug: post.slug,
}))
}
export default async function Post({ params }: { params: Promise<{ slug: string }> }) {
const { slug } = await params
const postData = getPostData(slug)
return (
<BlogPost title={postData.title} date={postData.date}>
<div className="markdown-body">
<MDXRemote
source={postData.content}
options={{
mdxOptions: {
remarkPlugins: [remarkGfm, remarkMath],
rehypePlugins: [rehypeHighlight, rehypeKatex],
},
}}
/>
</div>
</BlogPost>
)
}2-9단계: app/page.tsx
import Link from 'next/link'
import { getSortedPostsData } from '@/lib/posts'
export default function Home() {
const allPostsData = getSortedPostsData()
const recentPosts = allPostsData.slice(0, 5)
return (
<div className="flex min-h-screen flex-col items-center justify-center p-24">
<h1 className="text-4xl font-bold mb-8">Welcome to My Static Blog</h1>
<div className="w-full max-w-2xl">
<h2 className="text-2xl font-semibold mb-6">Recent Posts</h2>
{recentPosts.length > 0 ? (
<ul className="space-y-4">
{recentPosts.map(({ slug, date, title, description }) => (
<li key={slug} className="border p-4 rounded-lg hover:shadow-md transition-shadow bg-white dark:bg-gray-800 dark:border-gray-700">
<Link href={`/blog/${slug}`}>
<h3 className="text-xl font-bold mb-2">{title}</h3>
<div className="text-gray-500 text-sm mb-2">{date}</div>
{description && <p className="text-gray-700 dark:text-gray-300">{description}</p>}
</Link>
</li>
))}
</ul>
) : (
<p className="text-gray-500">No posts found.</p>
)}
<div className="mt-8 text-center">
<Link href="/blog" className="px-6 py-3 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors inline-block">
View All Posts
</Link>
</div>
</div>
</div>
)
}2-10단계: 콘텐츠 작성
실제 블로그 포스트(content/posts/hello-world.mdx)를 작성하여 테스트합니다.
---
title: '헬로 월드'
date: '2023-10-01'
description: '새로운 블로그에 오신 것을 환영합니다! 풍부한 콘텐츠와 함께하세요.'
tags: ['nextjs', 'mdx', 'math', 'tables']
---
> Next.js 16과 MDX로 만들어진 새로운 블로그에 오신 것을 환영합니다.
## 코드 스니펫
코드 예제입니다:
\`\`\`javascript
console.log('Hello, World!')
\`\`\`
## 수식
KaTeX를 사용하여 수식을 렌더링할 수도 있습니다:
인라인 수식: $E = mc^2$
블록 수식:
$$
L = \frac{1}{2} \rho v^2 S C_L
$$
... (생략)2-11단계: GitHub CSS 적용
import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import "./globals.css";
import 'katex/dist/katex.min.css'
import 'github-markdown-css/github-markdown-light.css'
const geistSans = Geist({
variable: "--font-geist-sans",
subsets: ["latin"],
});
const geistMono = Geist_Mono({
variable: "--font-geist-mono",
subsets: ["latin"],
});
export const metadata: Metadata = {
title: "Create Next App",
description: "Generated by create next app",
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="en">
<body
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
>
{children}
</body>
</html>
);
}
2-12단계: Github Actions을 사용한 배포 설정
GitHub Pages에 자동 배포하기 위한 워크플로우를 설정합니다.
- GitHub 저장소 설정
- GitHub 저장소 생성 후 코드 푸시
Settings > Pages에서 소스 선택 (GitHub Actions)next.js선택 후 commit 진행
3. 확장 기능
- 검색 기능
- 태그 필터링
- 다크 모드
- 댓글 시스템 (Utterances 등)
- RSS 피드
- 사이트맵 생성