본문 바로가기
react

Next 13에서 Markdown 다루기(next-mdx)

by HomieKim 2023. 8. 3.

현재 쓰고 있는 블로그를 이전 하려고 준비 중이다.

사실 Next 13 써보면서 뭘 만들어 보면 좋을 것 같아서 생각 하다가
RSC 컨셉 자체가 블로그 같은 정적 페이지를 만드는데 적합하다고 생각했고(fs 도 컴포넌트 단에서 접근 가능하고..), 마침 블로그 갈아 엎고 싶었어서 블로그의 가장 핵심기능인 마크다운 다루는 방법을 정리해 보려고 한다!

쉬울 줄 알았는데 생각보다 어려운 점이 많았고 다행히 레퍼런스가 많아서 일단은 마크다운 다루는 건 어느정도 파악한 것 같다.


놀랍게도 개발 전에 나는 '이거 그냥 마크다운을 브라우저에서 렌더링 해주는 라이브러리 쓰면 끝 아니야?' 이렇게 쉽게 생각함

사실 틀린말은 아닌데 생각 보다 고민할 지점이 많음 

 

블로그 구조를 잡으면서 내가 했던 고민들은 대충 요론게 있다.

  • 어떻게 가져올 것인가?
  • 프로젝트 구조는?
  • next-mdx-remote 어떻게 사용?
  • 마크다운 스타일링?
  • Next Image 쓸 수 없나?

이런 고민들을 하나씩 해결해 가다 보면 처음 들어본 개념이나 라이브러리들이 있어서 개발 시간 보다 검색하는 시간이 더 많았던 것 같다..

하나씩 살펴 보면

 

✔️ 게시글 리스트 가져오기

따로 DB를 두고 글 목록을 저장하는게 목적이 아닌 빌드 타임에 정적인 페이지들을 생성하는게 목적이기 때문에,
로컬에 posts라는 폴더를 만들어서 하위에 게시글들(.md 파일)을 저장해서 사용했음

폴더 구조를 카테고리나 날짜별로 분류할까 생각중..

그럼 next는 런타임에 파일 시스템에 접근가능 하니 posts 폴더 하위에 있는 모든 게시글들을 가져 오는 함수를 작성해야하는데,

이때, 문득 이런 생각이 들었음 게시글 제목은..? 게시글 쓴 날짜는..? 카테고리 같은건..?
게시글에 대한 컴포넌트를 설계하고 핸들링 하려면 이런 정보들이 있어야할 텐데.. 어디다 작성하지? 나는 여기서 처음 알았는데

"front-matter" 라는게 있음

front-matter란?

원래 프론트매터(Frontmatter)는 책 앞 쪽에 넣는 페이지로 책 제목, 저작권, 목차, 머릿말, 감사말, 소개 등 본문 앞에 나오는 내용들을 일컫는 말
작성한 게시글의 제목, 설명, 카테고리 등등 게시글의 메타데이터에 대한 정보라고 생각하면 된다.
마크다운 파일 최상단에  --- 사이에 yaml 형식의 데이터를 넣으면 사용가능

쉽게 정리해서 .md 파일에 게시글 내용 과 front-matter를 작성해주고 이를 파싱해주는 라이브러리로 데이터를 가져오면 된다!

front-matter를 다루는 라이브러리는 나는 두가지 정도를 고려 했는데 그 중 'gray-matter'라는 라이브러리를 사용했다.
'front-matter' 라는 라이브러리도 있는데 사용법은 거의 동일하고 gray-matter가 그냥 다운로드 더 많고 설명에 빠르다고 되있어서 이거 쓰기로 함

대충 코드로 보면

/* 의사코드 입니다. */
export const getPosts = async () => {
  const files = fs.readFileSync("./posts/*"); /* 이런식으로 posts 폴더 내에 .md 파일 다 가져옴*/
  const posts = files.map((path) => {
    const file = fs.readFileSync(path, { encoding: "utf-8" });
    const { data, content } = matter(file);

    return {
    	data, // front-matter data
        content // 마크다운으로 작성한 게시글 내용
    };
  }); // 게시글 배열을 리턴
  return posts;
};

gray-matter로 file을 파싱하면 이런 리턴 값 나옴

위와 같은 리턴값에서 필요한 정보를 담은 객체를 추출해서 배열 형태로 만들어서 리턴해서 page 내에서 쓰면 게시글 리스트는 끝!

여기서 Tip? 이랄까? 개발하면서 진짜 좋다고 느낀게 있는데 

Next 13 에서 RSC 기반으로 동작하면서 저런 비동기 함수를 컴포넌트 단에서 바로 가져올 수 있음

