🦾

NextJS로 블로그 만들며 겪은 좌충우돌 탐험기

blog

2025-04-03

사용한 기술

NextJS + TailwindCSS

마크다운 파싱: @next/mdx

처음에는 마크다운 파싱을 remark, remark-html로 했었다. 그러다가 @next/mdx 가 nextJS에서 지원하기도 하고 무엇보다 전역 MDXProvider를 제공하여서, 전역적인 스타일링 하기가 용이하다고 판단했다.

remark, remark-html의 경우에는 tailwind의 preflight 스타일 때문에, 기본 h1, h2, code와 같은 태그들의 스타일이 초기화가 된다. 그렇기에 @base 레이아웃에 오버라이드를 하여서 스타일을 재정의 해줘야 한다. 사실 @next/mdx도 스타일을 MDXProvider에서 재정의 해줘야 하지만. . .(조삼모사) 그래도 이 방식이 좀 더 깔끔하다고 생각했다. 그리고 @next/mdx를 이용함으로서 NextJS에 최적화 된 기능들을 사용할 수 있었기에 @next/mdx를 사용했다. 아래는 내가 했던 삽질에 대한 내용이다.

TailwindCSS Preflight

애 먹었던 부분 중 하나는, 마크다운에 스타일을 적용하는 것이었다. 하.. 정말 이거 때문에 고생을 했다. tailwind의 preflight 스타일 초기화 때문에 모든 태그들의 스타일이 초기화가 이루어져서 markdown파싱이 성공적으로 <pre><code> . . 와 같이 이루어졌어도 스타일은 그대로였다.

그래서 어떻게 해결을 시도했냐 . . . 맨 처음에는 전역 css 파일에 base 레이어를 확장해서 썼다. 그 이유는 Preflight를 유지하면서 필요한 커스텀 스타일을 덧붙이는 것이 일관성과 예측 가능한 결과를 보장하는 방법이기 때문이다. 그러다가, @next/mdx로 마크다운 파싱하는 방식을 바꾸고 나서는, mdx전역 Provider에 각 태그에 맞춘 커스텀 컴포넌트를 정의하여 매핑해주었다.

BEFORE

global.css
1@layer base {
2 .main {
3 display: flex;
4 flex-direction: column;
5 overflow: hidden;
6 word-wrap: break-word;
7 font-size: 1.125rem; /* 기본 본문 크기 = 18px */
8 padding: 30px 0;
9 box-sizing: border-box;
10 line-height: 1.8;
11 color: hsl(var(--foreground));
12 max-width: 1000px;
13 }
14
15 .main h1 {
16 font-size: 2.5rem; /* 약 40px */
17 font-weight: 800;
18 line-height: 1.2;
19 margin: 1rem 0;
20 }
21
22 .main h2 {
23 font-size: 2rem;
24 font-weight: 700;
25 margin: 1rem 0;
26 }
27
28 .main h3 {
29 font-size: 1.75rem;
30 font-weight: 600;
31 margin: 1rem 0;
32 }
33}

AFTER

mdx-components.tsx
1import type { MDXComponents } from "mdx/types";
2import React from "react";
3import { Pre } from "./components/Pre";
4import Header from "./components/Header";
5import Footer from "./components/Footer";
6
7const H1 = (props: React.HTMLAttributes<HTMLHeadingElement>) => (
8 <h1
9 className="text-[var(--text)] text-2xl sm:text-3xl md:text-4xl lg:text-5xl font-bold my-7"
10 {...props}
11 />
12);
13
14const H2 = (props: React.HTMLAttributes<HTMLHeadingElement>) => (
15 <h2
16 className="text-[var(--text)] text-xl sm:text-2xl md:text-3xl lg:text-4xl font-semibold my-6"
17 {...props}
18 />
19);
20
21const H3 = (props: React.HTMLAttributes<HTMLHeadingElement>) => (
22 <h3
23 className="text-[var(--text)] text-lg sm:text-xl md:text-2xl lg:text-3xl font-semibold my-5"
24 {...props}
25 />
26);
27. . .
28
29export function useMDXComponents(components: MDXComponents): MDXComponents {
30 return {
31 h1: H1,
32 h2: H2,
33 h3: H3,
34 . . .
35 ...components,
36 };
37}

