블로그로 돌아가기

클라이언트 측 마크다운 PDF 변환: html2pdf.js에서 브라우저 네이티브 인쇄까지

MD-TO Team •

브라우저에서 마크다운을 PDF로 변환하는 일은 간단해 보이지만 함정이 가득합니다. 이 글에서는 md-to.com의 PDF 내보내기 뒤에 있는 기술 선택, 아키텍처의 변천, 구현 세부 사항을 정리했습니다. html2pdf.js로 처음 시도한 단계부터 브라우저 네이티브 인쇄로 최종 전환하기까지, 그 과정에서 얻은 교훈도 모두 담았습니다.


클라이언트 측 PDF 생성의 세 가지 방법

서버 없이 프런트엔드만으로 구현해야 한다는 조건에서 PDF를 생성하는 방법은 크게 세 가지입니다.

방식동작 원리라이브러리
서버 측 렌더링Node.js에서 헤드리스 브라우저를 띄워 HTML을 렌더링하고 PDF로 내보냄Puppeteer, Playwright, wkhtmltopdf
클라이언트 측 JS 생성브라우저에서 HTML을 Canvas로 렌더링한 뒤 PDF로 변환html2pdf.js, jsPDF + html2canvas
브라우저 네이티브 인쇄window.print()를 호출하고, 사용자가 인쇄 대화상자에서 “PDF로 저장”을 선택별도의 서드파티 라이브러리 불필요

md-to.com은 모든 변환이 백엔드 없이 브라우저 로컬에서 이루어지는 완전한 정적 사이트입니다. 따라서 서버 측 방식은 처음부터 선택지에서 제외되고, html2pdf.js와 브라우저 네이티브 인쇄 중에서 골라야 했습니다.


버전 1: html2pdf.js 실험과 폐기한 이유

초기 접근 방식

처음에는 html2pdf.js를 도입했습니다. 동작 방식은 다음과 같습니다.

  1. HTML 요소를 html2canvas에 전달해 Canvas 비트맵으로 렌더링합니다.
  2. jsPDF로 Canvas를 잘라 여러 페이지의 PDF로 만듭니다.
  3. 브라우저 다운로드를 실행합니다.

핵심 설정은 다음과 같습니다.

const opt = {
  margin: [0.5, 0.5],
  filename: pdfFilename,
  image: { type: 'jpeg', quality: 0.98 },
  html2canvas: { scale: 2, useCORS: true, logging: false },
  jsPDF: { unit: 'in', format: 'letter', orientation: 'portrait' },
  pagebreak: {
    mode: ['css', 'legacy'],
    avoid: ['pre', 'code', 'blockquote', 'img', '.katex-display',
            'h1', 'h2', 'h3', 'h4', 'h5', 'h6'],
  },
};
await html2pdf().set(opt).from(wrapper).save();

pagebreak.avoid 설정은 html2pdf.js가 해당 요소 안에서는 페이지를 나누지 않도록 지정합니다. 이론상으로는 내용이 잘리는 문제가 해결되어야 합니다.

겪은 문제들

실제로 사용해 보니 심각한 문제가 여러 가지 드러났습니다.

1. 비트맵 출력 — 텍스트를 선택할 수 없음

html2pdf.js는 본질적으로 “HTML → Canvas 스크린샷 → PDF로 이어 붙이기” 방식입니다. 모든 페이지가 JPEG 이미지이므로 PDF의 텍스트를 선택하거나 검색할 수 없고, 하이퍼링크도 모두 사라집니다. 기술 문서에서는 치명적인 결함입니다.

2. 페이지 경계에서 내용이 잘리는 문제를 해결하기 어려움

pagebreak.avoid는 단순한 경우에는 잘 동작합니다. 하지만 코드 블록이 한 페이지보다 길거나 중첩 목록이 페이지에 걸쳐 있으면, html2pdf.js의 페이지 나누기 알고리즘은 요소 한가운데를 그대로 잘라 버립니다. 콘텐츠 구조를 이해하는 브라우저 레이아웃 엔진과 달리 픽셀 높이만으로 계산하기 때문입니다.

3. 성능 및 메모리 문제

html2canvas는 전체 DOM 트리를 Canvas에 다시 렌더링해야 합니다. 문서가 길면(10페이지 이상) 메모리를 엄청나게 소모하고, 모바일 기기에서는 페이지가 멈추기도 합니다.


버전 2: 브라우저 네이티브 인쇄

네이티브 인쇄를 선택한 이유

브라우저의 인쇄 기능은 운영체제의 레이아웃 엔진을 호출하므로 벡터 PDF가 만들어집니다. 텍스트를 선택하고 검색할 수 있고, 하이퍼링크도 그대로 동작하며, 파일 크기도 더 작습니다.

