바이브 코딩으로 블로그 만들어져버리기
Next.js 16으로 블로그 만들기
NOTE
바이브코딩으로 만들었습니다. Claude Code와 대화하면서 구조·UI·문구를 함께 다듬었고, 기술적인 내용보다는 그 과정에서 나온 고민들을 정리한 글입니다. 코드는 깃허브에서 보실 수 있습니다.
드디어 제 개발 블로그를 만들었습니다.사실 뭐 특별한 계기가 있는건 아니고, 블로그를 한동안 쓰지 않아서 블로그를 직접 만들면 아까워서라도 쓰지 않을까 하는 마음에 만들긴 했습니다 헿헿. 아직 도메인을 붙이지 않았는데 좀 찾아보니까 cloudflare에서 붙이는게 좋더라구요. 이건 붙이게 되면 따로 포스팅 하도록 하겠습니다. 의외로 결정할 게 많더라고요. 이 글에서는 왜 만들었고, 뭘 썼고, 어디서 막혔는지를 정리해보려고 합니다.
1. 스택
먼저 고른 스택을 정리해보면 이렇습니다.
| 항목 | 선택 | 왜 |
|---|---|---|
| 프레임워크 | Next.js 16 (App Router) | RSC + SSG, 최신 버전 |
| 콘텐츠 | MDX + Velite | frontmatter 검증·타입 자동생성 |
| 스타일 | Tailwind 4 + typography | prose 클래스로 본문 스타일 끝 |
| 테마 | next-themes | SSR flash 없는 다크모드 |
| 폰트 | 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 레이어를 지워버렸습니다. 나중에 시리즈·검색·댓글 같은 게 들어오면 그때 넣지 않을까 싶습니다.
대신 얻는 게 있습니다. 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로 커버했습니다.
헤더의 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)를 붙일지
정적 사이트에서 상태가 섞이기 시작하면 지금의 단순한 구조가 어떻게 바뀌는지를 다음 글에서 다뤄보려고 합니다.
8. 마무리
바이브 코딩으로 블로그를 만들면서 URL을 고민하는데 거의 50%, 다른 분들은 어떻게 URL을 구성했는지 찾아보는데 40%, 그리고 나머지 10% 정도인것 같습니다.
만들면서 제일 크게 배운 건 "URL은 한 번 정하면 바꾸기 어렵다" 는 당연한 사실이었습니다. 기능은 나중에 바꿔도 되는데, URL 구조는 초반에 진지하게 생각할 가치가 있더라고요.
특히 /posts랑 /categories/[category]를 합치려다 말았던 경험이 인상적이었습니다.
회사에서 B2B를 만들다 보니 URL를 많이 생각해본적도 없고, 검색엔진이 필요하지도 않으니 당연히 합칠 생각을 했던 ...
눈에 보이는 중복이 항상 나쁜 건 아니라는 것을 깨닫는 계기였습니다. SEO처럼 외부 세계가 관여하는 경계에서는, 오히려 "분리된 채로 유지되는 중복"이 자산이 됩니다. DRY를 적용할 층을 라우트가 아니라 컴포넌트로 내리면, 중복도 없고 URL도 안 깨집니다.
오타나 이상한 부분 발견하시면 이메일이나 GitHub 이슈로 알려주시면 감사하겠습니다. 다음 편에서 뵙겠습니다.
참고한 블로그
불필요한 요소를 덜어내고 최대한 깔끔한? 블로그를 만들고자 했습니다. 공간의 구성부터 기능 구현까지 큰 영감을 주신 아래 운영자분들께 깊은 감사를 전합니다.