개발 블로그의 핵심 - 코드블럭

다음으로 애 먹었던 부분은 바로 코드블럭이다. 코드 블럭에 각 언어에 맞춘 문법 하이라이트 기능이 있어야 하는데, 그게 하나도 없다 보니 너무 허접한 느낌이 들었다. 그래서 prism-react-renderer라이브러리를 도입했다.

1// mdx-components.tsx
2export function useMDXComponents(components: MDXComponents): MDXComponents {
3 return {
4 h1: H1,
5 . . . .
6 code: InlineCode,
7 pre: CodeBlock,
8 ...components,
9 };
10}
11
12// CodeBlock.tsx
13const CodeBlock: React.FC<CodeBlockProps> = ({ code, language }) => {
14 if (!code) return null;
15
16 return (
17 <Highlight theme={themes.shadesOfPurple} code={code} language={language}>
18 {({ style, tokens, getLineProps, getTokenProps }) => (
19 <pre style={style}>
20 {tokens.map((line, i) => (
21 <div key={i} {...getLineProps({ line })}>
22 <span>{i + 1}</span>
23 {line.map((token, key) => (
24 <span key={key} {...getTokenProps({ token })} />
25 ))}
26 </div>
27 ))}
28 </pre>
29 )}
30 </Highlight>
31 );
32};

원래는 위와 같이 컴포넌트를 정의하여 사용했다.

그러나 의도한 대로 코드블럭이 렌더링되지 않았다. 바로 props(code, language)가 undefined로 넘겨지고 있었다.

해당 문서에서 mdx의 provider에서는 코드블럭 정보를 children으로 받을 수 있다는 사실을 알아냈다. children은 Reac.Node 타입이다.

children
1{
2 '$$typeof': Symbol(react.transitional.element),
3 type: [Function: InlineCode],
4 key: null,
5 props: {
6 className: 'language-javascript',
7 children: 'function greet(name) {\r\n' +
8 ' return `Hello, ${name}!`;\r\n' +
9 '}\r\n' +
10 '\r\n' +
11 'console.log(greet("World"));\n'
12 },
13 _owner: {
14 name: 'MDXContent',
15 env: 'Server',
16 key: null,
17 owner: null,
18 props: { params: [Promise], searchParams: [Promise] }
19 },
20 _store: {}
21}

실제 우리가 작성한 코드는 children.props.children에 문자열 형태로 있다. 그리고 어떤 확장자로 썼는지에 대한 정보는 className으로 넘겨받을 수 있다.

