처음부터 배포까지: 나만의 풀스택 포트폴리오 만들기 - 4

이번 글에서는 지난 글에 이어서 본격적으로 블로그 글 관리를 어떤 방식으로 했는지 소개하도록 하겠습니다.
MDX란 무엇인가?
MDX는 Markdown + JSX
를 의미합니다.
- Markdown 파일(.md)처럼 텍스트 포맷팅을 Markdown 문법으로 작성할 수 있으며
- JSX 및 React 컴포넌트를 사용할 수 있습니다
Markdown
개발자로서는 보통 모듈의 최상단에 README.md
파일을 두고 여기에 모듈에 대한 설명, 빌드 방법, 배포 방법 등을 적기 때문에 md 파일은 그렇게 낯설지 않은 파일 확장자입니다. 아래 예시를 보면 #
을 하나만 선언하면 h1 레벨의 제목을 선언할 수 있고 -
을 사용하여 목록을 만들 수 있습니다.
# Title 1 (h1)
## Title 2 (h2)
- Item 1
- Item 2
- Sub item 1
- Sub item 2
Markdown은 경량 마크업 언어
로서 HTML보다 훨씬 간결하게 작성할 수 있고 사람이 읽기에도 아주 편한 문법 구조를 갖고 있습니다. md 파일로 작성 후 해당 파일을 HTML로 변환하여 브라우저를 통해 표현할 수 있기 때문에 아주 많이 사용되는 형태입니다.
여기서 1. HTML로 표현 될 수 있으며
, 2. 사람이 읽기 쉽다
에 주목해주세요.
JSX (React component)
JSX는 JavaScript XML
로서 Facebook이 React 라이브러리의 일부로 함께 발표한 문법입니다.
JavaScript 코드 내에서 XML(HTML과 유사한 문법)을 사용할 수 있도록 만든 문법 확장으로서 개발자는 React에서 UI를 선언적으로 작성할 수 있게 됩니다. 아래 예시 코드를 살펴봅시다.
function App() {
const name = "Jinyoung";
return (
<div>
<h1>Hello, {name}!</h1>
<p>XML in Javascript!</p>
</div>
);
}
위 JSX 코드를 transpile 하면 아래와 같습니다.
function App() {
const name = "Jinyoung";
return React.createElement(
'div',
null,
React.createElement(
'h1', null, "Hello, ", name, "!"
),
React.createElement(
'p', null, "XML in Javascript!"
)
);
}
HTML과 매우 유사한 문법으로 컴포넌트를 선언하면서 UI를 구성할 수 있기 때문에 개발자는 코드 구조를 직관적으로 이해하면서 애플리케이션을 만들 수 있습니다. 또한 코드 내에서 Javascript 코드와 자연스럽게 결합이 가능합니다. 예시 코드에서 name
변수를 중괄호({}
)를 통해 UI에 삽입하는 것을 볼 수 있습니다.
또한 하나의 UI 컴포넌트를 만들어놓고 다른 곳에서 재사용 할 수 있다는 장점도 있습니다.
function Button({ label }) {
return <button style={{ padding: "10px", fontSize: "16px" }}>{label}</button>;
}
function App() {
return (
<div>
<Button label="확인" />
<Button label="취소" />
</div>
);
}
위 예제 코드를 보면 Button
컴포넌트를 App 컴포넌트에서 재사용 하는 것을 확인할 수 있습니다. 하나의 UI 컴포넌트를 잘 만들어 놓으면 다른 곳에서 다양한 방식으로 재사용 할 수 있는 것입니다.
전체적인 과정은 위와 같습니다. 이 그림을 지금 당장 완전히 이해할 필요는 없습니다. 이 글을 읽는 과정에서 설정과 코드를 보면서 조금씩 그리고 확실하게 이해할 수 있기 때문입니다.
왜 MDX 인가?
MDX는 Markdown + JSX
로서 Markdown과 JSX의 장점을 모두 취할 수 있는 파일 포맷이라고 할 수 있습니다.
Markdown의 장점:
- HTML로 표현이 가능
- 사람이 읽기 쉬운 문법 구조
JSX의 장점:
- 선언적으로 UI 컴포넌트 개발 가능
- 재사용 가능한 UI 삽입
특징 | Markdown | JSX | MDX |
---|---|---|---|
간결한 문법 | ✅ | ❌ | ✅ |
HTML로 변환 가능 | ✅ | ✅ | ✅ |
React 컴포넌트 사용 가능 | ❌ | ✅ | ✅ |
재사용 가능한 UI 삽입 가능 | ❌ | ✅ | ✅ |
동적 데이터 연동 가능 | ❌ | ✅ | ✅ |
정리하자면, MDX는 이러한 장점만을 취해 Markdown의 편리함을 유지하면서도 JSX를 활용하여 동적인 UI를 구성할 수 있는 최적의 문서 작성 포맷이라고 할 수 있겠습니다.
이제 실제로 MDX 파일 작성의 예제를 살펴보겠습니다.
import Button from './Button';
# 🌟 MDX의 힘: Markdown + JSX
MDX에서는 일반적인 Markdown 문법을 사용할 수 있습니다.
- 간결한 문법
- 친숙한 구조
- 하지만 **JSX도 가능!** 🎉
## 🎯 JSX 컴포넌트 사용 예시
아래 버튼을 클릭해보세요!
<Button label="클릭하세요!" />
- Line 1: 미리 작성한
Button
컴포넌트를 import 합니다. 15번째 라인에서 사용합니다. - Line 3-13: 일반적인 Markdown 문법입니다.
- Line 15: 1번째 라인에서 import한 Button 컴포넌트를 선언합니다. '클릭하세요!' 라는 버튼이 화면에 그려집니다.
MDX를 선택한 이유
위에서 본 것처럼 MDX 포맷을 사용하면 일반적인 글 작성에 필요한 것들은 Markdown을 통해서 하면 되고 그 외에 추가적인 UI 스타일링은 JSX를 통해서 할 수 있게 됩니다. 따라서 글을 작성한다는 관점에서 봤을 때 필요한 모든 것이 MDX를 통해 모두 구현될 수 있는 것입니다.
다만 실제로 글을 작성해보기 전에는 과연 블로그 글에 얼마나 JSX 코드가 포함될지는 미지수였습니다. 하지만 이 글을 적는 현재 시점에서는 대략 6~7개 정도의 블로그 글을 작성한 상태인데요, 생각보다 많이 JSX 코드가 글에 삽입되었습니다. 구체적으로 어떤 컴포넌트를 선언해서 MDX에 선언했는지에 대해서는 다른 글을 통해 구체적으로 소개하도록 하겠습니다.
MDX 라이브러리
지금까지 MDX 파일 포맷이 무엇이고 어떤 장점이 있는지 살펴봤습니다. 이제부터는 MDX 라이브러리에 대해 알아보겠습니다. 어떤 라이브러리들이 있고 각각 어떤 장/단점이 있는지 보겠습니다.
Contentlayer
블로그 시스템 구현을 위해 제일 먼저 사용한 라이브러리입니다. 블로그 시스템을 YouTube의 클론 코딩 영상을 보면서 했고 해당 영상에서 이 라이브러리를 활용했기 때문입니다.
이 라이브러리에 대한 자세한 내용은 공식 개발 문서페이지를 참조하시길 바랍니다.
Contentlayer를 사용하여 Next.js에 통합하는 방법에 대해 간략하게 알아봅시다.
1. Setup
npm install contentlayer next-contentlayer date-fns
- 관련 라이브러리들을 설치합니다.
// next.config.js
const { withContentlayer } = require('next-contentlayer')
/** @type {import('next').NextConfig} */
const nextConfig = { reactStrictMode: true, swcMinify: true }
module.exports = withContentlayer(nextConfig)
- 이 설정을 통해
next dev
,next build
과정에서 Contentlayer가 같이 빌드할 수 있게끔 합니다
// tsconfig.json
{
"compilerOptions": {
"baseUrl": ".",
// ^^^^^^^^^^^
"paths": {
"contentlayer/generated": ["./.contentlayer/generated"]
// ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
}
},
"include": [
".contentlayer/generated"
// ^^^^^^^^^^^^^^^^^^^^^^
]
}
- tsconfig.json 또는 jsconfig.json 파일을 위와 같이 수정합니다. 파일의 기존 항목이 이미 있다면 추가해야 하고 없다면 항목을 새로 만들어야 합니다.
# .gitignore
# ...
# contentlayer
.contentlayer
.gitignore
파일에.contentlayer
를 추가합니다. contentlayer를 통해 생성되는 빌드 결과물들은 Git으로 관리할 필요가 없기 때문입니다.
2. Content Schema
// contentlayer.config.ts
import { defineDocumentType, makeSource } from 'contentlayer/source-files'
export const Post = defineDocumentType(() => ({
name: 'Post',
filePathPattern: `**/*.mdx`,
contentType: 'mdx',
fields: {
title: { type: 'string', required: true },
date: { type: 'date', required: true },
},
computedFields: {
url: { type: 'string', resolve: (post) => `/posts/${post._raw.flattenedPath}` },
},
}))
export default makeSource({ contentDirPath: 'posts', documentTypes: [Post] })
contentlayer.config.ts
파일을 모듈의 최상단에 생성합니다.- Line 6-7: mdx 파일에 대한 설정입니다.
- Line 8-14: MDX 파일 내용을 관리할 때, 어떤 스키마 구조로 관리할 것인가에 대한 선언입니다.
3. Create a content
설정이 완료되었으니 이제 MDX 파일을 작성할 차례입니다.
# Hello, World!
This is my first MDX file. Here's a button element <button>Click me!</button>.
<MyComponent />
- 모듈의 최상단에
posts
디렉토리를 만들고 하위에 mdx 파일을 작성합니다.- ex:
posts/post-01.mdx
- ex:
4. Create a compoent for MDX
MDX 파일 역시 최종적으로는 React 컴포넌트로서 변환되어야 그 이후에 화면에 그려질 수 있습니다. 따라서 이것을 처리할 컴포넌트를 선언해야 합니다.
// app/posts/[slug]/page.tsx
import { allPosts } from 'contentlayer/generated'
import { useMDXComponent } from 'next-contentlayer/hooks'
import { notFound } from 'next/navigation'
export async function generateStaticParams() {
return allPosts.map((post) => ({
slug: post._raw.flattenedPath,
}))
}
export default async function Page({ params }: { params: { slug: string } }) {
// Find the post for the current page.
const post = allPosts.find((post) => post._raw.flattenedPath === params.slug)
// 404 if the post does not exist.
if (!post) notFound()
// Parse the MDX file via the useMDXComponent hook.
const MDXContent = useMDXComponent(post.body.code)
return (
<div>
{/* Some code ... */}
<MDXContent />
</div>
)
}
- 파일은
app/posts/[slug]/page.tsx
에 만들어집니다. - Line 2: Contentlayer에 의해 빌드된 전체 글 목록을 import 합니다.
- Line 14:
[slug]
파라미터에 맞는 단 하나의 Post를 구합니다. - Line 20: MDX 파일을 Next.js에서 파싱하기 위해
useMdxComponent
hook을 사용합니다. - Line 25:
MDXContent
를 화면에 배치합니다.
여기까지 Contentlayer 라이브러리를 사용해 Next.js 애플리케이션에서 MDX를 파싱하여 화면에 그리는 방법에 대해 알아봤습니다.
Contentlayer는 Next.js와 결합했을 때 매우 잘 작동하고 개발 문서 또한 잘 작성된 편입니다. 다만 최신 React.js 버전을 지원하지 않으며 더 이상 관리되지 않는다고 maintainer가 밝혔습니다. 이것을 이어받은 Fork 버전인 contentlayer2가 있다고 하니 관심 있으신 분들은 한 번 살펴보셔도 좋을 것 같습니다.
Contentlayer로는 최신 버전의 Next.js(React)에서 블로그 시스템 구축이 불가능하기 때문에 다른 대안을 찾기로 했습니다. 그 다음으로 알아본 것이 Next.js에서 공식적으로 지원하는 라이브러리인 @next/mdx
입니다.
@next/mdx
@next/mdx
패키지는 Next.js가 마크다운 또는 MDX를 처리할 수 있도록 구성하는 데 사용되는 라이브러리입니다.
Next.js에서 공식적으로 지원하는 기능이기 때문에 아주 쉽게 설치 및 설정이 가능합니다. 아래 @next/mdx 설정 방법에 대해 알아봅시다.
1. Set up
npm install @next/mdx @mdx-js/loader @mdx-js/react @types/mdx
- 관련 라이브러리들을 설치합니다
import type { MDXComponents } from 'mdx/types'
export function useMDXComponents(components: MDXComponents): MDXComponents {
return {
...components,
}
}
src
디렉토리에 mdx-components.tsx 파일을 생성합니다
const withMDX = require('@next/mdx')()
/** @type {import('next').NextConfig} */
const nextConfig = {
// Configure `pageExtensions` to include MDX files
pageExtensions: ['js', 'jsx', 'mdx', 'md', 'ts', 'tsx'],
// Optionally, add any other Next.js config below
}
module.exports = withMDX(nextConfig)
- Next.js config file(next.config.js or next.config.mjs)를 위와 같이 수정합니다
- Line 6: nextConfig의 pageExtensions에
mdx
를 추가합니다 - Line 10: nextConfig를 withMdx로 감싸서 module.exports에 할당합니다
- Line 6: nextConfig의 pageExtensions에
2. Create a content
your-project
├── app
│ └── my-mdx-page
│ └── page.mdx
└── package.json
app
디렉토리 하위에 MDX 파일을 생성하여 내용을 채워넣습니다.
그리고 나서 /my-mdx-page
페이지로 이동하면 page.mdx가 렌더링 되는 것을 확인할 수 있습니다.
이게 @next/mdx를 이용하여 MDX를 렌더링하는 데 필요한 코드의 전부입니다. 앞서 살펴봤던 Contentlayer에 비해 설정이 훨씬 간결하다는 것을 알 수 있습니다. 또한 app
디렉토리 하위에 또 다른 디렉토리를 만들어서 그 안에 page.mdx
를 두면 해당 디렉토리가 페이지가 되는 것을 볼 수 있는데, 이는 Next.js의 파일 기반 라우팅 시스템과 동일합니다.
다만 Next.js14와 15버전은 MDX 렌더링 방식과 관련하여 주요한 차이점을 갖고 있습니다.
Next.js v14 vs v15
v14에서는 MDX 파일을 /app
디렉토리 하위에서 관리 해야 합니다 (App Router 기준)

