sungyup's.

이 블로그 만들기 #3

이 블로그 만들기 #3

Next.js와 MDX로 구현한 정적 웹사이트


쉬웠던 결정들

2024년 11월 무렵에 블로그 개발을 마음 먹은 웹개발자인 내게 우선 상대적으로 자연스러웠던 선택은 아래와 같다.

  • 언어: Typescript
  • 프레임워크: Next.js (App Router)

약간은 더 선택지가 다양해서 고민했지만,

  • 스타일링: Tailwind CSS

여기까지도 비교적 자연스러웠다. 왜냐하면 위 아래를 왔다갔다 하면서 스타일링을 하는 방식(Styled-Component)에 갈수록 회의감이 들던 차였기 때문이었다.

어려웠던 결정들

다음부터가 문제였다. 아직까지 블로그를 한다고 했을때 선택해야하는 것들이 있었다.

  • 블로그 포스트 DB관리: 글을 어디에 어떻게 저장할 것인가?
  • 블로그 포스트 스타일링: 글을 모두 html문서로 적을수도 없고, 어떻게 해야 최대한 간편하게 적고 그럴듯하게 스타일링할 수 있을까?

하지만 이 부분은 내심 오랫동안 마음에 두고 있었던게 있었다. Notion은 무슨 글을 써도 깔끔한 폰트에 가독성 좋은 줄간격 등을 가지고 있었고, 오랫동안 써온지라 단축키들도 꽤 익숙했다. 노션을 쓰듯이 글을 쓰고, 그걸 바로 웹에 퍼블리시 할 수 있으면 아주 쉽고 예쁘게 글을 쓸 수 있지 않을까? 여기에 약간의 커스터마이징만 할 수 있다면 더 이상 바랄게 없었다.

그래서 우선 찾아본건 노션에 쓴 내용을 쉽게 내 웹사이트에서 렌더할 수 있는 라이브러리들이었다. 그렇게 발견한 nextjs-notion-starter-kit은 아주 편하게 노션에 쓴 글을 nextjs 프로젝트에서 띄울 수 있었다. 그냥 키를 연결하고 웹사이트를 띄우니 바로 노션의 글들이 예쁘게 렌더되었다.

nextjs notion starter kit
이미지 출처 : #
키만 복사해서 붙이면 바로 웹사이트에 노션 포스트들이 연동되는 라이브러리, nextjs-notion-starter-kit.

하지만 문제가 몇개 있었다. 우선, 전혀 개발을 했다는 느낌이 들지 않았다. 이건 뭐 그냥 노션 페이지를 퍼블리시한것과 크게 다른 느낌이 없었다. 애초에 티스토리나 네이버 블로그가 아닌, 나만의 홈페이지를 가지고 싶어서 개발을 하려던건데 이 정도로 날로 먹어도 되나? 싶은 느낌이 들었다.

더 큰 문제는 커스터마이징이었다. 포스트들에 태그를 붙이고 태그별로 필터링이 된다거나, 다양한 탭들이 있어 이 탭들을 넘어 이동하는 기능을 만들고 싶었는데, 이름에서 알 수 있듯이 이 라이브러리는 'starter kit'이었고, 초보자들을 위해 만들어진만큼 편했으나 그만큼 제약이 컸다. 이미지를 보여주는 방식이나 탭간 이동 등을 수정하려면 꽤 깊이 들어가야했는데, 그렇게 들어가다가 발견한 것이 이 라이브러리의 개발자가 이 전에 만들어서 이 라이브러리를 개발할때도 사용한 React-Notion-X였다.

React-Notion-X는 유지보수도 잘 되고 꽤 다양한 커스터마이징도 가능하게 해주는 라이브러리였지만, 나름 열심히 만져보았으나 내가 원한만큼의 커스터마이징은 어렵게 느껴졌다. '이렇게 뭐가 어려우면 차라리 내가 노션 API를 만지고 말지...'하는 생각이 들었다. 음, 그러고보니 왜 지금까지 그렇게 하지 않고 라이브러리에만 의존하려고 했지?라는 생각이 들어, 아예 공식 노션 API를 쓰기로 했다. 하지만, 공식 문서를 읽고 노션 API를 써봤으나 blockrich text라는 형식으로 데이터를 받아 렌더해야하기에 원하는대로 커스터마이징하기가 쉽지 않았다. 이 방식으로 꽤 진행을 했는데, 하는 내내 이 blockrich text의 지나친 복잡성 때문에 회의감이 들었다.