항목html2pdf.js브라우저 네이티브 인쇄
출력 형식비트맵 (Canvas 스크린샷)벡터 (텍스트 선택/검색 가능)
페이지 나누기JS 픽셀 계산, 내용이 잘리기 쉬움CSS page-break-*, 브라우저 엔진이 처리
하이퍼링크사라짐유지됨
성능Canvas 렌더링으로 메모리 사용량이 큼시스템 인쇄 커널을 호출하므로 매우 빠름
유지보수성우려됨브라우저 기본 기능이라 장기적으로 안정적

유일한 “단점”은 사용자가 인쇄 대화상자에서 직접 “PDF로 저장”을 선택해야 한다는 점이며, 이는 좋은 UI 안내로 보완해야 합니다.


핵심 아키텍처: Overlay + IFrame

전체 아키텍처는 세 개의 계층으로 구성됩니다.

┌──────────────────────────────────┐
│           Toolbar                │  ← 인쇄 버튼, 취소 버튼
├──────────────────────────────────┤
│                                  │
│     ┌────────────────────┐       │
│     │                    │       │
│     │   IFrame (Preview) │       │  ← 스타일이 적용된 HTML 삽입
│     │   width: 8.5in     │       │
│     │                    │       │
│     └────────────────────┘       │
│                                  │
│       Overlay (Full-screen)      │  ← z-index: 70
└──────────────────────────────────┘

동작 흐름

  1. 사용자가 “PDF 다운로드” 버튼을 클릭합니다.
  2. showPdfOverlay()가 호출되어 전체 화면 오버레이를 만듭니다.
  3. 템플릿 시스템을 동적으로 import하고 현재 활성 템플릿을 가져옵니다.
  4. generatePrintStyles(template)를 호출해 전체 인쇄 스타일을 생성합니다.
  5. iframe을 만들고 스타일과 HTML 콘텐츠를 기록합니다.
  6. 사용자는 오버레이에서 결과를 미리 확인한 뒤 “PDF로 저장”을 클릭합니다.
  7. iframe.contentWindow.print()가 시스템 인쇄 대화상자를 엽니다.

IFrame을 사용하는 이유

현재 페이지에서 window.print()를 직접 호출하면 편집기와 사이드바 같은 UI를 포함한 페이지 전체가 인쇄됩니다. iframe을 사용하면 다음과 같은 이점이 있습니다.

  • 인쇄 대상을 렌더링된 마크다운 결과물로만 한정할 수 있습니다.
  • 메인 페이지에 영향을 주지 않고 독립적인 인쇄 스타일을 삽입할 수 있습니다.
  • WYSIWYG 미리보기를 제공할 수 있습니다. iframe에 보이는 모습이 그대로 인쇄됩니다.

핵심 구현은 다음과 같습니다.

const styles = generatePrintStyles(template);
const doc = iframe.contentDocument || iframe.contentWindow?.document;
if (doc) {
  doc.open();
  doc.write(`
    <!DOCTYPE html>
    <html>
    <head>
      <meta charset="UTF-8">
      <title>Document</title>
      ${styles}
    </head>
    <body>
      ${currentHtml}
    </body>
    </html>
  `);
  doc.close();
}

iframe의 너비는 8.5in(Letter 용지 너비)으로 지정하고, 높이는 콘텐츠에 맞춰 동적으로 계산합니다.

setTimeout(() => {
  const bodyHeight = doc.body.scrollHeight;
  iframe.style.height = Math.max(bodyHeight + 40, 800) + 'px';
}, 100);

CSS 페이지 나누기 전략 자세히 보기

네이티브 인쇄 방식의 핵심은 CSS에 있습니다. @media print 규칙으로 브라우저 레이아웃 엔진이 페이지를 나눌 때 우리가 의도한 대로 동작하도록 지시합니다.

콘텐츠가 잘리지 않게 하기

@media print {
  tr, pre, blockquote, img, .katex-display {
    page-break-inside: avoid;
    break-inside: avoid;
  }
}

page-break-inside(구 문법)와 break-inside(최신 문법)를 함께 사용하면 호환성을 최대한 확보할 수 있습니다. 적용 대상 요소는 다음과 같습니다.

  • pre: 코드 블록
  • blockquote: 인용문
  • img: 이미지
  • .katex-display: 블록 수식
  • tr: 표의 행

표는 여러 페이지에 걸치되 머리글 행은 반복

@media print {
  table {
    page-break-inside: auto;
  }
  thead {
    display: table-header-group;
  }
}

