Logo
duck blog | 기술 블로그
Published on

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

Authors

블로그 왜 바꿔야 했나

이 블로그는 2021년에 tailwind-nextjs-starter-blog를 기반으로 시작했습니다. Next.js 12, React 17, Pages Router 기반이었고, MDX 파일을 mdx-bundler로 빌드 타임에 처리하는 구조였죠.

3년 넘게 운영하면서 몇 가지 문제가 쌓였습니다.

1. 글 하나 쓰려면 배포를 해야 했다

기존 구조에서는 블로그 글 = MDX 파일이었습니다. 글을 쓰거나 수정하려면:

  1. 로컬에서 .mdx 파일 작성
  2. Git commit & push
  3. 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
  • yarnpnpm으로 패키지 매니저 변경

Pages Router에서 App Router로의 전환은 단순 마이그레이션이 아니라 아키텍처 자체를 다시 생각해야 하는 수준이었습니다.


무엇을 바꿨나

프레임워크 & 런타임 업그레이드

항목BeforeAfter
Next.js1216
React1719
RouterPages RouterApp Router
MDX 처리mdx-bundler (빌드 타임)next-mdx-remote/rsc (런타임 RSC)
패키지 매니저yarnpnpm
TypeScript4.x5.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.localsiteMetadata.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 조합으로 전환했습니다.

페이지BeforeAfter이유
블로그 상세force-dynamicrevalidate: false글 저장/수정 시에만 무효화
블로그 목록force-dynamicrevalidate: false새 글 발행 시에만 무효화
메인 페이지force-dynamicrevalidate: false최신 글 목록 변경 시에만 무효화
소개 페이지force-dynamicrevalidate: 86400거의 안 바뀌므로 24시간 캐싱
사이트맵force-dynamicrevalidate: 36001시간 주기 갱신이면 충분
관리자 페이지force-dynamicforce-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 훅

돌아보며

이번 마이그레이션의 핵심은 결국 두 가지였습니다.

  1. 글쓰기와 배포를 분리하기 — 콘텐츠는 콘텐츠답게, 코드는 코드답게
  2. 나만 쓸 수 있는 블로그에서 누구나 쓸 수 있는 블로그로 — 하드코딩 제거, 환경변수 기반 설정

3년간 쌓인 기술 부채를 한 번에 청산하는 건 쉽지 않았지만, Next.js 16의 App Router와 React Server Components 덕분에 오히려 코드가 더 깔끔해진 부분도 많습니다.

특히 next-mdx-remote/rsc를 사용하면서 MDX 렌더링이 서버 컴포넌트에서 바로 처리되는 건 꽤 만족스러운 변화였습니다. 빌드 타임에 모든 걸 처리하던 때와 비교하면 개발 경험이 확실히 좋아졌어요.

아직 개선할 부분은 남아 있습니다. 이미지 관리 파이프라인이라든가, 에디터 UX 개선이라든가. 하지만 이제는 글을 쓰고 싶을 때 바로 쓸 수 있는 환경이 되었고, 그게 이번 작업의 가장 큰 성과입니다.

새 글 알림 받기

실무에서 바로 써먹을 수 있는 개발 팁과 경험담을 받아보세요

#실무 개발 경험담#최신 기술 트렌드#성능 최적화 노하우#개발 팁과 인사이트

개인정보는 뉴스레터 발송 목적으로만 사용되며, 언제든 구독을 해지할 수 있습니다.