Next.js MDX 블로그를 만들면서 버린 것과 고른 것들
Contentlayer가 유지보수 중단이라 Velite로 갈아탔습니다. MDX 파이프라인 구성, FSD 폴더 구조, 테마 첫 페인트까지 만들면서 결정한 것들을 정리했습니다.
geonoooo
2 min read
NOTE
바이브코딩으로 만들었습니다. Claude Code와 대화하면서 구조·UI·문구를 함께 다듬었고, 기술적인 내용보다는 그 과정에서 나온 고민들을 정리한 글입니다. 작성자의 블로그 코드는 깃허브에서 보실 수 있습니다.
드디어 제 개발 블로그를 만들었습니다.사실 뭐 특별한 계기가 있는건 아니고, 블로그를 한동안 쓰지 않아서 블로그를 직접 만들면 아까워서라도 쓰지 않을까 하는 마음에 만들긴 했습니다 헿헿. 아직 도메인을 붙이지 않았는데 좀 찾아보니까 cloudflare에서 붙이는게 좋더라구요. 이건 붙이게 되면 따로 포스팅 하도록 하겠습니다. 의외로 결정할 게 많더라고요. 이 글에서는 왜 만들었고, 뭘 썼고, 어디서 막혔는지를 정리해보려고 합니다.
1. 스택
먼저 고른 스택을 정리해보면 이렇습니다.
| 항목 | 선택 | 왜 |
|---|---|---|
| 프레임워크 | Next.js 16 (App Router) | RSC + SSG, 최신 버전 |
| 콘텐츠 | MDX + Velite | frontmatter 검증·타입 자동생성 |
| 스타일 | Tailwind 4 + typography | prose 클래스로 본문 스타일 끝 |
| 테마 | 자체 구현 | 인라인 스크립트로 첫 페인트 전 적용 |
| 폰트 | Pretendard (self-host) | 한글 가독성 |
| 구조 | FSD | 뒤에서 자세히 |
MDX 처리에서 원래 Contentlayer를 쓰려고 했는데, 유지보수가 중단된 상태였습니다. 그래서 Velite로 갈아탔습니다. 하는 일은 거의 똑같습니다.. frontmatter 스키마를 검증해주고, 빌드 타임에 타입이 붙은 JSON을 만들어줍니다.
// velite.config.ts
const posts = {
name: "Post",
pattern: "posts/**/*.mdx",
schema: s.object({
title: s.string().max(120),
summary: s.string().max(240),
date: s.isodate(),
category: s.enum(POST_CATEGORIES),
tags: s.array(s.string()).default([]),
draft: s.boolean().default(false),
// ...
}),
};이렇게 해두면 MDX 파일 frontmatter에서 오타가 나거나 날짜 포맷이 틀리면 빌드가 깨집니다. 손으로 관리하는 것보단 훨씬 안정적이죠.
2. 폴더 구조 — FSD를 블로그에 쓴 이유
폴더 구조는 FSD(Feature-Sliced Design) 를 따랐습니다.
src/
app/ # Next.js 라우트 (페이지만)
entities/
post/ # ui · model · lib · api
shared/
ui/ config/ lib/ mdx/ providers/"블로그 같은 작은 프로젝트에 FSD는 오버엔지니어링이 아닌가?" 라는 생각에 그냥 features랑 widgets 레이어를 지워버렸습니다. 나중에 시리즈·검색·댓글 같은 게 들어오면 그때 넣지 않을까 싶습니다.
같은 고민을 나중에 사내 관리 도구에서 다시 했고, 거기서는 네 레이어로 정리했습니다. 파일 기반 라우팅이 폴더 구조를 먼저 정해버리는 문제까지 작은 React 프로젝트에도 FSD가 필요할까?에 적었습니다.
대신 얻는 게 있습니다. post 관련된 컴포넌트·함수가 전부 한 경로로 들어와서 일관성이 있어요.
import {
getAdjacentPosts,
getAllPosts,
getPostBySlug,
PostBody,
PostHeader,
PostNavigation,
} from "@/entities/post";페이지 컴포넌트에서 import 경로가 이 정도로 깔끔하면 앞으로 리팩터할 때 편합니다.
3. 가장 많이 고민한 것 — URL 라우팅
이 블로그를 만들면서 제일 오래 붙잡고 있었던 게 URL 구조입니다. 그래도 블로그를 쓴 김에 다른 분들의 피드백도 받고(아직 댓글 혹은 아무기능도 없지만 ㅠㅠ) 이왕 쓴거 많은 분들이 보러 오시면 좋으니 seo에 초점을 뒀습니다. 이 부분을 고려를 하다보니 url 설정을 계속 바꾸게 되는데 아 뭔가 비효율적인 생각이 들었습니다.검색엔진에 인덱싱된 URL이 바뀌면 SEO가 날아가고, 공유된 링크도 깨지고 뭐 이런저런...
3-1. /blog & /post로 할까, /로 할까
사실 가장 먼저 한 고민은 슬러그 구조가 아니라 "블로그를 루트에 둘 것이냐" 였습니다. 많은 개발자 블로그가 example.com/blog/... 형태로 되어 있습니다. Vercel 블로그, Josh Comeau, 한국 개발자 블로그들도 상당수가 이 패턴입니다.
/blog를 쓰는 이유는 명확합니다.
/— 랜딩/자기소개/blog— 글 모음/projects— 포트폴리오/about— 상세 소개
이렇게 사이트가 "개인 홈페이지 + 블로그"로 자라는 걸 전제할 때 제일 깔끔합니다. 각 섹션이 자기 네임스페이스를 갖고, 서로 간섭 안 하고, 나중에 늘려도 구조가 안 깨집니다.
처음엔 저도 이쪽으로 마음이 기울었습니다. "있어 보이기도 하고, 확장성도 있고". 근데 잠깐 멈춰서 생각해봤습니다. 지금 내가 만드는 게 포트폴리오 사이트인가, 블로그인가?
답은 블로그였습니다. 포트폴리오 페이지를 붙일 계획도 당장은 없고, /about 정도면 충분합니다. 그 상태에서 /blog를 쓰면 두 가지가 이상해집니다.
/와/blog가 중복됩니다. 랜딩에 최근 글 목록 넣으면/blog랑 차이가 없습니다./를 빈 인삿말 페이지로 두자니 그건 그거대로 이상하고 말이죠.- 모든 글 URL이 한 칸씩 깊어집니다.
/posts/my-post도 충분히 긴데/blog/posts/my-post는 과합니다.
그래서 "이 사이트의 본질은 블로그다"를 인정하고 루트가 곧 글 목록이 되게 했습니다. 나중에 포트폴리오가 붙으면 그땐 /projects로 옆에 세우면 됩니다. 루트를 다시 갈아엎는 것보단, 옆에 방을 하나 더 짓는 게 훨씬 쉽습니다.
3-2. slug 구조 — 네 가지 후보
다음은 글 URL 자체의 모양이었습니다. 후보는 네 가지.
/[slug]— 예:/my-post/posts/[slug]— 예:/posts/my-post/posts/[category]/[slug]— 예:/posts/react/my-post/[category]/[slug]— 예:/react/my-post
/[slug] (루트 flat) — URL이 제일 짧아서 예쁩니다. 근데 /about, /tags, /categories 같은 페이지랑 네임스페이스가 겹칩니다. catch-all 라우트로 피할 수는 있는데, 그럼 "이 경로가 글인지 페이지인지"를 런타임에 판별해야 해서 라우트 매칭이 약해집니다. 패스.
/posts/[category]/[slug] (카테고리 중첩) — URL만 봐도 분류가 보여서 좋아 보이는데, 치명적인 문제가 있습니다. 글의 카테고리를 바꾸는 순간 URL이 바뀌어 버립니다. "이거 사실 react보다 typescript 카테고리가 맞는 것 같은데?" 하고 바꾸면 기존 링크가 전부 깨집니다. 블로그에서 URL은 가능한 한 영구적이어야 하는데, 이건 그 원칙이랑 정면으로 부딪힙니다.
/posts/[slug] + /categories/[category] 분리 — 글의 "정체성"은 slug 하나로만 결정됩니다. 카테고리·태그는 별도의 필터된 목록 뷰로만 존재합니다. 글 URL은 카테고리가 바뀌든, 태그가 추가되든 절대 안 바뀝니다. 이게 제일 안전해 보여서 이걸로 갔습니다.
3-3. 실제 라우트 구조
src/app/
page.tsx # / (최근 글)
posts/
page.tsx # /posts (전체 글 목록)
[slug]/page.tsx # /posts/[slug] (개별 글)
categories/
page.tsx # /categories (카테고리 인덱스)
[category]/page.tsx # /categories/[category] (필터 뷰)
tags/
[tag]/page.tsx # /tags/[tag]원칙을 한 줄로 정리하면:
글의 정체는
/posts/[slug]하나. 카테고리/태그는 단지 뷰일 뿐이다.
3-4. /posts와 /categories/[category], 합칠 수 없을까?
여기서 진짜 오래 고민한 게 하나 있습니다. 두 페이지 코드를 같이 놓고 보면 이렇게 생겼습니다.
// src/app/posts/page.tsx
export default function PostsPage() {
const posts = getAllPosts();
return (
<Container>
<PostCategoryFilter />
<PostList posts={posts} />
</Container>
);
}// src/app/categories/[category]/page.tsx
export default async function CategoryPage({ params }: CategoryPageProps) {
const { category } = await params;
if (!isCategory(category)) notFound();
const posts = getPostsByCategory(category);
return (
<Container>
<PostCategoryFilter active={category} />
<PostList
posts={posts}
empty={`${CATEGORY_LABELS[category]} 카테고리에 아직 글이 없습니다.`}
/>
</Container>
);
}거의 똑같습니다. 진짜 다른 건 딱 세 줄입니다.
- 글 소스:
getAllPosts()vsgetPostsByCategory(category) - 필터의 활성 상태:
<PostCategoryFilter />vs<PostCategoryFilter active={category} /> - empty 메시지
헤더도 같고, Container도 같고, PostList도 같고. 이쯤 되면 "합칠 수 있지 않나?" 싶어집니다. 제가 떠올린 방법은 두 가지였습니다.
방법 1 — 쿼리 파라미터로 합치기
/posts # 전체
/posts?category=react # react 카테고리방법 2 — optional catch-all로 합치기
src/app/posts/[[...filter]]/page.tsx
/posts # filter = undefined → 전체
/posts/react # filter = ["react"] → react 카테고리코드 중복이 줄어드니까 당연히 끌렸습니다. 근데 조사해보니 SEO 관점에서 이 둘 다 문제더라고요.
쿼리 파라미터가 안 되는 이유. 검색엔진은 ?category=react 같은 쿼리 파라미터를 기본적으로 같은 페이지의 변형으로 봅니다. 크롤링은 해도 별도 페이지로 인덱싱 안 할 때가 많습니다. 크롤러 설정에 따라 아예 무시되기도 하고, "중복 콘텐츠"로 분류되어 하나만 남기고 나머지를 버리기도 합니다. /posts?category=react로 아무리 좋은 글을 모아놔도 검색 결과에 안 뜨면 의미가 없습니다. 게다가 공유할 때도 쿼리 파라미터는 "이건 링크의 일부인가, 트래킹용인가?" 하는 애매함을 줍니다.
catch-all이 안 되는 이유. /posts/[[...filter]]로 만들면 /posts/react는 카테고리 페이지가 됩니다. 그런데 이미 /posts/[slug]에서 /posts/react는 react라는 slug를 가진 글 페이지로 매칭될 수 있습니다. 네임스페이스가 겹칩니다. 런타임에 "이게 글이야 카테고리야?"를 판별하는 로직이 필요해지고, Velite의 generateStaticParams랑도 꼬입니다. 글 하나 추가했는데 카테고리 이름이랑 겹치면 라우트가 깨지는 폭탄이 되는 셈입니다.
정리하면 두 페이지를 "하나의 라우트"로 합치는 건 손실이 더 큽니다.
- 쿼리 파라미터: 별도 URL로 인덱싱 안 됨 →
/categories/react가 검색결과에 안 나옴 - catch-all: slug와 카테고리명 네임스페이스 충돌 → 운영 리스크
그래서 결론은: 라우트는 분리한다. UI는 컴포넌트로 공유한다.
중복돼 보이던 건 사실 라우트 레벨의 중복이 아니라 "같은 컴포넌트를 쓰고 있어서 생긴 착시" 였습니다. Container, PostCategoryFilter, PostList가 이미 공유되고 있습니다. 페이지 컴포넌트가 하는 일은 "어떤 데이터를 넘길지" 를 결정하는 것뿐입니다. 그게 4~6줄로 끝나는 거면, 그건 중복이 아니라 얇은 라우트 어댑터로 봐야 맞습니다.
한 줄로 요약:
라우트는 주소고, 컴포넌트는 조각이다. 주소는 검색엔진을 위해 분리하고, 조각은 재사용으로 중복을 없앤다.
3-5. /와 /posts는 어떻게 다르게?
그럼 남는 질문 하나. /(홈)과 /posts(전체 글 목록)는 어떻게 구분해야 하나?
지금 제 구조에서는:
/— 최근 글 5개만 + "All posts →" 링크/posts— 전체 글 + 카테고리 필터
역할은 나눠뒀는데, 만들다 보면 솔직히 이런 생각이 듭니다. "둘이 너무 비슷한데 /posts 지워버리고 /에 전체 목록 올리면 안 되나?" 카테고리 필터 있고 없고 정도의 차이라면 굳이 라우트를 둘로 가를 이유가 있나 싶었습니다.
그런데도 남겨뒀습니다. 이유는 세 가지입니다.
/posts/[slug]의 부모가 필요합니다./posts/my-post에서 한 단계 위로 올라갔을 때 기대하는 건 "글 목록"이지 "홈"이 아닙니다. URL 계층이 깨지면 뒤로 가기와 브레드크럼이 어색해집니다.- sitemap·RSS의 기준점이 됩니다. "전체 글 아카이브"라는 의미의 URL이 하나 있어야 외부에서 인용하기도 편합니다.
- 확장의 자리를 남겨둡니다. 글이 많아지면
/posts는 연도별 그룹·검색·페이지네이션이 붙는 아카이브가 되고,/는 프로필·최근 글·추천 태그가 놓이는 "랜딩"으로 자랍니다. 지금 합쳐두면 나중에 다시 쪼개야 합니다.
그러니까 "똑같아 보인다"의 해답은 라우트를 지우는 게 아니라 /를 아카이브스럽지 않게 만드는 것이었습니다. /는 "간단히 훑어보는 곳", /posts는 전체 글 모음 장소로 보면 되더라구요.
근데 그냥 냅뒀습니다. /페이지에 뭐를 많이 추가하고 싶지 않아서 결국에는 /와 /posts를 비슷하게 만들었습니다.
3-6. slug를 Velite에서 어떻게 만들었나
여기서 작은 삽질이 있었습니다. Velite가 s.path()로 파일 경로 기반 slug를 만들어주는데, content/posts/foo.mdx → posts/foo가 돼서 posts/ 접두어가 붙습니다. 그러면 라우트랑 합쳐졌을 때 /posts/posts/foo가 되는 참사가 벌어집니다. 그래서 Velite 쪽에서 접두어를 떼어줬습니다.
.transform((data) => ({
...data,
slug: data.slug.replace(/^posts\//, ""),
permalink: `/posts/${data.slug.replace(/^posts\//, "")}`,
}))permalink 필드를 같이 만들어두면 컴포넌트 어디서든 <Link href={post.permalink}> 한 줄로 쓸 수 있습니다. 나중에 라우트 prefix를 /blog/[slug]로 바꾸고 싶어지면 이 한 곳만 고치면 끝납니다.
3-7. SSG — 빌드 타임에 다 만들어두기
글도, 카테고리도, 태그도 전부 generateStaticParams로 빌드 타임에 정적 생성합니다. 런타임 DB가 필요 없습니다.
export function generateStaticParams() {
return getAllPosts().map((post) => ({ slug: post.slug }));
}이 부분은 나중에 CMS나 DB를 붙이면 크게 바뀔 텐데, 지금은 이 심플함이 제일 큰 장점입니다.
3-8. 아직 넣지 않은 기능들
- 페이지네이션 — 아직 글이 많지 않아서..(1개인걸용ㅜㅜ)
/page/2같은 경로 — 같은 이유.- i18n 라우팅 — 한국어만 쓸 거라 불필요.
뭐 어짜피 나중에 필요하면 그때 추가해도 되니까.. 당장은 이렇게 했습니다.
4. UI에서 고민한 자잘한 것들
다크모드 토글. 처음엔 Light / System / Dark 3버튼을 인라인으로 헤더에 붙였습니다. 그런데 헤더가 너무 빡빡해 보여서, 아이콘 하나 눌러서 순환되는 단일 토글로 바꿨습니다. 순서는 light → dark → system. "지금이 어떤 모드인지" 한눈에 안 보인다는 단점이 있긴 한데, 그건 aria-label로 커버했습니다. 이 토글의 첫 페인트 깜빡임을 나중에 useSyncExternalStore로 고쳤다고 커밋에 적었는데, 재보니 아니었습니다. 그 측정은 다크모드 깜빡임, useSyncExternalStore가 정말 해결했을까?에 적었습니다.
헤더의 Posts / Categories. 처음엔 "헤더가 답답하니까 아래로 내릴까?" 싶었는데, 블로그의 핵심 탐색 경로를 숨기는 건 아니다 싶어서 헤더에 그대로 뒀습니다. 헤더가 무거워 보였던 건 테마 토글을 압축해서 해결됐습니다. 요소 수보다 밀도가 문제일 때가 많더라고요.
5. 콘텐츠 파이프라인
MDX 렌더링은 Velite + rehype 플러그인 조합입니다.
mdx: {
remarkPlugins: [remarkGfm],
rehypePlugins: [
rehypeSlug, // 헤딩에 id
[rehypePrettyCode, { // shiki 기반 코드 하이라이팅
theme: "vesper",
keepBackground: true,
}],
[rehypeAutolinkHeadings, { // 앵커 링크
behavior: "append",
properties: { className: ["heading-anchor"] },
}],
],
},개발할 때는 Velite watch랑 Next dev를 동시에 돌려야 하는데, concurrently로 한 스크립트에 묶었습니다.
"dev": "concurrently -k -n velite,next -c cyan,magenta \"velite --watch\" \"next dev\""6. SEO / 메타
글마다 generateMetadata에서 Open Graph 메타를 넣어주고 있습니다.
export async function generateMetadata({ params }): Promise<Metadata> {
const { slug } = await params;
const post = getPostBySlug(slug);
if (!post) return {};
return {
title: post.title,
description: post.summary,
openGraph: {
type: "article",
title: post.title,
description: post.summary,
publishedTime: post.date,
tags: post.tags,
url: post.permalink,
},
};
}sitemap.ts, robots.ts, rss.xml도 같이 만들어뒀습니다. 구글 서치 콘솔에 사이트맵만 제출하면 끝입니다.
7. 앞으로 연결할 것들
지금 블로그는 완전 정적인 읽기 전용 사이트입니다. 다음 편에서는 여기에 쓰기·상태가 필요해지는 기능을 하나씩 붙일 예정입니다.
- 조회수 / 좋아요 — DB 연결 (Supabase vs PlanetScale 검토 중)
- 댓글 — Giscus부터 시작하되 자체 구현 가능성도 열어두기
- 검색 — 글이 적을 땐 Fuse.js로 충분, 많아지면 Pagefind
- 조회 분석 — Plausible 또는 PostHog
- 글 작성 UX — 계속 MDX 파일로 갈지, CMS(Sanity/Contentful)를 붙일지
이 중 조회수는 이 글 뒤에 Supabase로 붙였습니다. Hits·Upstash와 비교한 기준은 블로그 조회수 하나 넣는데 Supabase까지 필요할까?에 적었습니다.
정적 사이트에서 상태가 섞이기 시작하면 지금의 단순한 구조가 어떻게 바뀌는지를 다음 글에서 다뤄보려고 합니다.
8. 마무리
바이브 코딩으로 블로그를 만들면서 URL을 고민하는데 거의 50%, 다른 분들은 어떻게 URL을 구성했는지 찾아보는데 40%, 그리고 나머지 10% 정도인것 같습니다.
만들면서 제일 크게 배운 건 "URL은 한 번 정하면 바꾸기 어렵다" 는 당연한 사실이었습니다. 기능은 나중에 바꿔도 되는데, URL 구조는 초반에 진지하게 생각할 가치가 있더라고요.
특히 /posts랑 /categories/[category]를 합치려다 말았던 경험이 인상적이었습니다.
회사에서 B2B를 만들다 보니 URL를 많이 생각해본적도 없고, 검색엔진이 필요하지도 않으니 당연히 합칠 생각을 했던 ...
눈에 보이는 중복이 항상 나쁜 건 아니라는 것을 깨닫는 계기였습니다. SEO처럼 외부 세계가 관여하는 경계에서는, 오히려 "분리된 채로 유지되는 중복"이 자산이 됩니다. DRY를 적용할 층을 라우트가 아니라 컴포넌트로 내리면, 중복도 없고 URL도 안 깨집니다.
오타나 이상한 부분 발견하시면 이메일이나 GitHub 이슈로 알려주시면 감사하겠습니다. 다음 편에서 뵙겠습니다.
참고한 블로그
불필요한 요소를 덜어내고 최대한 깔끔한? 블로그를 만들고자 했습니다. 공간의 구성부터 기능 구현까지 큰 영감을 주신 아래 운영자분들께 깊은 감사를 전합니다.
Comments