항해 부트캠프를 수료한 후 참여한 스터디인 2주 만에 블로그 만들기 에 대한 회고...
크게 내가 고민했던 부분들, 막혔던 부분들 위주로 작성해보려고 한다!!
개인 블로그 글 포스팅 하는 방법으로 노션에서 불러오기, mdx 파일로 작성하기 등이 있었는데, 평소에 노션을 사용하지 않기도 하고 블로그 관련으로는 레포지토리 하나로 끝내고 싶어서 mdx 파일을 사용하기로 결정했다!
areumh-blog/
├── app/ # Next.js App Router 페이지
│ ├── category/ # 카테고리 페이지
│ ├── post/ # 블로그 포스트 상세 페이지
│ └── portfolio/ # 포트폴리오 페이지
├── components/ # 공통 컴포넌트
│ ├── layout/ # 레이아웃 컴포넌트
│ └── ui/ # ui 컴포넌트
├── hooks/ # 커스텀 훅
├── lib/ # 블로그 포스트 관련 유틸리티 함수
├── posts/ # 카테고리별 MDX 블로그 포스트 파일
├── public/ # 정적 파일
├── styles/ # 전역 스타일
├── utils/ # 유틸리티 함수
├── constants.ts # 상수 정의
└── types.ts # 타입 정의
루트 폴더에 posts라는 폴더를 생성한 후, 카테고리명으로 폴더를 생성한 뒤 mdx 파일을 작성하여 블로그 글을 추가하도록 했다.
// constants.ts
export const CATEGORIES = [
{ key: '개발', label: 'tech' },
{ key: '회고', label: 'review' },
];
// app/category/page.tsx
export default function Category() {
return (
<div className="flex flex-col items-center gap-10 md:px-10 py-3 md:py-5">
{CATEGORIES.map(({ key, label }) => (
<CategoryPostList key={key} category={label} />
))}
</div>
);
}
카테고리는 개발과 회고 외에는 딱히 작성할 것 같진 않고... 더 추가한다고 해도 글을 작성할 때 같이 수정하면 그만이라 상수 파일에 정의해두었고, 카테고리명을 인자로 받아 해당 카테고리의 글 목록을 보여주는 CategoryList 컴포넌트에 연결해주었다.
import fs from 'fs';
import path from 'path';
import matter from 'gray-matter';
import { Post } from '@/types';
const postsDirectory = path.join(process.cwd(), 'posts');
/**
* 모든 slug 가져오기
* category - 폴더명, slug - 파일명
*/
export function getPostSlugs(): string[] {
const categories = fs.readdirSync(postsDirectory).filter((name) => {
const categoryPath = path.join(postsDirectory, name);
return fs.statSync(categoryPath).isDirectory();
});
const slugs: string[] = [];
categories.forEach((category) => {
const files = fs.readdirSync(path.join(postsDirectory, category));
files.forEach((file) => {
if (file.endsWith('.mdx')) {
const slug = path.basename(file, '.mdx');
slugs.push(`${category}/${slug}`);
}
});
});
return slugs;
}
process.cwd()는 현재 프로젝트의 루트 경로이고, posts는 블로그의 글이 담긴 폴더명으로 postsDirectory는 루트 폴더/posts가 된다.
fs.readdirSync()는 해당 폴더 안의 모든 파일/폴더 이름을 동기적으로 가져온다. fs.statSync()는 해당 경로의 파일 상태 정보를 담은 fs.Stats 객체를 반환하고, isDirectory()는 그 경로가 디렉터리인지 아닌지를 알려준다.
따라서 filter는 모든 파일/폴더의 이름 중 폴더의 이름만 걸러주기 때문에 categories는 카테고리 폴더명만 담고있는 배열을 리턴한다.
그리고 카테고리 폴더 내부를 탐색하여 .mdx 파일만 필터링하고, path.basename(file, '.mdx')를 통해 확장자를 제거한다. 이런 과정을 통해 위의 함수는 카테고리명/슬러그의 문자열 배열을 리턴한다!
/**
* 특정 slug의 post 가져오기
*/
export function getPostBySlug(url: string) {
const [category, slug] = url.split('/');
const fullPath = path.join(postsDirectory, category, `${slug}.mdx`);
const fileContents = fs.readFileSync(fullPath, 'utf8');
const { data, content } = matter(fileContents);
const meta: Post = {
title: data.title,
date: data.date,
description: data.description,
tags: data.tags || [],
slug,
category,
};
return { meta, content };
}
gray-matter 라이브러리의 matter 함수를 사용하면 mdx 파일의 상단 메타데이터와 본문을 분리할 수 있다.
---
title: "Next.js로 블로그 만들기 회고"
date: "2025-10-21"
description: "Next.js와 MDX를 이용한 블로그 만들기"
tags: ["Next.js", "MDX", "블로그"]
---
블로그 본문 내용
mdx 파일이 위와 같이 생겼다면 gray-matter은 이를 아래와 같이 반환한다.
{
data: {
title: "Next.js로 블로그 만들기 회고",
date: "2025-10-21",
description: "Next.js와 MDX를 이용한 블로그 만들기",
tags: ["Next.js", "MDX", "블로그"]
},
content: "블로그 본문 내용"
}
이 data 객체를 기반으로 Post라는 타입을 따로 정의하여 카테고리와 슬러그 값을 포함하도록 했고, 블로그 본문의 헤더에 해당 데이터를 사용했다.
/**
* 전체 글 목록 가져오기
*/
export function getAllPosts(): Post[] {
const slugs = getPostSlugs();
return slugs.map((slug) => getPostBySlug(slug).meta).sort((a, b) => (a.date < b.date ? 1 : -1)); // 최신순
}
블로그의 홈 화면에서는 카테고리에 상관없이 모든 글들을 한번에 보여주도록 했기 때문에 위에 작성했던 함수들을 사용하여 모든 글의 메타데이터를 수집하고 이를 최신순으로 정렬한 배열을 리턴하게 했다!
/**
* 카테고리별 글 가져오기
*/
export function getPostsByCategory(category: string): Post[] {
return getAllPosts().filter((post) => post.category === category);
}
그리고 카테고리 페이지에서는 카테고리별 글 목록을 가져오기 때문에 카테고리 문자열을 인자로 받고 해당 카테고리의 글 목록을 리턴하는 함수를 작성했다.
/**
* 전체 태그 목록 가져오기
*/
export function getAllTags(): string[] {
const posts = getAllPosts();
const tags = posts.flatMap((post) => post.tags || []);
return Array.from(new Set(tags));
}
홈 화면에서 태그별 글 목록 확인이 가능하도록 하기 위해 중복을 제거한 태그 목록을 리턴하는 함수도 작성했다.
Next 15 부터는 params는 동기가 아닌 비동기식으로 접근하도록 변경되었다.
즉, 라우트 파라미터를 가져오는 과정을 await 처리해야 한다.
- 브라우저에서
/post/review/next-blog와 같은 url에 접근[category]/[slug]에 해당하는 동적 파라미터 추출 - 서버 컴포넌트에서 params로 제공 (Promise 형태)await params로 실제 객체{ category, slug }로 변환
export default async function Post({ params }: { params: Promise<{ category: string; slug: string }> }) {
const { category, slug } = await params;
const { meta, content } = getPostBySlug(`${category}/${slug}`);
// ...
}
위의 코드처럼 params의 타입을 Promise<{ category: string; slug: string }> 형태로 선언하고, 함수 내부에서 await으로 값을 꺼내는 방식으로 작성하여 mdx 파일의 글 데이터를 가져오도록 했다 👍
export default function Home() {
const posts = getAllPosts();
const tags = getAllTags();
return (
<div className="flex flex-col w-full max-w-[800px] mx-auto items-center md:px-10">
<Suspense fallback={null}>
<HomeContent posts={posts} tags={tags} />
</Suspense>
</div>
);
}
추가로 React 18 이후부터는 useSearchParams()를 사용하는 클라이언트 컴포넌트를 데이터가 준비될 때까지 안전하게 렌더링되도록 Suspense로 감싸는 처리가 필요하다.
fallback은 데이터가 로딩 중일 때 보여주는 ui를 지정하는 속성인데, 이번 프로젝트는 페이지가 복잡하지 않고 굳이 로딩 상태 ui가 필요하다고 느껴지지 않아 null 값을 넣어 처리했다. 🫠
mdx의 블로그 글을 화면에 렌더링하기 위해 next-mdx-remote와 여러 플러그인을 활용했다.
import { MDXRemote } from 'next-mdx-remote/rsc';
import rehypePrettyCode from 'rehype-pretty-code';
import rehypeSlug from 'rehype-slug';
import remarkGfm from 'remark-gfm';
import remarkBreaks from 'remark-breaks';
export default async function PostContent({ content }: { content: string }) {
return (
<div className="prose prose-sm md:prose-lg dark:prose-invert">
<MDXRemote
source={content}
options={{
mdxOptions: {
remarkPlugins: [remarkGfm, remarkBreaks],
rehypePlugins: [rehypeSlug, rehypePrettyCode],
},
}}
/>
</div>
);
}
<br> 처리// styles/globals.css 중 일부
@import 'tailwindcss';
@plugin "@tailwindcss/typography";
/* prose 문단 간격 조정 */
.prose p {
margin-top: 0.5rem;
margin-bottom: 0.5rem;
}
.prose h1,
.prose h2,
.prose h3,
.prose h4,
.prose h5,
.prose h6 {
margin-top: 1rem;
margin-bottom: 1rem;
}
.prose ul,
.prose li,
.prose ol {
margin-top: 0.5rem;
margin-bottom: 0.5rem;
}
/* 인용구 */
.prose blockquote {
border-left: 4px solid #6366f1;
padding: 0.5rem;
color: #374151;
font-style: normal;
margin-top: 1rem;
margin-bottom: 1rem;
background-color: #f9fafb;
}
/* 다크모드 대응 */
.dark .prose blockquote {
color: #e5e7eb;
background-color: #1f2937;
}
/* 코드 블록 스타일 */
pre {
padding: 1rem;
border-radius: 0.5rem;
overflow-x: auto;
margin: 0.5rem 0;
}
code {
font-family: 'Courier New', Courier, monospace;
font-size: 0.75rem;
}
@media (min-width: 768px) {
code {
font-size: 0.875rem;
}
}
// ...
globals.css 파일의 상단에 @plugin "@tailwindcss/typography";를 추가하여 prose 클래스를 사용한 컨텐츠에 스타일이 적용되도록 했다. 그리고 각종 태그들과 인용구, 코드 블록 등에 적용할 스타일들을 직접 작성했다. 🖌️
블로그 글의 헤딩 (h1, h2, h3)을 기반으로 동적인 목차를 제공하는 컴포넌트를 직접 구현해보았다.
useEffect(() => {
const headingElements = Array.from(document.querySelectorAll('h1, h2, h3')) as HTMLElement[];
const newHeadings = headingElements.map((el) => ({
id: el.id,
text: el.innerText,
level: Number(el.tagName.replace('H', '')),
}));
setHeadings(newHeadings);
}, []);
useEffect를 사용하여 컴포넌트 마운트 시 DOM에서 모든 h1, h2, h3 요소를 가져온다. 그리고 각 헤딩의 id, 텍스트, 레벨(h1일 경우 1, h2일 경우 2 ...)을 추출하여 상태에 저장한다.
const observer = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
setCurrentId(entry.target.id);
}
});
},
{ rootMargin: '0px 0px -80% 0px' }
);
const elements = document.querySelectorAll('h1, h2, h3');
elements.forEach((el) => observer.observe(el));
observer.observe(el)로 각 헤딩을 관찰 대상으로 등록하고, 해당 요소가 화면에 들어오거나 나갈 때 브라우저가 콜백 함수를 자동으로 호출하도록 구현했다.
const getHeadingMargin = (level: number): string => {
const indentClass = {
1: 'ml-0',
2: 'ml-3',
3: 'ml-6',
} as const;
return indentClass[level as keyof typeof indentClass] ?? 'ml-0';
};
// ...
return (
<nav className="flex pr-2 text-sm">
<ul className="space-y-1">
{headings.map((heading) => {
return (
<li
key={heading.id}
className={`${getHeadingMargin(heading.level)} ${
currentId === heading.id ? 'text-indigo-400 font-semibold' : 'text-gray-400'
} transition-colors`}
>
<a href={`#${heading.id}`}>{heading.text}</a>
</li>
);
})}
</ul>
</nav>
);
헤딩 레벨에 따라 TOC 내에 들여쓰기 스타일을 적용하기 위한 함수를 따로 작성해주었고, 현재 화면에 표시된 헤딩의 텍스트 색상을 강조하도록 했다.
html {
scroll-behavior: smooth;
}
추가로 css 파일에 위의 코드를 추가하면 헤딩 클릭 시 애니메이션처럼 부드럽게 스크롤이 이동된다. 하지만 Next.js는 라우트 전환 시 스크롤 동작을 제어하기 위해 data-scroll-behavior 속성을 확인하기 때문에 하나의 페이지 내에서 스크롤이 이동될 때는 smooth, 라우트 전환 시엔 auto로 임시 변경 (즉시 스크롤) 되도록 html 태그에 속성 값을 추가해야한다. 하지 않으면 경고문이 뜬다 🤕
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="en" data-scroll-behavior="smooth" suppressHydrationWarning> // ✅
<body className="flex flex-col min-h-screen">
<ThemeProvider attribute="class" defaultTheme="system" enableSystem>
<Header />
<main className="flex flex-col flex-grow w-full mx-auto px-7 md:px-20 py-5 md:py-8">{children}</main>
<Footer />
</ThemeProvider>
</body>
</html>
);
}
data-scroll-behavior="smooth" 코드를 추가하여 해결했다!
Tailwind CSS v4부터는 다크모드 설정 방식이 바뀌어서 globals.css 파일에 아래의 코드가 필수로 들어가야 한다.
@custom-variant dark (&:where(.dark, .dark *));
위의 코드가 있어야 .dark 클래스가 붙은 요소와 그 하위 요소에 dark: 스타일이 적용된다!
// components/ui/ThemeToggle.tsx
import { Sun, Moon } from 'lucide-react';
import { useTheme } from 'next-themes';
export default function ThemeToggle() {
const { resolvedTheme, setTheme } = useTheme();
const handleTheme = () => {
setTheme(resolvedTheme === 'dark' ? 'light' : 'dark');
};
return (
<button
className="relative flex w-7 h-7 md:w-8 md:h-8 rounded-full justify-center items-center hover:bg-gray-200 dark:hover:bg-gray-700 cursor-pointer"
onClick={handleTheme}
>
<Sun className="w-5 h-5 md:w-6 md:h-6 rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0" />
<Moon className="w-5 h-5 md:w-6 md:h-6 absolute rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100" />
</button>
);
}
next-themes의 useTheme 훅으로 현재의 테마 상태를 가져오고, 현재 적용된 실제 테마 값인 resolvedTheme 값을 기반으로 테마를 변경하는 setTheme 함수를 토글 컴포넌트에 연결해주었다.
import type { Metadata } from 'next';
import { ThemeProvider } from 'next-themes';
import Header from '@/components/layout/Header';
import Footer from '@/components/layout/Footer';
import '@/styles/globals.css';
// ...
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="en" suppressHydrationWarning>
<body className="flex flex-col min-h-screen">
<ThemeProvider attribute="class" defaultTheme="system" enableSystem> // ✅
<Header />
<main className="flex flex-col flex-grow w-full mx-auto px-7 md:px-20 py-5 md:py-8">{children}</main>
<Footer />
</ThemeProvider>
</body>
</html>
);
}
그리고 루트 레이아웃에 ThemeProvider를 추가하여 전역에서 다크모드 토글이 가능하도록 했다 🌙
Next.js는 SSR(Server-Side Rendering) 방식을 사용한다.
- 서버에서 HTML을 먼저 생성
- 브라우저가 HTML을 받아 화면에 표시
- React가 hydration -> JS를 연결하여 인터렉티브하게 만듦
그로 인해 다크모드를 구현할 때 서버는 사용자의 테마 설정을 모르기 때문에 기본 값 기준으로 HTML을 생성하고, 하이드레이션 과정 중 next-themes를 통해 실제로 사용자가 설정한 테마 값을 읽게 되어 이를 기반으로 DOM을 업데이트하여 하이드레이션 불일치 문제가 발생한다.
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="en" data-scroll-behavior="smooth" suppressHydrationWarning> // ✅
<body className="flex flex-col min-h-screen">
<ThemeProvider attribute="class" defaultTheme="system" enableSystem>
<Header />
<main className="flex flex-col flex-grow w-full mx-auto px-7 md:px-20 py-5 md:py-8">{children}</main>
<Footer />
</ThemeProvider>
</body>
</html>
);
}
이를 해결하기 위해 루트 레이아웃의 html 태그에 하이드레이션 불일치 경고를 허용하는 suppressHydrationWarning 속성을 추가했다.