이 글은 모든 사람에게 적합하지 않을 수 있습니다. 이 글을 이해하려면 적어도 지망 프론트엔드 개발자이거나 풀스택 개발자여야 하며, SSR, SSG, ISR 와 같은 개념에 대한 기본적인 이해가 있고 최소한 Next.js를 사용해 본 경험이 있어야 합니다.

머리말

아마도 나는 태어날 때부터 근본 원리에 대한 강한 호기심을 가지고 있었고, 때때로 왜 그렇게 할 수 없는지 계속해서 물어보는 집착이 있었기 때문인 것 같다; 그래서 가끔 독특한 아이디어를 떠올릴 수 있었던 것 같다: 이것도 그 중 하나로, 25년 11월에 이미 나타났다. 당시 많은 친구들과 이야기를 나눴지만, 진행 중인 프로젝트와 시간 충돌이 있어 2026년 새해 전후까지 실제로 버전을 만들기까지 미뤄졌다. 이후 다시 미뤄졌는데, 웹사이트를 만들러 갔기 때문이다. 블로그는 아직 완전히 갖춰지지는 않았지만, 적어도 기록할 수 있는 장소가 생겨서 이 일을 다시 꺼내어 이야기하고 싶다.

우선 한 가지 분명히 짚고 넘어가자면, SSR이 나쁘다고 말하려는 것은 아닙니다. 다만 제가 지켜본 흐름으로 볼 때, 너무나 많은 사람들이 이를 남용하며 대세에 휩쓸려 어떤 문제를 회피하고 있다고 느낄 뿐입니다. 이에 대해 여러 사람과 논의도 해보았지만, 돌아오는 반응은 대부분 확신이 없었습니다. 제 구상을 이야기해 본 적도 있지만, 상대방은 항상 다양한 문제점과 엣지 케이스가 존재할까 봐 걱정하곤 했습니다. 그리고 솔직히 말해 매번 전체적인 생각을 처음부터 끝까지 설명하는 것도 그리 내키지 않았습니다. 그래서 오늘은 아예 이를 상세히 글로 적어 몇 가지 의문점과 기술적 원리를 명확히 풀어보려고 합니다.

SSR의 남용

제가 Web 프론트엔드 개발에 막 입문했을 때, 처음 접한 풀스택 프레임워크는 아마 Next.js였을 것입니다. 이것이 매우 뛰어난 프레임워크라는 점은 인정합니다. 몇 가지 성능 문제가 있긴 하지만, 이러한 성능 문제는 실질적인 DX 경험과 맞바꾼 것이므로 비난할 일은 아닙니다. 하지만 프론트엔드의 개념과 지식을 조금씩 깊이 이해하게 되면서, 저에게 점점 맞지 않는다는 생각이 들었습니다. 저는 Rust를 쓰는 것을 정말 좋아하고, 저수준(low-level)을 연구하는 것도 좋아하며, 임베디드 시스템도 경험해 보았기에 성능에 대한 특별한 집착이 있기 때문입니다. 집착이라고 해서 모든 것에서 극한을 추구하는 것은 아닙니다. 다만, 약간의 노력만으로도 훨씬 더 좋은 성능을 얻을 수 있는 분명한 방법이 존재하는데, 왜 아무도 그렇게 하지 않는가 하는 점입니다. 그래서 Next를 사용할 때마다 생각하게 됩니다. 왜 아무리 간단한 페이지라도 대부분의 요소가 고정되어 있고 단 한 곳만 변경되면 될 뿐인데, 페이지 전체를 다시 렌더링해야 하는 걸까요? 왜 그럴까요? 제가 이해하기로는, 백엔드가 Rust로 작성되지 않았더라도, 극한을 추구하지 않더라도, JavaScript 인터프리터 위에서 실행되더라도, 실제로 변경되는 소량의 데이터는 상대적으로 매우 적은 오버헤드여야 하며, 전체 페이지를 다시 렌더링하는 대신 매우 적은 비용으로 "렌더링"을 구현해야 합니다.

SSR re-renders the entire page for one small changeA page where only the username changes,but traditional SSR rebuilds the entire component tree and re-renders everything.NavHero sectionUsernameSidebarContent blockFooterChangedStatic (unchanged)Traditional SSRrenderToString()Entire component treeNavHeroUserSideContentFooterAll re-rendered (~100-300ms)Only 1 field changedbut the entire page was rebuilt

급기야 나중에는 이것이 제 마음에 걸림돌이 되어 계속 고민할 수밖에 없는 문제가 되었습니다. 하지만 아이러니하게도 지난 10여 년간의 프론트엔드 발전 과정, 즉 Vue, Svelte, Solid 및 이들의 프레임워크인 Next.js, TanStack Start, Remix, Nuxt, Sveltekit, Soild Start를 둘러보고 나니 다들 방향을 잘못 잡은 것 같다는 생각이 들었습니다. 특히 ReactRSC는 하나의 실수라고 보지만, 사실 그 존재에도 나름의 이유는 있기에 이 부분은 나중에 기회가 되면 자세히 다뤄볼까 합니다...

제가 보기에, RSC와 여러 SSR 프레임워크들은 사실 render를 남용하고 있는 셈입니다. SSG은 제가 원하던 방식을 구현해 냈지만—페이지가 빌드 시점에 생성되고 요청 시 다시 렌더링할 필요가 없음—진정한 동적 능력(x)이 없습니다. 반면 ISR은 SSG와 SSR 사이에서 균형을 맞춰 어느 정도의 유연성을 확보한 것처럼 보이지만, 본질적으로 제 기대에 미치지 못했습니다. 저는 ISR의 출현이 사실 SSR의 부족한 성능을 보완하기 위한 것에 불과하며, 이후의 다양한 CDN 캐싱 전략과 같은 작업들 역시 진정으로 우아한 해결책이라기보다는 일종의 workaround에 불과하다고 봅니다.

