이 블로그는 어떻게 만들어졌나

의도

이 블로그를 짤 때 기준은 하나였습니다. 가능한 한 적은 코드. 인프라가 알아서 처리해주는 일은 끝까지 인프라에 맡기고, 내가 손대는 자리는 본문 렌더링과 에디터 정도로 줄였습니다.

그렇게 남은 줄기가 이 글의 전부입니다 — Astro, Cloudflare Workers, D1, TipTap, react-markdown, Cloudflare Access. 그 외엔 거의 없습니다.

Astro — "기본은 정적, 필요할 때만 server"

블로그는 본질적으로 콘텐츠 사이트입니다. 99%의 페이지는 사실상 정적이고, 서버가 진짜로 필요한 자리는 admin과 edit 둘뿐입니다. Astro의 "기본은 정적, 필요할 때만 server" 모델이 이 비대칭에 정확히 맞았습니다. Next처럼 모든 페이지를 SSR-able 컴포넌트로 다루면 머리속 모델이 무거워지는데, Astro는 그 비용을 처음부터 안 냈습니다.

추가로 file-based routing이 단순합니다. src/pages/posts/[slug].astro가 곧 /posts/<slug>고, middleware는 src/middleware.ts 한 파일. 매직이 적은 게 좋습니다.

Cloudflare Workers + D1 — 한 곳에서 다 굴리는 인프라

호스팅은 Cloudflare Workers입니다. 정적 자산과 서버 함수, 커스텀 도메인, SSL, DNS가 한 사업자에서 처리됩니다. 인프라 단을 여러 조각으로 나눠 들고 있을 이유가 없었습니다.

데이터는 D1을 씁니다. 처음엔 git-as-database로 시작해봤는데, 매 저장마다 commit이 일어나는 흐름이 글 쓰는 리듬을 자꾸 끊었습니다. D1으로 옮기자 명시 저장이 ~10ms로 떨어졌고, "git history가 그리워질까" 하는 걱정은 Time Travel(자동 PITR)이 가져갔습니다.

콘텐츠 — TipTap에서 마크다운으로, 다시 react-markdown으로

본문은 D1에 마크다운 그대로 저장합니다. 에디터(TipTap)와 리더(react-markdown)가 같은 마크다운을 양쪽에서 본다는 구조가 핵심입니다. 직렬화는 한 줄.

const body_md = (editor.storage.markdown as any).getMarkdown();

리더 쪽은 react-markdown + remark-gfm + rehype-highlight를 묶은 ClaudeMarkdown 컴포넌트 하나가 전부고, 에디터의 미리보기도 이 컴포넌트로 그립니다. 그래서 "쓰는 화면"과 "읽는 화면"이 픽셀 단위로 같습니다.

디자인 — sans 본문 + mono chrome

미감의 출발점은 claude-code의 터미널 톤이었습니다. ANSI 팔레트로 색을 잡고, chrome(네비·헤딩·코드)은 monospace, 본문은 sans로 분리했습니다.

sans   본문 단락, about, 메타           Noto Sans KR
mono   nav, hero name, 헤딩, 코드블록   Fira Code + D2Coding

폰트는 모두 self-host입니다. Noto Sans KR과 D2Coding은 프로젝트 안에 woff2로 박혀 있고, Fira Code만 Google Fonts에서 받습니다. 첫 페인트가 외부 도메인 응답에 묶이지 않게 만든 결정입니다.

테마는 18종의 ANSI 팔레트가 코드에 들어 있고, 그 중 두 슬롯만 노출합니다 — light = PaperColor Light, dark = mono-dark(monokai 액센트 + 무채 회색 배경). 헤더의 sun/moon 아이콘 한 개로 토글됩니다.

i18n — 서버에서 끝내기

한국어와 영어 두 언어. 기본 경로는 /, 영어는 /en/ prefix. 첫 방문에 미들웨어가 Accept-Language로 사용자의 선호 언어를 판단해 한 번만 302로 보냅니다. 이후엔 쿠키가 우선합니다. 클라이언트 JS 없이 서버에서 끝납니다.

각 페이지 <head>hreflang ko/en/x-default와 canonical, /sitemap.xml엔 양 언어 URL이 한 쌍으로 들어갑니다. 검색엔진 입장에서 "같은 글의 두 언어판"이라는 사실이 명시적입니다.

인증 — Cloudflare Access에 위임

/admin, /edit/*, /api/posts/* 같은 보호 경로는 Cloudflare Access 뒤에 묶었습니다. Identity provider는 Email OTP 한 가지. SSO 따로 셋업할 필요 없이, 등록된 이메일로 일회용 코드를 받으면 통과합니다. 미들웨어는 헤더 한 줄만 읽어 허용 이메일과 일치하는지 보고 끝냅니다. 인증 로직을 앱에 들이지 않은 게 가장 큰 코드 절감이었습니다.

아직 없는 것

  • 이미지 업로드 (R2 binding 풀고 /api/upload 추가하면 끝)
  • 검색 (D1 FTS5)
  • 백업 cron (D1 → R2 또는 GitHub mirror)

전부 "필요해지면 한두 시간" 거리의 작업입니다. 미리 만들지 않습니다. YAGNI는 오래 살아남는 원칙입니다.