모든 블로그는 결국 같은 질문에 답해야 합니다. 글 한 편이 어떻게 데이터베이스에서 독자의 화면까지 도달하는가? 제 사이트는 지난 2년 동안 가장 게으른 답을 쓰고 있었습니다. 바로 입니다. 이 글은 그 답을 세 번 다시 쓴 이야기입니다. 먼저 SSG로 옮겼고, 제가 직접 만든 prerender에 뒤통수를 맞고 ISR로 갈아탔으며, 마지막에는 「글에 들어갈 때 한 번 깜빡이지 않게」 하려고 렌더링 타이밍과 밤새 씨름했습니다.

먼저 용어 몇 개를 정리하겠습니다. 글 전체가 이 용어들을 중심으로 돌아갑니다.

  • SSR(): 요청이 올 때마다 서버가 즉석에서 HTML을 렌더링해 보내므로, 첫 화면부터 내용이 들어 있습니다.
  • SSG(): build 시점에 페이지를 미리 정적 HTML 파일로 렌더링해 두고, 이후에는 그 파일을 그대로 내보냅니다.
  • CSR(): 브라우저가 빈 껍데기를 받아 스스로 그리는 방식, 즉 제가 원래 쓰던 그것입니다.
  • ISR(): 둘 사이에 있는 방식으로, 먼저 미리 생성된 정적 HTML을 보내고(빠름) 백그라운드에서 주기적으로 혹은 트리거될 때 다시 생성합니다(신선함).

제가 잘못 판단한 지점과, 스스로를 멈춰 세우고 다시 생각하게 만든 지점까지 포함해서 전체 과정을 최대한 충실하게 옮겨 보겠습니다.

배경: 크롤러에게 보이지 않는 블로그, 그리고 전부 Rust로 가고 싶었던 나#

개조 전 이 사이트는 아주 평범한 React 19 SPA였습니다. BrowserRouter + <Routes>, native fetch + 직접 만든 캐시, SSR 사용량은 0, 100% CSR이었습니다. 사람이 첫 화면을 열면 받는 것은 빈 루트 노드 하나 + JS 뭉치였고, 내용은 전부 브라우저가 길러 냈습니다. 크롤러가 meta를 볼 수 있었던 것은 serve 계층이 user-agent로 bot을 감지해 <title>, OG, JSON-LD를 정적 HTML에 억지로 밀어 넣었기 때문입니다. 다시 말해 글의 body는 단 한 번도 서버에서 렌더링된 적이 없었습니다. 「콘텐츠가 전부」인 블로그에게 이 구조는 거꾸로 된 것입니다.

같은 시기에 제 머릿속에는 다른 일도 하나 있었습니다. 백엔드를 전부 Rust로 바꾸고 싶다는 것이었습니다. 이건 사실 「블로그를 빠르게 만드는 일」과는 아무 상관이 없어서, 배경을 모르는 독자를 위해 먼저 깔아 둘 필요가 있습니다.

덧붙이자면, 제가 처음 가장 신경 썼던 SEO 증상은 사실 Google이 「Koimsurai」를 「Katsurai」(교토의 어느 돈가스집)로 자동 교정하는 것이었습니다. 찾아보니 그건 브랜드 엔티티 인식 문제라 SSG로는 고칠 수 없었습니다. 그래도 「사람도 크롤러도 진짜로 render된 본문을 받아야 한다」는 것 자체는 여전히 할 만한 일이었습니다.

선택: SSR / SSG / CSR, 그리고 왜 Next가 아니었나#

펼쳐 놓으면 길은 셋뿐이었습니다. 현상 유지(전부 CSR + bot meta shim), 하이브리드 SSG(본문 페이지는 prerender, 대시보드는 CSR 유지), 아니면 사이트 전체 SSR. 블로그에게 답은 의외로 SSG 쪽으로 기울었고, 오히려 제 「전부 Rust」 정의에 더 잘 맞았습니다.

  • SSG의 산출물은 정적 HTML 뭉치이므로 Rust 백엔드가 그대로 serve하면 되고, production에 렌더링용 Node가 0개입니다.
  • 사이트 전체 SSR은 prod에 상주하는 Node 렌더링 process가 하나 더 생기므로 오히려 「전부 Rust」를 희석시킵니다.
  • 게다가 제 사이트는 콘텐츠가 자연스럽게 두 종류로 나뉩니다. 반정적인 글/정적 페이지(SEO 필요)와 실시간 대시보드(now / 시청 기록 / 음악처럼 계속 바뀌고 SEO가 전혀 필요 없음). 딱 맞습니다. 본문은 SSG, 대시보드는 CSR 유지.