block은 노션 특유의 형식이지만, rich text는 노션에서만 쓰는 형식이 아니라 1987년에 마이크로소프트에서 개발한 텍스트, 링크, 멘션, 페이지 링크 등을 객체로 표현한 JSON 구조다. 복잡한 표현을 지원하지만, HTML이나 마크다운처럼 간단히 렌더링하기 어렵다는 단점이 있다.

MDX와 정적 웹사이트

그러던 중 의외의 곳에서 탈출구를 찾았다. 회사에서 만들던 웹앱을 데스크탑 앱으로 바꾸는 프로젝트를 하면서 Electron의 존재를 알게되었는데, Electron의 공식 문서를 읽는 도중 새삼 굉장히 아름답다고 느꼈다. 이런 식으로 스타일링할 수 있다면 내가 원하는 방식의, 일관성있고 가독성 좋은 스타일링이 가능하겠는데?

electron official doc
이미지 출처 : #
가독성 좋고 아름다운 Electron의 공식 문서. 사실 공식 문서는 대개 가독성이 좋고 아름답게 만들어졌다. 공식 문서를 만들때 쓰이는 라이브러리들을 찾으며, 내가 필요로 하던 것은 MDX라는 사실을 깨달았다.

Electron 공식 웹사이트는 오픈 소스이기에 어떻게 만들었는지 볼 수 있었고, 여기서 Docusaurus를 발견했다. 간결하고 아름답다고 생각해서 도입하려고 하다보니, 이건 공식 문서를 위한 라이브러리였기에 사이드바가 있고, 사이드바가 있고 하는 디자인적인 제한들이 여전히 있었다. 그러나 여기서 MDX를 알게되었고, 내가 원했던 것은 처음부터 이것이었다는 것을 알 수 있었다.

MDX는 마크다운 파일 안에서 리액트 컴포넌트를 쓸 수 있게 결합한 문서 형식이다. 따라서, 간단하면서도 필수적인 텍스트 서식(볼드, 이탤릭, 제목1, 제목2, 본문 등)은 마크다운으로, 커스터마이징하고 싶은 컴포넌트들은 리액트 컴포넌트로 만들어 문서 내에서 쓸 수 있다. 이 방식은 여러가지 장점이 있다.

1. 글을 쓰고 편집하기 쉽다

마크다운 문법은 간단하다. 물론 워드프로세서나 네이버 블로그 텍스트 편집기만큼은 아니지만, 충분히 빠르고 간단하게 글을 쓰고 편집할 수 있다. Markdown tutorial에서 10분만 튜토리얼을 진행하면 왠만큼 다 할 수 있다.

2. 자유자재로 커스터마이징이 가능하다

리액트 컴포넌트를 문서 안에서 쓸 수 있는 것, 이것만큼 커스터마이징이 자유로울 수 있을까? 글은 문서 쓰듯 쉽게 쓰고, 커스터마이징이 필요한 부분은 리액트로 할 수 있다는 것만으로 MDX를 선택할 이유는 사실 차고 넘쳤다.

I can even do something like this

이런 것도 할 수 있다

내 블로그

3. 개발자들에게 보편적인 언어다

마크다운은 공식 문서 등 개발자 문서에 거의 표준처럼 쓰이는 언어다. 블로그를 통해 직접 자주 쓰다보면 마크다운 언어 구사 실력이 늘 것이고, 개발자로써 마크다운을 능숙하게 쓸 수 있다는 것은 (아주 크진 않더라도) 장점이 될 수 있다.

그렇게 해서 이 블로그의 포스트들은 MDX 파일들로 만들고, 코드베이스 자체에서 관리함으로 정적으로 웹사이트에 띄우기로 결정했다.

MDX 문서 띄우기

Next.js의 MDX 공식 지원

그렇다면 Next.js(App Router)에서 MDX 파일들을 어떻게 띄울 수 있을까? Next.js 공식문서에서 볼 수 있듯, Next.js는 Pages Router와 App Router 모두에서 로컬에 있든, 서버에 있든 MDX 파일들을 렌더할 수 있는 기능을 공식적으로 지원한다.

우선, 필요한 디펜던시들을 설치한다.

bash
npm install @next/mdx @mdx-js/loader @mdx-js/react @types/mdx