물론 SSR 자체가 잘못된 것은 아닙니다. 많은 실제 사용 사례를 해결해 주는 훌륭한 도구입니다. 하지만 문제는 이러한 프레임워크들이 SSR을 극단적인 기본 옵션의 위치로 밀어붙여, 개발자가 실제로 SSR을 사용하는 상황의 95%에서는 사실 전혀 필요하지 않다는 점입니다. 페이지 콘텐츠의 대부분은 고정되어 있고 실제로 동적인 부분은 극히 적은데도, 매 요청마다 전체 컴포넌트 트리를 다시 실행해야 합니다.

하지만 최근 몇 년간 React에도 어느 정도 개선이 있었습니다. React 19.2에는 PPR이 도입되었지만, 이것 역시 런타임에 비싼 renderToString()이 발생하여 근본적인 해결책이라기보다는 일종의 workaround에 가깝습니다. 그래서 저는 대부분의 요소가 빌드 시점에 이미 확정된다면, 왜 렌더링 자체를 컴파일 시점으로 직접 가져오지 않는 걸까 하는 생각이 들었습니다.

Rendering approaches spectrum from static to dynamicCompares SSG,ISR,SSR,PPR and RSC on a spectrum with two cost arrows between rows.SSGNo dynamicISRWorkaroundPPRWorkaroundSSR~100-300msRSCArbitraryStaticRuntime costDynamicWhat if?SSG's speed + SSR's dynamic=???

컴파일 타임 렌더링?

렌더링을 컴파일 시점으로 돌린다는 것은 언뜻 들으면 매우 황당한 이야기처럼 보입니다. SSR이 존재하는 핵심적인 이유는 일부 값이나 조건이 런타임이라는 시점에 도달하기 전까지는 결코 풀릴 수 없으며, 그것이 무엇인지 알 수 없어 판단을 내릴 수 없기 때문입니다. 그렇기에 대부분의 사람들이 제 생각을 즉시 부정했던 것도 일리가 없는 것은 아니며, 충분히 이해할 수 있습니다.

하지만 사실 저는 이를 구현하기 위한 나름 정교한 Pipeline 방안을 생각해 냈고, 이것이 제가 이것을 일종의 Protocol 라고 불러야 하는 이유이기도 합니다. 아울러 저는 풀스택 프레임워크가 프론트엔드와 백엔드의 경계를 모호하게 만드는 것이 참 멍청한 짓이라고 항상 느껴왔습니다(비록 최근 몇 년간 Next.js가 이 점에서 DX를 아주 잘 해내어 많은 초보자들이 풀스택 애플리케이션을 작성하는 것을 매우 쉬운 일로 느끼게 만들었다는 점은 인정해야 하지만, 실제로는 많은 보안 위험이 여기서 비롯되기도 합니다. 이야기가 샜네요).

Component and data separation — values become typed slotsShows how pure components have typed slots,and runtime values collapse into finite JTD types.TraditionalComponent + data coupledDashboard componentfetch("/api/user")if (role==="admin") ...Value unknown until runtimeInfinite possibilities?SeamComponent + data separatedPure componentUI only,no data fetchingData (typed)JTD schema contractstringbooleanenumint32Finite,enumerable → compile-time rendering possible

그래서 컴포넌트 내부에서 데이터 패칭을 작성하는 것도 마찬가지입니다. 저는 경계가 명확한 방식을 선호합니다—컴포넌트는 순수한 컴포넌트이고, 데이터 패칭은 완전히 분리해 내야 한다는 것이죠. 그리고 이 전제를 받아들이고 순수 컴포넌트와 데이터를 분리하고 나면 흥미로운 일이 벌어집니다. 데이터 자체도 사실상 분류할 수 있다는 것을 깨닫게 됩니다.

여기서 TypeScript가 준 영감에도 감사하고 싶습니다. 사실 이른바 "값" 자체가 컴파일 시점에 무엇인지는 중요하지 않으며, 중요한 것은 그 타입입니다. slot에 어떤 내용을 채워 넣어야 하든, Open String(무한한 가능성을 가진 문자열) 같은 종류만 아니라면 유한한 가능성을 가진 타입으로 감쌀 수 있습니다. 예를 들어 dashboard를 작성하면서 특정 영역의 콘텐츠를 조건부 렌더링해야 할 때, 결국 User나 Admin 등에 불과하지만 어쨌든 모두 정의해 둘 수 있는 타입입니다. 현실 세계에서 조건부 렌더링이나 논리적 판단이 필요한 거의 모든 곳은 몇 가지 확정된 가능성으로 귀납될 수 있습니다.

마침 이러한 가능성들은 아주 좋은 방법으로 기술할 수 있는데, 그것이 바로 JTD다!

JTD 명세

JTD (JSON Type Definition) RFC 8927 정의에는 8가지 Schema Form이 존재합니다: Empty, Ref, Type(boolean, string, timestamp 및 다양한 정밀도의 숫자 유형), Enum, Elements, Properties, Values, Discriminator이며, 여기에 추가로 임의의 schema를 nullable로 표시할 수 있습니다. JTD의 장점은 언어에 종속되지 않는다는 점으로, JavaScript, Rust, Go 등 거의 모든 언어에서 대응하는 타입 매핑을 찾을 수 있습니다. 이로 인해 자연스럽게 프론트엔드와 백엔드 사이의 교량 역할을 하게 되며, 매개체가 JSON이라는 점도 본래 프론트엔드와 백엔드 간의 최소 공통 분모입니다. 현실 세계의 웹사이트 응용 프로그램 중 **95%**는 문자열이나 숫자 필드를 표시하거나 불리언 값으로 판단을 내리는 것에 불과하며, 이들은 모두 이 범위 내에 들어옵니다. 이 점을 명확히 하고 나면 실제로 우리가 활용할 수 있는 공간이 매우 넓어집니다. 물론 JTD가 완벽한 것은 아니며 Markdown 같은 예외도 존재하지만, 이에 대해서는 뒤에서 자세히 다루겠습니다. 사실 바로 이 지점이 제가 SSR의 진정한 의미를 인정하는 부분입니다.

