마크다운을 포스팅으로 만들기

NextJS블로그 만들기

이전 게시글에 작성했지만, 프로젝트의 기술 스택은 Next.jsTypeScript 그리고 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 태그가 그대로 문자열로 표시됩니다(이스케이프 처리).

다음 게시글에서

제가 직접 뭘 했다기보단 라이브러리를 많이 활용해 마크다운을 콘텐츠화 하였는데요, 그렇기 때문에 동작 원리를 이해하지 않으면 도통 어떻게 된건지 알 수가 없었습니다.

이렇게 블로그를 만들 생각을 대체 어느 누가 언제부터 했던 걸까요. 새삼 대단하게 느껴집니다.

다음 게시글에서는 폴더 구조나 레이아웃을 다뤄보도록 하겠습니다.

읽어주셔서 감사합니다.