다음으로, next.config.ts를 업데이트한다. 이 과정으로 .mdx 확장자의 파일들이 애플리케이션 내에서 라우트로 동작할 수 있게 된다.

javascript
import type { NextConfig } from 'next'; import createMDX from '@next/mdx'; const withMDX = createMDX({ extension: /\.mdx?$/, }); const nextConfig: NextConfig = withMDX({ pageExtensions: ['ts', 'tsx', 'js', 'jsx', 'md', 'mdx'], env: {}, images: { remotePatterns: [ { hostname: 'prod-files-secure.s3.us-west-2.amazonaws.com', }, ], }, }); export default nextConfig;

다음으로, 각 요소들(#으로 쓰는 <h1>, ##으로 쓰는 <h2>나 **로 감싸는 <strong> 등)의 보다 구체적인 스타일링 및 추가적인 커스텀 리액트 컴포넌트(jsx) 추가를 위해 루트 디렉토리에 mdx-components.tsx 파일을 작성한다. 나의 경우는 src 폴더에 아래와 같이 작성했다. 아래는 일부 요소들의 스타일링 예시다.

이 코드는 다른 곳에서 따로 import 하지 않아도 정확한 이름으로 루트에 두면 App Router에서 자동으로 MDX 파일들에 적용한다.

javascript
export function useMDXComponents(components: MDXComponents): MDXComponents { return { h1: ({ children }) => { return ( <h1 id={id} className="text-4xl font-bold mb-6 text-slate-950 dark:text-slate-200 tracking-tight leading-[1.2] md:leading-[1.4] break-keep" > {children} </h1> ); }, p: ({ children }) => ( <p className="text-lg leading-relaxed text-slate-950 whitespace-normal dark:text-slate-200 break-words mt-8 mb-8 font-normal tracking-normal"> {children} </p> ), strong: ({ children }) => ( <strong className="font-bold text-slate-950 dark:text-slate-100 text-normal"> {children} </strong> ), // h2, h3, a, ul, li, theader, tbody, pre, em 등 기타 요소들 ...components, Highlight, // 기타 커스텀 jsx들. import 후 사용한다. }; }

폴더 구조

홈 화면과 각 탭(라우트)들은 정적으로 라우트 되지만, 각 포스트들은 해당 포스트로 동적 라우팅 되어야 한다. 따라서, 블로그 폴더 구조는 대략적으로 아래와 같이 되어야 한다. 대략적이라고 말한 이유는, study 탭은 여러개의 동적인 책 또는 강의 폴더로 들어간 이후 그 안에서 해당 포스트로 동적 라우팅이 되어야 하므로 2중 slug가 필요한데, 아래 폴더 구조에서는 편의상 생략했기 때문이다.

bash
├── app │ ├── (pages) │ │ ├── devlog │ │ │ ├── [slug] │ │ │ │ └── page.tsx ← 각 포스트를 동적으로 렌더링 │ │ │ └── page.tsx ← devlog 탭 │ │ ├── study │ │ │ ├── [slug] │ │ │ │ └── page.tsx ← 각 포스트를 동적으로 렌더링 │ │ │ └── page.tsx ← study 탭 │ │ └── review │ │ ├── [slug] │ │ │ └── page.tsx ← 각 포스트를 동적으로 렌더링 │ │ └── page.tsx ← review 탭 │ └── content │ ├── devlog │ │ ├── 포스팅1.mdx │ │ ├── 포스팅2.mdx │ │ └── 포스팅3.mdx │ ├── study │ │ ├── 포스팅4.mdx │ │ └── 포스팅5.mdx │ └── review │ ├── 포스팅6.mdx │ └── 포스팅7.mdx ├── components └── ...

각 탭들은 (pages) 폴더 안에 있는 탭들의 page.tsx로 구현된다. 그리고 해당 탭에 mdx 문서들의 목록을 보여주고, 이 문서들이 각 [slug] 폴더를 통해 동적으로 그 안의 page.tsx로 라우팅되면 Next.js(App Router)를 통해 정적 블로그를 만들 수 있다.

커스텀 코드 작성

각 탭들의 page.tsx에는 통일성을 위해 공용 <PageHeader> 컴포넌트를 두고, 그 아래는 포스트 목록을 보여주는 코드를 작성했다. 아래는 /app/(pages)/devlog/page.tsx의 사례다.

javascript
import DevlogPosts from './DevlogPosts'; import PageHeader from '@/components/PageHeader'; import { getDevlogPosts } from './getDevlogPosts'; export default async function DevlogPage() { // Devlog post 목록을 받아온다. const posts = await getDevlogPosts(); return ( <div className="px-4 py-8 mt-16"> {/* 공용 헤더 컴포넌트 */} <PageHeader title="Devlogs" description="to cope with my forgetfulness..." descriptionKorean="하도 자꾸 까먹어서..." /> {/* Devlog 포스트들을 렌더하기 위한 컴포넌트로, Devlog post 목록을 받아 띄운다. */} <DevlogPosts initialPosts={posts} /> </div> ); }

getDevlogPosts()/app/content/devlog안에 있는 mdx 파일들의 목록을 가져와 파싱하고, gray-matter를 통해 메타데이터를 얻는다. gray-matter는 mdx 파일을 작성할 때, 맨 위에 ---들로 구분한 구역에 메타데이터를 작성하면 해당 데이터를 읽어올 수 있게 도와주는 라이브러리로, 이를 통해 제목, 태그, 커버사진, 설명 등의 정보를 포함할 수 있다. 이는 이후 SEO를 위해서도 유용하게 활용할 수 있다.

각 탭마다 다른 종류의 메타데이터를 가지고 있었기 때문에(예를 들어 review에는 아티스트들의 이름을 모은 배열이, study에는 책 이름과 장 등) 이 getSomethingPosts()는 각기 다르게 구현했다.

javascript
import fs from 'fs'; import path from 'path'; import matter from 'gray-matter'; import { Devlog } from '@/types/postTypes'; export async function getDevlogPosts() { const contentDirectory = path.join(process.cwd(), 'src/app/content/devlog'); // 문자열로 된 파일명들로 이루어진 배열 const files = fs.readdirSync(contentDirectory); const posts: Devlog[] = files .filter((file) => file.endsWith('.mdx')) .map((file) => { // 각 파일 경로 const filePath = path.join(contentDirectory, file); // 파일 경로에 있는 mdx 문서를 파싱 const fileContent = fs.readFileSync(filePath, 'utf8'); // gray-matter를 통해 mdx 파일 맨 앞에 --- ---로 구분한 메타데이터를 파싱한다. const { data } = matter(fileContent); return { // 동적 경로에 들어갈 slug slug: file.replace('.mdx', ''), title: data.title || file.replace('.mdx', '').replace(/-/g, ' '), tags: data.tags || [], date: data.date || '', description: data.description || '', cover: data.cover || '', published: data.published === false ? false : true, summary: data.summary || '', }; }); return posts; }

MDX 파일을 런타임에 동적으로 불러오기 위해서 [slug]폴더안의 page.tsx 컴포넌트는 await import()를 쓴다. 앞서 getDevlogPosts()에서 파일명의 .mdx 확장자를 없앤 문자열을 파라미터로 전달하기 때문에, 이 파라미터(slug)를 이용해 파일을 찾고 해당 파일의 메타데이터 및 내용을 불러온다.

javascript
export default async function Page({ params }: { params: Params }) { const { slug } = await params; const { default: Post } = await import(`@/app/content/devlog/${slug}.mdx`); const filePath = path.join( process.cwd(), 'src/app/content/devlog', `${slug}.mdx` ); const fileContent = fs.readFileSync(filePath, 'utf8'); const { data } = matter(fileContent); // 해당 경로의 모든 포스팅 목록을 가져온다 const allPosts = getAllPosts(); // 각 포스트 맨 아래에 이전, 다음 포스팅으로 옮겨갈 수 있는 기능용 const currentIndex = allPosts.findIndex((post) => post.slug === slug); const prevPost = currentIndex < allPosts.length - 1 ? allPosts[currentIndex + 1] : undefined; const nextPost = currentIndex > 0 ? allPosts[currentIndex - 1] : undefined; return ( <article> {/* 스타일링 */} </article> ) }

여기까지가 이 블로그의 가장 핵심이라고 할 수 있는, 동적 포스팅 불러오기 방법의 구현이다. 일부 커스텀 jsx 컴포넌트나 Til 페이지의 구현, Board 페이지의 구현 등에 대한 얘기는 함께 담기에는 불필요하게 포스팅이 길어질것 같아 다음으로 미룬다.

이 블로그 만들기 #4에서 계속됩니다.