Sentinel

우선 쉬운 부분부터 이야기해 보자면, 모든 동적 값에 타입이 있다는 것을 이미 알고 있으므로 컴파일 시점에 React 컴포넌트를 renderToString()로 한 번 실행하는 작업을 할 수 있습니다. 하지만 실제 데이터가 아닌 Sentinel로 모킹한 데이터를 사용합니다. 그렇다면 이것은 무슨 뜻일까요?

js
{ user: { name: "Alice", age: 30 } }

데이터가 다음과 같이 생겼다고 가정하면, 이를 다음과 같이 교체할 수 있습니다.

js
{ user: { name: "%%SEAM:user.name%%", age: "%%SEAM:user.age%%" } }

%%SEAM:...%% 이 바로 Sentinel이며, 모든 동적 값의 위치를 차지합니다. React가 이 Sentinel 데이터를 가지고 renderToString() 을 실행하고 나면, 출력되는 HTML 내에서 이 위치들이 표시됩니다. 그다음 빌드 파이프라인이 이 센티널들을 HTML 주석 형태의 slot 마크로 변환합니다.

html
<!-- Sentinel -->
<span>%%SEAM:user.name%%</span>

<!-- Slot -->
<span><!--seam:user.name--></span>

여기까지 오면 이 slot 들이 "타입이 표시된 슬롯" 이라는 것을 눈치채셨을 겁니다. 이제 서버 런타임이 해야 할 일은 극도로 단순해집니다. 실제 데이터를 가져와서 이 구멍들에 값을 채워 넣는, 순수한 문자열 치환입니다. renderToString() 이 전혀 필요 없고, JavaScript 런타임도 필요 없고, vDOM 도 필요 없습니다. HTML 주석을 파싱하고 문자열 치환을 할 수 있는 언어라면 무엇이든 백엔드가 될 수 있습니다. Rust, Go, TypeScript 모두 가능합니다. 이것이 바로 이것이 프레임워크가 아니라 프로토콜인 이유입니다.

Sentinel to Slot build pipeline — horizontalBuild-time pipeline:real data becomes sentinel data,rendered by React,converted to typed slot markers.Build-time pipelineReal dataSentinel%%SEAM:name%%renderToString()React renderHTML skeleton<!--seam:name-->Before<span>%%SEAM:user.name%%</span>After<span><!--seam:user.name--></span>Typed slots — any language can inject data via string replacement

이쯤 되면 조건부 렌더링은 어떻게 처리하는지 궁금해질 수 있습니다. 예를 들어 특정 필드가 null일 때 전체 콘텐츠 블록이 나타나지 않아야 하는 경우입니다. 하지만 사실 이 역시 런타임 JavaScript로 판단할 필요가 없으며, 대신 프로토콜에서 이러한 상황을 정의할 수 있습니다.

html
<!--seam:if:user.avatar-->
<img src="<!--seam:user.avatar-->" />
<!--seam:endif:user.avatar-->

조건부 렌더링이 그러하듯, 리스트 렌더링도 마찬가지입니다.

html
<!--seam:each:messages-->
<li><!--seam:$.text--></li>
<!--seam:endeach-->

심지어 패턴 매칭도

html
<!--seam:match:status-->
<!--seam:when:active--><span class="green">Active</span>
<!--seam:when:disabled--><span class="red">Disabled</span>
<!--seam:endmatch-->

이것이 바로 주석의 예술입니다. 여기서 주석을 선택한 이유는 단순히 올바른 HTML 문자였기 때문이며, 그 이상도 이하도 아닙니다. 그렇다면 이러한 조건부 블록과 루프 블록은 어떻게 build-time에 결정되고 생성될까요? 사실 알고 보면 매우 영리한 방법입니다. vDOM은 없지만 HTML을 두 번 렌더링하여 Diff를 수행할 수 있기 때문입니다. 조건부 렌더링을 예로 들면, 첫 번째는 완전한 Sentinel 데이터로 렌더링하고, 두 번째는 특정 nullable 필드를 null로 설정한 뒤 다시 렌더링합니다. 두 출력을 비교했을 때 사라진 HTML 부분이 바로 이 필드가 제어하는 조건부 블록이므로, 그냥 <!--seam:if:...-->로 감싸주기만 하면 됩니다. 배열 역시 동일한 원리입니다.

어쩌면 CTR?

그러면 분명 또 누군가는 걱정할 것입니다. 조건부 렌더링의 조합이 지수적으로 폭발하지 않을까? 예를 들어 3~5개의 변수가 조건부 렌더링을 제어하고 각 변수에 10가지 가능성이 있다면, 곱했을 때 확실히 커 보이는 숫자가 됩니다. 하지만 실제로는 이 숫자가 겉보기에만 엄청나 보일 뿐입니다. 첫째, 수많은 문자열 타입 값의 내용을 우리가 일일이 나열할 필요는 없으며, 값이 있는지 없는지 nullable 만 관심이 있으므로 여기서는 실질적으로 2가지뿐입니다. 둘째, 실제로 조건 판단이 필요한 필드의 타입은 반드시 나열 가능할 것입니다. 예를 들어 (설마 open stringif 판단을 하지는 않겠죠?). 게다가 정말 모든 조합을 나열해야 한다 해도, 현대의 CPU에서 실행하면 몇 ms 만에 끝날 것입니다. 또한 이것은 완전히 컴파일 시점의 부담입니다. Rust 컴파일과 마찬가지로 컴파일 시점에 비교적 큰 비용을 치르지만, 런타임에는 순수한 문자열 치환으로 바뀝니다. 어떻게 계산하든 이득인 장사이며, 더군다나 여기에는 어떠한 특별한 마법도 없습니다. 다만 여기서 작게 힌트를 하나 남겨두자면, 뒤에서 복잡한 추론을 처리하기 위해 아주 작은 임베디드 JavaScript 실행 환경을 도입했습니다. 하지만 이는 일종의 타협이며, 그 이유와 이론적으로는 없어도 되는 까닭에 대해서는 뒤에서 설명하겠습니다.

