junyeokk
Blog
React·2025. 03. 05

react-markdown

서버에서 받아온 마크다운 문자열을 React 컴포넌트로 렌더링해야 하는 상황은 꽤 흔하다. 블로그 본문, AI가 생성한 요약, 사용자가 작성한 코멘트 등 마크다운 형식의 텍스트를 화면에 보여줘야 할 때가 많다.

가장 단순한 방법은 dangerouslySetInnerHTML로 HTML을 직접 삽입하는 것이다.

tsx
function Content({ html }: { html: string }) {
  return <div dangerouslySetInnerHTML={{ __html: html }} />;
}

하지만 이 방식은 이름 그대로 위험하다. 서버에서 내려주는 HTML에 <script> 태그나 이벤트 핸들러가 포함되어 있으면 XSS(Cross-Site Scripting) 공격에 그대로 노출된다. 그리고 마크다운을 HTML로 변환하는 과정도 별도로 필요하다.

react-markdown은 이 문제를 깔끔하게 해결한다. 마크다운 문자열을 입력받아서 React 엘리먼트 트리로 변환한다. dangerouslySetInnerHTML을 사용하지 않고, 마크다운의 각 요소를 실제 React 컴포넌트로 렌더링하기 때문에 XSS 공격에 안전하다.


내부 동작 원리

react-markdown이 마크다운을 React 컴포넌트로 바꾸는 과정은 세 단계로 이루어진다.

마크다운 문자열 → AST(추상 구문 트리) → HAST(HTML AST) → React 엘리먼트
  1. 파싱: remark가 마크다운 문자열을 mdast(Markdown Abstract Syntax Tree)로 파싱한다
  2. 변환: remark-rehype가 mdast를 hast(HTML Abstract Syntax Tree)로 변환한다
  3. 렌더링: hast의 각 노드를 대응하는 React 엘리먼트로 변환한다

이 파이프라인 덕분에 각 단계에 플러그인을 끼워넣을 수 있다. remark 플러그인은 마크다운 파싱 단계에, rehype 플러그인은 HTML 변환 단계에 개입한다.

markdown → [remark 플러그인들] → mdast → [rehype 플러그인들] → hast → React

핵심은 이 과정에서 innerHTML이나 dangerouslySetInnerHTML을 전혀 사용하지 않는다는 점이다. 모든 출력이 React.createElement 호출로 생성되기 때문에 React의 이스케이프 처리가 자동으로 적용된다.


기본 사용법

설치는 간단하다.

bash
마크다운 문자열 → AST(추상 구문 트리) → HAST(HTML AST) → React 엘리먼트

가장 기본적인 사용법은 Markdown 컴포넌트에 마크다운 문자열을 children으로 전달하는 것이다.

tsx
markdown → [remark 플러그인들] → mdast → [rehype 플러그인들] → hast → React

이렇게만 해도 제목, 리스트, 볼드, 이탤릭, 링크, 코드 블록 등 기본적인 마크다운 문법이 모두 렌더링된다.

서버에서 받은 문자열에 이스케이프된 줄바꿈(\n)이 포함되어 있는 경우가 있다. 마크다운 파서는 실제 줄바꿈 문자를 기준으로 파싱하기 때문에, 이런 이스케이프 시퀀스를 실제 줄바꿈으로 변환해줘야 한다.

tsx
npm install react-markdown

커스텀 컴포넌트

react-markdown의 강력한 기능 중 하나는 마크다운 요소를 커스텀 React 컴포넌트로 대체할 수 있다는 것이다. components prop에 태그 이름을 키로, 컴포넌트를 값으로 전달하면 된다.

tsx
import Markdown from "react-markdown";

function PostContent({ content }: { content: string }) {
  return (
    <div className="prose">
      <Markdown>{content}</Markdown>
    </div>
  );
}

이 방식의 장점은 마크다운 렌더링의 시각적 표현을 완전히 제어할 수 있다는 것이다. Tailwind CSS 클래스를 적용하거나, 링크에 target="_blank"를 자동으로 추가하거나, 이미지에 loading="lazy"를 넣는 등의 커스터마이징이 자유롭다.

