[DevLog] Unified를 활용한 Markdown ↔ HTML 양방향 변환기 구현하기

현재 개발 중인 서비스에서는 PDF 파일의 내용을 추출하여 DB에 저장하고, 웹에서 보여준 뒤 편집하는 기능이 필요했습니다.

이 과정에서 우리는 데이터 효율성을 위해 Markdown 형식을 저장소로, 사용자 편의성을 위해 HTML 형식을 뷰어/에디터로 사용하기로 결정했습니다. 즉, 프론트엔드 단에서 이 두 포맷을 자유자재로 변환하는 파이프라인이 필요해진 것이죠.

오늘은 Node.js 생태계의 표준 텍스트 처리 라이브러리인 unified를 사용하여 커스텀 양방향 파서를 구현한 경험을 공유합니다.

1. 기술 스택 (The Stack)

텍스트 변환을 위해 다음과 같은 unified 생태계 라이브러리들을 조합했습니다.

  • Remark: 마크다운 파서 (Markdown → AST)
  • Rehype: HTML 컴파일러 (AST → HTML)
  • Plugins: remark-gfm (테이블 지원), rehype-prism-plus (코드 하이라이팅)

2. Viewer 구현: Markdown to HTML

DB에 저장된 마크다운을 브라우저에 렌더링하는 단계입니다. 단순히 라이브러리만 돌리면 될 것 같지만, PDF에서 추출한 텍스트라는 특성상 전처리(Pre-processing)가 필수적이었습니다.

🛠️ 핵심 로직: 커스텀 전처리

PDF 파싱 과정에서 인용문(>)이나 특수문자가 깨져서 들어오는 경우가 많았습니다. 이를 파서에 넣기 전에 정규식과 문자열 조작으로 먼저 다듬어주는 전략을 취했습니다.

import { unified } from 'unified';
import remarkParse from 'remark-parse';
import remarkRehype from 'remark-rehype';
import rehypeStringify from 'rehype-stringify';
// ... 기타 플러그인 import

export const convertMarkdownToHtml = (markdownText) => {
  if (!markdownText) return '';

  // 1. 전처리: PDF 아티팩트 제거 및 인용문 구조 정규화
  // 라이브러리가 해석하기 힘든 깨진 인용문(>) 구조를 강제로 <blockquote> 태그로 변환
  const processQuotes = (text) => {
    const lines = text.split('\n');
    let inQuote = false;
    let result = '';
    let quoteContent = '';

    for (const line of lines) {
      if (line.trim().startsWith('>')) {
        inQuote = true;
        quoteContent += line.replace(/^>\s*/, '') + '\n';
      } else {
        if (inQuote) {
          result += `<blockquote><p>${quoteContent}</p></blockquote>\n`;
          inQuote = false;
          quoteContent = '';
        }
        result += line + '\n';
      }
    }
    // 마지막 줄 처리
    if (inQuote) {
      result += `<blockquote><p>${quoteContent}</p></blockquote>\n`;
    }
    return result;
  };

  const cleanedText = markdownText.replace(/\\/g, ''); // 불필요한 백슬래시 제거
  const processedText = processQuotes(cleanedText);

  // 2. Unified 파이프라인 실행
  const result = unified()
    .use(remarkParse) // MD 파싱
    .use(remarkGfm)   // GitHub Flavored Markdown 지원
    .use(remarkRehype, { allowDangerousHtml: true }) // 전처리된 HTML 태그 허용
    .use(rehypePrismPlus) // 코드 블럭 하이라이팅
    .use(rehypeStringify, { allowDangerousHtml: true })
    .processSync(processedText);

  return result.value;
};

3. Editor 구현: HTML to Markdown

사용자가 웹 에디터(WYSIWYG)에서 내용을 수정한 뒤 저장 버튼을 누르면, HTML을 다시 깔끔한 마크다운으로 변환하여 DB에 저장해야 합니다.

이때는 역방향 파이프라인(rehyperemark)을 사용합니다.

import rehypeParse from 'rehype-parse';
import rehypeRemark from 'rehype-remark';
import remarkStringify from 'remark-stringify';

export const convertHtmlToMarkdown = async (htmlText) => {
  const result = await unified()
    .use(rehypeParse, { fragment: true }) // HTML 조각 파싱
    .use(rehypeRemark) // 핵심: HTML AST -> Markdown AST 변환
    .use(remarkGfm)
    .use(remarkStringify, {
      fences: true, // 코드 블럭에 ``` 사용
      bullet: '-',  // 리스트 아이템에 - 사용
    })
    .process(htmlText);

  return result.value;
};

이렇게 하면 사용자가 작성한 <b>Bold</b> 태그는 **Bold**로, <li>List</li> 태그는 - List로 자동 변환되어 저장됩니다.

📝 마치며

결국 이 파서는 “PDF의 정적인 데이터”“웹의 동적인 데이터”로 연결해 주는 다리 역할을 합니다.

  • DB 저장 효율: 마크다운으로 저장하여 용량 최적화
  • 사용자 경험: HTML로 렌더링하여 위지윅(WYSIWYG) 편집 환경 제공

unified 생태계를 활용하면 단순히 포맷을 바꾸는 것을 넘어, 우리 서비스의 데이터 특성(PDF 파싱 데이터)에 맞는 커스텀 전처리 로직을 유연하게 끼워 넣을 수 있어 매우 효과적이었습니다.

* TOC {:toc}

© 2021. All rights reserved.