Cartesian product build cost calculationEven worst case 100,000 combinations,each renderToString takes ~1ms,total build time is ~100 seconds — a one-time compile cost.Worst case math5 variablesx 10 values each=10⁵=100,000x~1ms eachrenderToString()=~100s build timeRealityMost are nullable / bool2 states,not 102⁵=32 combosx ~1ms each~32ms — one-time build cost

이것이 제가 말하는 **CTR (Compile-Time Rendering)**입니다. 제한 사항에 대해 말하자면, 사실 SSR의 제한 사항과 완전히 동일합니다. 예를 들어 <!--seam:path-->으로 텍스트를 삽입할 때 자동으로 HTML 이스케이프가 수행되고(&, <, > 등), 원본 HTML을 삽입해야 하는 경우 <!--seam:path:html-->를 명시적으로 사용해야 합니다. 누락된 데이터 경로는 텍스트 slot에서는 빈 문자열이 되고, 속성 slot에서는 주입이 건너뛰어집니다. each 블록이 배열이 아닌 것을 전달받으면 그대로 건너뜁니다. 이러한 동작은 SSR 프레임워크에서 발생하는 엣지 케이스와 본질적으로 차이가 없으며, 저는 단지 SSR의 렌더링 단계를 컴파일 타임으로 가져와 실행했을 뿐입니다. 그리고 CTRbuild-time에 모든 조건 변수의 타입 값에 대해 데카르트 곱 순회를 수행하며, 타입에 부합하는 임의의 Mock 값 또는 사용자가 Override한 특수 Mock 값(일반적으로 HTML 변형을 생성하는 데 필요한 Mock 값을 수동으로 작성할 필요는 없지만 수동으로 제공할 수 있으며, 그렇지 않으면 해당 타입에 맞는 값이 자동으로 들어옵니다)을 사용합니다. 이렇게 각 조합을 한 번씩 렌더링하여 모든 조건 블록과 루프 블록의 경계를 diff하고, 최종적으로 완벽하게 펼쳐진 HTML 스켈레톤을 얻게 됩니다. 수학적으로 말해, runtime에 전달되는 데이터가 이러한 타입 정의에 부합하기만 하면 이 스켈레톤에 반드시 올바르게 주입되며, 가능한 모든 분기 경로는 컴파일 타임에 이미 열거되었습니다.

일관성?

그렇다면 일관성은 어떻게 보장해야 할까요? 사실 그 답은 바로 JTD라는 Contract입니다. 프론트엔드와 백엔드가 분리되어 있더라도 동일한 JTD schema를 준수하기만 하면 데이터 타입이 일치하므로 불일치 문제는 존재하지 않습니다.

하지만 저는 다른 프레임워크보다 한 가지 더 큰 과제를 안고 있었습니다. 백엔드가 JavaScript 런타임에서 해방된 이상, 프론트엔드와 하나로 묶여 있을 필요가 없으므로 RustGo를 사용해 백엔드를 작성하는 것도 얼마든지 가능합니다. TypeScript 풀스택 프레임워크에서는 프론트엔드와 백엔드의 일관성을 타입을 통해 직접 보장할 수 있지만, 다른 언어는 어떨까요? 실제로는 여기서 codegen을 사용했습니다. 백엔드를 어떤 언어로 작성하든 백엔드를 기준(source of truth)으로 삼아, 프론트엔드에서 사용할 수 있는 변수와 타입을 TypeScript로 직접 codegen하여 프론트엔드로 불러오도록 한 것입니다. 이렇게 하면 프론트엔드와 백엔드의 경계는 명확해지지만, 실제로는 TypeScript 풀스택 프레임워크처럼 하나의 폴더 안에서 공존할 수 있어 Monorepo 관례에 부합하며, API나 gen OpenAPI 같은 것을 수동으로 작성할 필요 없이 직접 서로를 호출할 수 있습니다. 실제로 여기서는 프레임워크 기본 엔드포인트로 프라이빗 경로 /_seam/을 사용했으며(Nuxt처럼 설정 가능), JTD Typed-RPC를 실행함으로써 데이터 전송과 CORS 문제를 해결했습니다.

CTR x SSR

그리고 raw HTML slot 입니다. 이것은 결국 그 5%Edge Case 로 돌아가는 셈인데, 예를 들어 Markdown, Rich Text 같은 것들은 컴파일 타임에 그 값을 아는 것이 불가능하며, 타입으로 이를 제약하는 비용이 매우 큽니다. 물론 프로토콜을 확장하여 Markdown의 모든 문법을 나열하고 런타임에 다시 파싱하면 된다고 말할 수도 있겠지만, 그 작업량은 사실상 Markdown 렌더러를 다시 작성하는 것과 다름없지 않을까요? 반면 가장 최소한의 몇 가지 타입만 제약하는 것은 전체 렌더링 엔진을 다시 작성하는 작업량과 완전히 다른 이야기이며, 이는 제가 처음에 언급했던 철학, 즉 더 적은 비용과 더 우아한 방식으로 문제를 해결한다는 점으로 다시 돌아옵니다.

CTR and SSR coexistence on a single pageA page where 95% of UI is handled by CTR with near-zero cost,and only the Markdown article area uses SSR via raw HTML slot.Nav — CTR slot injectionSidebarCTRMarkdownraw HTML slotSSRFooter — CTR slot injectionUser info — CTR slot injectionCTR (~0.1ms)SSR (raw HTML slot)Raw HTML slot backendTypeScript — your existing SSRorRust — pulldown-cmark / comrakorGo — goldmark→ HTML string → insert slot

