블로그: 마크다운으로 글 관리하기 — 파싱부터 모델링까지
블로그: 마크다운으로 글 관리하기에 대해 "왜?"라고 물으면 답할 수 있으신가요?
\n\n> 블로그: 마크다운으로 글 관리하기에 대해 "왜?"라고 물으면 답할 수 있으신가요?\n블로그를 GitHub Pages에 올리긴 했는데, 글을 어떻게 관리할지가 문제였습니다.
서버도 없고 DB도 없으니까 CMS를 붙일 수도 없고, 매번 JSX에 직접 글을 쓸 수도 없는 노릇이고. 고민하다가 결국 마크다운 파일 을 선택했습니다.
평소에 옵시디언으로 메모를 관리하고 있었거든요. 그냥 옵시디언에서 글 쓰고, 커밋하고, 푸시하면 배포되는 구조를 만들면 되겠다 싶었습니다.
워크플로우

생각한 흐름은 이렇습니다.
- 리액트 프로젝트를 iCloud에 저장
src/assets/posts폴더를 옵시디언 저장소로 연결- 옵시디언에서 글 작성
- Git 커밋 & 푸시 → GitHub Pages에 자동 반영
"글 작성은 옵시디언, 배포는 GitHub Pages"라는 아주 단순한 흐름입니다. 별도의 어드민 페이지나 API 없이 돌아가는 게 핵심이었습니다.
파일 저장 구조
글은 전부 src/assets/posts/ 아래에 넣고, 폴더 구조 자체가 카테고리 역할을 합니다.
src/assets/posts/
├── 개발/
│ ├── 백엔드/
│ │ ├── 스프링/
│ │ │ └── Spring Boot 란.md
│ │ └── 네티/
│ └── 프론트엔드/
├── 데브옵스/
└── 사이드 프로젝트/
└── 블로그/
└── 이 글.md
폴더 경로가 곧 카테고리 경로가 됩니다. 개발/백엔드/스프링/Spring Boot 란.md면 카테고리는 개발 > 백엔드 > 스프링이고, 글 제목은 Spring Boot 란이 되는 식이죠.
프론트매터 규칙
각 마크다운 파일 맨 위에 YAML 프론트매터를 넣어서 메타데이터를 관리합니다.
---
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을 썼습니다.
const globs = import.meta.glob('../assets/posts/**/*.md', {
eager: true,
query: 'raw'
});
eager: true는 빌드 시점에 모든 파일을 즉시 import하는 옵션이고, query: 'raw'는 마크다운을 파싱하지 않고 원시 문자열로 가져오라는 뜻입니다.
이렇게 하면 globs에 이런 형태의 객체가 들어옵니다.
{
'../assets/posts/개발/백엔드/스프링/Spring Boot 란.md': {
default: '---\nsummary: ...\n---\n\n본문...'
},
'../assets/posts/사이드 프로젝트/블로그/이 글.md': {
default: '---\nsummary: ...\n---\n\n본문...'
},
// ...
}
키가 파일 경로, 값의 default에 마크다운 원문이 통째로 들어있습니다.
프론트매터 파싱
원시 마크다운에서 메타데이터와 본문을 분리해야 합니다. MarkdownUtil이라는 유틸리티를 만들었습니다.
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 클래스를 만들었습니다.
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이 됩니다. categories는 path에서 파일명을 뺀 나머지 경로입니다.
솔직히 getter를 이렇게까지 쓸 필요가 있나 싶기도 한데, 나중에 속성 접근 방식을 바꿀 때 편하더라고요.
PostUtil: 글 목록 관리
이제 globs에서 읽은 파일들을 Post 인스턴스로 변환하고, 조회할 수 있는 유틸리티를 만들었습니다.
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인 글은 필터링해서 빼고, 생성일 기준 내림차순 정렬합니다. 아직 작성 중인 글을 숨길 때 유용합니다.
실제 화면에서 쓰기
글 목록
// 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>
);
};
글 상세
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가 부담이 될 수 있다는 건데, 개인 블로그 수준에서는 한참 먼 얘기입니다.
다음 글에서는 이렇게 가져온 마크다운을 실제로 화면에 어떻게 렌더링하는지 다루겠습니다.