해당 포스팅은 Next.js 기반의 프로젝트에서 마크다운 콘텐츠 렌더링을 위한 라이브러리를
react-markdown에서 next-mdx-remote/rsc로 마이그레이션 한 과정과 그 결과를 공유한 내용입니다.
길고 복잡한 마크다운 콘텐츠 처리를 클라이언트 대신 서버에서 수행함으로써
클라이언트 번들 크기를 줄이고, RSC의 이점을 활용하여 초기 로딩 성능과
전반적인 UX를 개선한 과정을 설명하고자 합니다.
1️⃣ 마이그레이션을 결심한 이유
React Server Components(RSC)와 tanstack-query의 조합으로 서버 컴포넌트를 사용하기 전,
클라이언트 컴포넌트에서 마크다운 콘텐츠가 포함된 페이지를 구성해보고 있었습니다.
당시 서버로부터 문자열(string) 형태로 전달받은 markdown 데이터를 UI로 보여줘야 했는데요.
이를 위해 번들 사이즈가 작고 초기 페이지 로드가 빠른 것으로 알려진
react-markdown 라이브러리를 선택했습니다.
실제로 여러 개인의 기술 블로그에서도 이 라이브러리를 추천하기도 했었고요.
그러나 실제 구현 과정에서 문제점이 발견되었습니다.
markdown 데이터가 복잡해지거나 이미지, 긴 텍스트, 링크, 테이블 등
추가 콘텐츠가 많아질수록 성능 이슈가 발생했는데요.
화면에 렌더링하는 과정에서 4초가 넘는 시간이 소요되었고,
클라이언트에서 마크다운 파싱부터 렌더링까지 모든 과정이 이뤄지며 초기 로드 시간이 길어졌습니다.
이러한 문제를 해결하기 위해 Next.js 공식 문서에서 추천하는 MDX를 도입했습니다.
MDX는 서버에서 마크다운 HTML 렌더링을 처리해서 전달하기 때문에 초기 페이지 로드 시
마크다운 콘텐츠의 로드 시간을 단축할 수 있으며, 서버 사이드에서의 마크다운 컴파일을 통한
번들 사이즈 감소도 가능했거든요.
2️⃣ Trouble-shooting
1. MDX 스타일링 이슈
MDX를 화면에 렌더링 하는 데 성공했지만, 마크다운 형식이 아닌 서버로부터 전달받은
일반 문자열 형태의 텍스트만 보이는 문제가 발생했습니다.
이는 global.css에서 설정한 속성으로 인한 문제였는데요.
global.css에는 모든 요소의 기본 스타일을 제거하고 display 속성만 복원하는 코드가 포함되어 있었습니다:
*:where(:not(html, iframe, canvas, img, svg, video, audio):not(svg *, symbol *) {
all: unset;
display: revert;
}
이 문제를 해결하기 위해 마크다운 전용 CSS 파일을 생성하여 마크다운 콘텐츠의 wrapper 태그에
특정 태그들에 대한 스타일을 추가했습니다.
.markdown-container
:where(h1, h2, h3, h4, h5, h6, p, blockquote, ul, ol, li, table, th, td) {
all: unset;
display: block;
}
2. 서버 사이드 렌더링 오류
두 번째 문제는 서버 컴포넌트에서 MDX를 사용할 때 발생했습니다.
react-syntax-highlighter 라이브러리가 서버 컴포넌트와 호환되지 않아
rehype-pretty-code 라이브러로 변경해 해결했습니다.
(해당 이슈는 아래에서 좀 더 자세히 다루겠습니다.)
3️⃣ 코드로 보는 마이그레이션 과정
저는 클라이언트 컴포넌트일 때의 상황과 서버 컴포넌트일 때의 상황을 비교해보고 싶어
개선 전에는next-mdx-remote를, 개선 후엔 next-mdx-remote/rsc를 사용했습니다.
둘의 차이에 대해 간략히 짚고 넘어가자면
공통점으로는 서버 사이드에서 HTML을 렌더링해 클라이언트에 전달한다는 것이고
차이점으로는 next-mdx-remote는 모든 마크다운 요소에 대한 hydration이 이뤄지는 반면
next-mdx-remote/rsc는 필요한 부분에만 hydration이 이뤄진다는 것입니다.
Before
RSC와 tanstack-query를 통한 prefetch로 streaming을 적용했고
Container라는 클라이언트 컴포넌트를 하위에 두고 있습니다.
// 특정한페이지.tsx라고 가정해볼게요.
import { Suspense } from 'react';
import { HydrationBoundary } from '@/components';
import Skeleton from './components/skeleton';
import { Container } from './container';
import { specialQueryOptions } from './hooks/use-special-query';
interface Props {
params: { id: string };
searchParams: { tab?: string };
}
const SpecialPage = async ({ params, searchParams }: Props) => {
const specialId = Number(params.id);
const selectedTab = searchParams.tab || 'intro';
return (
<Suspense fallback={<Skeleton />}>
<HydrationBoundary prefetchOptions={specialQueryOptions(specialId)}>
<Container specialId={specialId} selectedTab={selectedTab} />
</HydrationBoundary>
</Suspense>
);
};
export default SpecialPage;
Container에서는 마크다운과 추가 정보들을 담당하는 컴포넌트로 구성되어있습니다.
유저 인터렉션에 따라 마크다운이 다르게 보입니다.
'use client';
import AnotherInfo from '../components/another-info';
import MarkDown from '../components/markdown';
import useSpecial from '../services/use-special';
interface Props {
modelId: number;
selectedTab: string;
}
const Container = ({ modelId, selectedTab }: Props) => {
const { name, etc, markdown1, markdown2 } = useSpecial(specialId);
const markdownSource = selectedTab === 'intro' ? markdown1 : markdown2;
return (
<>
<AnotherInfo
specialId={specialId}
name={name}
etc={etc}
/>
<MarkDown markdownSource={markdownSource} />
</>
);
};
export default DetailContainer;
Markdown 컴포넌트는 next-mdx-remote로 클라이언트 컴포넌트에서 사용 가능하게 import,
마크다운 내부 특정 태그들을 관리하기 위해 mdx-components로 커스텀했습니다.
'use client';
import { MDXRemote, MDXRemoteSerializeResult } from 'next-mdx-remote';
import { mdxComponents } from '@/components/mdx-components';
import { getClassNames } from '@/lib/styles';
import styles from './markdown.module.css';
interface Props {
markdownSource: MDXRemoteSerializeResult;
}
const MarkDown = ({ markdownSource }: Props) => {
const cx = getClassNames(styles);
return (
<section className={cx('markdown-container')}>
<MDXRemote {...markdownSource} components={mdxComponents} />
</section>
);
};
export default MarkDown;
또한, 서버로부터 넘겨받은 마크다운은 serialize가 필요했는데, 이를 api 통신 코드에서 진행하고 있었습니다.
이 때는 유저 인터렉션 이전에 serialize를 진행하는 것이 성능상 더 나을 거라 판단해
response로 넘겨받을 당시 serialize를 진행했습니다.
// api 통신 코드가 모여있는 곳이라고 할게요.
...
export const getSpecialInfo = async (
specialId: number
): Promise<직렬화 후 response type> => {
const response = await baseApi
.get(`api/~~~/${specialId}`)
.json<직렬화 전 response type>();
// 서버에서 string 형태로 전달받는 markdown에 대한 serialize 진행.
// option에서의 remark, rehype이 수행하는 역할
// - remark: Markdown을 처리하는 라이브러리
// - rehype: HTML을 처리하는 라이브러리
const [markdown1Source, markdown2Source] = await Promise.all([
serialize(response.markdown1, {
parseFrontmatter: true,
mdxOptions: {
remarkPlugins: [remarkGfm],
rehypePlugins: [rehypeRaw],
development: process.env.NODE_ENV === 'development',
format: 'mdx',
},
}),
serialize(response.markdown1, {
parseFrontmatter: true,
mdxOptions: {
remarkPlugins: [remarkGfm],
rehypePlugins: [rehypeRaw],
development: process.env.NODE_ENV === 'development',
format: 'mdx',
},
}),
]);
return {
...response,
markdown1: markdown1Source,
markdown2: markdown2Source,
};
};
After
// 업데이트된 특정한페이지.tsx
import { Suspense } from 'react';
import { HydrationBoundary } from '@/components';
import Skeleton from './components/skeleton';
import { Container } from './container';
import { specialQueryOptions } from './hooks/use-special-query';
interface Props {
params: { id: string };
searchParams: { tab?: string };
}
const SpecialPage = async ({ params, searchParams }: Props) => {
const specialId = Number(params.id);
const selectedTab = searchParams.tab || 'intro';
return (
<Suspense fallback={<Skeleton />}>
<HydrationBoundary prefetchOptions={specialQueryOptions(specialId)}>
<Container specialId={specialId} selectedTab={selectedTab}>
<Markdown/>
</Container>
</HydrationBoundary>
</Suspense>
);
};
export default SpecialPage;
서버 컴포넌트로 구성된 Markdown 컴포넌트를 클라이언트 컴포넌트인 Container에 slot으로 사용,
slot으로 전달받은 Markdown 컴포넌트는 Container에서 children prop으로 전달받을 수 있게 됩니다.
(+ 25.02.28 - children의 type을 PropsWithChildren으로 변경)
'use client';
import AnotherInfo from '../components/another-info';
import useSpecial from '../services/use-special';
interface Props {
modelId: number;
selectedTab: string;
children: React.ReactNode;
}
const Container = ({ modelId, selectedTab, children }: Props) => {
const { name, tag, etc, markdown1, markdown2 } = useSpecial(specialId);
const markdownSource = selectedTab === 'intro' ? markdown1 : markdown2;
return (
<>
<AnotherInfo
specialId={specialId}
tag={tag}
etc={etc}
/>
/* Markdown 컴포넌트를 children으로 전달받아 사용 */
{children}
</>
);
};
export default DetailContainer;
하지만 문제가 있었는데요, 마크다운 콘텐츠를 커스텀하기 위한 mdx-component에서 사용 중인
react-syntax-highlighter는 클라이언트 사이드에서만 동작 가능했습니다.
대책으로 서버 사이드에서도 동작하는 코드 하이라이팅 라이브러리인 shiki라는 라이브러리를 발견했고,
이후 내부적으로 Shiki를 사용하는 rehype-pretty-code라는 라이브러리로 변경했는데요.
이렇게 문제가 해결된 줄 알았는데 rehype-pretty-code에서는 shiki와 연관된 문제가 발생했습니다.
Trace: [SHIKI DEPRECATE]: getHighlighter is deprecated. Use createHighlighter or getSingletonHighlighter instead.
"shiki": "^2.1.0” 버전과 호환되지 않아 발생하는 문제였는데요.
rehype-pretty-code의 최신 버전에서는 shiki의 버전으로 1.9.1을 사용하고 있었네요. 이런..
1.9.1과 2.0 사이의 버전을 찾아 적용을 해주니 해결되었습니다.
4️⃣ 이젠 성능을 개선해 보자
아래는 개선 전의 성능 지표입니다. LCP, FCP, Start Render 모두 3.5초를 초과하는 것을 확인할 수 있었는데요.
문제 발생 지점에 대해 개선을 진행했습니다.
Before
After
MDX 성능 개선은 여기까지가 최선이었는데요.
초반에는 마크다운으로 보여주는 자료가 적으니 MDX 파일로 직접 관리하며 유저에게 더욱 빠른
화면 서빙을 제공해 볼까도 고려했지만 추후 100개가 넘는 파일들을 관리하게 된다면 파일 관리가
감당이 안될 것이라 판단했습니다. 허깅페이스에서는 마크다운 콘텐츠 렌더링이 상당히 빠른데,
처리 방법이 궁금해 깃허브를 조사해 봤지만 마땅한 답을 얻지 못했습니다.
아쉽지만 제 역량으로는 여기까지가 한계였고, 업무 도중 틈틈이 리팩토링 가능한 파트를 찾아
개선해 나가는 것으로 마무리 지어보려 합니다.
5️⃣ 번외) UI로 보는 Before & After
Before
초기 마크다운 UI를 들고 디자이너 분에게 보여드렸더니 5분간 웃으셨습니다.
보노보노 스타일의 디자인이라는 말을 들었는데요.. 솔직히 디자인이 촌스럽긴 했어요.
마크다운 커스텀 컴포넌트와 css 설정을 통해 좀 더 보기 좋은 마크다운 콘텐츠로 탈바꿈했습니다.
After
Reference
npm - shiki
npm - rehype-pretty-code
'TIL' 카테고리의 다른 글
사내 코드에 openapi generator를 적용해보자. (0) | 2025.02.03 |
---|---|
HydrationBoundary 사용 이유와 동작 원리를 파악해보자 (0) | 2025.01.14 |
yarn classic에서 pnpm으로의 시작 (1) | 2024.12.27 |
<img/> vs <Image />, 유연하게 대처하기 (0) | 2024.12.07 |
응집도와 결합도 (0) | 2024.11.18 |