마크다운을 포스팅으로 만들기
이전 게시글에 작성했지만, 프로젝트의 기술 스택은 Next.js와 TypeScript 그리고 Tailwind를 사용했습니다.
다음 테마 관리를 위해 next-themes, 마크다운 파싱을 위한 gray-matter, remark, rehype, rehype-highlight를 사용했고 날짜 처리 포맷팅을 위해 date-fns를 사용했습니다.
마크다운이 콘텐츠가 되는 과정
포스팅은 모두 마크다운으로 만들어집니다.
---
title: 'Next.js 블로그 만들기'
date: '2025-10-16'
excerpt: '기본 세팅과 흐름 이해'
thumbnail: '/images/thumbnail/crying.webp'
tags: ['NextJS', '블로그 만들기']
---
이전 게시글에 작성했지만, 프로젝트의 기술 스택은 <code>Next.js</code>와 <code>TypeScript</code> 그리고 <code>Tailwind</code>를 사용했습니다.
이게 제가 지금 작성하고 있는 글인데요. --- 안에 들어 있는 title, date와 같은 것들을 Frontmatter(프론트매터)라고 합니다. 이게 메타데이터 역할을 하고, 그 아래의 본문은 순수한 마크다운 문법으로 작성합니다.
YAML(YAML Ain't Markup Language): 데이터를 사람이 읽기 쉽게 표현한 형식입니다.
gray-matter로 파일 분리하기
lib 폴더에 작성한 posts.ts가 파일을 읽고 프론트매터와 본문을 분리합니다. gray-matter 라이브러리가 이 역할을 해줍니다.
import matter from 'gray-matter';
import fs from 'fs';
// 마크다운 파일 읽기
const fileContents = fs.readFileSync('posts/251016.md', 'utf8');
// gray-matter로 파싱
const matterResult = matter(fileContents);
// 결과
{
data: {
title: 'Next.js 블로그 만들기',
date: '2025-10-16',
excerpt: '기본 세팅과 흐름 이해',
tags: ['NextJS', '블로그 만들기'],
thumbnail: '/images/thumbnail/studying.webp'
},
content: `이전 게시글에...`,
excerpt: '',
isEmpty: false
}
matterResult.data에는 프론트매터 내용이 객체로 변환되어 있고, matterResult.content에는 순수 마크다운 본문이 들어있습니다. 이렇게 분리된 데이터를 가지고 remark와 rehype에서 HTML 변환을 하게 됩니다.
마크다운을 HTML로 바꾸기
lib/posts.ts는 /posts 폴더 안의 마크다운 파일들을 읽고 정리하여 HTML 변환을 하는 파일입니다.
posts/
┣ 251015.md
┣ 251016.md
┗ 251017.md
이런 파일들을 getSortedPostsData(), getAllPostSlugs(), getPostData() 세 가지 함수로 처리합니다. 각 함수는 역할이 다릅니다.
세 가지 핵심 함수의 차이
① getSortedPostsData() - 홈 화면용 (목록만)
export function getSortedPostsData(): PostMeta[] {
// posts/ 폴더의 모든 .md 파일 읽기
const fileNames = fs.readdirSync(postsDirectory);
const allPostsData = fileNames.map(fileName => {
const slug = fileName.replace(/\.md$/, ''); // 251016.md → 251016
const fileContents = fs.readFileSync(`posts/${fileName}`, 'utf8');
const matterResult = matter(fileContents);
// 목록에서는 본문을 HTML로 변환하지 않음
return {
slug,
title: matterResult.data.title,
date: matterResult.data.date,
excerpt: matterResult.data.excerpt,
thumbnail: matterResult.data.thumbnail,
tags: matterResult.data.tags || [],
};
});
// 날짜 기준 내림차순 정렬 (최신글이 위로)
return allPostsData.sort((a, b) => (a.date < b.date ? 1 : -1));
}
이 함수는 홈 화면에서 포스트 목록을 보여줄 때 사용됩니다. 본문 내용은 필요 없고 제목, 날짜, 썸네일, 태그 등 메타데이터만 필요하기 때문에 HTML 변환을 하지 않습니다.
② getAllPostSlugs() - Next.js SSG용 (라우트 생성)
export function getAllPostSlugs() {
const fileNames = fs.readdirSync(postsDirectory);
return fileNames.map(fileName => ({
params: { slug: fileName.replace(/\.md$/, '') },
}));
// 결과: [
// { params: { slug: '251015' }},
// { params: { slug: '251016' }}
// ]
}
이 함수는 Next.js의 SSG(Static Site Generation)를 위해 필요합니다. Next.js가 빌드 시점에 어떤 페이지들을 미리 생성해야 하는지 알려주는 역할입니다.
// src/app/posts/[slug]/page.tsx
export async function generateStaticParams() {
return getAllPostSlugs();
// Next.js가 /posts/251015, /posts/251016 페이지 미리 생성
}
③ getPostData(slug) - 상세 페이지용 (HTML 변환)
export async function getPostData(slug: string): Promise<Post> {
const fullPath = path.join(postsDirectory, `${slug}.md`);
const fileContents = fs.readFileSync(fullPath, 'utf8');
const matterResult = matter(fileContents);
// TOC 추출
const toc = extractToc(matterResult.content);
// 마크다운 → HTML 변환
const processedContent = await remark()
.use(remarkGfm)
.use(remarkRehype, { allowDangerousHtml: true })
.use(rehypeSlug)
.use(rehypeHighlight)
.use(rehypeStringify, { allowDangerousHtml: true })
.process(matterResult.content);
const contentHtml = processedContent.toString();
return {
slug,
content: contentHtml, // HTML로 변환된 본문
...matterResult.data,
toc,
};
}
이 함수만 마크다운을 HTML로 변환합니다. 상세 페이지에서 실제 본문을 보여줘야 하기 때문입니다.
사용한 라이브러리들
| 라이브러리 | 역할 |
|---|---|
| fs | 파일 읽기 (Node.js 기본 내장) |
| path | 경로 조작 (Node.js 기본 내장) |
| gray-matter | 프론트매터(YAML) 파싱 |
| remark | Markdown → HTML 중간 트리(AST)로 변환 |
| remark-gfm | GitHub Flavored Markdown (표, 체크박스 등 지원) |
| remark-rehype | remark 트리 → rehype 트리 (HTML 트리)로 변환 |
| rehype-slug | # 제목에 자동으로 id 속성 추가 (<h2 id="제목">) |
| rehype-highlight | 코드 블록에 하이라이팅 추가 |
| rehype-stringify | HTML 문자열로 변환 |
| github-slugger | GitHub처럼 제목 → slug(id) 문자열로 변환 |
AST(Abstract Syntax Tree)란?
갑자기 많은 정보가 들어와서 처음에 정리가 안 됐는데, 순서로 정리해보자면 마크다운 작성 → 마크다운 문법 파싱 → HTML 구조로 바꾸기 → id나 코드 하이라이트 등 추가 → 최종 출력이라고 할 수 있습니다.
여기서 중요한 개념이 **AST(Abstract Syntax Tree, 추상 구문 트리)**입니다.
마크다운 문자열
→ remark AST (마크다운 구조 트리)
→ rehype AST (HTML 구조 트리)
→ HTML 문자열
왜 중간 단계가 필요할까요? AST는 "문법 트리"로, 프로그램이 수정하기 쉬운 형태입니다. "## 제목" 문자열보다 { type: "heading", depth: 2 } 객체가 조작하기 쉽습니다. 플러그인들이 이 트리를 수정해서 기능을 추가합니다 (id 추가, 하이라이팅 등)
const processedContent = await remark() // 마크다운 파서 시작
.use(remarkGfm) // GitHub 스타일 문법 지원 (ex: 체크박스)
.use(remarkRehype, { allowDangerousHtml: true }) // remark → rehype로 변환 (HTML AST로)
.use(rehypeSlug) // <h2 id="..."> 추가
.use(rehypeHighlight) // 코드 하이라이팅
.use(rehypeStringify, { allowDangerousHtml: true }) // HTML 문자열로 출력
.process(matterResult.content);
remark 단계에서는 마크다운을 AST로 변환합니다.
(## 제목)
**굵은 글씨**
위 마크다운은 아래와 같은 AST로 변환됩니다:
[
{
type: 'heading',
depth: 2,
children: [{ type: 'text', value: '제목' }],
},
{
type: 'paragraph',
children: [
{
type: 'strong',
children: [{ type: 'text', value: '굵은 글씨' }],
},
],
},
];
rehype 단계에서는 HTML AST로 변환되고, 최종적으로 HTML 문자열이 됩니다:
<h2>제목</h2>
<p><strong>굵은 글씨</strong></p>
이렇게 변환이 되어 드디어 제가 아는 모양새가 됩니다.
TOC나 코드 하이라이팅을 위해서 rehype-slug, rehype-highlight를 추가하면:
<h2 id="제목">제목</h2>
<p><strong>굵은 글씨</strong></p>
<pre><code class="hljs language-javascript">
<span class="hljs-keyword">const</span> hello = <span class="hljs-string">'world'</span>;
</code></pre>
이렇게 되어 그려집니다.
참고로 rehype-slug는 HTML 요소에 id를 붙이고, github-slugger는 그 id 문자열을 TOC에 활용하는 용도입니다.
allowDangerousHtml이 왜 필요한가?
코드에서 { allowDangerousHtml: true } 옵션을 볼 수 있습니다. 이건 마크다운 안에 HTML을 직접 쓸 수 있게 허용하는 옵션입니다.
<img src="/images/251015/1.webp" width="300">
마크다운 안에 이런 HTML 태그를 쓸 수 있게 해줍니다. 이 옵션이 없으면 HTML 태그가 그대로 문자열로 표시됩니다(이스케이프 처리).
다음 게시글에서
제가 직접 뭘 했다기보단 라이브러리를 많이 활용해 마크다운을 콘텐츠화 하였는데요, 그렇기 때문에 동작 원리를 이해하지 않으면 도통 어떻게 된건지 알 수가 없었습니다.
이렇게 블로그를 만들 생각을 대체 어느 누가 언제부터 했던 걸까요. 새삼 대단하게 느껴집니다.
다음 게시글에서는 폴더 구조나 레이아웃을 다뤄보도록 하겠습니다.
읽어주셔서 감사합니다.