마크다운 3포맷 자동 감지 파서 설계: legacy, frontmatter, plain을 한 파서로

기존 사용자의 레거시 포맷을 깨지 않으면서 표준 frontmatter와 일반 마크다운까지 지원해야 했습니다. 감지 순서, fallback 설계, 그리고 strategy 패턴을 포기한 이유를 정리합니다.

마크다운 3포맷 자동 감지 파서 설계: legacy, frontmatter, plain을 한 파서로

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 서버를 처음 설치한 사용자는 두 가지 방식을 기대합니다.

  1. YAML frontmatter — 대부분의 정적 사이트 생성기(Jekyll, Hugo, Astro, Gatsby)에서 표준
  2. 일반 마크다운 — frontmatter 없이 # 제목 + 본문만 있는 가장 단순한 형태
# 방식 1: 표준 YAML frontmatter
---
slug: my-post
meta_title: SEO Title
tags: [javascript, debugging]
---
# Real Title

본문...
# 방식 2: 순수 마크다운
# Just a Title

그냥 본문만 있어요.

이 두 가지를 지원하지 않으면 ghost-mcp는 "내부용"에서 벗어날 수 없습니다. 하지만 기존 사용자(=저 자신)의 레거시 포맷을 깨뜨릴 수도 없습니다. 이미 수십 개의 글이 그 형식으로 쌓여 있으니까요.

가능한 해결책들

여기서 두세 가지 선택지가 있습니다.

  1. 포맷별로 별도 도구ghost_push_legacy, ghost_push_frontmatter, ghost_push_plain
  2. 사용자가 포맷을 명시ghost_push_local({ filename, format: 'frontmatter' })
  3. 자동 감지 — 파서가 내용을 보고 어떤 포맷인지 스스로 판단

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-titlemeta_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)을 테스트 파일에 확장해서, 어떤 소스 코드가 어떤 테스트에 의해 보호받고 있는지를 양방향으로 추적하는 방법을 살펴봅니다.