블로그: 마크다운을 예쁘게 렌더링하기 — ReactMarkdown 커스터마이징
블로그: 마크다운을 예쁘게 렌더링하기에 대해 "왜?"라고 물으면 답할 수 있으신가요?
\n\n> 블로그: 마크다운을 예쁘게 렌더링하기에 대해 "왜?"라고 물으면 답할 수 있으신가요?\n이전 글에서 마크다운 파일을 읽어서 Post 객체로 만드는 데까지 했습니다. 이제 이 마크다운 텍스트를 실제 화면에 보여줘야 합니다.
처음엔 그냥 dangerouslySetInnerHTML로 때려 넣을까도 생각했는데, 그러면 커스터마이징이 너무 힘들어집니다. Heading에 id를 붙인다든지, 코드 블록에 언어 배지를 넣는다든지 — 이런 작업이 전부 문자열 파싱이 되거든요.
그래서 ReactMarkdown 을 선택했습니다. 마크다운을 리액트 컴포넌트 트리로 변환해주니까, 각 요소를 컴포넌트 단위로 오버라이드할 수 있습니다.
기본 구조
먼저 글 상세 페이지의 레이아웃부터 잡았습니다. 본문과 사이드바(목차)를 나누는 구조입니다.
import ReactMarkdown from "react-markdown";
const PostDetailPage = () => {
const post = ...;
return (
<article>
{/* 본문 */}
<section>
<ReactMarkdown>{post.content}</ReactMarkdown>
</section>
{/* 사이드바 — 나중에 TOC가 들어갈 자리 */}
<aside>
...
</aside>
</article>
);
};
ReactMarkdown에 마크다운 문자열을 넘기면 알아서 HTML로 변환됩니다. 여기까지는 간단합니다.
플러그인 추가
ReactMarkdown은 remark(마크다운 파싱)와 rehype(HTML 변환) 기반이라서, 플러그인을 끼워 넣기가 쉽습니다.
<ReactMarkdown
remarkPlugins={[remarkGfm]}
rehypePlugins={[
rehypeRaw,
rehypeSlug,
rehypeHighlight,
]}
>
{post.content}
</ReactMarkdown>
각 플러그인이 하는 일:
| 플러그인 | 역할 |
|---|---|
| remarkGfm | 테이블, 체크박스 등 GitHub Flavored Markdown 지원 |
| rehypeRaw | 마크다운 안에 섞인 HTML 태그 처리 |
| rehypeSlug | heading에 id 자동 부여 (목차 연동에 필요) |
| rehypeHighlight | 코드 블록 구문 강조 |
remarkGfm 없으면 테이블이 안 됩니다. 블로그 글에서 테이블을 꽤 자주 쓰는데, 이거 빼먹고 "왜 테이블이 안 되지?" 하고 한참 헤맸습니다.
Heading 커스터마이징
기본 heading은 스타일 적용이 어렵고, 목차(TOC)를 만들려면 id가 필요합니다. rehypeSlug가 id를 자동으로 붙여주니까, 그걸 가져다 쓰면 됩니다.
<ReactMarkdown
components={{
h1({ children, ...props }) {
return <h1 id={props.id} className="post-heading post-heading__h1">{children}</h1>;
},
h2({ children, ...props }) {
return <h2 id={props.id} className="post-heading post-heading__h2">{children}</h2>;
},
h3({ children, ...props }) {
return <h3 id={props.id} className="post-heading post-heading__h3">{children}</h3>;
},
h4({ children, ...props }) {
return <h4 id={props.id} className="post-heading post-heading__h4">{children}</h4>;
},
}}
>
post-heading이라는 공통 클래스로 기본 스타일을 주고, post-heading__h1 같은 레벨별 클래스로 크기나 여백을 다르게 줍니다.
코드 블록 커스터마이징
코드 블록은 좀 더 신경을 썼습니다. 기본 <pre><code> 구조로는 언어 표시도 안 되고 밋밋하니까, 별도 컴포넌트로 분리했습니다.
<ReactMarkdown
components={{
pre({ node, children }) {
return <Code node={node}>{children}</Code>;
},
}}
>
Code 컴포넌트에서는 rehype-highlight가 붙여준 클래스에서 언어 정보를 꺼내서 상단에 표시합니다.
const Code = ({ node, children }) => {
const element = node?.children?.[0];
const classes = element?.properties?.className || [];
const match = /language-(\w+)/.exec(classes.join(' '));
const language = match ? match[1] : 'plaintext';
return (
<figure className="code-block-wrapper">
<figcaption className="code-block-header">
<span className="code-block-lang">
{language.toUpperCase()}
</span>
</figcaption>
<pre className="code-block hljs">
{children}
</pre>
</figure>
);
};
마크다운에서 ```jsx처럼 언어를 지정하면, 렌더링 시 코드 블록 위에 JSX라고 배지가 붙습니다. 작은 디테일인데 있고 없고의 차이가 꽤 큽니다.
이미지 경로 처리
이게 은근히 까다로웠습니다. 옵시디언에서 마크다운을 쓸 때 이미지는 보통 상대 경로로 넣게 됩니다.

근데 이 상대 경로를 그대로 쓰면 웹에서 못 찾습니다. 마크다운 파일은 src/assets/posts/ 안에 있지만, 이미지는 public/images/posts/ 아래에 있거든요.
그래서 img 태그를 오버라이드해서 경로를 변환합니다.
img({ src, alt }) {
const imgSrc = (src.startsWith('http') || src.startsWith('/'))
? src
: `/images/posts/${post.categories.join('/')}/${src}`;
return (
<img
src={imgSrc}
alt={alt}
loading="lazy"
decoding="async"
/>
);
}
외부 URL이나 절대 경로는 그대로 두고, 상대 경로만 /images/posts/{카테고리 경로}/로 바꿔줍니다. 예를 들어 사이드 프로젝트/블로그 카테고리의 글에서 diagram.png를 쓰면 /images/posts/사이드 프로젝트/블로그/diagram.png로 변환되는 식입니다.
loading="lazy"는 뷰포트에 들어올 때만 로드하는 거고, decoding="async"는 이미지 디코딩을 메인 스레드에서 분리하는 옵션입니다. 둘 다 넣으면 스크롤이 좀 더 부드러워집니다.
전체 구조
최종적으로 ReactMarkdown 설정은 이렇습니다.
<ReactMarkdown
remarkPlugins={[remarkGfm]}
rehypePlugins={[rehypeRaw, rehypeSlug, rehypeHighlight]}
components={{
h1({ children, ...props }) { /* heading 커스텀 */ },
h2({ children, ...props }) { /* ... */ },
h3({ children, ...props }) { /* ... */ },
h4({ children, ...props }) { /* ... */ },
pre({ node, children }) { return <Code node={node}>{children}</Code>; },
img({ src, alt }) { /* 이미지 경로 변환 */ },
}}
>
{post.content}
</ReactMarkdown>
필요에 따라 blockquote, table, a 같은 것도 오버라이드할 수 있는데, 지금은 이 정도면 충분합니다. 과하게 커스터마이징하면 나중에 유지보수가 힘들어지니까요.
다음 글에서는 이렇게 렌더링된 heading을 활용해서 사이드바 목차(TOC)를 만들어보겠습니다.