Theme:

블로그: 마크다운을 예쁘게 렌더링하기에 대해 "왜?"라고 물으면 답할 수 있으신가요?

\n\n> 블로그: 마크다운을 예쁘게 렌더링하기에 대해 "왜?"라고 물으면 답할 수 있으신가요?\n이전 글에서 마크다운 파일을 읽어서 Post 객체로 만드는 데까지 했습니다. 이제 이 마크다운 텍스트를 실제 화면에 보여줘야 합니다.

처음엔 그냥 dangerouslySetInnerHTML로 때려 넣을까도 생각했는데, 그러면 커스터마이징이 너무 힘들어집니다. Heading에 id를 붙인다든지, 코드 블록에 언어 배지를 넣는다든지 — 이런 작업이 전부 문자열 파싱이 되거든요.

그래서 ReactMarkdown 을 선택했습니다. 마크다운을 리액트 컴포넌트 트리로 변환해주니까, 각 요소를 컴포넌트 단위로 오버라이드할 수 있습니다.


기본 구조

먼저 글 상세 페이지의 레이아웃부터 잡았습니다. 본문과 사이드바(목차)를 나누는 구조입니다.

JSX
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 변환) 기반이라서, 플러그인을 끼워 넣기가 쉽습니다.

JSX
<ReactMarkdown
    remarkPlugins={[remarkGfm]}
    rehypePlugins={[
        rehypeRaw,
        rehypeSlug,
        rehypeHighlight,
    ]}
>
    {post.content}
</ReactMarkdown>

각 플러그인이 하는 일:

플러그인역할
remarkGfm테이블, 체크박스 등 GitHub Flavored Markdown 지원
rehypeRaw마크다운 안에 섞인 HTML 태그 처리
rehypeSlugheading에 id 자동 부여 (목차 연동에 필요)
rehypeHighlight코드 블록 구문 강조

remarkGfm 없으면 테이블이 안 됩니다. 블로그 글에서 테이블을 꽤 자주 쓰는데, 이거 빼먹고 "왜 테이블이 안 되지?" 하고 한참 헤맸습니다.


Heading 커스터마이징

기본 heading은 스타일 적용이 어렵고, 목차(TOC)를 만들려면 id가 필요합니다. rehypeSlug가 id를 자동으로 붙여주니까, 그걸 가져다 쓰면 됩니다.

JSX
<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> 구조로는 언어 표시도 안 되고 밋밋하니까, 별도 컴포넌트로 분리했습니다.

JSX
<ReactMarkdown
    components={{
        pre({ node, children }) {
            return <Code node={node}>{children}</Code>;
        },
    }}
>

Code 컴포넌트에서는 rehype-highlight가 붙여준 클래스에서 언어 정보를 꺼내서 상단에 표시합니다.

JSX
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라고 배지가 붙습니다. 작은 디테일인데 있고 없고의 차이가 꽤 큽니다.


이미지 경로 처리

이게 은근히 까다로웠습니다. 옵시디언에서 마크다운을 쓸 때 이미지는 보통 상대 경로로 넣게 됩니다.

MARKDOWN
![설명](diagram.png)

근데 이 상대 경로를 그대로 쓰면 웹에서 못 찾습니다. 마크다운 파일은 src/assets/posts/ 안에 있지만, 이미지는 public/images/posts/ 아래에 있거든요.

그래서 img 태그를 오버라이드해서 경로를 변환합니다.

JSX
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 설정은 이렇습니다.

JSX
<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)를 만들어보겠습니다.

댓글 로딩 중...