현록

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 = html

innerHTML에 문자열을 넣으면 브라우저가 태그를 해석해 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>

breaksgfm: 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를 검토한다.

참고 자료

관련 포스트
JavaScript ==와 === 차이 thumbnail
JavaScript ==와 === 차이
JavaScript ==와 ===의 차이를 암시적 타입 변환, null과 undefined, boolean과 문자열, 객체 참조, NaN, 비교 연산자 선택 기준으로 정리합니다.
JavaScript var let const 차이 thumbnail
JavaScript var let const 차이
JavaScript var, let, const 차이를 함수와 블록 스코프, 재할당과 재선언, 호이스팅과 TDZ, 반복문 closure, 객체 변경 기준으로 정리합니다.
URL path parameter와 query string 차이 thumbnail
URL path parameter와 query string 차이
URL path parameter와 query string의 차이를 초보자 기준으로 정리합니다. 리소스 식별자, 조회 조건, URLSearchParams, 인코딩, API 설계 기준을 함께 봅니다.
쿠키와 localStorage 차이 thumbnail
쿠키와 localStorage 차이
쿠키와 localStorage의 차이를 초보자 기준으로 정리합니다. 서버 자동 전송, JavaScript 접근, 만료 시간, HttpOnly, Secure, SameSite, 로그인 상태 저장 기준을 함께 봅니다.
script async와 defer 차이 thumbnail
script async와 defer 차이
HTML script 태그의 기본 동작, async와 defer의 다운로드 방식, 실행 순서, DOMContentLoaded와의 관계를 초보자 기준으로 정리합니다.
CORS 에러와 해결 기준 thumbnail
CORS 에러와 해결 기준
브라우저의 Same-Origin Policy, CORS 응답 헤더, preflight, 서버에서 해결해야 하는 이유를 초보자 기준으로 정리합니다.
fetch와 axios 차이 thumbnail
fetch와 axios 차이
fetch와 axios의 차이를 초보자 기준으로 정리합니다. 설치 여부, JSON 처리, HTTP 에러 처리, timeout, interceptor, 언제 어떤 방식을 쓰면 좋은지 함께 봅니다.
Vue 3 입문기 thumbnail
Vue 3 입문기
Vue의 메인 버전이 3가 된지 꽤 됐다. 작성 당시에는 Nuxt 3가 RC 단계였기 때문에 실서비스에 바로 적용하기에는 조심스러웠다. 그래서 궁금하고 심심하던 참에 간단한 Todo App을 만들어봤다.
marked.js renderer custom 하기 thumbnail
marked.js renderer custom 하기
marked.js renderer custom 방법을 정리합니다. Renderer의 역할, image와 link 렌더러 커스텀, alt와 rel 처리, sanitize-html 같은 HTML 정화 주의점까지 함께 봅니다.
가독성 있게 상수 넣기 thumbnail
가독성 있게 상수 넣기
오늘 회사 동료의 PR 리뷰 과정에서 좋은 기능을 공유해주셔서 TIL로 남겨봅니다. 아래와 같이 상수에 언더바(_)로 콤마처럼 구분을 시켜줄 수 있습니다. 앞으로 깔끔하고 좋은 코드를 작성하기 위해 자주 사용해야겠습니다 :)
TS에서 generic optional 하게 설정하기 thumbnail
TS에서 generic optional 하게 설정하기
오늘 next에서 `getStaticProps`와 `getLayout` 패턴을 함께 사용할 때, typescript generic을 넘겨주는 작업을 하고 있었는데, 기본값이 없다보니 기존 코드에 에러가 발생했었다. 이를 해결하기 위해 찾아보니 단순히 아래 예시처럼 `= {}`을 추가해주면 해결된다고 한다.
Backend에서 API Response가 snake_case인 경우엔? thumbnail
Backend에서 API Response가 snake_case인 경우엔?
안녕하세요. 프론트엔드 개발자의 경우, 가끔 백엔드의 API Response 값이 snake_case일 경우 어떻게 관리할지에 대해 고민에 빠지게 됩니다. 저도 오늘 같은 상황을 겪게 되었는데,  이번엔 네이밍 컨벤션을 맞춰주기로 했습니다. 컨벤션을 맞추는 데에는 여러가지 방법이 있겠지만, 고민 끝에 저는 axios의 interceptors를 통해 해결을 해보았습니다.
Promise 다루기 (feat. 병렬실행, 순차실행) thumbnail
Promise 다루기 (feat. 병렬실행, 순차실행)
오늘은 Promise를 통해 구문을 동기 처리 할 때, 여러 Promise들을 다루는 법을 소개해보겠습니다. Javscript를 작성하다 보면, 가끔 여러 Promise들을 다룰 때가 있습니다. 필자도 Nodejs 서버에서 동시에 여러 쿼리를 실행할 때 자주 마주쳤었는데요. 오늘은 어떻게 하면 Promise들을 유연하게 다룰 수 있는지 알아보겠습니다. 시작하기 앞서, 네 가지 Promise를 선언하고, 그들을 하나의 Array에 묶어보겠습니다.