따라서 좋은 소식은 raw HTML slot 덕분에 실제로 CTRSSR가 공존할 수 있다는 점입니다. 이는 페이지 UI의 대부분을 CTR로 구현하여 런타임 비용이 거의 들지 않음을 의미하며, 가장 핵심이 되는 Markdown 아티클은 SSR를 사용해 렌더링합니다. 백엔드가 해방되었기 때문에 TypeScript로 기존의 SSR 렌더링 방식을 가져올 수도 있고, 시야를 넓힐 수도 있습니다! 백엔드로 Rust를 쓴다면 당연히 Rust의 Markdown 컴파일러를 사용해 렌더링하면 되며, 어차피 최종적으로 HTML 문자열만 제공하여 이 raw slot에 삽입하면 정상적으로 표시됩니다. CTR를 쓴다고 해서 SSR를 쓸 수 없는 것은 아니며, 둘은 완전히 공존할 수 있습니다.

PPR의 이상과 현실

이렇게 이해하고 보면, CTR은 본질적으로 **PPR(Partial Prerendering)**의 가장 이상적인 경우다. 캐시할 수 있는 모든 것이 컴파일 타임에 완전히 렌더링되어 런타임 오버헤드가 제로이고, 실제로 변화하는 그 작은 데이터만 비용이 든다. 그럼 React 19.2의 PPR과 다른 점은 무엇일까? 답은 내가 더 철저하고 더 과감하다는 것이다 PPR 설령 가장 이상적인 경우라 해도, 모든 정적 부분이 캐시되어 있고 업데이트해야 할 동적 콘텐츠가 아주 작은 일부분뿐이라 해도, 그 동적 콘텐츠가 단순히 문자열 하나가 바뀐 것에 불과하더라도, React는 여전히 renderToReadableStream()를 다시 한번 실행해야 하며, 그 비용은 결코 작지 않다.

PPR vs CTR — how they handle dynamic contentPPR runs renderToReadableStream for any change. CTR splits into two parallel paths:string replacement for simple types,render for complex types.PPRStatic partsAny dynamic changerenderToReadableStream()Cached ✓Even a simple stringStill heavyCTRStatic partsstring / number / boolString replace (~0.1ms)Markdown / Rich TextRender (any language)Build-time ✓95% of cases5% edge cases95% string replacement — 5% actual render,in any language

반면 CTR은 이 경계를 매우 명확하게 구별합니다. 문자열, 숫자, 불리언과 같은 단순 타입이라면 모두 직접 문자열 치환을 수행하며, Markdown처럼 나열 비용이 극도로 큰 복잡한 타입만이 비로소 진짜 render 프로세스를 거칩니다. 게다가 여기서의 render는 더 이상 전통적인 의미의 SSR 개념이 아니며, 어떤 프로그래밍 언어의 렌더링 방식이든 될 수 있습니다.

RSC도 빼놓을 수 없다

그럼 RSC(React Server Component)에 대해 다시 이야기해 봅시다. 이전에 RSC가 실수였을 수도 있다고 언급했었죠. 그건 프론트엔드와 백엔드의 경계를 너무 모호하게 만들었고, ~~적지 않은 보안 위험을 초래했으며~~ 수많은 CVE를 불러왔기 때문이지만, 사실 이는 Next.js와 기타 SSR 트렌드로 인한 잘못이 더 큽니다. 사용자에게는 쓰지 않을 기회조차 주어지지 않고, 동적인 처리를 하려면 무조건 SSR을 써야 하는 상황이 되어 버렸죠. 하지만 실제로는 우리 같은 문자열 치환 방식으로도 충분히 잘 작동합니다. 또한 인정할 수밖에 없는 점은 RSC가 서버에 임의의 React 컴포넌트를 렌더링하는, 즉 임의의 코드를 실행하는 매우 중요한 능력을 부여했다는 사실입니다.

하지만 어차피 임의의 코드를 실행해야 한다면, 타입을 통해 이를 완벽히 열거해 내는 것은 거의 불가능하며, 설령 가능하다 하더라도 작업량이 React 컴파일러를 작성하는 것보다 적다고 보긴 어렵습니다. 따라서 RSC의 이러한 기능은 존재할 만한 이유가 있습니다. CTRRSC는 공존할 수 있을까요? 정답은 '가능하다'이며, 전혀 충돌하지 않습니다. 다만 이는 프레임워크 레이어에서 해결해야 할 문제이지 프로토콜 자체의 범주는 아닙니다. 아직 직접 구현하지는 않았지만, TanStack Start를 참고하면 될 것 같습니다. html 패키지와 js 패키지를 하나씩 더 전송하기만 하면 이론적인 구현은 매우 쉬우며, 적어도 명확히 보이는 작업량입니다.

Raw HTML slot — dead HTML inside a React component boundaryShows how raw HTML slot injects static HTML via inner-HTML setter,surrounded by interactive React components for styling.React componentBorder,style,layout — interactive ✓dangerouslySetInnerHTMLMarkdown / Rich Text — dead HTMLNo interaction after hydrationWhen you need SSR60% — raw HTML slot~zero cost,static inject40% — RSCServer execution,TODO

다시 raw HTML slot 이야기로 돌아가면, 이 해결책은 주로 Markdown, Rich Text와 같은 시나리오를 대상으로 합니다. 렌더링된 HTMLdangerouslySetInnerHTML을 통해 주입되며, 하이드레이션 이후 이 영역은 상호작용에 참여하지 않는 "죽은" 상태가 됩니다. 이 부분의 콘텐츠는 최소화해야 하며, 아티클에 테두리나 스타일을 추가하고 싶다면 이 HTML 안에 섞기보다는 React 컴포넌트로 작성해야 합니다. 이러한 방식의 단점은 하이드레이션 이후에 변경될 수 없다는 점이지만, 이러한 전제 하에서 "SSR" 비용을 Zero Cost에 가까울 정도로 매우 낮게 낮춰 줍니다. 실제로 SSR이 필요한 시나리오 중 약 60%는 이러한 정적 HTML 주입 형태이며, 나머지 40%만이 서버에서 임의의 컴포넌트를 실행하는 RSC와 같은 능력을 필요로 합니다.

프론트엔드 UI 무관성