// page.tsx
import { getPosts } from "@/utils/posts";
import Link from "next/link";

export default async function Home() {
  const posts = await getPosts();
  return (
    <div>
      <h3 className="text-3xl">Post List</h3>
      <ul className="flex flex-col gap-4 my-3">
        {posts.map((post) => (
          <Link key={post.slug} href={`posts/${post.slug}`}>
            <li className="p-4 border border-solid border-1 border-gray-100 rounded-lg shadow-md">
              <h3 className="text-xl font-bold">
                title : {post.fontMatter.title}
              </h3>
              <span>description : {post.fontMatter.description}</span>
              <br />
              <span>date : {post.fontMatter.date}</span>
            </li>
          </Link>
        ))}
      </ul>
    </div>
  );
}

이걸 CSR 방식으로 하면?

const App = () => {
	const [postList, setPostList] = useState([])
    
    useEffect(()=>{
    	const fetchPost = async () => {
        	await getAllPosts().then(res => setPostList(res))
        }
        fetchPost()
    },[])
    return (
    	...
    )
}

대충 요론식으로 했겠지?  이런 문제 때문에 리액트 18에서는 use 라는 hook을 개발하고 있다던데 stable해지면 써봐야 겠다..

 

✔️ 게시글 보여주기

게시글 리스트를 가져왔다면 해당 게시글을 클릭했을 때 게시글 내용을 보여줄 수 있어야 합니다.
이런 마크다운을 브라우저에 렌더링 하기위한 방법이 많은데 그 중에서 공식문서에 나와있는 방법인 MDX 방식을 선택함! 

공식문서 설명이 잘되어있고 실험적인 기능으로 Rust 기반에 마크다운 컴파일러를 사용할 수 있다고함.. 굿..

MDX란?

마크다운 파일에서 직접 JSX 작성할 수 있는 superset을 말함 @next/mdx 패키지를 사용해서 마크다운 파일을 직접 import해서 JSX 내에서 컴포넌트 처럼 사용할 수 있다.

Next.js 에서는 마크다운 콘텐츠를 직접 사용하는 방법과 동적으로 가져와서 마크다운 소스만 렌더링하는 방법을 제공함!

정리 : 마크다운 작성해서 바로 임포트해서 쓸 수 있고 next-mdx-remote라고 해서 동적으로 마크다운 소스만 가져와서 사용하는 방법이 있음

나는 서버사이드에서 리스트를 가져오고 slug에 따라 게시물 가져와서 보여줄 것 이기 때문에 next-mdx-remote 사용했고 

사용법은 굉장히 간단하다

import { MDXRemote } from 'next-mdx-remote/rsc'
 
export default async function Home() {
  const res = await getPost(slug)
  const markdown = await res.text()
  return <MDXRemote source={markdown} />
}
slug란? 게시물이나 페이지를 설명하는 단어를 말하며 보통 url의 path네임을 사용하고 내 블로그의 구조는 "domian.com/post/[slug]"의 구조로 slug를 가져와서 해당 게시물을 페칭해서 MDXRemote로 불러옴

 

✔️ 마크다운 스타일링

이제 게시글 리스트를 가져오고 해당 게시글을 클릭할 때 마크다운으로 작성된 게시글을 보여줄 수 있는 페이지까지 완성했다.

근데 게시글에 스타일링이 하나도 적용안되어 있어서 당황함.. tailwind css 처음 사용해봤는데 기본 css를 전부 reset 시켜서 이걸 하나하나 다 스타일링 해주기는 부담스러워서 방법을 한참 찾아 봤었음.. 크게 두가지 방법이 있는데

 

1. tailwind 에서 제공하는 plugin 사용

 tailwind 공식 페이지 보면 @tailwindcss/typography  플러그인이 있더라 해당 플로그인을 설정 후 prose 라는 className을 적용하면 기본 css 가 적용됨!

 

2. markdown에 대한 스타일링이 라이브러리 화 되어있는 패키지 사용 

 대표적으로 github-markdown-css 패키지가 있는데 이것도 괜찮은 듯

 

이제 스타일링이 적용된 마크다운을 화면에 보여줄 수 있다. 여기 까지만 해도 괜찮은데 codeblock이 상당히 안이쁨 코드하이라이터 해줄 수 있는 plugin을 MDXRemote에 설정 해 줄 수 있다. prism.js도 많이 쓰긴하는데 나는 rehype-prettye-code 라는 플러그인을 설정해 줬다.