그럼 Next.js는요? 사이트를 React에서 Next로 옮기는 것도 평가를 받아 봤지만 결국 고르지 않았습니다. Next에 대한 원망은 사실 다른 프로젝트에서 이미 쌓여 있었습니다. Next를 쓴 Tauri 데스크톱 앱 하나는 빌드가 못 견딜 만큼 느렸고, NAS 프런트엔드는 더 심해서 Next 16.0.6의 취약점으로 침입당해 채굴 프로그램이 심어졌습니다.

평가 결과 Next는 TanStack Start를 이기지 못했고, 프레임워크 후보는 TanStack Start vs framework mode로 좁혀졌습니다. Next의 간판인 도 제게는 쓸 데가 없었습니다. 데이터가 전부 Rust 백엔드에 있으니 server component가 할 수 있는 건 기껏해야 await fetch(rustApi)라, RSC의 이점이 그대로 상쇄됩니다.

저울을 진짜로 쪽으로 눌러 준 건 딱 한 가지였습니다.

NOTE

판을 뒤집은 건 「나는 ISR이 갖고 싶다」였습니다 React Router 7의 framework mode도 사실 거의 낙점될 뻔했습니다. 저는 원래 RR7 위에 있었으니 framework mode로 전환해 SSG를 얻는 쪽이 마이그레이션 마찰이 가장 적었습니다(그 83개의 <Link>, 24개의 useNavigate가 거의 그대로 갑니다). 하지만 RR7 framework mode에는 네이티브 ISR이 없습니다. ISR을 하려면 prod에 그 Node 렌더링 계층이 하나 더 붙는 걸 받아들여야 했습니다. 그런데 저는 정말로 ISR이 갖고 싶었고(글을 올리면 바로 갱신, rebuild를 기다릴 필요 없음), 거기에 「가장 트렌디한 TanStack 풀세트를 한 번 굴려 본다」는 것 자체가 제 개인 vessel 프로젝트에는 정당한 dogfood 동기였습니다. router 문제는 그렇게 Start로 정해졌습니다.

이 세트가 얼마나 새것인지도 한마디 하자면, TanStack Start v1.0은 2026년 3월에야 나왔고, Vite는 8.x(내부가 Rolldown으로 바뀐 버전), 그 밑의 서버 엔진 는 아직 v3의 beta입니다(TanStack Start가 이걸 그대로 통합했습니다). 세트 전체가 bleeding edge입니다. 뒤에서 보게 되겠지만 이 「새것」은 즐거움이자, 저를 몇 번이나 문 이빨이기도 했습니다.

JS를 더 아끼는 는요? 1차에서는 three.js, Monaco 같은 무거운 인터랙션이 있어서 넘겼습니다(React-first가 현실에 맞았습니다). 나중에 「eval을 없애고 CSP를 켜기 위해」 한 번 더 진지하게 평가했는데, 결론은 아래에서 이야기하겠습니다. 최종 멘탈 모델은 아주 깔끔합니다. 「SSG/ISR의 첫 화면 + SPA식의 이후 내비게이션」. 최초 로드는 서버가 미리 생성해 둔 HTML이고(SEO와 첫 화면의 배당금이 여기서 나옵니다), 이후 사이트 내 페이지 전환은 여전히 SPA처럼 동작해서, 양쪽의 장점을 다 가져갑니다.

구현: 본문은 HTML에 구워 넣고, 무거운 인터랙션은 client에#

먼저 PoC로 검증했습니다. 실제 글로 prerender를 돌려 본문이 HTML에 구워졌는지 확인했습니다. /blog/39/index.html은 38KB였고, body 안에 완전한 <article>, 제목, 표가 들어 있었습니다. 네 항목 전부 초록불. 핵심 결론은 한 줄입니다. 본문은 prerender, 무거운 인터랙션은 CSR 유지. 실제로 만들었을 때 렌더링 파이프라인은 이렇게 생겼습니다.

핵심 결정은 두 가지였습니다. 첫째는 어떤 페이지를 prerender할지를 API에서 뽑아 오는 것입니다. /en/blog/:id 같은 걸 5개 하드코딩하는 대신, 동적 라우트 $locale/blog/$id 하나로 모든 언어를 처리합니다. build 때 /api/posts를 호출해 글마다의 available_locales를 받아 실제로 존재하는 언어만 생성하고, 도 이에 맞춰 생성하며 절대 지어내지 않습니다.

둘째는 본문은 HTML에 굽고, 무거운 인터랙션은 client에 남기는 것입니다. mermaid / shiki를 끌어오는 1,800줄짜리 인터랙티브 버전 BlogPost는 lazy + 로 가서, three.js / mermaid처럼 Node 안에서 터지는 것들이 서버 쪽 번들(server bundle)에 절대 들어가지 않도록 보장했습니다.