위 설정에서 본 것처럼 /app/my-mdx-page
디렉토리 하위에 page.mdx를 두어야 하고 이렇게 하면 자동으로 파일 기반 라우팅 시스템으로 처리되어 사용자가 /my-mdx-page
에 접속하면 page.mdx가 렌더링 되는 것입니다.
그런데 v15에서는 dynamic imports가 가능해졌습니다.

위 이미지(Next.js v15 공식 문서 캡쳐)를 보면 MDX 파일을 app
디렉토리와 별도의 다른 디렉토리(content)에서 관리할 수 있게 됩니다. 물론 v15는 기존의 파일 기반 라우팅 방식도 같이 지원합니다.
Schema 미지원
Contentlayer의 장점 중에 하나는 블로그 글의 Schema를 지정하고 사용할 수 있다는 것입니다. @next/mdx 에서는 이렇게 schema를 지정할 수 있는 방법이 없는 것으로 보입니다.
Metadata 미지원
@next/mdx는 기본적으로 블로그 글의 메타데이터 추출 기능을 지원하지 않습니다. Frontmatter와 같은 특별한 규칙을 사용하여 MDX에 블로그 글 제목, 커버 이미지, Author 등을 지정할 수 있어야 하는데 이를 기본적으로 지원하지 않는 것입니다. 따라서 추가적인 플러그인 라이브러리 설치가 필요합니다.
Velite
이제 Velite 라는 또 다른 MDX 라이브러리에 대해 알아봅시다.
Velite는 Contentlayer 처럼 수 년 동안 많은 개발자들에 의해 사용된 성숙한 라이브러리는 아닙니다. 2024년 초에 처음으로 릴리즈 되었고 2025년 3월 기준으로 최신 버전은 0.2.2
입니다.
1. Set up
npm install velite -D
- 관련 라이브러리 설치
{
"compilerOptions": {
"paths": {
"#site/content": [
"./.velite"
]
}
},
}
- tsconfig.json 또는 jsconfig.json 파일을 수정합니다. velite 빌드 결과물의 참조 경로를 설정합니다.
# .gitignore
# ...
# velite
.velite
.gitignore
파일에.velite
를 추가합니다. velite를 통해 생성되는 빌드 결과물들은 Git으로 관리할 필요가 없기 때문입니다.
2. Content Schema
import { defineConfig, s } from 'velite'
// `s` is extended from Zod with some custom schemas,
// you can also import re-exported `z` from `velite` if you don't need these extension schemas.
export default defineConfig({
root: "content",
collections: {
posts: {
name: 'Post', // collection type name
pattern: 'posts/**/*.mdx', // content files glob pattern
schema: s
.object({
title: s.string().max(99), // Zod primitive type
slug: s.path(), // auto generate slug from file path
date: s.isodate(), // input Date-like string, output ISO Date string.
cover: s.image(), // input image relative path, output image object with blurImage.
content: s.markdown(), // transform markdown to html
body: s.mdx(),
})
// more additional fields (computed fields)
.transform(data => ({ ...data, permalink: `/blog/${data.slug}` }))
},
others: {
// other collection schema options
}
}
})
- 모듈의 루트 디렉토리에
velite.config.js
파일을 생성합니다 - Line 7: MDX 파일의 root 디렉토리 이름
- Line 11:
root
디렉토리로부터 어떤 경로의 MDX를 처리할 것인지 경로를 지정합니다 - Line 12-20: 하나의 MDX에 대한 스키마를 지정합니다. 이렇게 지정된 스키마는 tsx 또는 ts 코드에서 사용됩니다
const isDev = process.argv.indexOf('dev') !== -1
const isBuild = process.argv.indexOf('build') !== -1
if (!process.env.VELITE_STARTED && (isDev || isBuild)) {
process.env.VELITE_STARTED = '1'
const { build } = await import('velite')
await build({ watch: isDev, clean: !isDev })
}
/** @type {import('next').NextConfig} */
export default {
// next config here...
}
- Next.js 설정 파일(next.config.js or next.config.mjs)의 최상단에 위 코드를 추가합니다. Next.js 빌드 시 Velite 빌드가 수행되도록 하는 설정입니다
3. Create a content
root
+├── content
+│ ├── posts
+│ │ └── hello-world.mdx
├── public
├── package.json
└── velite.config.js
- 모듈 최상단에
content/posts
디렉토리를 만들고 그 안에 MDX 파일을 작성합니다.
4. Create a compoent for MDX
import * as runtime from 'react/jsx-runtime'
const sharedComponents = {
// Add your global components here
}
// parse the Velite generated MDX code into a React component function
const useMDXComponent = (code: string) => {
const fn = new Function(code)
return fn({ ...runtime }).default
}
interface MDXProps {
code: string
components?: Record<string, React.ComponentType>
}
// MDXContent component
export const MDXContent = ({ code, components }: MDXProps) => {
const Component = useMDXComponent(code)
return <Component components={{ ...sharedComponents, ...components }} />
}
- 본인이 원하는 위치에 원하는 이름으로 위와 같이 tsx 파일을 생성합니다
import { posts } from '#site/content'
import { Chart } from '@/components/chart' // import your custom components
import { MDXContent } from '@/components/mdx-content'
export default function Post({ params: { slug } }) {
const post = posts.find(i => i.slug === slug)
return (
<article>
<h1>{post.title}</h1>
<MDXContent code={post.body} components={{ Chart }} />
</article>
)
}
app/posts/[...slug]
디렉토리 하위에 page.tsx 파일을 만듭니다.posts/hello-world
와 같은 경로의 페이지를 처리하는 컴포넌트입니다- Line 1: velite 빌드 결과물 디렉토리(
.velite
)에서 posts를 가져와 import 합니다 - Line 3 & 10: MDXContent 컴포넌트를 선언함으로서 최종적으로 화면에 MDX가 렌더링 됩니다
여기까지가 velite의 설정입니다. Contentlayer와 상당히 유사한 것을 알 수 있습니다.
최종 MDX 라이브러리 선택
지금까지 총 3개의 MDX 라이브러리를 살펴봤습니다. 각 라이브러리 별 장/단점이 무엇인지 그리고 블로그 시스템 구현을 위해 선택한 라이브러리가 무엇인지 소개하겠습니다.
- Contentlayer
- 장점: 수년 간 개발 및 운영되었기 때문에 풍부한 레퍼런스가 있습니다. 또한 개발문서가 상대적으로 잘 작성되어 있습니다
- 단점: 위에서도 잠깐 언급했지만 이제 더 이상 관리되지 않는 라이브러리입니다. folk 버전이 있지만 얼마나 잘 운영될지는 모르겠습니다. 최신 버전의 Next.js(React)에서 잘 동작하지 않습니다.
- @next/mdx
- 장점: Next.js 진영에서 공식적으로 지원합니다. 설정이 매우 간편합니다
- 단점: Next.js v14에서는 파일 기반 라우팅 방식의 MDX 렌더링만 가능해서 MDX 파일을 app 디렉토리 내부에 두어야 했습니다. 또한 스키마 지원을 기본적으로 하지 않습니다
- Velite
- 장점: Type-safe 하도록 스키마를 지정할 수 있습니다. app 디렉토리 외부에서 Content를 관리할 수 있습니다 그리고 현재도 활발하게 관리 및 운영되고 있는 라이브러리로서 최신 Next.js와의 통합이 매끄럽게 잘 작동합니다
- 단점: 상대적으로 빈약한 개발 문서
저는 블로그 시스템을 구현하면서 처음에 Contentlayer를 사용했었는데요. 하지만 더 이상 관리되지 않는 것을 알고 그 다음으로 @next/mdx를 사용해보려고 했습니다. 그 때는 모듈의 Next.js 버전이 14였고 이 버전에서는 외부에서 Content를 관리할 수 없었기 때문에 다른 라이브러리를 알아보기 시작했습니다. 그리고 나서 마지막에 찾은 것이 Velite였습니다.
Velite는 Contentlayer의 장점을 거의 그대로 승계하고 있는 느낌입니다. Velite 공식 문서에서도 Contentlayer에 영감을 받았다고 밝히고 있을 정도니까요.
Velite는 많은 장점을 갖고 있습니다. Type-safe 하면서 커스텀 타입도 선언할 수 있습니다. 그리고 Content를 외부 디렉토리에서 관리하는 것이 가능합니다. 반면 시작한지 1년 정도 된 신생 라이브러리이기 때문에 개발문서나 지원하는 기능이 다른 것들에 비해 빈약할 수 있습니다. 블로그 글 관리를 위한 기본적인 기능은 충실히 구현되어 있지만 앞으로 서비스를 운영하면서 필요한 것들이 있을 때 라이브러리 차원에서 지원받지 못할 수도 있습니다.
이러한 장단점을 고려해본 결과 저는 Velite를 사용하기로 결정했습니다. 2025년 3월 현재 Velite로 블로그 시스템을 구축하여 운영중이고 아직까지 별다른 문제점을 발견하지는 못했습니다.
마치면서
위에서 살펴본 것처럼 MDX 라이브러리 별로 이름은 조금 다르지만 설정하는 방식은 거의 대동소이 합니다. Next.js 빌드 타임에 MDX를 빌드하도록 설정을 변경하고 MDX 빌드 결과물을 TSX 컴포넌트에서 사용하는 것입니다. 처음에 Contentlayer로 구축한 것을 @next/mdx를 거쳐서 Velite로 바꾸는 과정은 (물론 중간에 약간의 삽질이 있긴 했지만) 그렇게 오래 걸리지 않았습니다.
어쩌면 제일 중요한 것은 라이브러리 보다는 Contents 그 자체일 수도 있겠다는 생각을 합니다. 🧐
자, 이제 글을 마칠 시간입니다.
이 글에서는 MDX가 무엇이고 왜 사용하게 되었는지, 어떤 라이브러리들을 고려했는지 등에 대해 소개했습니다. 다음 글에서는 블로그 글을 좀 더 풍부하게 표현하기 위해 사용하는 MDX 플러그인에 대해 알아보겠습니다.
끝까지 읽어주셔서 감사합니다!
Comments (0)
Checking login status...
No comments yet. Be the first to comment!