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

의도

이 블로그는 Astro 6 + Cloudflare Workers + D1 위에 올라가 있습니다. 에디터는 TipTap, 본문 렌더는 react-markdown, 인증은 Cloudflare Access에 맡겼습니다. 모든 결정의 기준은 하나, 가능한 한 적은 코드입니다.

스택

선택지는 Next.js, Remix, SvelteKit, Astro 정도였습니다. 익숙한 Next.js를 먼저 생각했지만, 블로그는 본질적으로 콘텐츠 사이트입니다. SSR이 필요한 페이지는 admin과 edit 둘뿐이고 나머지는 사실상 정적입니다. "기본은 정적, 필요할 때만 server"라는 Astro의 모델이 mental overhead가 가장 적었습니다.

결정적이었던 건 file-based routing이 단순하다는 점입니다. src/pages/posts/[slug].astro가 곧 /posts/<slug> 경로이고, middleware는 src/middleware.ts 하나입니다. 매직이 적습니다.

호스팅은 Cloudflare Workers를 골랐습니다. 정적 자산과 서버 함수를 한 곳에서 굴리고, 도메인을 Custom Domain으로 매핑해 줍니다. SSL과 DNS도 자동으로 처리됩니다.

데이터는 D1입니다. 처음엔 git-as-database로 시작하려 했지만, 매 저장마다 commit이 일어나는 흐름이 글 작성에 잘 맞지 않았습니다. D1으로 옮기자 명시 저장이 ~10ms로 떨어졌습니다. D1의 Time Travel이 30일 PITR을 무료로 제공해 "git history가 그리워질까" 하는 걱정도 사라졌습니다.

에디터는 TipTap입니다. @tiptap/starter-kittiptap-markdown 조합으로 끝났습니다. markdown 직렬화·역직렬화가 한 줄입니다.

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

나중에 Callout이나 Embed 같은 custom node가 필요해지면 그때 schema를 만들 생각입니다.

콘텐츠 렌더링

본문은 markdown 그대로 D1에 저장됩니다. 화면에 그릴 때는 react-markdown + remark-gfm + rehype-highlight 파이프라인을 거칩니다. 이 파이프라인을 감싼 ClaudeMarkdown 컴포넌트 하나가 reader와 editor 양쪽에서 동일하게 쓰입니다. 같은 컴포넌트로 그려지기 때문에 "편집 화면에서 본 모양"과 "게시 후 보이는 모양"이 픽셀 단위로 일치합니다.

처음에는 markdown-it과 highlight.js 조합이었지만, 같은 markdown이 reader와 editor에서 미묘하게 다르게 그려지는 케이스가 누적되었습니다. react-markdown으로 통일하면서 같은 트리를 거치도록 만들었습니다.

TipTap이 본문에서 <li><li><p>...</p></li> 형태로 wrap하는 특성이 있어, reader의 <li>...</li>와 줄간격이 어긋나는 문제가 있었습니다. .claude-markdown li > p { margin: 0 } 한 줄로 맞췄습니다.

디자인

미감은 claude-code-markdown의 터미널 톤을 그대로 가져왔습니다. monospace 본문, ANSI 팔레트, 14px / line-height 1.6. 영문은 JetBrains Mono Variable, 한국어는 D2Coding로 self-host 되어 외부 도메인을 거치지 않습니다.

테마 시스템은 18개의 ANSI 팔레트 — Monokai Pro, Dracula, Tokyo Night, Solarized 등 — 를 갖고 있고 html.theme-{slug} 클래스로 스위치합니다. 다만 사이트 톤을 단일하게 유지하기 위해 현재는 PaperColor Light로 고정해 두었습니다. 토글 UI는 주석으로 보관되어 있고, 다시 노출하려면 두 줄을 풀면 됩니다.

i18n

한국어와 영어 두 언어를 지원합니다. 기본 경로는 한국어(/)이고 영어는 /en/ prefix입니다. 첫 방문 시 미들웨어가 Accept-Language 헤더로 사용자의 선호 언어를 추론합니다. 영어 사용자는 /에 도착하면 즉시 /en으로 302됩니다. 한국어 사용자는 그대로 통과합니다. 첫 결정 후엔 lang 쿠키에 저장되어 이후 방문은 쿠키를 우선합니다.

언어 토글은 /api/set-lang?lang=...&next=... 라우트를 거칩니다. 서버에서 쿠키를 set한 다음 302로 next 경로로 보냅니다. JS 없이 서버만으로 처리됩니다.

각 페이지의 <head>에는 hreflang ko/en/x-default와 canonical을 출력합니다. /sitemap.xml은 모든 published 글을 양 언어 URL과 xhtml:link alternate로 포함합니다. /robots.txt는 admin/edit/api를 disallow하고 sitemap을 광고합니다.

인증

/admin, /edit/*, /api/posts/* 같은 보호 경로는 Cloudflare Access의 self-hosted app으로 감싸 두었습니다. Identity provider는 Cloudflare Zero Trust의 빌트인 Email OTP만 사용합니다. Google SSO를 따로 셋업하지 않아도, 등록된 이메일로 일회용 코드를 받으면 통과됩니다. middleware는 cf-access-authenticated-user-email 헤더만 읽어 ALLOWED_EMAIL과 일치하는지 확인합니다.

작은 결정들

Workers-native ULID. ulid npm 패키지가 Workers 환경에서 window를 PRNG 소스로 찾는 바람에 실행이 깨졌습니다. crypto.getRandomValues 기반으로 Crockford Base32 ULID를 30줄짜리 모듈로 직접 작성했습니다. 외부 의존을 하나 줄이고 Workers 친화 코드를 얻었습니다.

⌘S 명시 저장. 자동저장은 일부러 도입하지 않았습니다. 명시 저장이 글의 분기점을 만들고 PoR(point of return)을 명확히 합니다. 변경 후 창을 닫으면 beforeunload 가드가 한 번 물어봅니다.

번역본 묶기. 글이 ko/en 두 버전을 가질 수 있도록 family_id 컬럼을 두었습니다. 에디터에서 [+ create translation] 버튼을 누르면 반대 언어의 빈 sibling 글이 생기고 같은 family_id로 묶입니다. 각 글은 자기 published 사이클을 따로 갖되, hreflang에서는 한 쌍으로 묶입니다.

아직 없는 것

  • 이미지 업로드 (R2 binding 풀고 /api/upload 추가하면 끝)
  • 검색 (D1 FTS5)
  • 테마 토글 재노출 (현재는 PaperColor Light 고정)
  • 백업 cron (D1 → R2 또는 GitHub mirror)

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