영리한 조합입니다. table은 매우 길 수 있으므로 여러 페이지에 걸치도록 허용하고, thead를 table-header-group으로 지정해 브라우저가 매 페이지 상단에 표 머리글을 반복해서 출력하게 합니다. 긴 표에서 특히 유용합니다. 열 이름을 확인하려고 첫 페이지로 되돌아갈 필요가 없습니다.

제목이 페이지 맨 아래에 홀로 남지 않게 하기

@media print {
  h1, h2, h3, h4, h5, h6 {
    page-break-after: avoid;
    break-after: avoid;
  }
}

제목은 페이지 하단에 있는데 본문이 다음 페이지에서 시작하면 읽기 불편합니다. page-break-after: avoid는 브라우저에 “제목 바로 뒤에서는 페이지를 나누지 말고, 제목과 뒤따르는 내용을 같은 페이지에 두라”고 알려 줍니다.

배경색 유지하기

@media print {
  body {
    -webkit-print-color-adjust: exact;
    print-color-adjust: exact;
  }
}

브라우저는 기본적으로 잉크를 아끼기 위해 배경색을 인쇄하지 않습니다. 이 두 줄의 CSS는 배경색을 강제로 유지하며, 코드 블록 배경, 표의 줄무늬, 인용문 배경에 꼭 필요합니다. 다만 이 설정이 실제로 적용되려면 사용자가 인쇄 대화상자에서 “배경 그래픽”을 체크해야 합니다.


템플릿 시스템과 동적 스타일 생성

md-to.com은 20개가 넘는 문서 템플릿을 제공하며, 각 템플릿은 시각적 매개변수 전체를 정의합니다.

interface DocumentTemplate {
  fonts: { body: string; heading: string; code: string };
  fontSizes: { body: number; h1: number; h2: number; /* ... */ code: number };
  colors: {
    text: string; heading: string; link: string;
    codeBackground: string; codeText: string; codeBorder: string;
    quoteBorder: string; quoteBackground: string; quoteText: string;
    tableBorder: string; tableHeaderBg: string; tableHeaderText: string;
    tableRowOdd: string; tableRowEven: string;
  };
  spacing: {
    lineHeight: number;
    headingBefore: number; headingAfter: number;
    paragraphBefore: number; paragraphAfter: number;
  };
}

generatePrintStyles(template) 함수는 템플릿 매개변수를 CSS 템플릿 문자열에 주입해 완성된 <style> 태그를 생성합니다. 따라서 같은 마크다운 콘텐츠라도 템플릿을 바꾸면 글꼴, 색상, 간격이 모두 템플릿에 맞춰 달라지면서 완전히 다른 PDF가 만들어집니다.

코드 하이라이트 스타일(highlight.js)도 생성된 CSS에 인라인으로 포함되므로, PDF의 코드 블록에도 구문 색상이 그대로 유지됩니다.


스마트 파일 이름 생성

다운로드되는 PDF 파일 이름은 흔한 download.pdf가 아닙니다. getDownloadFileName() 함수가 마크다운 원문에서 첫 번째 제목을 추출합니다.

