Theme:

블로그: 마크다운으로 글 관리하기에 대해 "왜?"라고 물으면 답할 수 있으신가요?

\n\n> 블로그: 마크다운으로 글 관리하기에 대해 "왜?"라고 물으면 답할 수 있으신가요?\n블로그를 GitHub Pages에 올리긴 했는데, 글을 어떻게 관리할지가 문제였습니다.

서버도 없고 DB도 없으니까 CMS를 붙일 수도 없고, 매번 JSX에 직접 글을 쓸 수도 없는 노릇이고. 고민하다가 결국 마크다운 파일 을 선택했습니다.

평소에 옵시디언으로 메모를 관리하고 있었거든요. 그냥 옵시디언에서 글 쓰고, 커밋하고, 푸시하면 배포되는 구조를 만들면 되겠다 싶었습니다.


워크플로우

blog_posting_workflow.png

생각한 흐름은 이렇습니다.

  1. 리액트 프로젝트를 iCloud에 저장
  2. src/assets/posts 폴더를 옵시디언 저장소로 연결
  3. 옵시디언에서 글 작성
  4. Git 커밋 & 푸시 → GitHub Pages에 자동 반영

"글 작성은 옵시디언, 배포는 GitHub Pages"라는 아주 단순한 흐름입니다. 별도의 어드민 페이지나 API 없이 돌아가는 게 핵심이었습니다.


파일 저장 구조

글은 전부 src/assets/posts/ 아래에 넣고, 폴더 구조 자체가 카테고리 역할을 합니다.

PLAINTEXT
src/assets/posts/
├── 개발/
│   ├── 백엔드/
│   │   ├── 스프링/
│   │   │   └── Spring Boot 란.md
│   │   └── 네티/
│   └── 프론트엔드/
├── 데브옵스/
└── 사이드 프로젝트/
    └── 블로그/
        └── 이 글.md

폴더 경로가 곧 카테고리 경로가 됩니다. 개발/백엔드/스프링/Spring Boot 란.md면 카테고리는 개발 > 백엔드 > 스프링이고, 글 제목은 Spring Boot 란이 되는 식이죠.

프론트매터 규칙

각 마크다운 파일 맨 위에 YAML 프론트매터를 넣어서 메타데이터를 관리합니다.

MARKDOWN
---
summary: 글 목록에 보여줄 한 줄 설명
tags:
  - 태그1
  - 태그2
references:
  - 참고 자료
created_date: 2025-09-30T13:00:00.000Z
last_modified_date: 2026-01-09T15:00:00.000Z
---

여기서부터 본문...

프론트매터는 목록 화면에서 요약이나 태그를 보여줄 때 쓰고, 아래 본문은 상세 페이지에서 렌더링합니다. 깔끔하게 분리되니까 관리가 편합니다.


마크다운 파일 한 번에 읽기

파일이 수십 개인데 하나씩 import할 수는 없으니까, Vite의 import.meta.glob을 썼습니다.

JAVASCRIPT
const globs = import.meta.glob('../assets/posts/**/*.md', {
    eager: true,
    query: 'raw'
});

eager: true는 빌드 시점에 모든 파일을 즉시 import하는 옵션이고, query: 'raw'는 마크다운을 파싱하지 않고 원시 문자열로 가져오라는 뜻입니다.

이렇게 하면 globs에 이런 형태의 객체가 들어옵니다.

JAVASCRIPT
{
  '../assets/posts/개발/백엔드/스프링/Spring Boot 란.md': {
    default: '---\nsummary: ...\n---\n\n본문...'
  },
  '../assets/posts/사이드 프로젝트/블로그/이 글.md': {
    default: '---\nsummary: ...\n---\n\n본문...'
  },
  // ...
}

키가 파일 경로, 값의 default에 마크다운 원문이 통째로 들어있습니다.


프론트매터 파싱

원시 마크다운에서 메타데이터와 본문을 분리해야 합니다. MarkdownUtil이라는 유틸리티를 만들었습니다.

JAVASCRIPT
import yaml from 'js-yaml';

const MarkdownUtil = {
    metadata(raw) {
        const match = raw.match(/^---[\s\S]*?---\s*/);
        if (!match) return {};

        const text = match[0]
            .replace(/^---\s*/, '')
            .replace(/---\s*$/, '');

        return yaml.load(text) || {};
    },

    content(raw) {
        return raw.replace(/^---[\s\S]*?---\s*/, '');
    },
};