이 길에서는 server bundle과 hydration의 함정을 줄줄이 밟았는데, 대표적인 것 몇 개만 골라 보겠습니다.

  • LinkCard가 BlogPost 전체를 끌고 들어왔습니다. History, AboutSite 두 페이지가 import { LinkCard } from './BlogPost'를 했는데, LinkCard 자체는 완전히 SSR 가능합니다(링크 미리보기 카드일 뿐이고 window를 건드리지 않습니다). 그런데 mermaid와 함께 그 2,000줄짜리 파일에 갇혀 있어서, import 한 번에 mermaid 전체가 server bundle로 딸려 들어왔습니다. 해법은 LinkCard를 독립적인 SSR-safe 모듈로 빼서 mermaid와 분리하는 것이었습니다.
  • 내비게이션 바가 한국어로 말하는 . 사이트 전체 셸(Header/Footer)이 __root에 달려 있으면서 각 페이지의 LocaleProvider 바깥에 있었고, 그래서 셸의 번역이 전역 i18next 인스턴스로 fall back했습니다. 여러 페이지를 함께 prerender하면 언어가 새어 나옵니다. SSR HTML은 <html lang=en>, Hero는 영어(맞음)인데 내비게이션 바는 한국어. SSR 내비게이션 바 ko, client 내비게이션 바 en → #418. 해법은 root에 URL로 언어를 판단하는 provider를 한 겹 감싸서 셸도 올바른 로케일을 받게 하는 것이었습니다.
  • 옛 Service Worker의 망령. 옛 SPA에는 PWA plugin이 등록한, 옛 자산을 precache하는 SW가 있었습니다. 새 아키텍처에는 없습니다. 재방문자는 옛 셸 + 새 HTML을 받게 되고 → 스타일이 깨지고 #418이 납니다. 해법은 serve 계층에서 스스로 목숨을 끊는 /sw.js(등록 해제 + 캐시 정리 + reload)를 보내는 것이었습니다.

렌더링 경로 위의 자잘한 함정도 몇 개 있었는데, 하나같이 「SSR과 client가 글자 단위로 일치해야 한다」와 관련되어 있어서 같이 적어 둡니다.

  • 제목 콜론으로 주/부제 자동 분리. schema에 부제 필드가 없어서, 프런트엔드의 splitTitle이 첫 콜론에서 잘라 주제는 h1, 부제는 p로 넣습니다. 순수 표현 계층이라 document.titleog:title은 여전히 전체 제목을 쓰고, SEO에는 영향이 없습니다.
  • scroll-spy가 각주 id에 납치되었습니다. 는 원래 「페이지 위의 id를 가진 모든 요소」를 추적했는데, 각주나 alert의 user-content-fn-… id들이 active 상태를 빼앗아 가서 목차 하이라이트가 아예 사라졌습니다. 해법은 「목차에 실제로 올라간 제목 id 집합」만 보는 것이었습니다. 앵커에는 scroll-margin-top도 보태서, 이동한 뒤 제목이 sticky header에 가려지지 않게 했습니다.
  • mermaid의 ELK layout이 SSR 사이트에서 circular JSON으로 크래시. Adaptive(ELK) 배치로 바꾸자 바로 Converting circular structure to JSON이 떴습니다. 끝까지 파 보니 @mermaid-js/layout-elk가 ELK 그래프 전체에 JSON.stringify를 하는데, 이 사이트의 <html>은 TanStack Start가 React로 렌더링한 것이라 documentElement__reactFiber가 붙어 있어서 직렬화가 순환 참조에 부딪힙니다. 이건 React SSR 사이트 특유의 함정입니다. 해법은 참조 카운팅 guard로, render 동안만 JSON.stringify를 DOM 노드를 건너뛰는 버전으로 바꿔치기하는 것이었습니다.

prerender가 파일 111개를 만들었는데, 하나도 전송되지 않았다#

구현 도중에 아주 황당한 사실 하나에 부딪혔습니다. docker build 안의 prerender는 빠르고 깨끗하게 돌았고(7.8초, 111개 파일, 에러 0), .output/public/blog/index.html도 분명히 생성되었습니다. 그런데 실제로 /blog/index.html을 요청하면 무조건 404가 돌아오고, /en을 연속으로 두 번 때리면 md5가 서로 달랐습니다. 뜻은 이렇습니다. 매 요청마다 다시 SSR을 하고 있었고, 그 111개 파일은 단 하나도 전송된 적이 없었습니다. 근본 원인은 Nitro가 정적 자산 목록을 등록하는 시점이, prerender가 파일을 쓰기 전에 이미 스캔을 마치는 것이었습니다. 파일을 잔뜩 만들어 놓고 아무도 보내지 않으니, 순수하게 build 시간만 낭비했고 build 도중에 라이브 사이트에 접속해 글 목록을 긁어 와야 하는 부담까지 있었습니다.

그래서 prerender를 통째로 걷어 내고 순수 ISR로 갔습니다. 여기서 ISR의 작동 원리를 먼저 짚고 가겠습니다. 공식 경로가 제 환경에서는 통하지 않았기 때문입니다.