1export default function CodeBlock({
2 children, // React 노드 - 코드 데이터 얻기 위함
3
4}: CodeBlockProps): JSX.Element {
5. . .
6const className = children?.props.className; // 코드가 어떤 포맷으로 쓰였는지 확인하기 위함('language-<포맷>' 형태)

이제 해당 언어에 대한 내용을 prism-react-renderer에서 제공하는 인터페이스인 Highlight에 넘겨주면 된다.

code: children.props.childer, language: children.props.className 이렇게 추출해서 사용하면 된다.

그러나 타입스크립트를 사용하면 다음과 같은 인터페이스를 추가해서 해당 속성에 대해 명시해줘야 한다. 그러지 않으면 unknown' 형식에 'className' 속성이 없습니다. 에러가 생긴다.

1interface CodeElementProps {
2 children?: string;
3 className?: string;
4}

위 내용을 반영한 코드는 아래와 같다.

1const { className: codeClassName } = children.props as CodeElementProps;
2const { title, highlight, language } = parseMeta(codeClassName);
3
4const { children: codeString } = children.props as CodeElementProps;
5const code = typeof codeString === "string" ? codeString : "";
6
7return (
8 <Highlight theme={themes.dracula} code={code} language={language}>
9 {({ style, tokens, getLineProps, getTokenProps }) => {
10 const slicedTokens = tokens.slice(0, -1);
11
12 return (
13 <pre
14 style={{
15 ...style,
16 borderRadius: "10px",
17 padding: "10px 0",
18 margin: "20px 0",
19 }}
20 >
21 {slicedTokens.map((line, i) => (
22 <div key={i} {...getLineProps({ line })}>
23 <span style={{ padding: "0 15px 0 10px", color: "#6a6192" }}>
24 {i + 1}
25 </span>
26 {line.map((token, key) => (
27 <span key={key} {...getTokenProps({ token })} />
28 ))}
29 </div>
30 ))}
31 </pre>
32 );
33 }}
34 </Highlight>
35 );

이번엔 posts가 안보임

/posts 경로로 가면 다음과 같이 내가 작성한 글들이 리스트 형태로 보여야 한다.

원래라면 보여야할 광경

배포를 하고 들어가 보니 에러가 나를 반겨줬다.(언제봐도 전혀 반갑지 않은 친구다.)

내가 본 광경 서버 로그

해당 경로에 존재하는 파일이 없다는 것이었다. . .

왜지? 로컬에선 잘 됐는데, 빌드하여 배포를 하니 해당 문제가 생기는 걸까?

아뿔싸! 현재 mdx 파일들을 찾기 위해 /(contents)아래의 폴더들의 page.mdx를 다음과 같이 가져오고 있었다.

getPosts
1const posts = await Promise.all(
2 slugs.map(async (slug) => {
3 const data = await import(`../app/(contents)/${slug}/page.mdx`);
4 const metadata = data.meta;
5 return { slug, ...metadata } as PostData;
6 })
7);

그러나 여기서 문제가 되는 부분은 바로 상대경로를 이용하고 있다는 점이다.

배포를 하게 되면 현재 작업 디렉토리(process.cwd())와의 상대 관계가 달라질 수 있어 올바른 디렉토리를 찾지 못하게 된다. 그래서 process.cwd()를 통해 절대경로로 명시를 해줘야 한다.

1// MDX 파일들이 위치한 디렉토리
2const postsDir = path.join(process.cwd(), "src", "app", "(contents)");

사소한 오류

remark 플러그인을 적용하고,pnpm run dev 를 통해 dev 서버를 돌렸는데,,, 오류가 발생하여 돌아가질 않았다.

next.config.ts
1import remarkGfm from "remark-gfm";
2import createMDX from "@next/mdx";
3
4/** @type {import('next').NextConfig} */
5const nextConfig = {
6 // Allow .mdx extensions for files
7 pageExtensions: ["js", "jsx", "md", "mdx", "ts", "tsx"],
8 // Optionally, add any other Next.js config below
9};
10
11const withMDX = createMDX({
12 // Add markdown plugins here, as desired
13 options: {
14 remarkPlugins: [remarkGfm],
15 },
16});
17
18// Combine MDX and Next.js config
19export default withMDX(nextConfig);

이게 무슨 에러일까..

1> next dev --turbo
2
3Next.js 15.1.7 (Turbopack)
4 - Local: http://localhost:3000
5 - Network: http://192.168.35.53:3000
6 - Experiments (use with caution):
7 · turbo
8
9Starting...
10[Error: loader /Users/castle_bell/Documents/Github/bellog/node_modules/.pnpm/@next+mdx@15.2.4_@mdx-js+loader@3.1.0_acorn@8.14.1__@mdx-js+react@3.1.0_@types+react@19.0.10_react@19.0.0_/node_modules/@next/mdx/mdx-js-loader.js for match "*.mdx" does not have serializable options. Ensure that options passed are plain JavaScript objects and values.]

이슈를 찾아보니 dev 스크립트에서 아래와 같이 뒤에 —turbo 키워드가 포함되어 있으면 해당 문제가 생기는 듯 헀다.

package.json
1 "scripts": {
2 "dev": "next dev --turbo",
3 "build": "next build",
4 "start": "next start",
5 "lint": "next lint",
6 "prepare": "husky"
7 },

그래서 — turbo를 빼줬더니 잘 돌아갔다ㅎㅎ turbo는 NextJS의 기본 번들러인 Webpack 대신, 더 진보된 turbopack을 사용하도록 하는 키워드다. 그러나 아직 불안정한 부분이 있는 것 같다.

© castle_bell · All rights reserved