- Published on
블로그 프로젝트를 갈아엎은 이야기 — Next.js 12에서 16으로, 파일 기반에서 클라우드 CMS로
- Authors

- Name
- Deokgoo Kim
블로그 왜 바꿔야 했나
이 블로그는 2021년에 tailwind-nextjs-starter-blog를 기반으로 시작했습니다. Next.js 12, React 17, Pages Router 기반이었고, MDX 파일을 mdx-bundler로 빌드 타임에 처리하는 구조였죠.
3년 넘게 운영하면서 몇 가지 문제가 쌓였습니다.
1. 글 하나 쓰려면 배포를 해야 했다
기존 구조에서는 블로그 글 = MDX 파일이었습니다. 글을 쓰거나 수정하려면:
- 로컬에서
.mdx파일 작성 - Git commit & push
- Vercel 빌드 트리거 → 배포 완료까지 대기
간단한 오타 하나 고치는 데도 이 과정을 거쳐야 했습니다. 빌드 시간이 짧다고 해도, "글을 쓴다"는 행위와 "코드를 배포한다"는 행위가 결합되어 있는 것 자체가 부담이었습니다.
2. 하드코딩 투성이
siteMetadata.js에 제 이름, 이메일, GitHub 주소가 직접 박혀 있었고, 뉴스레터는 Mailchimp API 키가 코드에 묶여 있었습니다. 누군가 이 프로젝트를 포크해서 자기 블로그로 쓰려면 수십 군데를 찾아서 바꿔야 했죠.
// 기존 siteMetadata.js — 하드코딩의 향연
const siteMetadata = {
title: 'duck 블로그',
author: 'deokgoo',
email: 'kkddgg1001@gmail.com',
github: 'https://github.com/deokgoo',
siteUrl: 'https://duck-blog.vercel.app',
// ...
}
3. 기술 스택의 노후화
- Next.js 12 → App Router 미지원
- React 17 → Concurrent Features 사용 불가
- Preact alias로 번들 최적화 → React 18+ 기능과 충돌
mdx-bundler→ contentlayer2로 전환 후 다시next-mdx-remote로yarn→pnpm으로 패키지 매니저 변경
Pages Router에서 App Router로의 전환은 단순 마이그레이션이 아니라 아키텍처 자체를 다시 생각해야 하는 수준이었습니다.
무엇을 바꿨나
프레임워크 & 런타임 업그레이드
| 항목 | Before | After |
|---|---|---|
| Next.js | 12 | 16 |
| React | 17 | 19 |
| Router | Pages Router | App Router |
| MDX 처리 | mdx-bundler (빌드 타임) | next-mdx-remote/rsc (런타임 RSC) |
| 패키지 매니저 | yarn | pnpm |
| TypeScript | 4.x | 5.x |
| Preact alias | 사용 | 제거 |
라우팅 구조 변경
Pages Router의 플랫한 파일 구조에서 App Router의 Route Group 패턴으로 전환했습니다.
# Before (Pages Router)
pages/
├── index.tsx
├── about.tsx
├── blog.tsx
├── blog/[...slug].tsx
├── tags.tsx
├── tags/[tag].tsx
└── api/mailchimp.ts
# After (App Router + Route Groups)
app/
├── (site)/ # 공개 페이지
│ ├── page.tsx
│ ├── about/
│ ├── blog/[...slug]/
│ ├── search/ # tags 페이지를 대체
│ └── layout.tsx
├── (app)/ # 관리자 영역
│ ├── admin/
│ │ ├── editor/ # 에디터
│ │ ├── blog-ideas/
│ │ └── profile/
│ └── login/
└── api/
├── blog/save/
├── blog/delete/
├── blog/search/
└── ...
(site)와 (app)으로 Route Group을 나눠서 공개 페이지와 관리자 영역의 레이아웃을 완전히 분리했습니다.
콘텐츠 관리: 파일 → Firestore
가장 큰 변화입니다. MDX 파일을 Git으로 관리하던 방식에서 Firebase Firestore를 콘텐츠 저장소로 전환했습니다.
# Before: 글 작성 플로우
로컬 에디터 → .mdx 파일 작성 → git push → Vercel 빌드 → 배포
# After: 글 작성 플로우
관리자 페이지 → 에디터에서 작성 → 저장 API 호출 → Firestore 저장 → ISR 캐시 무효화 → 즉시 반영
에디터도 직접 만들었습니다. 마크다운 에디터, WYSIWYG 에디터, 실시간 미리보기, 템플릿 관리까지 관리자 페이지에서 모두 처리할 수 있습니다.
// 글 저장 API — Firestore에 직접 저장하고 캐시를 즉시 무효화
const postData = {
slug,
title: metadata.title,
date: metadata.date,
tags: metadata.tags || [],
content: content,
status: metadata.draft ? 'draft' : 'published',
layout: metadata.layout || 'PostLayout',
};
await db.collection('posts').doc(slug).set(postData);
// ISR 캐시 즉시 무효화
revalidatePath(`/blog/${slug}`);
revalidatePath('/blog');
revalidatePath('/');
포크 가능한 템플릿화
하드코딩을 전부 환경변수와 설정 파일로 분리했습니다.
// After: 환경변수 기반 설정
const siteMetadata = {
title: 'My Blog',
author: 'Blog Author',
siteUrl: process.env.NEXT_PUBLIC_SITE_URL || 'https://your-blog.vercel.app',
siteRepo: process.env.NEXT_PUBLIC_SITE_REPO || 'https://github.com/your-username/your-repo-name',
email: 'your.email@example.com',
// ...
}
이제 포크한 뒤 .env.local과 siteMetadata.js만 수정하면 자기만의 블로그로 바로 사용할 수 있습니다.
검색 시스템 개편
기존에는 /tags 페이지에서 태그별로만 글을 찾을 수 있었습니다. 이걸 /search 페이지로 통합하면서 키워드, 태그, 날짜 기반의 동적 검색을 지원하도록 바꿨습니다.
보안 강화
CSP(Content Security Policy)를 본격적으로 적용했습니다. 기존에는 주석 처리되어 있던 CSP 헤더를 활성화하고, Google AdSense, Analytics, Firebase 등 사용하는 외부 서비스에 맞게 세밀하게 설정했습니다.
const ContentSecurityPolicy = `
default-src 'self';
script-src 'self' 'unsafe-eval' 'unsafe-inline' giscus.app analytics.umami.is ...;
connect-src * https://*.googleapis.com https://*.firebaseio.com ...;
frame-src giscus.app https://*.firebaseapp.com ...;
`;
캐싱 전략: force-dynamic에서 ISR + On-demand Revalidation으로
Firestore로 전환하면서 가장 고민했던 부분이 캐싱이었습니다. 기존 파일 기반 MDX는 빌드 타임에 모든 페이지가 정적으로 생성되니까 캐싱을 따로 신경 쓸 필요가 없었거든요. 하지만 Firestore에서 런타임에 데이터를 가져오는 구조로 바뀌면서, 초기에는 모든 페이지에 force-dynamic을 걸어놨습니다.
당연히 매 요청마다 Firestore를 호출하게 되었고, 응답 속도도 느려지고 비용도 올라갔습니다.
결국 revalidate: false (영구 캐시) + On-demand Revalidation 조합으로 전환했습니다.
| 페이지 | Before | After | 이유 |
|---|---|---|---|
| 블로그 상세 | force-dynamic | revalidate: false | 글 저장/수정 시에만 무효화 |
| 블로그 목록 | force-dynamic | revalidate: false | 새 글 발행 시에만 무효화 |
| 메인 페이지 | force-dynamic | revalidate: false | 최신 글 목록 변경 시에만 무효화 |
| 소개 페이지 | force-dynamic | revalidate: 86400 | 거의 안 바뀌므로 24시간 캐싱 |
| 사이트맵 | force-dynamic | revalidate: 3600 | 1시간 주기 갱신이면 충분 |
| 관리자 페이지 | force-dynamic | force-dynamic 유지 | 인증/보안상 항상 최신 데이터 필요 |
핵심은 글 저장/삭제 API에서 revalidatePath()를 호출해서 관련 페이지의 캐시만 선택적으로 무효화하는 것입니다.
// 글 저장 시 — 해당 페이지만 정확히 무효화
revalidatePath(`/blog/${slug}`); // 해당 포스트
revalidatePath('/blog'); // 목록 페이지
revalidatePath('/'); // 메인 페이지
이렇게 하면 평소에는 CDN에서 캐시된 페이지를 바로 내려주고, 콘텐츠가 실제로 변경될 때만 Firestore를 다시 호출합니다. Firestore 호출이 체감상 90% 이상 줄었고, 페이지 로딩도 눈에 띄게 빨라졌습니다.
contentlayer2에서 next-mdx-remote/rsc로 전환한 것도 이 캐싱 전략과 맞물립니다. contentlayer는 빌드 타임에 모든 콘텐츠를 처리하는 구조라 Firestore 같은 런타임 데이터 소스와 궁합이 안 맞았거든요. next-mdx-remote/rsc는 React Server Component에서 런타임에 MDX를 렌더링하면서도 Next.js의 캐싱 레이어를 그대로 활용할 수 있어서, ISR + On-demand Revalidation 패턴과 자연스럽게 결합됩니다.
삭제한 것들
리뉴얼 과정에서 꽤 많은 코드를 걷어냈습니다.
lib/mdx.ts— mdx-bundler 기반 MDX 처리 로직 전체lib/tags.ts,lib/remark-*.ts— 커스텀 remark 플러그인들 (pliny 라이브러리로 대체)lib/utils/files.ts,formatDate.ts,kebabCase.ts— 유틸리티 함수들pages/디렉토리 전체 — App Router로 이전scripts/compose.js,generate-sitemap.js— Next.js 내장 기능으로 대체- Preact alias — React 19에서는 불필요
- Mailchimp 연동 — Buttondown으로 교체
types/디렉토리 —lib/types.ts로 통합
추가한 것들
- Firebase 인증 + Firestore 연동 (
lib/firebase.ts,lib/firebaseAdmin.ts,lib/firestore.ts) - 관리자 페이지 (
app/(app)/admin/) — 에디터, 프로필 관리, 블로그 아이디어 관리 - 에디터 컴포넌트 (
components/editor/) — 마크다운/WYSIWYG/미리보기/템플릿 - ISR + On-demand Revalidation — 글 저장/삭제 시 캐시 즉시 무효화
- Mermaid 다이어그램 지원
- Google Search Console 자동 인증 (환경변수 기반)
- Windows 파일명 호환성 검사 pre-commit 훅
돌아보며
이번 마이그레이션의 핵심은 결국 두 가지였습니다.
- 글쓰기와 배포를 분리하기 — 콘텐츠는 콘텐츠답게, 코드는 코드답게
- 나만 쓸 수 있는 블로그에서 누구나 쓸 수 있는 블로그로 — 하드코딩 제거, 환경변수 기반 설정
3년간 쌓인 기술 부채를 한 번에 청산하는 건 쉽지 않았지만, Next.js 16의 App Router와 React Server Components 덕분에 오히려 코드가 더 깔끔해진 부분도 많습니다.
특히 next-mdx-remote/rsc를 사용하면서 MDX 렌더링이 서버 컴포넌트에서 바로 처리되는 건 꽤 만족스러운 변화였습니다. 빌드 타임에 모든 걸 처리하던 때와 비교하면 개발 경험이 확실히 좋아졌어요.
아직 개선할 부분은 남아 있습니다. 이미지 관리 파이프라인이라든가, 에디터 UX 개선이라든가. 하지만 이제는 글을 쓰고 싶을 때 바로 쓸 수 있는 환경이 되었고, 그게 이번 작업의 가장 큰 성과입니다.
새 글 알림 받기
실무에서 바로 써먹을 수 있는 개발 팁과 경험담을 받아보세요
개인정보는 뉴스레터 발송 목적으로만 사용되며, 언제든 구독을 해지할 수 있습니다.