metadata()---로 감싸진 부분만 잘라서 js-yaml로 파싱합니다. content()는 프론트매터를 날리고 본문만 돌려줍니다.

정규식이 좀 지저분해 보이긴 하는데, 이 정도면 충분히 동작합니다. YAML 파싱 라이브러리를 직접 만들 이유는 없으니까요.


Post 도메인 모델

파싱한 데이터를 그냥 객체로 넘겨도 되긴 하는데, 글이 많아지면 관리가 힘들어질 것 같아서 Post 클래스를 만들었습니다.

JAVASCRIPT
class Post {
    constructor({ absolutePath, metadata, content }) {
        const prefix = absolutePath.indexOf('/posts/');
        this._path = absolutePath.slice(prefix + '/posts/'.length).replace(/\.md$/, '');
        this._title = this._path.split('/').pop();

        this._metadata = metadata;
        this._content = content;
        this._tags = metadata['tags'] || [];
        this._summary = metadata['summary'] || '';
        this._createdDate = new Date(metadata['created_date']);
        this._lastModifiedDate = metadata['last_modified_date']
            ? new Date(metadata['last_modified_date'])
            : this._createdDate;
    }

    get path() { return this._path; }
    get title() { return this._title; }
    get content() { return this._content; }
    get summary() { return this._summary; }
    get tags() { return this._tags; }
    get createdDate() { return this._createdDate; }
    get lastModifiedDate() { return this._lastModifiedDate; }

    get categories() {
        const parts = this._path.split('/');
        return parts.slice(0, -1);
    }
}

absolutePath에서 /posts/ 이후 부분을 잘라서 path로 쓰고, 마지막 세그먼트가 title이 됩니다. categoriespath에서 파일명을 뺀 나머지 경로입니다.

솔직히 getter를 이렇게까지 쓸 필요가 있나 싶기도 한데, 나중에 속성 접근 방식을 바꿀 때 편하더라고요.


PostUtil: 글 목록 관리

이제 globs에서 읽은 파일들을 Post 인스턴스로 변환하고, 조회할 수 있는 유틸리티를 만들었습니다.

JAVASCRIPT
const _posts = Object.entries(globs)
    .map(([path, obj]) => {
        const raw = obj.default;
        const metadata = MarkdownUtil.metadata(raw);
        const content = MarkdownUtil.content(raw);

        return new Post({
            absolutePath: path,
            metadata: metadata,
            content: content,
        });
    })
    .filter(post => post.visibility !== 'hidden')
    .sort((a, b) => b.createdDate - a.createdDate);

const PostUtil = {
    get posts() {
        return _posts;
    },

    findByPath({ path }) {
        return _posts.find(p => p.path === path);
    },

    findByCategory({ category }) {
        return _posts.filter(p => p.categories.includes(category));
    },
};

visibility: hidden인 글은 필터링해서 빼고, 생성일 기준 내림차순 정렬합니다. 아직 작성 중인 글을 숨길 때 유용합니다.


실제 화면에서 쓰기

글 목록

JSX
// src/pages/PostsPage.jsx
import PostUtil from '../posts/post-util';

const PostsPage = () => {
    return (
        <main>
            {PostUtil.posts.map(post => (
                <article key={post.path}>
                    <h2>{post.title}</h2>
                    <p>{post.summary}</p>
                    <span>{post.createdDate.toLocaleDateString()}</span>
                </article>
            ))}
        </main>
    );
};

글 상세

JSX
const PostDetailPage = () => {
    const location = useLocation();
    const pathname = decodeURIComponent(location.pathname);
    const path = pathname.split('/posts/')[1];

    const post = PostUtil.findByPath({ path });

    return (
        <article>
            <h1>{post.title}</h1>
            <p>{post.summary}</p>
        </article>
    );
};

URL에서 /posts/ 뒤의 경로를 추출해서 PostUtil.findByPath로 찾습니다. 한글 경로도 decodeURIComponent로 디코딩하면 문제없이 동작합니다.


돌아보면

이 구조의 장점은 단순함 입니다. DB도 API도 없이, 파일 시스템 하나로 글을 관리할 수 있다는 게 생각보다 편합니다. 옵시디언에서 글 쓰고 커밋만 하면 끝이니까요.

단점이라면 글이 수백 개로 늘어나면 eager: true가 부담이 될 수 있다는 건데, 개인 블로그 수준에서는 한참 먼 얘기입니다.

다음 글에서는 이렇게 가져온 마크다운을 실제로 화면에 어떻게 렌더링하는지 다루겠습니다.

댓글 로딩 중...