마지막으로 매우 매력적인 프로토콜 독립성입니다. 본질적으로 우리는 renderToString이라는 핵심 포인트 하나만 잡으면 되며, 앞에서 어떤 UI 프레임워크를 사용하는지는 사실 프로토콜과 아무런 상관이 없습니다. 그렇다면 Astro와는 어떤 차이가 있을까요? 하지만 안심하세요, 제가 만드는 것은 결코 또 다른 Astro가 아닙니다. 겉보기에는 Astro의 아일랜드 개념과 조금 비슷해 보일 수 있지만, 실제로는 큰 차이가 있습니다.

Astro vs Seam — key architectural differencesAstro hydrates multiple runtimes and stays MPA. Seam picks one runtime per page,hydrates from MPA to SPA.AstroSingle pageReactruntimeVueruntimeSvelteruntimeMultiple runtimes hydratedMPA — always MPANo client routingSeamSingle page,one runtimeReactVueSvelteSolidPick one per pageMPA beforeSPA afterClient routing + transitions ✓

저는 단일 페이지에서 여러 런타임을 하이드레이션하지 않았습니다. Humm, 사실 이게 실제로 필요한 시나리오는 매우 제한적이라고 생각합니다. 서로 다른 기술 스택 간의 컴포넌트 상태 통신은 매우 비용이 많이 들며, 한 번에 전부 교체할 수 없어 우선 전환하기 위해 한 기술 스택에서 다른 기술 스택으로 마이그레이션할 때 사용하는 과도기적 솔루션에 가깝습니다. 둘째로, Astro는 본질적으로 MPA이지만, 우리는 하이드레이션 전에는 MPA, 하이드레이션 후에는 SPA가 되도록 만들 수 있습니다. Next.js처럼 클라이언트 사이드 라우팅이 있어 페이지 간 애니메이션을 구현할 수 있는데, 이는 Astro가 간절히 바라는 부분입니다.

Astro와 SSG

또한 Astro의 설계는 본래 여러 기술 스택을 넘나드는 시나리오에서야 비로소 진가를 발휘합니다. 만약 속도 때문에 사용하면서 단 하나의 프레임워크만 쓴다면(예를 들어 React만 도입하고 Vue은 사용하지 않는 경우), Astro가 말하는 "빠른 속도"는 허상에 가깝다고 생각합니다. 첫 화면이 HTML로 로드되는 것은 맞지만, 어떤 상호작용이라도 원한다면 하이드레이션이 필요하며, 하이드레이션의 비용은 결국 React Runtime 전체를 다운로드하는 것이므로 이는 우리의 하이드레이션과 본질적인 차이가 없습니다. 물론 향후 우리도 Island 개념을 활용하여 shell router를 추가하고 UI 프레임워크 간 SPA 탐색을 구현할 수도 있겠지만, 이는 추후 Roadmap의 일일 뿐 적어도 지금 당장 급한 건 아닙니다.

SSG vs CTR at request timeSSG serves the same static HTML every time. CTR serves the same skeleton but injects fresh data per request.SSGRequestStatic HTMLBuilt at deploy timeSame page every timeHello,Alice — always AliceFast,but frozen — no per-request dataCTRRequestHTML skeletonBuilt once,reused+Fresh dataPer requestDynamic pageHello,Bob — ~0.1msSame speed as SSG + different data every request

마지막으로 전통적인 SSG와의 비교입니다. 저는 SSG와 동일한 것을 구현했습니다. 본질적으로 컴파일 타임에 확정할 수 있는 모든 것을 전부 렌더링해 두는 것이죠. 하지만 우리는 더욱 동적입니다. 단순한 타입의 slot 값은 런타임에 완전히 교체될 수 있기 때문입니다. SSG를 일종의 MPA 진입점으로 렌더링하여, 하이드레이션 전에는 MPA, 하이드레이션 후에는 SPA로 변환되면서도 "동적" 능력을 유지하는 것으로 이해하시면 됩니다.

어쩌면 하이드레이션 오류와 작별?

마지막으로 제가 정말 싫어하고 여러분도 싫어하실 hydration mismatch입니다. 하지만 결국 이것은 DOM 상태의 불일치일 뿐이며, Next.jsApp Router와 같은 기존 프레임워크는 전체 애플리케이션을 React로 감싸려고 하기 때문에 사용자 측 브라우저에서 어떤 태그를 주입하면 하이드레이션 오류가 발생할 가능성이 있습니다. 우리의 제약은 기존 SSR과 동일하지만, TS를 사용할 때 __root(이)라는 하이드레이션 div(을)를 감싸서 하이드레이션 영역이 metadata 영역을 더 이상 덮지 않도록 하여 더욱 내구성 있는 하이드레이션을 구현했습니다. 또한 React 19에는 <title>, <meta>, <link> 등 문서 메타데이터 태그에 대한 지원이 내장되어 있습니다. 페이지 내의 특정 <div>만 하이드레이션하더라도(전체 <html>이 아니라), 컴포넌트 내에서 <title>My Page</title>을 직접 렌더링하면 React가 이를 자동으로 <head>(으)로 hoist해 줍니다.

Hydration mismatch — traditional SSR vs CTR equivalence checkTraditional SSR discovers mismatch at runtime. CTR checks DOM equivalence at build time using mock data.Traditional SSRServer HTMLRuntime generatedvsClient DOMReact hydrationMismatch discoveredAt runtime — too lateCTR — build-time equivalence checkCTR skeleton+ mock data filledvsrenderToReadableStream()Same mock dataDOM-tree diffAt build time — before deployMatch ✓ safeMismatch → CLI warnsEscape hatch:any — like TypeScript,you accept the risk

