marked.js 사용법과 기본 설정
Markdown 문자열을 웹 페이지에 보여 주려면 HTML로 변환하는 과정이 필요하다.
marked.js는 JavaScript 환경에서 Markdown을 HTML 문자열로 변환하는 파서다.
Node.js, 브라우저, CLI에서 사용할 수 있고 기본 변환은 marked.parse() 하나로 시작할 수 있다.
다만 marked가 반환하는 값은 안전한 UI 컴포넌트가 아니라 HTML 문자열이다.
사용자가 입력한 Markdown을 다룬다면 변환 결과를 정화하는 보안 단계까지 함께 설계해야 한다.
marked.js의 역할
marked.js는 Markdown 소스를 읽고 HTML 문자열을 만든다.
Markdown source -> lexer and parser -> HTML string다음 Markdown을 marked에 넘긴다고 가정해 보자.
const markdown = [
'# 오늘의 메모',
'',
'이 문장에서 **중요한 부분**을 표시합니다.',
].join('\n')변환 결과는 다음과 같은 HTML 문자열이다.
<h1>오늘의 메모</h1>
<p>이 문장에서 <strong>중요한 부분</strong>을 표시합니다.</p>marked는 이 HTML에 스타일을 입히지 않는다.
제목 크기, 문단 간격, 코드 블록 색상은 애플리케이션의 CSS로 정해야 한다.
설치
JavaScript 프로젝트에서는 marked 패키지를 설치한다.
npm install marked설치한 뒤 ESM 환경에서 marked를 import할 수 있다.
import { marked } from 'marked'CommonJS 환경을 사용하는 기존 프로젝트라면 require 형식도 사용할 수 있다.
const { marked } = require('marked')새 프로젝트에서는 현재 모듈 설정과 빌드 도구에 맞는 import 방식을 고른다.
marked.parse 기본 사용법
marked.parse()에 Markdown 문자열을 넘기면 HTML을 반환한다.
import { marked } from 'marked'
const markdown = `
## 작업 목록
- Markdown 작성
- HTML 변환
- 화면 출력
`
const html = marked.parse(markdown, { async: false })
console.log(html)async: false를 지정하면 동기 파싱 결과를 문자열로 받는다.
<h2>작업 목록</h2>
<ul>
<li>Markdown 작성</li>
<li>HTML 변환</li>
<li>화면 출력</li>
</ul>기본 async 값은 false다.
비동기 walkTokens 같은 확장을 사용해 async: true로 설정하면 marked.parse()는 Promise를 반환할 수 있다.
const html = await marked.parse(markdown, { async: true })일반적인 동기 변환과 비동기 확장을 혼합하지 않도록 함수의 반환 타입을 명확히 정하는 편이 좋다.
parse와 parseInline의 차이
marked.parse()는 제목, 문단, 목록, 코드 블록처럼 block 수준의 Markdown까지 파싱한다.
marked.parse('**중요**', { async: false })결과에는 문단 태그가 포함된다.
<p><strong>중요</strong></p>marked.parseInline()은 문단 안에 들어갈 인라인 Markdown만 파싱한다.
marked.parseInline('**중요**')<strong>중요</strong>완성된 본문을 변환할 때는 parse, 버튼 라벨이나 짧은 설명처럼 블록 태그가 필요하지 않은 문자열은 parseInline을 검토한다.
브라우저에 HTML 출력
브라우저에서는 변환한 HTML 문자열을 DOM에 넣어 보여 줄 수 있다.
<article id="preview"></article>import { marked } from 'marked'
const preview = document.querySelector('#preview')
const markdown = '# 미리 보기\n\nMarkdown으로 작성한 **본문**입니다.'
const html = marked.parse(markdown, { async: false })
preview.innerHTML = htmlinnerHTML에 문자열을 넣으면 브라우저가 태그를 해석해 DOM을 만든다.
이 방식은 입력을 완전히 신뢰할 수 있을 때만 그대로 사용해야 한다.
외부 사용자가 Markdown을 작성할 수 있다면 HTML 정화 단계가 필요하다.
CDN으로 시작하는 방법
빌드 도구 없이 간단히 테스트할 때는 CDN의 ESM 모듈을 import할 수 있다.
<!doctype html>
<html lang="ko">
<head>
<meta charset="UTF-8" />
<title>marked.js 예제</title>
</head>
<body>
<article id="content"></article>
<script type="module">
import { marked } from 'https://cdn.jsdelivr.net/npm/marked/lib/marked.esm.js'
const markdown = '# marked.js\n\nMarkdown을 **HTML**로 변환합니다.'
const content = document.querySelector('#content')
content.innerHTML = marked.parse(markdown, { async: false })
</script>
</body>
</html>이 예제는 동작을 확인하기에는 편하지만 실제 서비스에서는 버전을 고정하는 기준이 필요하다.
네트워크 장애와 CDN 정책이 서비스 로딩에 영향을 주지 않아야 한다면 패키지로 설치해 빌드하는 편이 낫다.
gfm 설정
gfm은 GitHub Flavored Markdown 규칙을 사용할지 정하는 옵션이다.
marked의 기본값은 true다.
const html = marked.parse(markdown, {
async: false,
gfm: true,
})GFM을 켜면 표, 취소선, URL autolink 같은 규칙을 사용할 수 있다.
~~취소된 내용~~
https://example.com<p><del>취소된 내용</del></p>
<p><a href="https://example.com">https://example.com</a></p>프로젝트의 Markdown 문법이 CommonMark만을 전제한다면 gfm: false를 선택할 수 있다.
기본값을 무조건 바꾸기보다 작성자가 사용할 Markdown 문법을 먼저 정하는 편이 좋다.
breaks 설정
Markdown에서 단순한 한 번의 줄바꿈은 일반적으로 같은 문단의 공백으로 처리된다.
breaks: true를 설정하면 한 번의 줄바꿈을 <br>로 출력한다.
const markdown = `
첫 번째 줄
두 번째 줄
`
const html = marked.parse(markdown, {
async: false,
gfm: true,
breaks: true,
})<p>첫 번째 줄<br>두 번째 줄</p>breaks는 gfm: true일 때 적용된다.
게시판이나 채팅처럼 사용자가 줄바꿈을 그대로 보기를 기대하는 화면에서 유용하다.
문서형 Markdown에서는 문단 규칙을 유지하기 위해 기본값인 false를 쓰는 경우가 많다.
전역 설정과 인스턴스
marked.use()로 설정하면 기본 marked 인스턴스에 옵션과 확장이 저장된다.
import { marked } from 'marked'
marked.use({
gfm: true,
breaks: false,
})같은 모듈 인스턴스를 공유하는 다른 코드에도 이 설정이 영향을 준다.
서로 다른 Markdown 규칙을 동시에 사용해야 한다면 Marked 인스턴스를 분리하는 편이 명확하다.
import { Marked } from 'marked'
const documentMarkdown = new Marked({
gfm: true,
breaks: false,
})
const commentMarkdown = new Marked({
gfm: true,
breaks: true,
})
const documentHtml = documentMarkdown.parse(documentSource)
const commentHtml = commentMarkdown.parse(commentSource)문서와 댓글처럼 용도가 다른 Markdown을 구분할 수 있다.
marked.use()를 반복문이나 컴포넌트 렌더링 함수 안에서 매번 호출하지 않는다.
확장이 계속 중첩 등록되면 예상하지 못한 재귀 호출이나 중복 동작이 생길 수 있다.
HTML sanitize 주의점
marked는 출력 HTML을 sanitize하지 않는다.
다음처럼 Markdown 안에 HTML이 들어 있으면 출력에도 포함될 수 있다.
<img src="x" onerror="alert('xss')">이 결과를 그대로 innerHTML에 넣으면 신뢰할 수 없는 HTML이 실행될 수 있다.
사용자 입력을 다룰 때는 DOMPurify나 sanitize-html 같은 정화 도구를 marked 뒤에 적용한다.
import { marked } from 'marked'
import DOMPurify from 'dompurify'
const dirtyHtml = marked.parse(markdown, { async: false })
const safeHtml = DOMPurify.sanitize(dirtyHtml)
preview.innerHTML = safeHtml처리 순서는 Markdown 파싱 뒤 HTML 정화다.
Markdown -> marked.parse -> dirty HTML -> sanitizer -> safe HTML어떤 태그와 속성을 허용할지는 서비스의 편집 기능과 보안 정책에 맞게 설정해야 한다.
외부 링크, 이미지 URL, style, class, target 속성을 허용할지도 함께 검토한다.
코드 블록 스타일
marked는 fenced code block을 <pre><code> 형태로 변환한다.
const markdown = "```javascript\nconsole.log('hello')\n```"출력된 code 요소에는 보통 language-javascript 같은 class가 붙는다.
<pre><code class="language-javascript">console.log('hello')</code></pre>하지만 키워드에 색상을 입히는 syntax highlighting을 marked 핵심 기능이 직접 수행하지는 않는다.
코드 highlighting이 필요하면 marked-highlight 확장과 highlight.js, Shiki 같은 도구를 조합할 수 있다.
예전의 highlight 옵션은 marked 8에서 제거되었으므로 최신 문서의 확장 방식을 기준으로 잡는다.
제목 id와 목차
marked의 기본 heading 출력에 자동 id가 항상 포함된다고 가정하면 안 된다.
## 설치 방법기본 출력은 다음과 같은 heading이다.
<h2>설치 방법</h2>제목에 id를 만들어 목차 링크를 연결하려면 marked-gfm-heading-id 같은 확장을 사용하거나 renderer를 직접 정의해야 한다.
예전 headerIds, headerPrefix 옵션은 marked 8에서 제거되었다.
검색 결과에서 오래된 예제를 복사할 때는 현재 버전의 옵션 목록을 먼저 확인한다.
Renderer 확장
기본 HTML 출력을 바꾸려면 renderer를 확장할 수 있다.
외부 링크에 target 속성을 추가하거나 이미지에 class를 붙이고, heading에 id를 만드는 작업이 예시다.
import { marked } from 'marked'
marked.use({
renderer: {
link(token) {
const label = this.parser.parseInline(token.tokens)
return `<a href="${token.href}">${label}</a>`
},
},
})속성 값을 HTML 문자열에 넣을 때는 escape와 URL 허용 기준이 필요하다.
이미지와 링크를 안전하게 재정의하는 방법은 marked.js renderer custom 하기에서 자세히 정리했다.
서버와 클라이언트 선택
Markdown을 서버에서 HTML로 변환하면 클라이언트가 파서를 다운로드하지 않아도 된다.
검색 엔진과 JavaScript를 실행하지 않는 환경에도 완성된 HTML을 제공하기 쉽다.
또한 저장된 Markdown을 매 렌더링마다 반복 파싱하는 비용을 줄일 수 있다.
클라이언트 변환은 사용자가 작성하는 Markdown의 미리 보기처럼 입력과 함께 결과를 즉시 갱신해야 할 때 유용하다.
프로젝트에서는 초기 페이지 표시, 입력 반응성, 번들 크기, 정화 환경을 함께 보고 변환 위치를 정한다.
자주 하는 실수
첫 번째 실수는 marked가 스타일까지 만들어 준다고 기대하는 것이다.
marked는 HTML을 만들고 화면의 모양은 CSS가 담당한다.
두 번째 실수는 사용자 입력을 정화하지 않고 innerHTML에 넣는 것이다.
Markdown 문법이 간단해 보여도 HTML 주입 가능성은 따로 차단해야 한다.
세 번째 실수는 marked.use()를 렌더링할 때마다 호출하는 것이다.
설정은 모듈 초기화 지점에서 한 번 등록하거나 독립된 Marked 인스턴스를 만든다.
네 번째 실수는 오래된 예제의 sanitize, highlight, headerIds 옵션을 그대로 사용하는 것이다.
이 옵션들은 현재 marked에서 제거되었으므로 정화 라이브러리나 공식 확장으로 역할을 나눈다.
기본 구성 예시
다음은 문서형 Markdown을 변환하는 간단한 구성이다.
import { Marked } from 'marked'
const markdownParser = new Marked({
gfm: true,
breaks: false,
})
export function renderMarkdown(source) {
return markdownParser.parse(source, { async: false })
}신뢰할 수 없는 입력을 다룬다면 호출하는 측에서 sanitizer를 함께 사용한다.
import DOMPurify from 'dompurify'
import { renderMarkdown } from './markdown-parser.js'
const dirtyHtml = renderMarkdown(markdown)
const safeHtml = DOMPurify.sanitize(dirtyHtml)
preview.innerHTML = safeHtml이렇게 파서 설정, Markdown 변환, HTML 정화, DOM 출력의 역할을 나누면 테스트하기 쉬워진다.
선택 기준
Markdown을 HTML 문자열로 빠르게 변환하는 것이 핵심이라면 marked는 단순한 선택지다.
기본적으로 marked.parse()를 사용하고, 문장 안의 Markdown만 필요하면 parseInline()을 쓴다.
줄바꿈 규칙이 필요하면 breaks, GFM 문법 여부는 gfm으로 정한다.
서로 다른 설정을 공존시켜야 하면 전역 marked보다 Marked 인스턴스를 분리한다.
사용자 입력을 HTML로 표시하면 marked와 sanitizer를 항상 한 세트로 생각한다.
기본으로 제공하지 않는 문법과 HTML 출력이 필요해지면 marked.use()와 공식 확장, renderer를 검토한다.
참고 자료