다만 매번 컴포넌트 맵을 인라인으로 정의하면 리렌더링 시마다 새 객체가 생성된다. 컴포넌트 바깥에 정의하거나 useMemo로 감싸서 불필요한 리렌더링을 방지하자.

tsx
const markdownString = rawContent
  .replace(/\\n/g, "\n")
  .replace(/\\r/g, "\r");

return <Markdown>{markdownString}</Markdown>;

remark / rehype 플러그인

react-markdown의 플러그인 시스템은 remarkPluginsrehypePlugins 두 prop으로 사용한다.

remark-gfm: GitHub Flavored Markdown

기본 마크다운 문법에는 테이블, 취소선, 체크리스트, 자동 링크가 없다. GFM(GitHub Flavored Markdown) 확장을 지원하려면 remark-gfm 플러그인이 필요하다.

bash
import Markdown from "react-markdown";

const components = {
  h1: ({ children }) => (
    <h1 className="text-3xl font-bold mb-4 text-blue-900">{children}</h1>
  ),
  h2: ({ children }) => (
    <h2 className="text-2xl font-semibold mb-3 border-b pb-2">{children}</h2>
  ),
  a: ({ href, children }) => (
    <a
      href={href}
      target="_blank"
      rel="noopener noreferrer"
      className="text-blue-600 hover:underline"
    >
      {children}
    </a>
  ),
  code: ({ inline, className, children }) => {
    if (inline) {
      return (
        <code className="bg-gray-100 px-1.5 py-0.5 rounded text-sm">
          {children}
        </code>
      );
    }
    return (
      <pre className="bg-gray-900 text-gray-100 p-4 rounded-lg overflow-x-auto">
        <code className={className}>{children}</code>
      </pre>
    );
  },
  img: ({ src, alt }) => (
    <img
      src={src}
      alt={alt}
      className="rounded-lg shadow-md max-w-full"
      loading="lazy"
    />
  ),
  blockquote: ({ children }) => (
    <blockquote className="border-l-4 border-blue-500 pl-4 italic text-gray-600">
      {children}
    </blockquote>
  ),
};

function StyledContent({ markdown }: { markdown: string }) {
  return <Markdown components={components}>{markdown}</Markdown>;
}
tsx
// 컴포넌트 바깥에 정의 (추천)
const components = { /* ... */ };

// 또는 useMemo 사용
const components = useMemo(() => ({ /* ... */ }), []);

이제 다음과 같은 GFM 문법이 동작한다:

markdown
npm install remark-gfm

rehype-highlight: 코드 하이라이팅

코드 블록에 구문 하이라이팅을 적용하려면 rehype-highlight를 사용한다.

bash
import Markdown from "react-markdown";
import remarkGfm from "remark-gfm";

function GfmContent({ markdown }: { markdown: string }) {
  return (
    <Markdown remarkPlugins={[remarkGfm]}>
      {markdown}
    </Markdown>
  );
}
tsx
| 기능 | 지원 |
|------|------|
| 테이블 | ✅ |
| ~~취소선~~ | ✅ |

- [x] 완료된 항목
- [ ] 미완료 항목

https://example.com (자동 링크)

코드 블록에 언어를 명시하면 해당 언어의 구문 하이라이팅이 적용된다.

markdown
npm install rehype-highlight highlight.js

rehype-raw: HTML 태그 허용

마크다운 안에 HTML 태그가 섞여 있을 때 기본적으로는 무시된다. HTML을 그대로 렌더링하고 싶다면 rehype-raw를 사용한다.

bash
import Markdown from "react-markdown";
import rehypeHighlight from "rehype-highlight";
import "highlight.js/styles/github-dark.css";

function HighlightedContent({ markdown }: { markdown: string }) {
  return (
    <Markdown rehypePlugins={[rehypeHighlight]}>
      {markdown}
    </Markdown>
  );
}
tsx

단, rehype-raw를 사용하면 HTML이 그대로 렌더링되기 때문에 XSS 위험이 다시 생긴다. 신뢰할 수 없는 입력에 사용할 때는 반드시 rehype-sanitize와 함께 사용해야 한다.

bash
tsx
npm install rehype-raw

여러 플러그인 조합

플러그인은 배열로 여러 개를 동시에 적용할 수 있다. 옵션이 있는 플러그인은 [플러그인, 옵션] 형태의 튜플로 전달한다.