import MDXComponents from "@/components/MDXComponents";
import { getPost } from "@/utils/posts";
import { MDXRemote } from "next-mdx-remote/rsc";
import rehypePrettyCode from "rehype-pretty-code";
import remarkGfm from "remark-gfm";

interface Props {
  params: { slug: string };
}

export default async function PostDetailPage({ params }: Props) {
  const post = await getPost(params.slug);
  const source = post?.mdData ?? "";
  return (
    <div>
      <MDXRemote
        source={source}
        components={MDXComponents}
        options={{
          mdxOptions: {
            remarkPlugins: [remarkGfm],
            rehypePlugins: [
              [
                rehypePrettyCode,
                {
                  theme: "slack-dark",
                },
              ],
            ],
          },
        }}
      />
    </div>
  );
}

여기서 remark, rehype이 뭔지 몰라서 찾아봤는데 쉽게 설명해서

remark : markdown to AST 변환
rehype : html로 변환

이런 느낌인데 MDXRemote같은 마크다운 렌더러가 동작할 때 마크다운 컨텐츠를 분석해 AST로 파싱한 다음 html로 변환하는 과정을 거친다고 한다. 이 동작과정에 추가적인 플러그인을 설정할 수 있는 것!

 

이제 여기까지 게시글 작성, 게시글 리스트, 게시물 내용 보여주는 것 까지 기본적인 블로그에 기능은 갖추었다. 여기에 추가적인 스타일링이나 커스텀만 하면되는데 나는 추가적으로 NextImage를 사용하는 것까지 설정해 주었다.

✔️ Next Image 컴포넌트 사용하기

마크다운에서 이미지를 사용하려면 ![alt]('image src ') 이런 문법으로 사용할 수 있다.
next-mdx에서도 마크다운을 img 태그로 바꿔서 렌더링 하는데 Next.js에서는 Layout Shift 등 최적화된 Image 컴포넌트를 제공하는데 기본 html img태그말고 Next Image 컴포넌트를 사용하고 싶었다.

 

 MDXRemote 컴포넌트의 props로 component를 설정할 수 있는데 여기에 옵션 값을 넣으면 마크다운에서 변환된 html태그들을 커스텀 할 수 있다. 다음은 공식문서에서 제공하는 예제임

import { MDXProvider } from '@mdx-js/react'
import Image from 'next/image'
import { Heading, InlineCode, Pre, Table, Text } from 'my-components'
 
const ResponsiveImage = (props) => (
  <Image
    alt={props.alt}
    sizes="100vw"
    style={{ width: '100%', height: 'auto' }}
    {...props}
  />
)
 
const components = {
  img: ResponsiveImage,
  h1: Heading.H1,
  h2: Heading.H2,
  p: Text,
  pre: Pre,
  code: InlineCode,
}
 
export default function Post(props) {
  return (
    <MDXProvider components={components}>
      <main {...props} />
    </MDXProvider>
  )
}

여기서 문제가 하나있었는데..  Next Image 컴포넌트는 필수적으로 width와 height를 명시적으로 지정해줘야 한다는 것...

마크다운 문법으로 width와 heigth를 지정해줄 수 있는 방법이 없고, 이미지 쓸 때마다 width, height를 일일이 지정해주는게 번거롭게 느껴졌음..

그렇다면 해당 이미지의 원본 사이즈 만큼 width ,height를 자동으로 지정해줄 수 있는 방법? 을 찾고 있었음

그래서 원본이미지의 width, height를 구해서 NextImage로 반환해주는 컴포넌트를 작성했음

import Image from "next/image";
import { getIntrinsicSize } from "@/utils/getIntrinsicSize";

interface Props {
  src: string;
  alt: string;
}

const CustomImage = async ({ src, alt }: Props) => {
  const size = await getIntrinsicSize(src);
  return (
    <Image
      src={src}
      alt={alt}
      width={size.width}
      height={size.height}
    />
  );
};

export default CustomImage;

요론 커스텀이미지 컴포넌트를 만들어서

MDXRomote components에 해당 컴포넌트를 사용함

import { MDXComponents } from "mdx/types";
import CustomImage from "./CustomImage";

const MDXComponents: MDXComponents = {
  img: (v) => {
    return <CustomImage src={v.src!} alt={v.alt!} />;
    
  },
};

export default MDXComponents;

여기 까지 해서 블로그에 기본적인 마크다운 다루는 기능을 완성하였고

내 입맛에 맞게 이쁘게 커스텀하고?! 블로그 레이아웃 도 좀 만들고 난 뒤에

2편으로 SEO 설정이나 서치콘솔 등록, 배포 이런 내용에 대해서 업로드 할 예정!

댓글