마크다운 3포맷 자동 감지 파서 설계: legacy, frontmatter, plain을 한 파서로
기존 사용자의 레거시 포맷을 깨지 않으면서 표준 frontmatter와 일반 마크다운까지 지원해야 했습니다. 감지 순서, fallback 설계, 그리고 strategy 패턴을 포기한 이유를 정리합니다.
1. 문제 상황
출발점: 한 가지 포맷만 지원하던 파서
ghost-mcp의 마크다운 파서는 처음에 한 가지 포맷만 지원했습니다. 블로그 글 작성용 내부 워크플로우에서 만들어진 "레거시 마커 포맷"입니다.
# 글 제목
**작성일:** 2026-01-25
---
[마커: 본문 시작]
여기가 Ghost에 올라갈 본문입니다.
본문 단락 둘.
[마커: 파싱 종료]
## Ghost SEO 설정
| Post URL | `my-post-slug` |
| Meta title | SEO용 제목 |
| Meta description | 검색 결과 설명 |
참고: 위 예시의
[마커: ...]는 실제로는 HTML 주석 형식의 마커입니다. 블로그 원문에 마커를 그대로 넣으면 ghost-mcp 파서가 간섭받으므로 플레이스홀더로 표기했습니다.
두 개의 HTML 주석 마커(본문 시작, MCP 파싱 마커) 사이를 본문으로 추출하고, 마커 뒤의 테이블에서 SEO 메타데이터를 뽑아내는 구조였습니다.
이 포맷은 한 사람의 블로그 워크플로우에는 완벽했습니다. 표시용 제목과 SEO용 제목을 분리할 수 있고, 본문 전후에 내부 메모를 남길 수 있었죠. 그런데 ghost-mcp를 오픈소스로 공개하면서 한 가지 문제가 드러났습니다.
"이 포맷을 알지 못하는 외부 사용자들은 어떻게 쓰지?"
외부 사용자가 기대하는 것
오픈소스 MCP 서버를 처음 설치한 사용자는 두 가지 방식을 기대합니다.
- YAML frontmatter — 대부분의 정적 사이트 생성기(Jekyll, Hugo, Astro, Gatsby)에서 표준
- 일반 마크다운 — frontmatter 없이
# 제목+ 본문만 있는 가장 단순한 형태
# 방식 1: 표준 YAML frontmatter
---
slug: my-post
meta_title: SEO Title
tags: [javascript, debugging]
---
# Real Title
본문...
# 방식 2: 순수 마크다운
# Just a Title
그냥 본문만 있어요.
이 두 가지를 지원하지 않으면 ghost-mcp는 "내부용"에서 벗어날 수 없습니다. 하지만 기존 사용자(=저 자신)의 레거시 포맷을 깨뜨릴 수도 없습니다. 이미 수십 개의 글이 그 형식으로 쌓여 있으니까요.
가능한 해결책들
여기서 두세 가지 선택지가 있습니다.
- 포맷별로 별도 도구 —
ghost_push_legacy,ghost_push_frontmatter,ghost_push_plain - 사용자가 포맷을 명시 —
ghost_push_local({ filename, format: 'frontmatter' }) - 자동 감지 — 파서가 내용을 보고 어떤 포맷인지 스스로 판단
1번은 API 표면을 3배로 부풀리고, 2번은 사용자에게 인지 부하를 강요합니다. **3번(자동 감지)**이 가장 매력적이지만, 감지 알고리즘이 틀릴 위험이 있습니다. 이 글은 3번을 어떻게 안전하게 구현했는지에 대한 이야기입니다.
2. 원인 분석
2.1 포맷의 특징 파악하기
세 포맷이 각각 어떤 "표지"를 가지고 있는지 분석해봅니다.
| 포맷 | 표지(Signature) | 신뢰도 |
|---|---|---|
| 레거시 마커 | 본문 시작 + 파싱 종료 HTML 주석 마커 2개 | 매우 높음 (두 개 동시 존재) |
| YAML frontmatter | 파일이 ---\n으로 시작 |
높음 (하지만 구분선과 헷갈림 가능) |
| 일반 마크다운 | (특별한 표지 없음) | 낮음 (나머지 전부) |
흥미로운 점은 **"가장 강한 표지를 가진 포맷부터 검사"**하는 순서로 접근해야 한다는 것입니다. 만약 일반 마크다운부터 검사하면 세 포맷 모두 일반 마크다운으로 분류되어 버립니다(특별한 표지가 없으므로).
2.2 감지 순서의 중요성
순서에는 의미가 있습니다. 같은 입력이 여러 포맷에 매칭될 가능성이 있을 때, 어느 순서로 검사하는가가 결과를 결정합니다.
입력: "---\n제목: X\n---\n[본문 시작 마커]\n..."
시나리오 A: plain → frontmatter → legacy
→ plain으로 분류됨 (일반 마크다운으로 보임)
시나리오 B: legacy → frontmatter → plain
→ legacy로 분류됨 (본문 시작 마커 존재)
ghost-mcp는 시나리오 B를 선택했습니다. 이유는 **"기존 사용자 데이터 보호"**가 최우선이기 때문입니다.
만약 어떤 파일이 레거시 마커 형식이면서 동시에 frontmatter로도 해석 가능하다면, 그 파일은 십중팔구 기존 사용자의 글입니다. 의도는 "레거시로 처리해 달라"일 확률이 훨씬 높습니다.
반대로 신규 사용자는 본문 시작 마커를 우연히 쓸 가능성이 거의 없습니다. 이 마커는 내부 워크플로우에서만 생성되는 특수한 표식이기 때문입니다.
즉, 감지 순서는 위험 대칭성을 따라야 합니다. 어느 쪽 오류가 더 치명적인지를 기준으로 결정합니다.
2.3 frontmatter 검증의 미묘함
frontmatter를 검사하는 조건은 언뜻 단순해 보입니다.
if (content.trimStart().startsWith('---')) {
return parseFrontmatterFormat(content);
}
하지만 마크다운에서 ---는 수평 구분선이기도 합니다. 파일 맨 위에 구분선이 있다면 어떻게 될까요?
---
# 그냥 구분선이 있는 일반 마크다운
본문...
이 파일은 ---로 시작하지만 frontmatter가 아닙니다. 두 번째 ---가 없으면 유효한 YAML frontmatter가 아니죠. 그래서 파서는 "시작 ---을 보면 바로 frontmatter로 간주"하는 것이 아니라, **"시작 ---과 종료 ---이 모두 있는지"**를 확인해야 합니다. 종료 ---이 없으면 일반 마크다운으로 fallback 합니다.
3. 해결 방법
3.1 3단계 fallback 체인
최종 파서는 이런 형태입니다.
// src/parsers/markdown-parser.ts
const LEGACY_BODY_START = /<!--\s*본문 시작\s*-->/;
const LEGACY_END_MARKER = /<!--\s*MCP 파싱 마커/;
/**
* Auto-detect format and parse markdown into a Ghost-ready structure.
*
* Detection order (priority matters):
* 1. Legacy markers (strongest signature, protect existing data)
* 2. YAML frontmatter (standard format, common in SSGs)
* 3. Plain markdown (fallback — minimal assumptions)
*/
export function parseBlogMarkdown(content: string): ParsedBlogPost {
// 1. 레거시 마커 형식 — 두 마커가 모두 존재하면 레거시로 처리
if (LEGACY_BODY_START.test(content) && LEGACY_END_MARKER.test(content)) {
return parseLegacyFormat(content);
}
// 2. YAML frontmatter — '---'로 시작하면 시도
if (content.trimStart().startsWith('---')) {
return parseFrontmatterFormat(content);
}
// 3. 일반 마크다운 — 제목 + 본문
return parsePlainMarkdown(content);
}
세 줄의 if-else로 감지가 끝납니다. 각 분기는 독립된 파서 함수를 호출하므로 테스트하기 쉽고, 새 포맷을 추가할 때도 위로 한 줄만 삽입하면 됩니다.
3.2 레거시 포맷 파서
레거시 포맷은 두 개의 HTML 주석 마커 사이를 본문으로 취합니다.
function parseLegacyFormat(content: string): ParsedBlogPost {
const titleMatch = content.match(/^# (.+)$/m);
const title = titleMatch ? titleMatch[1].trim() : '';
// 두 마커 사이의 본문 추출
const bodyMatch = content.match(
/<!--\s*본문 시작\s*-->([\s\S]*?)(?=<!--\s*MCP 파싱 마커)/
);
const body = bodyMatch ? bodyMatch[1].trim() : '';
// 종료 마커 이후의 SEO 테이블
const seoSection = content.split(LEGACY_END_MARKER)[1] || '';
const postUrlMatch = seoSection.match(/\|\s*Post URL\s*\|\s*`([^`]+)`\s*\|/);
const metaTitleMatch = seoSection.match(/\|\s*Meta title\s*\|\s*(.+?)\s*\|/);
const metaDescMatch = seoSection.match(/\|\s*Meta description\s*\|\s*(.+?)\s*\|/);
return {
title,
body,
slug: postUrlMatch?.[1] ?? '',
metaTitle: metaTitleMatch?.[1] ?? '',
metaDescription: metaDescMatch?.[1] ?? '',
excerpt: '',
tags: [], // 레거시에는 태그 필드가 없음
};
}
이 파서는 **"마커가 있다는 가정"**이 보장되어 있으므로 단순합니다. 만약 마커가 없는 파일이 여기로 들어오면 감지 로직이 잘못된 것이므로 body가 빈 문자열이 되어 에러가 드러납니다(실패 모드 예측 가능).
3.3 Frontmatter 파서 — 외부 라이브러리 없이
YAML frontmatter 파서는 js-yaml 같은 라이브러리를 가져오는 대신 직접 구현했습니다. 이유는 ghost-mcp의 전체 의존성을 MCP SDK + Zod 두 개로 유지하기 위해서였습니다.
const FRONTMATTER_REGEX = /^---\s*\n([\s\S]*?)\n---\s*\n([\s\S]*)/;
function parseFrontmatterFormat(content: string): ParsedBlogPost {
const match = content.match(FRONTMATTER_REGEX);
// 종료 '---'가 없으면 frontmatter가 아님 → plain으로 fallback
if (!match) return parsePlainMarkdown(content);
const [, yamlBlock, body] = match;
const fields = parseSimpleYaml(yamlBlock);
// body의 첫 '# 제목'을 제거하고 본문만 남긴다
const bodyWithoutTitle = body.replace(/^#\s+[^\n]+\n+/, '');
const titleFromHeading = body.match(/^#\s+(.+)$/m)?.[1]?.trim();
return {
// frontmatter의 title 필드가 있으면 우선, 없으면 heading
title: fields.title ?? titleFromHeading ?? '',
body: bodyWithoutTitle.trim(),
slug: fields.slug ?? '',
metaTitle: fields.meta_title ?? '',
metaDescription: fields.meta_description ?? '',
excerpt: fields.excerpt ?? '',
tags: Array.isArray(fields.tags) ? fields.tags : [],
};
}
흥미로운 디테일은 **"제목 우선순위"**입니다.
- 1순위: frontmatter의
title필드 (명시적 의도) - 2순위: 본문의 첫
# 제목heading (묵시적)
이 순서는 PR 리뷰에서 확정되었습니다. 사용자가 frontmatter에 title을 명시했다면, 그건 "이게 진짜 제목이다"라는 강한 선언이므로 heading을 덮어씁니다.
3.4 간이 YAML 파서
parseSimpleYaml은 ghost-mcp에서 사용하는 부분집합만 처리합니다.
function parseSimpleYaml(yaml: string): Record<string, unknown> {
const result: Record<string, unknown> = {};
const lines = yaml.split('\n');
let i = 0;
while (i < lines.length) {
const line = lines[i];
const match = line.match(/^([a-zA-Z_][\w-]*)\s*:\s*(.*)$/);
if (!match) { i++; continue; }
let [, rawKey, value] = match;
// 하이픈 키 정규화: meta-title → meta_title
const key = rawKey.replace(/-/g, '_');
// 인라인 배열: tags: [a, b, c]
const arrayMatch = value.match(/^\[(.*)\]$/);
if (arrayMatch) {
result[key] = arrayMatch[1]
.split(',')
.map((s) => s.trim())
.filter(Boolean);
i++; continue;
}
// YAML block sequence:
// tags:
// - javascript
// - debugging
if (value === '' && lines[i + 1]?.match(/^\s*-\s+/)) {
const items: string[] = [];
i++;
while (i < lines.length && lines[i].match(/^\s*-\s+/)) {
items.push(lines[i].replace(/^\s*-\s+/, '').trim());
i++;
}
result[key] = items;
continue;
}
// 단일 값
result[key] = value.trim();
i++;
}
return result;
}
이 파서는 네 가지 특징을 가집니다.
1. 하이픈 → 언더스코어 정규화
meta-title과 meta_title을 모두 허용합니다. 사용자가 어느 쪽으로 써도 파서 내부에서는 meta_title로 통일됩니다. PR 리뷰(#5)에서 "왜 meta-title이 안 먹히냐"는 피드백을 받고 추가한 기능입니다.
2. YAML 인라인 배열 + block sequence
# 인라인 형식
tags: [javascript, debugging]
# block sequence 형식 — PR 리뷰(#4)에서 추가
tags:
- javascript
- debugging
두 형식 모두 같은 결과를 반환합니다. YAML 표준에서는 둘 다 유효하므로 어느 쪽을 쓸지는 사용자 취향입니다.
3. 실패 시 무시
regex에 매칭되지 않는 줄은 조용히 건너뜁니다. 이는 일반적으로는 안티 패턴이지만, frontmatter 파싱에서는 의도적입니다 — 사용자가 주석(# comment)이나 빈 줄을 넣어도 파서가 망가지지 않아야 하니까요.
4. 제한된 타입
문자열, 배열만 지원합니다. 중첩 객체, 숫자, 불리언, 앵커 등은 없습니다. 블로그 frontmatter에 필요한 범위 이상을 의도적으로 지원하지 않습니다 — YES이 커지면 보안 표면도 커지기 때문입니다.
3.5 Plain 마크다운 파서 — 가장 관대한 fallback
function parsePlainMarkdown(content: string): ParsedBlogPost {
const titleMatch = content.match(/^#\s+(.+)$/m);
const title = titleMatch ? titleMatch[1].trim() : '';
// 제목 줄을 제거한 나머지가 본문
const body = titleMatch
? content.replace(/^#\s+[^\n]+\n+/, '')
: content;
return {
title,
body: body.trim(),
slug: '',
metaTitle: '',
metaDescription: '',
excerpt: '',
tags: [],
};
}
이 파서는 거의 아무것도 가정하지 않습니다. 첫 # heading을 제목으로, 나머지를 본문으로 추출합니다. 제목이 없으면 빈 문자열로 설정합니다. SEO 메타데이터, 태그, slug는 모두 비어 있고, 나중에 Ghost 측에서 slug를 자동 생성합니다.
왜 이렇게 관대한가? 이 경로에 들어오는 파일은 이미 "다른 포맷 아님"으로 분류된 상태입니다. 여기서도 까다로우면 사용자는 "그럼 도대체 뭘 써야 하는 거냐"고 좌절합니다. fallback의 미덕은 **"최소한의 성공"**입니다.
4. 핵심 개념 정리
자동 감지 파서 설계 원칙
| 원칙 | 내용 |
|---|---|
| 1. 강한 표지부터 검사 | 우연히 매칭될 확률이 낮은 형식을 먼저 |
| 2. 위험 대칭성 고려 | 오분류의 비용이 큰 쪽을 우선 보호 |
| 3. fallback은 관대하게 | 감지 실패 시 최소한의 결과라도 반환 |
| 4. 감지 로직과 파서 분리 | 감지는 if-else, 파서는 독립 함수 |
| 5. 감지 순서의 의미를 주석으로 기록 | 순서가 뒤바뀌면 안 되는 이유를 남김 |
왜 strategy 패턴을 쓰지 않았나
이 파서는 일반적으로 strategy 패턴의 교과서 예시처럼 보입니다. 감지 + 포맷별 처리라는 전형적인 구조니까요. 하지만 ghost-mcp는 if-else 체인으로 구현했습니다. 이유는 세 가지입니다.
| 비교 | if-else 체인 | Strategy 패턴 |
|---|---|---|
| 코드 양 | 3줄 감지 | 클래스 2-3개 + 레지스트리 |
| 순서 제어 | 코드 순서 = 감지 순서 | 우선순위 속성 필요 |
| 신규 포맷 추가 | 한 줄 삽입 | 클래스 + 레지스트리 등록 |
| 테스트 | 함수 단위 | 모킹 필요 |
| 가독성 | 위에서 아래로 읽으면 끝 | 클래스를 찾아 이동 |
포맷이 3개일 때는 if-else가 이긴다는 게 결론입니다. 10개가 넘어가거나 포맷이 동적으로 등록되어야 한다면 strategy가 나을 수 있지만, 그전까지는 직접성이 승리합니다.
5. 베스트 프랙티스
자동 감지 파서 체크리스트
- [ ] 가장 강한 표지를 가진 포맷부터 검사 — 우연 매칭 방지
- [ ] 기존 사용자 데이터를 우선 보호 — 하위 호환성을 감지 순서로 표현
- [ ] 감지 순서를 코드 주석에 "왜"와 함께 기록
- [ ] fallback 파서는 최소한의 결과라도 반환 — 완전 실패 금지
- [ ] 감지 로직과 파서 함수를 분리 — 테스트 쉬움
- [ ] 포맷이 3~5개 이하라면 if-else가 strategy보다 낫다
- [ ] 새 포맷 추가 시 테스트를 먼저 — 감지 로직의 사소한 실수가 기존 포맷을 깨뜨릴 수 있음
하위 호환성 유지 체크
- [ ] 기존 포맷의 모든 예시를 테스트 스위트에 포함
- [ ] CI에서 기존 포맷 테스트를 필수 통과로 설정
- [ ] 새 포맷 추가 시 기존 테스트가 전부 통과하는지 확인
- [ ] 레거시 파서 함수에는 "건드리지 말라"는 주석
- [ ] 레거시 포맷 deprecation은 2단계 이상으로 — 경고만 → 경고 + 로그 → 삭제
6. FAQ
Q: 자동 감지를 포기하고 사용자가 명시하게 하면 안 되나요?
A: 가능은 합니다. 그리고 그게 더 안전한 선택일 때도 있습니다. 특히 포맷이 유사해서 자동 감지가 자주 틀린다면요. 하지만 블로그 글 작성 같은 "빈번한 작업"에서는 인지 부하를 1g이라도 줄이는 게 좋습니다. ghost-mcp는 filename 하나만 받으면 자동으로 포맷을 골라주는 경험을 우선했습니다. 오감지가 0.1%라면 충분히 받아들일 만한 트레이드오프입니다.
Q: js-yaml 같은 정식 YAML 라이브러리를 쓰면 안 되나요?
A: 써도 됩니다. 더 많은 YAML 기능(앵커, 다중 문서, 복합 타입)을 지원하고 싶다면 js-yaml이 낫습니다. ghost-mcp가 직접 구현한 이유는 (1) 의존성 최소화, (2) 필요한 부분집합이 명확했음, (3) YAML의 어두운 구석(예: norway problem, billion laughs)을 피하고 싶었음 때문입니다. 보안 표면을 좁히고 예측 가능성을 높이는 선택입니다.
Q: 포맷을 추가할 때마다 감지 순서를 다시 검토해야 하나요?
A: 네, 필수입니다. 새 포맷을 추가할 때는 반드시 **"이 포맷이 기존 포맷과 겹칠 수 있는가?"**를 확인해야 합니다. 겹친다면 어느 순서에 배치할지, 왜 그 순서여야 하는지를 커밋 메시지와 주석에 남깁니다. 이 검토를 빼먹으면 다음 리팩토링 때 순서가 뒤바뀌어 기존 사용자 데이터가 오해되는 사고가 생깁니다.
Q: 제목을 frontmatter와 heading 둘 다에 쓰는 건 어떤가요?
A: 허용합니다. 우선순위가 명확하기 때문입니다 — frontmatter가 이깁니다. 하지만 문서화는 해야 합니다. "둘 다 있으면 frontmatter가 우선된다"라는 내용을 README에 적어두지 않으면, 사용자가 heading을 바꿨는데 제목이 그대로라며 버그로 오인할 수 있습니다. 명시적 우선순위 + 명시적 문서화가 한 세트입니다.
Q: 감지가 틀렸을 때 사용자가 어떻게 알 수 있나요?
A: 파싱 결과에 감지된 포맷을 포함시켜서 사용자가 확인할 수 있게 합니다. ghost-mcp의 ghost_push_local은 응답에 "Parsed from: frontmatter" 같은 줄을 넣어 줍니다. 사용자가 "어? 난 레거시로 쓴 것 같은데 왜 frontmatter로 잡혔지?"라고 생각하면 즉시 피드백이 가능합니다. 사일런트 감지는 사일런트 버그를 만듭니다.
Q: 테스트는 어떻게 구성했나요?
A: 포맷별로 테스트 파일을 나누고, 각 포맷에 **"경계 케이스"**를 집중 배치했습니다.
- 레거시: 마커가 하나만 있는 경우, 순서가 바뀐 경우
- frontmatter: 종료
---가 없는 경우, 빈 frontmatter, 하이픈 키, block sequence - plain: 제목이 없는 경우, 여러
#이 있는 경우
추가로 **"자동 감지 테스트"**를 별도로 만들어서, 각 포맷의 샘플을 주면 정확한 감지 함수가 호출되는지 확인합니다. 감지 로직과 파서 로직이 분리되어 있어 각각 테스트하기 쉽습니다.
7. 참고 자료
8. 다음 단계
이 글은 파서의 내부 구조를 다뤘습니다. 다음 편에서는 테스트 커버리지 추적으로 넘어갑니다 — 이전에 소개한 양방향 링크 시스템(@handbook / @code)을 테스트 파일에 확장해서, 어떤 소스 코드가 어떤 테스트에 의해 보호받고 있는지를 양방향으로 추적하는 방법을 살펴봅니다.