tsx
import Markdown from "react-markdown";
import rehypeRaw from "rehype-raw";

function HtmlContent({ markdown }: { markdown: string }) {
  return (
    <Markdown rehypePlugins={[rehypeRaw]}>
      {markdown}
    </Markdown>
  );
}

Tailwind Typography와 함께 사용

마크다운 렌더링 결과물에 일일이 스타일을 적용하는 건 번거롭다. Tailwind의 @tailwindcss/typography 플러그인을 사용하면 prose 클래스 하나로 마크다운 콘텐츠에 적절한 타이포그래피 스타일을 자동 적용할 수 있다.

bash
npm install rehype-sanitize
tsx
import rehypeRaw from "rehype-raw";
import rehypeSanitize from "rehype-sanitize";

<Markdown rehypePlugins={[rehypeRaw, rehypeSanitize]}>
  {untrustedMarkdown}
</Markdown>

prose 클래스가 제목, 본문, 리스트, 코드 블록, 인용문 등에 기본 타이포그래피 스타일을 적용한다. max-w-full을 추가하면 prose의 기본 max-width 제한을 해제할 수 있다. 다크 모드는 dark:prose-invert로 지원한다.


성능 고려사항

react-markdown은 마크다운을 파싱하고 AST를 순회하는 과정이 있기 때문에, 매우 긴 마크다운 문자열을 렌더링할 때 성능에 영향을 줄 수 있다.

React.memo로 불필요한 파싱 방지

마크다운 콘텐츠가 변경되지 않았는데 부모 컴포넌트의 리렌더링으로 인해 다시 파싱되는 걸 방지하자.

tsx
import remarkGfm from "remark-gfm";
import remarkMath from "remark-math";
import rehypeHighlight from "rehype-highlight";
import rehypeKatex from "rehype-katex";

<Markdown
  remarkPlugins={[remarkGfm, remarkMath]}
  rehypePlugins={[
    rehypeHighlight,
    [rehypeKatex, { strict: false }],
  ]}
>
  {markdown}
</Markdown>

긴 콘텐츠는 분할 렌더링

매우 긴 마크다운을 한 번에 렌더링하면 초기 렌더링이 느려질 수 있다. 섹션 단위로 분할해서 IntersectionObserver와 조합하면 뷰포트에 보이는 부분만 렌더링할 수 있다.

tsx
npm install @tailwindcss/typography

대안 라이브러리와 비교

라이브러리특징XSS 안전React 통합
react-markdownAST 기반, 플러그인 생태계✅ 기본 안전✅ 네이티브
marked + dangerouslySetInnerHTMLHTML 문자열 생성❌ 별도 새니타이징 필요❌ innerHTML
markdown-it플러그인 풍부, HTML 출력❌ 별도 처리 필요❌ innerHTML
mdx-jsJSX 지원, 컴파일 타임✅ 컴파일 필요

react-markdown의 가장 큰 장점은 별도 조치 없이 XSS에 안전하면서도 React 컴포넌트로 자연스럽게 통합된다는 점이다. marked나 markdown-it은 HTML 문자열을 생성하기 때문에 dangerouslySetInnerHTML이 필수고, 별도로 새니타이징을 해야 안전하다.

반면 react-markdown의 단점은 런타임에 파싱이 일어나기 때문에 marked보다 약간 느리다는 것이다. 정적 콘텐츠라면 빌드 타임에 변환하는 MDX가 성능면에서 유리하다. 하지만 동적으로 마크다운을 받아서 렌더링해야 하는 상황이라면 react-markdown이 가장 균형 잡힌 선택이다.


정리

  • 마크다운 → AST → React 엘리먼트 파이프라인으로 dangerouslySetInnerHTML 없이 XSS에 안전하게 렌더링한다
  • remark/rehype 플러그인으로 GFM, 코드 하이라이팅, 수식 등을 확장하고, components prop으로 각 요소의 렌더링을 완전히 제어할 수 있다
  • 긴 콘텐츠는 React.memo와 IntersectionObserver 기반 분할 렌더링으로 성능을 관리하고, 정적 콘텐츠라면 MDX를 검토한다

관련 문서