export function getDownloadFileName(
  mdText: string,
  extension: string,
  fallbackPrefix: string = 'markdown-export',
): string {
  const lines = mdText.split(/\r?\n/);
  const headingLine = lines.find((line) => /^\s{0,3}#{1,6}\s+/.test(line));
  if (headingLine) {
    const raw = headingLine
      .replace(/^\s{0,3}#{1,6}\s+/, '')
      .replace(/\s+#*\s*$/, '');
    const cleaned = sanitizeFilename(raw);
    if (cleaned) return `${cleaned}.${extension}`;
  }
  return `${fallbackPrefix}-${getDateStamp()}.${extension}`;
}

처리 순서는 다음과 같습니다.

  1. 마크다운 텍스트를 줄 단위로 나누고 # 제목 형식에 해당하는 첫 번째 줄을 찾습니다.
  2. # 접두사와 뒤에 붙은 # 장식을 제거합니다.
  3. sanitizeFilename()으로 사용할 수 없는 문자(\ / : * ? " < > |)를 제거하고, 공백을 정규화하며, 80자로 자릅니다.
  4. 제목이 없으면 markdown-to-pdf-20260311.pdf 형식으로 대체합니다.

예를 들어 문서가 # 프로젝트 기술 제안서로 시작하면 다운로드되는 파일 이름은 프로젝트 기술 제안서.pdf가 됩니다.


시행착오에서 얻은 교훈

1. html2pdf.js 버튼 상태 버그

html2pdf.js로 다운로드를 마친 뒤에도 버튼의 disabled 상태가 false로 되돌아가지 않았습니다. 첫 다운로드에 성공한 후 버튼이 계속 회색으로 비활성화되어 있었습니다. 성공 경로에서는 버튼 텍스트만 복원하고 disabled 속성은 복원하지 않았기 때문입니다.

// 성공 경로 — btnDownloadPdf.disabled = false 누락
await html2pdf().set(opt).from(wrapper).save();
showToast(texts.downloadStarted);
btnDownloadPdf.innerText = texts.downloadPdf;

반면 실패 경로는 올바르게 처리하고 있었습니다.

// 실패 경로 — 올바르게 복원
btnDownloadPdf.disabled = false;
btnDownloadPdf.innerText = texts.downloadPdf;

2. innerHTML로 인한 XSS 위험

초기 버전에서는 tipText.innerHTML = texts.tip으로 툴팁 텍스트를 삽입했습니다. texts.tip은 사용자 입력이 아니라 i18n 설정에서 오는 값이지만, innerHTML 자체가 위험한 API입니다. 코드 리뷰를 거쳐 textContent로 변경했습니다.

// 변경 전: tipText.innerHTML = texts.tip;
tipText.textContent = texts.tip;

3. z-index 남용

오버레이의 z-index를 처음에는 99999, 툴바는 100000으로 지정했습니다. 이런 무식한 방식은 복잡한 페이지에서 다른 컴포넌트와 쉽게 충돌합니다. 이후 의미 있는 계층 구조로 바꿔, 오버레이는 z-index: 70을 쓰고 툴바는 position: relative만으로 별도의 z-index 없이 오버레이 콘텐츠 위에 자연스럽게 쌓이도록 했습니다.

4. 점진적 마이그레이션 전략

네이티브 인쇄로 전환한 후에도 html2pdf.js 코드는 삭제하지 않고, 주석 처리로 버튼만 숨겼습니다.

// toolbar.appendChild(btnDownloadPdf); 다운로드에 버그가 있어 버튼을 임시로 숨김
toolbar.appendChild(btnPrint);

html2pdf.js의 다운로드 로직, 설정, 이벤트 핸들러는 모두 그대로 두고 진입점만 숨겼습니다. 덕분에 나중에 (인쇄 대화상자에서 사용자 조작 없이) “원클릭 다운로드”를 제공해야 할 때 빠르게 복원할 수 있습니다.


사용자에게 인쇄 설정 안내하기

브라우저 네이티브 인쇄의 사용자 경험은 인쇄 대화상자 설정에 좌우됩니다. 명확한 UI 안내가 필수입니다.

  1. 대상: “PDF로 저장”을 선택합니다.
  2. 배경 그래픽: 반드시 체크해야 합니다. 체크하지 않으면 코드 블록 배경, 표의 줄무늬, 인용문 배경이 모두 사라집니다.
  3. 머리글과 바닥글: 체크를 해제합니다. PDF 상단에 표시되는 불필요한 URL과 날짜가 사라집니다.
  4. 용지 크기: A4(국제적으로 일반적) 또는 Letter(북미에서 일반적)를 선택합니다.
  5. 여백: “기본값”을 선택합니다. CSS에서 이미 padding: 20mm를 지정해 두었습니다.

마치며

기술이 변천해 온 과정을 돌아보면 다음과 같이 정리할 수 있습니다.

  • html2pdf.js는 “클라이언트 측 PDF 생성”이라는 문제를 풀어 주었지만, 출력 품질(비트맵, 내용 잘림, 검색할 수 없는 텍스트)이 기대에 미치지 못했습니다.
  • 브라우저 네이티브 인쇄는 OS 레이아웃 엔진을 활용해 벡터 PDF를 만들고 CSS로 페이지 나누기를 제어하므로 출력 품질이 크게 향상되었습니다.
  • Overlay + IFrame 아키텍처는 인쇄 콘텐츠를 페이지 UI와 분리하고 WYSIWYG 미리보기 경험을 제공합니다.
  • CSS 페이지 나누기 규칙이 이 솔루션의 핵심입니다. page-break-inside: avoid, table-header-group, page-break-after: avoid가 코드 잘림, 표 머리글 누락, 제목만 홀로 남는 문제라는 세 가지 큰 불편을 해결합니다.

프런트엔드에서 PDF 내보내기 기능을 만든다면 브라우저 네이티브 인쇄를 가장 먼저 고려해 보세요. 서드파티 의존성이 필요 없고, 출력 품질이 가장 뛰어나며, 인쇄는 브라우저의 기본 기능이라 시간이 지나도 구식이 되지 않습니다.


관련 링크: