개발 블로그 개발 기록 (feat. Next.js & MDX)

Next.js와 MDX를 사용하는 개발 블로그가 거쳐온 1년 반 동안의 여정을 공유합니다. MDX 관리를 위해 Contentlayer를 도입하고, 포스팅을 별도 리포지토리로 분리해서 관리하고, Pages Router를 App Router로 마이그레이션했던 과정에서의 고민들을 소개합니다.

2024-03-31에 씀

oooooroblog.com(지금 보고 계신 이 블로그)은 2022년 10월부터 운영되고 있는 개인 개발 블로그입니다. Next.js 프레임워크 기반으로 개발되어 Vercel을 통해 제공되고 있으며, 포스팅은 MDX를 활용해 작성되고 있습니다.

1년 6개월의 시간 동안 블로그를 운영하면서, 블로그에는 내부적으로 두 번의 큰 변화가 있었습니다. 두 번 모두 MDX 파일을 관리하는 것과 관련된 변화였는데요, 어떤 변화가 있었고 현재는 MDX 포스팅을 어떻게 관리하고 있는지 공유해 보려고 합니다.

블로그 1.0 - Next.js@12

2022년 10월에 처음 완성된 블로그의 파일 구조는 아래와 같았습니다.

Pages Router를 사용하고 있어서, 블로그에서 제공하는 페이지들은 /src/pages 경로에 위치하고 있습니다. 이 당시 특이한 점은 포스팅을 작성한 .mdx 파일을 /src/pages/posts 경로 하위에 그대로 위치시켰다는 점입니다. MDX 파일을 페이지로 변환해주는 @next/mdx extension을 사용해서 포스팅을 보여주고 있었습니다.

블로그 메인 페이지에서는 포스팅 리스트를 보여줘야 하는데, 이때 포스팅 리스트 데이터를 가져오는 방법으로 Next.js에서 제공하는 API Routes를 선택했습니다.

이런 구조 때문에, MDX 파일 하나를 작성하려면 몇 가지 보일러플레이트가 필요했습니다. 페이지의 레이아웃 컴포넌트로 본문을 감싸서 내보내거나, 메타데이터를 내보내는 코드를 매번 작성해줘야 했습니다.

메타데이터는 Frontmatter를 활용하고 싶었지만, 페이지로 렌더링하니 Frontmatter가 제대로 동작하지 않는 문제가 있어 객체로 내보내게 했습니다.

이런 코드들을 매번 글을 쓸 때마다 직접 작성해줘야 하다 보니, 특정 메타를 누락하는 실수가 생겨 에러가 발생하기도 했고, 무엇보다도 너무 귀찮았습니다.

Next.js@12 + Contentlayer

MDX 파일을 좀더 효과적으로 관리하기 위해, Contentlayer를 도입하기로 했습니다. 도입을 통해 아래와 같은 문제를 풀고 싶었습니다.

변경된 폴더 구조

이전과 비교해 아래와 같은 부분이 변경되었습니다.

Contentlayer

Contentlayer는 마크다운, MDX 등의 정적 컨텐츠와 그 메타데이터를 JSON으로 변환해 일관적으로 사용할 수 있게 해주는 툴입니다. 특히 메타데이터에 대한 유효성 검사도 수행해 줘서, 메타데이터를 type-safe하게 사용할 수 있습니다.

defineDocumentType 으로 contentLayer에서 사용할 도큐먼트를 정의할 수 있습니다. 저는 블로그에서 사용하는 포스팅 MDX 파일의 타입을 위와 같이 정의했습니다.

MDX 파일의 Frontmatter로 작성된 메타데이터는 fields에 정의된 필드의 타입에 맞게 변환됩니다. computedField를 활용하면 컨텐츠의 데이터를 기반으로 계산된 값을 필드로 만들 수 있습니다.

이에 따라 MDX 파일의 구조를 변경했습니다. 기존에 내보내고 있던 meta 값을 Frontmatter로 변환했고, export default 문도 제거했습니다.

이 MDX 파일은 Contentlayer 빌드 시점에 아래와 같은 JSON 파일로 변환됩니다.

각 MDX 파일이 JSON으로 변환되고, 이 JSON 파일들을 담고 있는 allPosts 배열 변수도 생성됩니다.

포스팅 데이터가 필요한 곳에서는 allPosts 데이터를 가져다가 사용하면 됩니다. 빌드 시점에 모두 생성되어 있기 때문에, 포스팅 데이터를 동기로 가져올 수 있게 되었습니다.

포스팅 페이지에서는 위에서 정의한 함수를 호출해서 데이터를 가져옵니다. MDX 본문은 next-contentlayer 에서 제공하는 useMDXComponent 훅을 사용해 컴포넌트로 만들어 렌더링합니다.

아쉬운 점은 여전히 포스팅이 src 폴더 하위에 위치하고 있다는 것입니다. 포스팅은 블로그 소스 코드가 아니기 때문에 src 바깥으로 꺼내고 싶습니다.

포스팅 리포지토리 분리하기

블로그 소스 코드를 관리하는 리포지토리와 포스팅을 올리는 리포지토리를 분리하기로 결정했습니다. 이유는 다음과 같습니다.

현재는 포스팅만을 업로드하는 private 리포지토리를 만들어서, 여기에만 포스팅을 업로드하고 있습니다.

Contentlayer를 활용하면 현재 프로젝트 내의 파일을 불러오는 것 뿐만 아니라, 다른 리포지토리의 파일을 불러와서 데이터를 만들 수도 있습니다.

아래 코드를 contentlayer.config.ts 파일에 넣고 contentlayer build를 실행시키면, 환경 변수로 넘어온 POST_REPOSITORY_URL 에 해당되는 깃 리포지토리를 contentDirPath 위치에 clone 한 다음 /.contentlayer/generated 폴더에 컨텐츠를 생성합니다.

(원래는 contentlayer 문서에 있는데, 문서가 한동안 터져 있어서 코드를 남겨 둡니다)

1import { spawn } from "child_process";
2import { defineDocumentType } from "contentlayer/source-files";
3import { makeSource } from "contentlayer/source-remote-files";
4
5const runBashCommand = (command: string) =>
6 new Promise((resolve, reject) => {
7 const child = spawn(command, [], { shell: true });
8
9 child.stdout.setEncoding("utf8");
10 child.stdout.on("data", (data) => process.stdout.write(data));
11
12 child.stderr.setEncoding("utf8");
13 child.stderr.on("data", (data) => process.stderr.write(data));
14
15 child.on("close", function (code) {
16 if (code === 0) {
17 resolve(void 0);
18 } else {
19 reject(new Error(`Command failed with exit code ${code}`));
20 }
21 });
22 });
23
24const syncContentFromGit = async (contentDir: string) => {
25 const syncRun = async () => {
26 const gitUrl = process.env.POST_REPOSITORY_URL;
27 await runBashCommand(`
28 if [ -d "${contentDir}" ];
29 then
30 cd "${contentDir}"; git pull;
31 else
32 git clone --depth 1 --single-branch ${gitUrl} ${contentDir};
33 fi
34 `);
35 };
36
37 let wasCancelled = false;
38 let syncInterval: NodeJS.Timeout;
39
40 const syncLoop = async () => {
41 await syncRun();
42
43 if (wasCancelled) return;
44
45 syncInterval = setTimeout(syncLoop, 1000 * 60);
46 };
47
48 // Block until the first sync is done
49 await syncLoop();
50
51 return () => {
52 wasCancelled = true;
53 clearTimeout(syncInterval);
54 };
55};
56
57export default makeSource({
58 syncFiles: syncContentFromGit,
59 contentDirPath: "src/posts", // repository를 clone할 경로
60 documentTypes: [Post], // defineDocumentType 함수로 만든 documentType을 이 배열에 넣으세요
61});

빌드 워크플로우

기존에는 main 브랜치에 push 이벤트가 발생하면 블로그 리포지토리에서 두 가지 워크플로우가 실행되었습니다.

  1. 블로그 빌드 및 배포
  2. 최신 블로그 포스팅 5개를 가져와서 깃허브 프로필 리포지토리의 워크플로우를 트리거시키기

깃허브 프로필 리포지토리의 워크플로우에서 어떤 일을 하는지 궁금하다면? 내 블로그의 최신 글을 깃허브 프로필에 자동으로 등록하기 포스팅을 확인해 보세요!

블로그 포스팅이 전부 다른 리포지토리로 이동되면서, 워크플로우를 실행시키는 주체도 변경되어야 했습니다. 위의 두 워크플로우는 블로그 포스팅 리포지토리의 main 브랜치에 push 이벤트가 발생할 때에 실행돼야 합니다.

블로그 포스팅 리포지토리에서 Vercel 배포를 트리거하기 위해, Vercel에서 제공하는 Deploy Hooks를 사용했습니다. Vercel의 Settings에 들어가 보면 배포를 트리거 시킬 수 있는 URL을 얻을 수 있습니다.

push 이벤트마다 이 훅에 요청을 보내는 워크플로우를 생성해 두면, 새로운 포스팅이 올라올 때마다 블로그를 자동으로 배포할 수 있습니다.

App Router 도입

블로그 리포지토리를 분리하면서 Next.js의 버전을 올려주었고, 13 버전부터 도입된 App Router를 사용해 봤습니다. 새로 추가된 layout 기능을 써보고 싶었고, 그 외에 추가된 편의 기능도 도입해 보고 싶었습니다. Next.js에서 codemod를 제공하고 있어서, 대부분의 코드를 자동으로 마이그레이션할 수 있었습니다.

결론적으론 layout 기능도, Server Component도 제대로 활용하진 못 했던 것 같습니다. layout을 사용하기에는 path 뎁스가 너무 얕았고, Server Component를 사용하기에는 styled-components를 사용하고 있어서 대부분의 컴포넌트에 ‘use client’를 붙여주어야 해서 아쉬웠습니다.;


대신에 Next.js@13에서 추가된 몇 가지 기능은 유용하게 사용하고 있습니다.

이전 버전에서는 SEO를 위한 메타데이터나 아이콘, manifest 등을 JSX 내부에 작성해야 했는데, Next@13에서는 metadata 변수를 내보내면 이 값대로 메타데이터가 알아서 설정됩니다.

특히 템플릿을 사용할 수 있다는 점이 편합니다. 상위에서 %s | oooooroblog 와 같은 식으로 타이틀 템플릿을 설정해 두면, 하위 페이지에서는 타이틀만 작성해도 일관적으로 같은 형식의 타이틀을 보여줄 수 있습니다.

as-is: 메타데이터를 JSX 안에 작성해야 했다

to-be: metadata 객체를 만들어서 내보내면 페이지에 메타데이터가 적용된다


폰트를 관리하는 것도 편해졌습니다. 매번 구글 폰트에 들어가서 폰트 관련 스크립트를 복사해와야 했는데, next/font 에서 제공하는 폰트를 import 해와서 적용해주면 됩니다.

CSS variable 형식으로 font-family를 적용해 줄 수 있습니다.

이것 외에도 og-image를 자동으로 생성해 주는 API Route를 만들 수 있는 기능이 있길래 추가해 보려고 했는데, 빌드 후 용량이 너무 커서 Vercel 배포에 실패하길래 보류해 두었습니다.

FSD(Feature-Sliced Design) 맛보기

App Router를 사용하도록 변경하면서 폴더 구조도 조금 정리했는데, FSD(Feature-Sliced Design, 기능 분할 설계)를 적용해 보려고 했습니다.

위와 같은 구조를 적용해 보면서, 아토믹 디자인을 UI에서 비즈니스 로직까지 확장시킨 것 같다고 느꼈습니다. 초기 폴더 구조에서는 포스팅과 관련된 도메인 로직이 여기저기 흩어져 있었던 점이 아쉬웠는데, 이 구조를 도입하면서 도메인과 관련된 로직과 UI를 모아둘 수 있게 된 점이 좋았습니다.

한편, 어떤 모듈이 있을 때 widgets와 entities 중 어느 폴더로 보내면 좋을지에 대한 구분은 조금 어려웠습니다. (이런 부분도 아토믹 디자인과 닮아 있다고 느꼈습니다) 좀 더 규모가 있거나 다인원이 함께하는 프로젝트라면 각 폴더의 역할과 기준을 명확히 정해 두어야 할 것 같습니다.

프로젝트 인원들이 아키텍처를 충분히 이해하고 기준에 대한 합의가 잘 이루어진다면 UI와 비즈니스 로직을 분리하는 데 큰 도움이 될 것 같다고 느꼈습니다.

더 하고 싶은 것들

프로필 사진

조예진

이전 포스트
프론트엔드 TDD 튜토리얼 with React & Testing Library
다음 포스트
《리액트 훅 인 액션》 리뷰