iBetter Books
수정

Ch 02. 마크다운으로 글 쓰기 — SSG 블로그

개인 기술 블로그를 운영하는 개발자들이 선택하는 방식이 있습니다. 마크다운 파일로 글을 쓰고, 빌드 시 정적 HTML로 변환하는 것입니다. 데이터베이스 없이 파일만으로 블로그를 운영합니다. 빠르고, 단순하고, 관리하기 쉽습니다.

Next.js의 generateStaticParams로 이 방식을 구현합니다.

마크다운 파일 구조

posts/├── hello-nextjs.md├── server-components.md└── server-actions.md

각 파일은 프론트매터와 본문으로 구성됩니다.

---
title: "Next.js를 처음 만나다"
date: "2024-03-15"
description: "Next.js 14의 App Router를 소개합니다"
tags: ["nextjs", "react"]
---

# Next.js를 처음 만나다

Next.js는 React 기반의 풀스택 프레임워크입니다...
```text

### 필요한 패키지 설치

```bash
npm install gray-matter remark remark-html rehype-highlight
npm install -D @types/mdast
```text

- `gray-matter`: 마크다운 파일에서 프론트매터 파싱
- `remark`: 마크다운을 HTML로 변환
- `remark-html`: remark의 HTML 출력 플러그인
- `rehype-highlight`: 코드 블록 구문 강조

### 마크다운 파싱 유틸리티

```typescript
// 파일: lib/posts.ts
import fs from "fs";
import path from "path";
import matter from "gray-matter";
import { remark } from "remark";
import remarkHtml from "remark-html";

const postsDirectory = path.join(process.cwd(), "posts");

export type PostMeta = {
  slug: string;
  title: string;
  date: string;
  description: string;
  tags: string[];
};

export type Post = PostMeta & {
  contentHtml: string;
};

// 모든 글 목록 (최신순 정렬)
export function getAllPosts(): PostMeta[] {
  const fileNames = fs.readdirSync(postsDirectory);

  const posts = fileNames
    .filter((name) => name.endsWith(".md"))
    .map((fileName) => {
      const slug = fileName.replace(/\.md$/, "");
      const fullPath = path.join(postsDirectory, fileName);
      const fileContents = fs.readFileSync(fullPath, "utf8");
      const { data } = matter(fileContents);

      return {
        slug,
        title: data.title as string,
        date: data.date as string,
        description: data.description as string,
        tags: (data.tags as string[]) ?? [],
      };
    });

  // 최신 글이 먼저 오도록 날짜 역순 정렬
  return posts.sort((a, b) => (a.date < b.date ? 1 : -1));
}

// 특정 글 내용 가져오기
export async function getPostBySlug(slug: string): Promise<Post | null> {
  const fullPath = path.join(postsDirectory, `${slug}.md`);

  if (!fs.existsSync(fullPath)) {
    return null;
  }

  const fileContents = fs.readFileSync(fullPath, "utf8");
  const { data, content } = matter(fileContents);

  // 마크다운 → HTML 변환
  const processedContent = await remark()
    .use(remarkHtml, { sanitize: false })
    .process(content);
  const contentHtml = processedContent.toString();

  return {
    slug,
    title: data.title as string,
    date: data.date as string,
    description: data.description as string,
    tags: (data.tags as string[]) ?? [],
    contentHtml,
  };
}
```text

### 글 목록 페이지

```typescript
// 파일: app/posts/page.tsx
import { getAllPosts } from "@/lib/posts";
import Link from "next/link";

export const metadata = {
  title: "블로그 글 목록",
  description: "모든 블로그 글을 확인하세요",
};

export default function PostsPage() {
  const posts = getAllPosts();

  return (
    <main className="container mx-auto px-4 py-12 max-w-3xl">
      <h2 className="text-3xl font-bold mb-8">블로그</h2>
      <div className="space-y-8">
        {posts.map((post) => (
          <article key={post.slug} className="border-b pb-8">
            <Link href={`/posts/${post.slug}`}>
              <h3 className="text-xl font-semibold hover:text-blue-600 transition-colors mb-2">
                {post.title}
              </h3>
            </Link>
            <p className="text-gray-500 text-sm mb-3">{post.date}</p>
            <p className="text-gray-700">{post.description}</p>
            <div className="flex gap-2 mt-3">
              {post.tags.map((tag) => (
                <span
                  key={tag}
                  className="px-2 py-1 bg-gray-100 text-gray-600 text-xs rounded"
                >
                  {tag}
                </span>
              ))}
            </div>
          </article>
        ))}
      </div>
    </main>
  );
}
```text

### 글 상세 페이지 (SSG)

```typescript
// 파일: app/posts/[slug]/page.tsx
import { getAllPosts, getPostBySlug } from "@/lib/posts";
import { notFound } from "next/navigation";
import type { Metadata } from "next";

type Props = {
  params: Promise<{ slug: string }>;
};

// SSG: 빌드 시 모든 slug를 미리 생성
export async function generateStaticParams() {
  const posts = getAllPosts();
  return posts.map((post) => ({ slug: post.slug }));
}

// 동적 메타데이터
export async function generateMetadata({ params }: Props): Promise<Metadata> {
  const { slug } = await params;
  const post = await getPostBySlug(slug);

  if (!post) {
    return { title: "글을 찾을 수 없습니다" };
  }

  return {
    title: post.title,
    description: post.description,
  };
}

export default async function PostPage({ params }: Props) {
  const { slug } = await params;
  const post = await getPostBySlug(slug);

  if (!post) {
    notFound();
  }

  return (
    <article className="container mx-auto px-4 py-12 max-w-3xl">
      <header className="mb-8">
        <h2 className="text-4xl font-bold mb-4">{post.title}</h2>
        <p className="text-gray-500">{post.date}</p>
        <div className="flex gap-2 mt-3">
          {post.tags.map((tag) => (
            <span
              key={tag}
              className="px-2 py-1 bg-blue-50 text-blue-700 text-xs rounded"
            >
              {tag}
            </span>
          ))}
        </div>
      </header>
      <div
        className="prose prose-lg max-w-none"
        dangerouslySetInnerHTML={{ __html: post.contentHtml }}
      />
    </article>
  );
}

generateStaticParams가 반환하는 모든 slug에 대해 빌드 시점에 정적 HTML이 생성됩니다. 사용자가 접속하면 이미 만들어진 HTML을 그대로 돌려주므로 서버 부하가 없고 응답이 빠릅니다.

dangerouslySetInnerHTML이 위험해 보이지만, 우리가 직접 작성한 마크다운 파일에서 변환된 HTML이므로 XSS 위험은 없습니다. 사용자가 입력하는 데이터를 그대로 HTML로 렌더링하는 경우에는 반드시 sanitize 과정을 거쳐야 합니다.

다음 챕터에서는 Prisma와 bcryptjs를 이용한 실제 회원가입과 로그인을 구현합니다.