WARNING

공식 ISR은 CDN이 돌리는 방식이라, CDN 없는 셀프 호스팅에서는 재생성이 일어나지 않습니다 TanStack Start 공식 ISR의 메커니즘은 build 때 prerender + 응답에 Cache-Control: stale-while-revalidate를 붙이는 것이고, 백그라운드 재생성은 CDN이 수행합니다. 저는 nginx를 직접 운영하고 DNS-only이며 일부러 남의 CDN proxy 아래에 두지 않았습니다. CDN이 없으니 그 header에는 실행자가 없고, 백그라운드 재생성은 아예 일어나지 않습니다.

구해 준 것은 Nitro의 이었습니다. SWR이 내장되어 있어 routeRules 한 줄이면 끝나고, 백그라운드 재생성이 제 서버 안에서 일어납니다.

ts
// vite.config.start.ts —— ISR은 한 줄이면 끝, 직접 100줄을 짤 일이 아니다
const ISR_ROUTE_RULES = {
  ...swrRules(ISR_PAGES.flatMap(localeVariants), 3600),        // UI 페이지: 1시간
  ...swrRules(localeVariants('blog'), 300),                    // 목록 페이지: 새 글이 빨리 보여야 함 → 5분
  ...swrRules(localeVariants('blog').map((p) => `${p}/**`), 3600), // 글 페이지: 내용이 거의 안 바뀜 → 1시간
};

사이트 전체의 요청 흐름은 이렇게 되었습니다. Rust가 유일한 진짜 백엔드이고, Node/Nitro는 렌더링 껍데기 한 겹일 뿐입니다(이게 바로 제 「비즈니스 로직은 전부 Rust, 앞단 Node는 렌더링만」이라는 정의가 착지한 모습입니다).

이 대목에는 펼쳐 볼 만한 함정이 셋 있습니다.

목록 페이지가 빈 껍데기를 캐시했습니다. ISR이 캐시한 /blog는 처음에 비어 있었습니다. Blog 컴포넌트가 useEffect 안에서 데이터를 가져오는데 useEffect는 server에서 실행되지 않기 때문입니다 → SSR은 loading 스켈레톤만 뱉었습니다(prerender도 똑같이 구해 주지 못합니다. 마찬가지로 useEffect를 돌리지 않으니까요). 해법은 데이터 가져오기를 라우트 loader로 옮기고 useLoaderData로 초기 데이터를 먹이는 것이었습니다. /blog의 SSR은 19,541 bytes의 빈 껍데기에서 74,242 bytes, 제목과 8개의 글 링크를 포함한 것으로 바뀌었습니다.

