대충 만든 게 아니라, 설계로 만들었습니다
Duck Blog는 Next.js 16 + Vercel + Firebase로 구축된 개인 블로그입니다. 각 선택은 "최신이라서"가 아니라, 이 블로그의 워크로드에서 어떤 이득을 얻는지 알고 고른 것입니다.
기술 스택
최신 스택을 쓰되, 안정성을 우선합니다. Next.js 16의 App Router와 Previous Caching Model, React 19, Tailwind CSS로 구성됩니다.
Next.js 16
App Router · Previous Caching Model · Turbopack. Metadata API로 SEO를 관리합니다.
React 19 + TypeScript
Server Components 기반. 타입으로 데이터 구조를 보장합니다.
Tailwind CSS 3.4
Vercel Minimal 디자인 토큰(ink/line/accent)으로 통일된 디자인 시스템.
Firebase Firestore
포스트·저자·댓글·좋아요를 Firestore로 관리하는 헤드리스 CMS.
next-mdx-remote
MDX + GFM + KaTeX + Prism. 수학 공식과 코드 하이라이팅을 지원합니다.
Vercel
main 브랜치 push 시 자동 배포. Edge 네트워크에서 정적 서빙.
블로그는 읽기 집중 워크로드입니다. 그래서 RDBMS 대신 Firestore(운영 부담 0, 서버리스와 궁합이 좋음), 정적 생성 대신 MDX(코드·수학 공식 지원), 별도 CMS 대신 Firestore 문서(배포 없이 발행)를 골랐습니다. Next.js 16의 Server Components는 Firestore SDK를 클라이언트 번들에서 제외시켜 포스트 페이지 JS 크기를 줄여줍니다.
요청 흐름
브라우저 요청이 Edge에서 콘텐츠까지 거쳐 다시 돌아오는 경로입니다. 대부분 요청은 캐시에서 바로 응답됩니다.
정적 페이지는 빌드 시 생성되어 Edge에서 영구 서빙되고, 콘텐츠 변경 시에만 온디맨드 무효화됩니다. 캐시 계층과 무효화 매트릭스는 아래 섹션에서 자세히 다룹니다.
이 블로그의 트래픽은 "한 번 발행된 글을 수백 번 읽는" 패턴입니다. 그래서 2번째 요청부터는 Vercel Edge에서 정적 HTML을 바로 서빙하고, 서버리스 함수는 빌드 시와 무효화 시에만 실행됩니다. 결과적으로 읽기 요청의 서버 비용이 사실상 0에 가깝습니다.
캐싱 전략
원칙은 하나입니다: 영구 캐시 + 온디맨드 무효화. 시간 기반 재검증을 쓰지 않고, 콘텐츠가 실제로 바뀌는 순간에만 캐시를 갱신합니다.
React Request Cache
cache() — 단일 렌더 패스 내 동일 호출 중복 제거.
Data Cache
unstable_cache — 요청 간 데이터 캐싱, tag 기반 무효화.
Full Route Cache
HTML + RSC Payload 서버 캐싱, path 기반 무효화.
| CRUD 작업 | Tag 무효화 | Path 무효화 |
|---|---|---|
| Post Create | posts-all · tags-* | / · /blog · /blog/{cat} · 각 tag 페이지 |
| Post Update | posts-all · post-{slug} · tags-* | 위 + 포스트 페이지 · 카테고리/slug 변경 시 이전 경로 |
| Post Delete | posts-all · post-{slug} · tags-* | 위와 동일 |
| Author Update | author-{slug} | / · /about |
왜 3계층인가 — 각 계층이 다른 문제를 해결합니다. Request Cache는 포스트 페이지가 같은 데이터를 3번 조회해도 Firestore는 1번만 호출되게 하고, Data Cache는 Firestore SDK가 Next.js fetch 캐시의 대상이 아니라는 한계를 메우며, Route Cache는 렌더링 자체를 없앱니다. TTL 기반 재검증을 버린 이유: 불필요한 Firestore 호출을 유발하고, 변경 직후에도 TTL까지 지연되기 때문입니다. "변경 시에만"이 이 워크로드에서 정확합니다.
SEO
2026-10 SEO 오딧에서 발견한 12개 문제를 반영한 최종 구성입니다. 타이틀, 언어, canonical, sitemap, 구조화 데이터까지 Metadata API로 관리합니다.
서픽스 없는 타이틀
template: '%s' — 포스트 타이틀이 검색 결과에 그대로 노출됩니다.
로케일별 html lang
LocaleLangSync가 pathname 기준으로 ko/en/ja를 동적으로 설정합니다.
페이지별 self canonical
root 전역 canonical 제거 — 각 페이지가 자기 URL을 canonical로 냅니다.
다국어 연결
홈 3개 라우트 + 포스트별 ko/en/ja + x-default를 sitemap과 함께 출력합니다.
구조화 데이터
포스트: BlogPosting + BreadcrumbList, 홈: Organization. script 태그로 렌더링합니다.
실시간 인덱싱
포스트 저장/상태 변경 시 IndexNow에 URL을 제출해 검색 반영을 앞당깁니다.
오딧 전에는 root layout이 모든 페이지에 한국어 서픽스를 붙이고, /en·/jp에도 lang="ko-KR"을 내는 치명적 문제가 있었습니다. 페이지별 위임(self canonical, 로케일별 lang)으로 정리했고, IndexNow로 Bing 인덱싱 대기 시간을 수 주에서 수 분으로 줄였습니다.
호스팅 & 보안
Vercel Edge에서 보안 헤더를 일괄 적용합니다. CSP는 필요한 도메인만 허용하는 엄격한 설정입니다.
| 헤더 | 설정 | 의도 |
|---|---|---|
| Content-Security-Policy | GA · AdSense · Firebase 도메인만 허용 | 스크립트 주입 차단 |
| X-Frame-Options | DENY | 클릭재킹 방지 |
| X-Content-Type-Options | nosniff | MIME 스니핑 방지 |
| Strict-Transport-Security | max-age + preload | HTTPS 강제 |
| Referrer-Policy | strict-origin-when-cross-origin | 리퍼러 정보 최소화 |
| Permissions-Policy | 불필요한 브라우저 기능 비활성 | 표면 축소 |
개인 블로그도 XSS·클릭재킹 표적이 될 수 있습니다. Edge에서 헤더를 일괄 적용하면 개별 페이지에서 누락될 일이 없고, CSP allowlist는 광고·트래킹 스크립트만 정확히 허용해 공격 표면을 최소화합니다.
분석 & 광고
트래킹과 수익화는 렌더링을 해치지 않는 방식으로 설계됩니다. 둘 다 env 기반이라 미설정 시 완전히 비활성화됩니다.
Google Analytics
gtag.js를 lazyOnload로 로드해 렌더링 블로킹을 방지합니다. logEvent() 헬퍼로 이벤트를 추적합니다.
Google AdSense
디스플레이(6079435296) + 인아티클(4907054773) 슬롯. 1초 지연 push로 AdBlocker 대응, CSP에 광고 도메인만 허용합니다.
광고 스크립트는 LCP를 망가뜨리는 주범입니다. lazyOnload + 지연 push로 핵심 콘텐츠 렌더링이 끝난 뒤에 로드되도록 했고, env가 없으면 스크립트 자체가 삽입되지 않아 개발·미설정 환경에서 제로 오버헤드입니다.
콘텐츠 관리 (Firebase CMS)
리포지토리에 콘텐츠를 두지 않고 Firestore로 관리합니다. MDX를 Firestore 문서에 저장해 코드 배포 없이 글을 발행합니다.
posts 컬렉션
status: published / draft / deleted. MDX 콘텐츠 + 번역(translations) 필드.
이중 인증
클라이언트는 Firebase Auth, 서버 API는 admin SDK의 verifyIdToken으로 검증합니다.
/admin
포스트 CRUD, MDX 에디터, 아이디어, 댓글, 참여도, 프로필 관리.
댓글
비밀번호 해싱 + HTML 이스케이프 + 유효성 검증으로 스팸·XSS 대응.
좋아요
post-engagement 컬렉션, FieldValue.increment으로 원자적 증가.
vitest
rateLimit, comments, engagement, 캐싱, 문서 구조 — property 테스트 포함.
콘텐츠와 코드를 분리하면 글 한 편을 발행할 때마다 배포(빌드·캐시 무효화 대기)를 할 필요가 없습니다. 저장 API가 Firestore에 쓰고 revalidation을 트리거하는 순간 모든 캐시가 갱신됩니다. 화이트리스트는 클라이언트에 노출되므로 실제 방어는 서버 토큰 검증이 담당합니다.
다국어 (i18n)
ko(기본) / en / jp 세 언어를 로케일별 라우트로 제공합니다. 번역이 없는 콘텐츠는 해당 언어에서 숨겨집니다.
| 콘텐츠 | ko | en | jp | 전략 |
|---|---|---|---|---|
| 홈 | ✓ | ✓ | ✓ | 로케일별 라우트, 번역 있는 글만 표시 |
| 포스트 | ✓ | 번역 시 | 번역 시 | translations 필드, content은 ko 원문 fallback |
| About | ✓ | — | — | ko 전용 (번역 없으면 숨김) |
| Projects | ✓ | — | — | ko 전용 |
쿼리 파라미터(?lang=en) 대신 /en·/jp 라우트를 쓰는 이유: 각 언어가 독립 URL을 가지면 검색엔진이 각각 인덱싱하고 hreflang 관리가 명확해집니다. 번역이 없는 포스트는 해당 언어에서 숨겨져, 반쪽짜리 페이지가 노출되지 않습니다.
사이트 구조
페이지 구성과 각 페이지의 역할을 정리했습니다.
| Route | 역할 | 캐싱 |
|---|---|---|
| / | Hero + 최신 포스트 + 소개 | Static (revalidate: false) |
| /blog | 전체 포스트, 태그 필터 + 무한 스크롤 | Static |
| /blog/[category]/[...slug] | 포스트 상세 (MDX 렌더링) | Static + generateStaticParams |
| /about | 개발자 소개 (로컬 MDX) | Static |
| /projects | 프로젝트 카드 | Static |
| /search | 실시간 검색 (client) | Shell only |
| /admin/* | 콘텐츠 관리 (인증 필요) | Shell only |
검색과 어드민만 force-dynamic(Shell only)입니다. 검색은 사용 패턴이 예측 불가능해 캐싱 대신 항상 최신 결과를 반환하고, 어드민은 인증이 필요한 동적 영역이라 정적 캐시를 쓰지 않습니다. 나머지는 전부 정적입니다.
상세 문서
이 페이지는 요약본입니다. 각 영역의 전체 설계 문서는 레포지토리 docs/ 폴더에 있습니다.