다시 hydration mismatch로 돌아와서, 우리는 이미 컴파일 타임에 모든 slot의 타입을 미리 알고 있으므로 CTR 동등성 검사를 하나 더 추가할 수 있습니다. 즉, 타입 정의에서 도출된 mock 데이터를 완전히 전개된 HTML에 채워 넣은 후, 기존의 renderToReadableStream()을 호출하여 한 번 실행하고, 두 DOM-Tree의 의미론적 동등성을 비교하는 것입니다. 서식(fmt)의 차이는 신경 쓰지 않으며, 서식상의 미세한 차이가 있을 수는 있지만 DOM 구조가 엄격하게 완전 동등하기만 하다면 사실상 hydration mismatch와 작별할 수 있습니다. 왜 기존 SSR에서는 이것이 불가능할까요? 그들은 런타임에 이 작업을 수행하는 반면, CTR 구조에서는 컴파일 타임에 이러한 제약 조건을 모두 적용해야 하기 때문입니다. 물론 우리에게도 any라는 탈출구가 존재하지만, 적어도 TypeScript와 마찬가지로 any을 사용했다면 그 결과는 스스로 책임져야 합니다. CLI 컴파일 시 any 탈출 open string이 mismatch를 유발할 수 있다는 warn 메시지가 출력됩니다.

Serverless 탐색

물론 Serverless의 가능성도 빠질 수 없습니다. 최근 몇 년간 Serverless의 경험은 매우 훌륭해졌으며, 저는 "돈이 많이 든다는 것 외에는 단점이 없다"고 평가합니다. 하지만 CTR은 본래 이 시나리오에 매우 잘 맞습니다. 우리 런타임이 하는 일은 매우 가볍고 작기 때문에 Serverless에서 실행하면 매우 빨라지며, 응답 시간과 오버헤드가 모두 현저히 개선됩니다. 개선할 수 없는 나머지 부분은 Markdown을 렌더링하는 것과 같은 시나리오이지만, 이는 비즈니스 로직에서 완전히 최적화해야 하는 문제입니다. 예를 들어 Markdown을 미리 렌더링하여 저장해 두고 매 요청마다 다시 렌더링하지 않도록 하는 방식입니다. 마치 지금 제 웹사이트가 하는 것처럼요. 이는 비즈니스 차원의 문제이지 프레임워크가 해결할 수 있는 문제는 아니지만, 프레임워크는 그 외의 간단한 로직의 오버헤드를 거의 0에 가깝게 줄여줄 수 있습니다. 기존 SSR과 비교하면 오버헤드의 자릿수 자체가 다릅니다. 얼마나 작은가 하면, 대략 몇백 us ~ 1ms 사이의 수준인데, 이는 기존 SSR에서는 감히 생각조차 할 수 없는 일입니다.

Traditional SSR pipelineEvery request rebuilds the component tree and runs renderToString,costing 100-300ms per request.Traditional SSREvery requestRequestBuild treeComponent treerenderToString()~100-300msHTML responsePer-request cost
CTR pipelineRendering happens once at build time producing an HTML skeleton with typed slots. At request time only slot injection runs,costing 0.1-1ms.CTR (Compile-Time Rendering)Build time (once)renderToString()Sentinel dataDiff & detectif / each / matchHTML skeletonTyped slot markersOne-time build costRequest timeRequestSlot injection~0.1-1msHTML responseClient hydrationReact takes overPer-request cost

그렇다면 백엔드가 다른 언어로 작성된 경우는 어떻게 해야 할까요? Cloudflare Workers를 예로 들면, 사실 많은 Serverless가 WASM을 지원하므로 다른 언어도 WASM BIN으로 컴파일한 후 동일하게 백엔드로 사용할 수 있습니다. 본질적으로 저는 프론트엔드를 CSR처럼 순수 정적 리소스로 컴파일할 뿐이지만, 이 정적 리소스들이 프라이빗 브릿지 /_seam/와 결합되면 진정한 풀스택 프레임워크와 동일한 동적 기능을 구현할 수 있어 당연히 Serverless와도 완벽하게 호환됩니다.

SeamJS

그래서 SeamSeamJS의 관계 때문에 머리가 복잡해지셨나요? 사실 이 둘은 매우 간단합니다. Seam은 프로토콜입니다. Sentinel로 동적 위치를 마킹하는 방법, slot 마크로 변환하는 방법, 조건 블록과 반복 블록의 diff 검사를 수행하는 방법, 그리고 런타임에 AST를 기반으로 데이터 주입을 수행하는 방법을 정의합니다. 프로토콜 자체는 언어에 종속되지 않으며, HTML 주석을 파싱하고 문자열 치환을 수행할 수 있는 백엔드라면 무엇이든 구현할 수 있습니다. SeamJS는 프레임워크로, 이 프로토콜을 기반으로 만든 구체적인 구현체입니다. Vite, TanStack Router, TanStack Query 같은 바퀴들을 함께 꿰매어 붙이고, 내 skeleton 추출, 주입 엔진, CLI 등 이들이 커버하지 못하는 부분을 보완합니다.

Seam (protocol) vs SeamJS (framework)Seam is the language-agnostic protocol defining sentinel,slot,diff and injection. SeamJS is a concrete implementation stitching existing tools with custom pipelines.Seam — protocolLanguage agnosticSentinelSlot markersDiff detectionAST injectionAny backend:Rust,Go,TypeScript,...SeamJS — frameworkConcrete implementationViteTanStack RouterTanStack QuerySkeletonCLIExisting tools stitchedCustom built

솔직히 말해서 SeamJS는 아직 매우 기초적인 상태입니다. 실행하는 데는 아무런 문제가 없지만, 실제로 프로젝트를 구축하는 데 사용하려면 아직 많은 다듬질이 필요합니다. 데이터 전송 채널의 추상화와 같은 아키텍처 개선에 대해서도 많이 고민하고 있지만, 향후 버전에서도 이 방향을 계속 유지할지는 확실치 않습니다. 프레임워크 차원에서는 TypeScript 풀스택 프레임워크와 Rust를 백엔드로 하는 플래그십 프레임워크를 반드시 만들 생각입니다. Go 관련 구현은 향후 버전에서 제거할 예정이며, 제 에너지가 더 이상 유지보수를 감당하기 어렵다고 느낍니다.

::github
repo = "canmi21/seam"
align = "left"

단순한 웹 프레임워크 그 이상