글을 올리면 즉시 갱신: on-demand 재생성. TTL만으로는 부족합니다. 새 글은 크롤러가 즉시 볼 수 있어야 합니다. 방법은 Nitro server route /_revalidate(header secret으로 보호; 일부러 /api/*에 두지 않았습니다. nginx가 /api/를 전부 Rust로 보내기 때문에 프런트엔드가 아예 받지 못합니다)를 두고, Rust 백엔드가 글 작성/수정 성공 후 fire-and-forget으로 호출해 캐시를 비우는 것입니다. 그리고 이걸 14개 쓰기 엔드포인트마다 다는 대신 axum 로 만들었습니다. 하나씩 달면 반드시 빠뜨리고, 빠뜨려도 에러가 나지 않은 채 조용히 갱신되지 않을 뿐입니다.

CAUTION

한 글자 차이로 사이트 전체 캐시가 날아갑니다 「이건 글을 쓰는 요청이다」를 판단할 때 직관대로 path.contains("/posts")라고 쓰면 아주 조용히 터집니다. /api/posts/:id/view(누군가 아무 글이나 볼 때마다 호출됩니다)도 /posts를 포함하기 때문입니다 → 누가 글을 볼 때마다 사이트 전체 ISR 캐시가 비워집니다 → ISR이 그대로 무력화되는데 에러조차 나지 않습니다. /view, /like, /reactions, /comments를 명시적으로 제외하고, unit test를 하나 써서 못을 박아 둬야 합니다.

캐시 규칙은 화이트리스트로, /**를 쓰지 않습니다. 사이트 전체 포함(/**)은 fail-open입니다. 앞으로 cookie를 읽거나 사용자 데이터를 render하는 페이지를 새로 추가하면 기본으로 공개 캐시되면서 아무도 눈치채지 못합니다. 화이트리스트는 반대로, 새 페이지는 기본이 캐시하지 않음입니다. 명시적으로 캐시하지 않는 곳은 /(cookie / Accept-Language를 읽어 언어 리다이렉트를 하므로, 이걸 캐시하면 첫 방문자의 언어를 전 세계에 뿌리는 셈입니다), /admin, /auth입니다.

번외: 모든 SSR 페이지가 조용히 멈춰 버린, 겹쳐 있던 버그 세 마리

Nitro로 옮긴 날, 모든 SSR 페이지가 조용히 멈췄습니다(000을 반환하고 에러도 timeout도 없음). 그런데 /api proxy와 정적 자산은 200이었습니다. 파 보니 각각 단독으로도 사이트 전체를 죽일 수 있는 버그 세 마리가 겹쳐 있었고, 가장 치명적인 한 마리가 제일 음흉했습니다. nitro@3.0.0이 9개월 전의 구버전이었습니다. package.json에는 ^3.0.0-beta라고 썼는데, semver의 prerelease 비교 규칙은 날짜로 번호가 매겨진 beta들에는 영원히 매칭되지 않습니다(latest = 3.0.260610-beta). 이게 앞에서 말한 「세트 전체가 bleeding edge면 물린다」의 구체적인 한입입니다. 구버전에는 routeRules.swr × ssr-renderer 충돌이 있어서 요청이 자기 자신으로 되돌아옵니다 → 무한 자기 순환. 최신으로 올리니 전부 초록불이었습니다.

더 적어 둘 만한 건 후반부입니다. 저는 한때 다 고쳤다고 생각했지만, 멈춰 서서 스스로에게 한 번 되물었습니다. 「이 수정들은 진짜인가, 아니면 증상을 우회한 것뿐인가?」 ablation 테스트를 해 보니 5개의 「수정」 중 진짜는 2개뿐이었고(nitro 업그레이드, SSR이 로컬 백엔드를 호출하도록 변경), 나머지 3개(server.ts, noExternals, /api proxy)는 전부 구버전 버그를 우회하는 것이라 새 버전에서는 아예 필요가 없었습니다. 그 /api proxy는 심지어 「존재하지 않는 문제를 해결하는」 것이었습니다. 최종 설정은 공식 최소 형태인 plugins: [tanstackStart(), viteReact(), nitro()] + SWR 화이트리스트 한 벌로 무너져 내렸습니다. 우회는 고친 것처럼 보이기 아주 쉽습니다. 진짜로 고치려면 먼저 내가 그냥 우회했을 수도 있다는 걸 인정해야 합니다.

MDX: eval을 하는 파이프라인, 그리고 갚고 싶은 빚 하나#

여기까지 글은 여전히 react-markdown으로 렌더링되고 있었습니다. 이후에 <Note>, <Annot>, <Diff>, <Chart> 같은 커스텀 block이 갖고 싶어져서(독자의 몰입감을 충분히 깊게 만들고 싶었습니다) 파이프라인을 한 겹 얹었습니다(글마다 opt-in, format=mdx인 글만 이 경로를 탑니다). 이 파이프라인이 이 글에서 가장 기술적인 대목인데, 저는 처음부터 이해에 바로잡아야 할 부분이 하나 있었습니다.

IMPORTANT

eval은 인터랙션이 데려오는 게 아니라, 「글 안에 실행 가능한 JS가 있다」가 데려옵니다 저는 원래 「인터랙션이 있는 block이라야 eval이 필요하다」고 생각했는데, 정확하지 않습니다. react-markdown파싱(문자열을 AST로 파싱한 뒤 컴포넌트에 대응)일 뿐 아무것도 실행하지 않습니다. 반면 MDX는 「JS로 컴파일한 뒤 실행」합니다. 글 안의 {new Date().getFullYear()} 같은 인라인 표현식은 진짜 JavaScript이고, 컴파일된 그 JS를 브라우저에서 실행해야 합니다. 그러니 eval을 밟느냐 마느냐는 「글 안에 실행 가능한 JS가 있느냐」에 달린 것이지 「인터랙션이 있느냐」가 아닙니다.

컴파일은 server에서만 합니다. @mdx-js/mdx의 컴파일러는 micromark + acorn이라 아주 무겁고, client bundle에 들어가서는 안 됩니다. 그래서 TanStack의 을 썼습니다. SSR 때는 in-process로 돌고, client 쪽 내비게이션 때는 RPC로 server에 가서 컴파일 결과를 받아 오며, 산출물은 function-body 문자열입니다(직렬화 가능하고, dehydrate해서 HTML에 넣을 수 있습니다). 실행은 client에서 합니다. 프런트엔드가 그 문자열을 받아 runSync로 실행해 React 컴포넌트로 만드는데, runSync의 내부는 바로 new Function, 즉 입니다.

ts
// server-only: 컴파일러가 무거우므로 client bundle에 넣지 않는다; 산출물인 function-body 문자열은 직렬화 가능
export const compileMdx = createServerFn({ method: 'POST' })
  .handler(async ({ data: source }) => {
    const { compile } = await import('@mdx-js/mdx');
    return String(await compile(source, { outputFormat: 'function-body', remarkPlugins: [remarkGfm, remarkAlert] }));
  });

// client: runSync로 동기 실행해 컴포넌트로 만든다(SSR과 hydration 모두 동작); ⚠️ 내부는 new Function = eval
const { default: Content } = runSync(compiled, jsxRuntime);

에디터 쪽 곁길도 한 토막 이야기하겠습니다. 저는 한때 MDXEditor(시중에서 유일한 MDX 네이티브 WYSIWYG 에디터)를 설치해 spike를 해 봤는데, 의존성이 131개나 되는 데다 제 커스텀 block을 전부 「톱니바퀴 + 라벨」이 늘어선 범용 UI로 렌더링해서 진짜 컴포넌트가 아니었습니다. 게다가 저는 Monaco의 생김새를 더 좋아합니다. 나중에 깨달았습니다. MDXEditor는 애초에 「MDX를 동작하게 만드는」 필요조건이 아니었습니다. Monaco로 MDX 원본을 쓰고 렌더러(compileMdx + runSync)가 그려 주면, 그걸로 충분합니다. spike 전체를 깨끗이 철수하고 Monaco를 남겼습니다. 나머지 결정 몇 가지. MDX 컴파일이 실패하면 자동으로 markdown으로 후퇴합니다(손이 미끄러진 태그 하나가 글 전체를 터뜨리지 않게). 그리고 MDX 글과 markdown 글은 같은 기반 컴포넌트 묶음을 공유합니다(shiki 하이라이트, mermaid, 링크 카드, 제목 앵커).

갚고 싶은 빚은 바로 이 runSync에 있습니다.

NOTE

사실 저는 아직까지 CSP를 켜지도 않았습니다 이 eval은 지금으로선 통제 가능합니다. 콘텐츠는 전부 제가 직접 쓰고 직접 검수한 것이지, 사용자 투고가 아닙니다. 그리고 좀 웃긴 얘기인데, 저는 아직까지 를 켜지도 않았습니다. __root.tsx 안에 인라인으로 들어간 「깜빡임 방지/intro」 script가 있어서, 무턱대고 CSP를 켜면 사이트가 깨지기 때문입니다. 먼저 그걸 nonce화해야 합니다. 그러니 그 unsafe-eval 주석들의 의미는 「언젠가 엄격한 CSP를 켤 때, 이 eval이 나에게 unsafe-eval을 허용하라고 강요할 것이다」입니다. 저는 이걸 남겨 두고 싶지 않습니다.

그럼 이 eval을 어떻게 걷어 낼까요? 처음 생각한 건 ****였습니다. 인터랙티브 block 하나하나를 「CSS + 번들된 vanilla JS 한 벌」로 다시 쓰고, 무거운 block은 registry로 마운트하는 것입니다. 하지만 이건 많은 걸 다시 써야 하고 동작 회귀 위험이 있습니다. 나중에 훨씬 깨끗한 길을 깨달았습니다. eval이 존재하는 이유는 단지 제가 MDX를 function-body로 컴파일했기 때문입니다(그 포맷은 반드시 new Function이라야 돌아갑니다). 「진짜 ES module」로 컴파일하면 client는 import()로 그걸 불러올 수 있고, 동일 출처의 import()는 엄격한 CSP script-src 'self' 아래에서도 허용됩니다(그건 모듈 로딩이지 eval이 아니니까요). 파이프라인 전체, 모든 block, 모든 애니메이션이 전혀 바뀌지 않고, 재작성 0, 회귀 0입니다. islands는 이 길이 벽에 부딪혔을 때의 후퇴로일 뿐입니다. 목표 흐름은 이렇습니다.

(이건 아직 착수하지 않았고 할 일 목록에 넣어 두었습니다. 같은 목표를 위해 Astro도 다시 진지하게 평가했습니다. Astro는 태생부터 islands라 CSP 친화적이지만, Astro는 독립된 프레임워크이고 독립된 build라 기존 TanStack Start 안에 끼워 넣을 수 없습니다. 「블로그에만 Astro를 쓴다」는 것은 실무적으로 사이트를 두 개의 앱으로 쪼개는 것과 같고, 공유하는 Header / 목차 / 리액션 / 댓글을 전부 island로 다시 써야 하고 i18n / ISR / SEO / Rust 데이터 계층도 전부 다시 세워야 합니다. 소 잡는 칼을 쓰면서 주방까지 쪼개는 격이라, dynamic-import 쪽이 가성비가 압도적으로 좋습니다.)

첫 프레임이 더는 깜빡이지 않게: 진입 렌더링 디버깅 원정#

여기까지는 전부 배포되었지만, 진짜로 밤늦게까지 갈아 낸 것은 「글에 들어가는 그 한순간」이었습니다. 이 대목은 전부 렌더링 타이밍의 함정이고, 이 글의 핵심이기도 합니다. 먼저 결과부터 보겠습니다. 왼쪽이 당시의 참상, 오른쪽이 마무리한 뒤입니다.

스타일 없는 깜빡임 —— Query 캐시가 아니었다

가장 먼저 눈에 띈 것은, 글에 들어가는 순간 한 번 깜빡이고 Query 캐시가 잡히면 더 이상 깜빡이지 않는다는 것이었습니다. 그런데 진실은 Query가 아니었습니다. 그 ClientOnly의 fallback이 먼저 순수 sans-serif글 CSS가 전혀 없는 본문을 뱉고, 무거운 FullBlogPost chunk(BlogPost.css + shiki + mermaid 포함)가 로드되고 나서야 통째로 갈아 끼우는 것이었습니다. 스타일을 덧입히는 게 아니라 전면 재도색이었습니다.

첫 번째 수정은 idle 상태에서 그 chunk를 미리 데워 두는 것이었고, 사이트 내 내비게이션은 구했습니다. 하지만 URL을 직접 붙여 넣어 콜드 스타트하면 여전히 깜빡였습니다. ClientOnly는 언제나 fallback을 먼저 보내니까요. 두 번째 버전에서야 근본을 고쳤습니다. fallback을 FullBlogPost와 완전히 똑같은 구조로 다시 만들고 같은 CSS를 참조하게 했습니다. JS를 전부 꺼도 첫 프레임부터 완전한 스타일의 글이 나옵니다.

내가 원한 건 「layout이 먼저 나오고 각 block이 loading」

「원래 있던 HTML에 스타일을 덧입히는」 중간 버전은 제가 봐도 아니었습니다. 제가 원한 것은 스켈레톤 로딩이었습니다. 전체 레이아웃(사이드바, 목차 포함)이 첫 프레임에 틀로 먼저 나오고, 각 구역이 각자 loading으로 채워지는 것. 그래서 shimmer 스켈레톤을 넣었습니다. 사이드바와 목차는 스켈레톤을 먼저 내보내고, 본문은 그대로 진짜입니다(SEO를 빠뜨릴 수 없습니다).

이중 렌더링 —— 페이지 요소에 지워졌다가 다시 도는 것 같은

제가 본 문제는 명백히 「페이지 요소에 지워지고, 애니메이션을 다시 돌려 삽입되는」 것이었습니다. 근본 원인은 같은 본문이 두 번 렌더링되는 것이었습니다. BlogPostPage(SSR로 나옴) → 그다음 FullBlogPost(ClientOnly)가 마운트된 뒤 한 번 더 렌더링해서 덮어씁니다. 서로 다른 두 개의 컴포넌트 트리이므로 React는 뜯어내고 다시 만드는 수밖에 없고 = 눈에 보이는 「지웠다가 다시 삽입」이 됩니다. 두 단계로 치료했습니다.

Tier 1(인수인계를 안 보이게): 진입 애니메이션을 initial={false}로 바꾸고(이미 있는 콘텐츠를 새로 로드된 것처럼 다시 슬라이드시키지 않기), 목차는 loader에서 바로 계산하고(server 쪽, 본문이 이미 SSR되었으니 공짜입니다), heading 추출과 읽기 시간 계산 로직을 공용 lib으로 빼서 두 버전이 글자 단위로 일치하게 했습니다. 인수인계가 약 95% 매끄러워졌습니다.

Tier 2(본체): 먼저 렌더링 트리 전체를 훑어 모든 SSR blocker를 찾아낸 뒤, 그 1,800줄짜리 파일에 손을 댔습니다. FullBlogPostSSR-safe로 만들고(localStorage는 「SSR에서는 기본값을 주고 useEffect에서 읽어 보완」으로, 날짜에는 타임존 보완, window 접근은 guard로 감싸고, mermaid의 zoom은 작은 ClientOnly 섬으로 포장) → 그다음 바깥의 ClientOnly통째로 제거단일 SSR 렌더링, 그 자리에서 hydrate가 되었습니다. 이중 렌더링이 뿌리부터 사라졌고, 스켈레톤 잔상 0, hydration 에러 0이며, BlogPostPage는 그길로 죽은 코드가 되었습니다.

무언가가 스크롤을 「붙잡고」 있었다

글 하나를 reload하고 앵커가 화면을 원래 위치로 되돌릴 때, 먼저 위로 갔다가 순간적으로 아래로 갔다가 다시 위로 가면서 한 번 걸렸습니다. 제 직감은 「높이 계산 문제가 아니라 무언가가 붙잡고 있는 느낌」이었고, 이 직감은 맞았지만 저는 한동안 엉뚱한 방향으로 고치고 있었습니다.

진짜 원인은 두 가지가 겹친 것이었습니다. 전역의 html { scroll-behavior: smooth } + .post-content에 걸린 가 높이를 잘못 추측한 것입니다(1200px로 추측, 실제는 약 9648px, 8배 차이). 그리고 scroll-behavior: smooth모든 프로그래밍적 스크롤을 애니메이션으로 만들어, 다음 호출이 이전 호출을 끊고 제자리에서 다시 시작하게 합니다 → 영원히 목표에 도달하지 못합니다. 앵커 복원에 쓰는 scrollTo가 그렇게 계속 붙들려 있었습니다. A/B 실측 후 결론을 냈습니다. 전역 smooth를 걷어 내고, 목차 클릭이나 맨 위로 가기처럼 부드러워야 하는 곳만 JS로 명시적으로 지정합니다.

진입 애니메이션은 되살릴 수 있지만, framer 말고 CSS로

단일 렌더링이 된 뒤에는 진입 애니메이션을 되살릴 수 있게 되었습니다. 다만 본체 텍스트의 애니메이션은 반드시 CSS @keyframes여야 하고, framer-motioninitial={{opacity:0}}을 쓰면 안 됩니다. 이유는 아주 하드코어합니다. Chrome의 opacity:0인 요소를 계산에 넣지 않습니다. framer로 본문을 투명에서 페이드인시키면 LCP를 hydration 이후로 못 박아 버리는 셈입니다. 그래서 본체는 CSS transform으로 가고(opacity는 건드리지 않고), framer는 퇴장이나 stagger처럼 LCP에 영향을 주지 않는 곳에만 남겼습니다.

74,242bytes
/blog SSR 크기
이전 19,541(빈 껍데기)
1
글 진입 시 렌더링 횟수
이전 2회(이중 렌더링)
0.0019
레이아웃 이동 CLS
Google 기준의 1/50

여기까지 쓰고 나니 저도 궁금해졌습니다. 같은 콘텐츠 사이트라면 다들 결국 어느 칸에 멈춰 있을까요.

당신의 블로그(또는 콘텐츠 사이트)는 지금 어떻게 렌더링하나요?
0

정리: 세 번의 재작성이 남긴 계산서#

이 렌더링 원정은 사실 층위가 다른 세 장의 계산서였습니다.

  1. SEO와 첫 화면은 SSG/ISR의 배당금이고, hydration은 그 대가로 얻은 완전히 새로운 버그 한 부류입니다. 이제 글 본문, 제목, hreflang이 전부 진짜로 HTML에 render되어 크롤러가 JS를 돌리지 않아도 볼 수 있습니다. 대가는 Node 렌더링 껍데기 한 겹이 늘어난 것과, 「SSR이 보낸 것과 client가 그린 것이 글자 단위로 일치해야 한다」는, 예전에는 존재하지도 않던 버그입니다.
  2. 측정하라, 추측하지 말고. 그날 밤 가장 값어치 있었던 한 가지는, 스스로를 멈춰 세우고 「이건 진짜로 고친 건가, 아니면 그냥 우회하거나 찍어서 맞힌 건가?」라고 되묻게 만든 것입니다. 그 질문이 인상만으로 내렸던 잘못된 진단 두 개(그 세 마리 버그의 가짜 수정, 스크롤의 직렬화 버그)를 도로 눌러 앉혔고, ablation 테스트와 Playwright로 진실을 측정하게 만들었습니다.
  3. 반복해서 물어뜯는 규칙 몇 개: useEffect는 server에서 돌지 않으므로, 그걸로 데이터를 가져오는 페이지는 SSR이든 prerender든 빈 껍데기만 뱉습니다. fail-open인 사이트 전체 캐시 규칙은 안전 지뢰입니다. 경로 접두사로 캐시를 비울 때는 고빈도 엔드포인트를 제외하는 걸 잊지 마십시오.

아직 못 갚은 빚도 몇 개 있어서 정직하게 적어 둡니다. MDX의 runSync는 client eval이고, 지금은 CSP를 아예 켜지 않아서 당장은 무해하지만, 엄격한 CSP를 켜기 전에 먼저 걷어 내고 싶은 것입니다. 계획은 MDX를 ESM 모듈로 컴파일하고 import()runSync를 대체하는 것입니다(islands는 후퇴로일 뿐입니다). ISR 캐시는 아직 메모리 안에 있어서 배포하거나 재시작할 때마다 사이트 전체가 0으로 돌아갑니다(fs driver를 아직 연결하지 않았습니다). 옛 SEOHead(react-helmet)도 아직 완전히 은퇴하지 못했습니다. 렌더링이라는 주제는 아마 영원히 다 고치지 못하겠지만, 적어도 지금은 글 한 편이 데이터베이스에서 당신의 화면까지 오는 모든 단계에 대해, 왜 이렇게 생겼는지 제가 다 알고 있습니다.

參考連結