Seam protocol — swap transport,same resultSame Seam protocol works on Web via HTTP and Desktop via IPC.Seam protocolHTTPBrowserServerless,CDNFull SSR-like pageData ready on arrivalSeam protocolIPCElectronTauriNo loading screenInstant renderSame protocol,swap transport

그렇다면 이걸 만들어서 어디에 쓸 수 있을까요? 실제로 이는 Web에만 국한되지 않습니다. 예를 들어 Transport 채널까지 추상화하고 나면, 추후 Electron이나 Tauri 같은 Desktop 환경으로 이식할 수 있습니다. HTTP 전송 파이프라인을 IPC 통신으로 교체하기만 하면 여전히 동일한 Seam 프로토콜로 작동하며, 그 결과 이러한 Electron 앱들이 실행될 때 더 이상 loading을 띄울 필요가 없어집니다. 많은 요소들이 SSR처럼 로컬에서 직접 렌더링될 수 있으니, 이것이 바로 CTR의 마법입니다!

No JS Runtime의 대가

그렇다면 결국 SeamJS는 왜 JS Runtime을 추가하게 되었을까요? 이 방안을 실현하는 과정에서 한 가지 문제에 부딪혔기 때문입니다. 바로 CTR의 이상적인 솔루션이 첫 화면 데이터에 요구하는 제약이 매우 엄격하다는 점이었습니다. 데이터는 완전히 도출 가능한 확정된 구조체여야 하며, 심지어 어떠한 계산 로직도 없이 오직 조건만 포함되어야 했습니다. 이는 매우 까다로운 조건이 되었습니다. 프레임워크의 개발자 경험 측면에서 상상할 수 있듯이, 기존에 React를 작성하던 사람들은 당연히 컴포넌트 내부에서 어느 정도 계산을 거쳐 몇 가지 값을 가져와 바로 사용하기를 기대합니다. 만약 CTR의 제약을 엄격히 따라 ready-to-display 상태에 도달하고자 한다면 과정이 너무나 번거로워지고, 심지어 하나의 컴포넌트를 두 번 작성해야 하는 상황까지 발생하게 됩니다.

Why SeamJS needs a JS Runtime — the derive stepRaw data from backend goes through a small JS derive step to become ready-to-display,then CTR injects it.Strict mode — no JS RuntimeRaw dataFrom DB / APIMust be ready-to-displayNo compute,only conditionsComponent written 2xTedious DXWith JS Runtime — derive modeRaw dataFrom DB / APIJS derive()Compute logicready-to-displayTyped structCTR injectWrite once ✓

하지만 사실 해결책이 있습니다. Web의 특성상 프론트엔드는 JavaScript에서만 동작할 수 있으므로, 당연히 컴포넌트도 JS만 실행할 수 있습니다. 따라서 이 문제를 해결하려면 백엔드에도 JS 실행 능력이 있어야 합니다. TypeScript 풀스택 프로젝트 자체의 런타임으로 이를 해결할 수 있고, Rust의 경우에는 QuickJS와 같이 매우 작은 JS Runtime을 임베딩하기만 하면 됩니다. 여기서 JavaScript Runtime은 표준 JS 서브셋이며, Bun이나 Node처럼 완전한 운영체제 API를 갖춘 런타임이 결코 아니라는 점에 주의해야 합니다. 정말로 추론과 derive를 위해서만 사용될 뿐입니다.

JS Runtime size comparison — QuickJS vs Node vs BunQuickJS is 200-300KB,compared to Node at ~100MB and Bun at ~50MB.JS Runtime size comparisonQuickJS~200-300KBJS subset,derive onlyBun~50MBFull runtime + OS APIsNode.js~100MBFull runtime + OS APIs + npm

이렇게 하면 컴포넌트 내에 여전히 일부 계산 로직을 작성할 수 있으며, 이 로직은 백엔드에서 데이터를 가져온 후 작은 JS를 실행하여 ready-to-display 상태로 유도(derive)한 다음, 첫 화면의 하이드레이션을 위해 프론트엔드로 다시 전송됩니다. 이렇게 함으로써 엄격한 구조의 파생 데이터를 얻어 CTR을 만족시키고, 개발자의 DX도 좋아지며, 컴포넌트는 한 번만 작성하면 됩니다. 물론 순수한 타입 제약을 엄격히 준수하겠다면 백엔드는 실제로 No JS Runtime을 달성할 수 있으며, 이 약속은 사실 여전히 성립합니다. 하지만 어쨌든 새롭게 추가된 이 아주 작은 JavaScript Runtime은 Node 같은 것들에 비해 성능 오버헤드, 메모리 차지, 용량이 훨씬 적어 기본적으로 200~300KB 정도에 불과하지만 엄청난 유연성을 제공합니다.

향후 전망

이렇게 말을 많이 했는데, 언제쯤 실제로 써볼 수 있을까요? 아마도 아주, 아주 오래 걸릴 것 같습니다. 정말 펑크를 내려는 건 아니고, 지금 단계에서 이 녀석이 제 시간과 에너지를 너무 많이 뺏는다고 느끼기 때문입니다. 프레임워크 자체만 개발할 수 있는 상태라면 사실상 애플리케이션은 작성할 수 없는데, 뭘 만들든 프레임워크에 발목을 잡히기 일쑤이고, 게다가 최근에는 주로 이 웹사이트를 만드는 데 집중하고 있었거든요. 그래서 생각을 고쳐먹었습니다. 일단 웹사이트를 어느 정도 규모 있게 만들어 보면 내게 필요한 게 진짜 무엇인지 알 수 있을 테니까요. 나중에 다시 SeamJS를 작성할 때 TODO List가 생기는 셈이니, 기능을 하나씩 구현해 나가면 쓸 만해질 겁니다. 나중에 마이그레이션과 Benchmark 글도 하나 더 날로 먹을 수 있겠네요? 자, 그림은 다 그렸고 개념은 여기 제시해 두었으니, 당장 오늘 쓸 수 있는지 여부를 떠나 적어도 아이디어 차원의 프로토타입은 검증되었습니다. 안녕히 주무세요 💤