오늘로 내가 미국으로 이사 온 지 딱 한 달이 됐다. 노스캐롤라이나에 도착해 일상을 정리하고 나니 주변 지역이며 비교적 가까운 명소도 거의 다 돌아봤고, 그래서 갑자기 할 일이 없어졌다. 그런데 하필 나는 가만히 못 있는 사람이라, 한가해지면 꼭 뭔가 할 일을 만들어낸다 블로그를 다시 붙잡은 것도 그중 하나잖아. 다만 공교롭게도 미국은 물류가 그리 편하지 않아서 보드는 당분간 그릴 수 없으니, 결국 돌아와 소프트웨어나 좀 끄적이는 수밖에. 겸사겸사 몇 달 전에 던져둔 구덩이도 다시 주워서 메워볼까?
그런데 남을 타박하기 전에, 아무래도 seam & SeamJS 관련 배경 지식을 좀 채워 넣어야 할 것 같다(x 이게 대체 뭐 하는 물건인지 지난 글이 몇 달이나 떨어져 있으니 여러분은 십중팔구 깨끗이 잊어버렸을 테고, GitHub의 README.md는 나도 당분간 고치기가 귀찮다
프로토콜로서의 렌더링
사용자 인터페이스는 실행되지 않고 기술되어야 한다.
간단히 말하면 Seam1 은 또 하나의 SSR runtime 이 아니라 우선 하나의 프로토콜이다. 즉 빌드 타임에 slot 이 들어간 HTML 뼈대를 만들어 두고, 런타임 / 요청 시점에는 그 slot 에 값만 꽂아 넣는다. if / each / match 같은 것들도 마찬가지로 뼈대에 박아 넣은 프로토콜 노드다. 그래서 요청이 들어온 시점에 실행된 UI 컴포넌트는 하나도 없고, 서버가 할 일은 그거 하나뿐입니다. 그러니 굳이 Node 나 Bun 일 필요도 없어지죠. Rust / Go 같은 백엔드가 HTML 한 장 채우는 것쯤은 되지 않겠어요, 어려울 게 뭐가 있나요?! 물론 뒤에 가서도 JS Runtime 을 완전히 없애지는 못했습니다. 제 말은 안 써도 된다는 거지, 다만 일부 제약을 받아들일 수 있어야 한다는 뜻입니다 어쨌든 전체 성능 향상은 분명 기하급수적입니다. 대부분의 SSR 효과를 내는 데 더 이상 런타임 / 요청 시점에 UI render 를 돌릴 필요가 없으니까요…
요청 시점 모델? 2
대략 올해 하반기부터 이 사이트를 TanStack Start React 에서 SvelteKit Svelte 로 완전히 port. 마이그레이션을 다 끝내고 나니 확실히 좋았다. ssr 의 성능 비용이 거의 한 자릿수 규모로 줄었고, 하이드레이션 부담이 아주 작으며, 돌려 보면 쾌적하고, 프레임워크의 컴파일 철학도 훌륭하고 매우 가볍다. 그런데 Kit 쪽은 늘 어딘가 마음에 차지 않는 부분이 있다. 그중 대부분은 플러그인으로 해결할 수 있지만 어차피 Vite를 이어 붙인 것이니까, 그래도 손댈 수 없는 부분은 언제나 남는다. 다만 이건 십중팔구 내 문제이기도 한데, seam이 바꾸고 싶어 하는 지점이 기존의 어떤 meta 프레임워크와도 본질적으로 충돌할 가능성이 크기 때문이다. 결국 내가 도전하고 있는 건 그들의 실행 모델(요청마다 render 함수를 실행), 데이터 경계(load와 UI가 뒤엉킴), 백엔드 역량(JS 런타임에 묶여 있음)이다.
유사 SSR 3
굳이 생각해 볼 것도 없이 분명 누군가는 물을 것이다. 그거 그냥 Astro / Qwik / Marko 아니냐고. . seam은 언어의 생김새로는 Marko에 가장 가깝고, 기본적으로 JS를 덜 보낸다는 점에서는 Astro에 가장 가깝고, replay hydrate을 하지 않는다는 점에서는 Qwik에 가장 가깝다. 그런데 그쪽의 request-time은 여전히 UI 프로그램을 돌린다. island 각자의 render, Qwik의 그 한 번의 SSR, Marko가 컴파일해 낸 JS 템플릿 함수 — 전부 요청마다 renderer 한 덩어리를 실행한다.
그 {#each} 천 줄도 본질적으로는 결국 내가 HTML 을 이어 붙이는 것이라, 나도 문서4를 생성하지 않는 게 아니다. 차이는 내가 이어 붙이는 것이 이미 자기완결적인 프로토콜이지, 컴포넌트 Tree 를 다시 생성하는 게 아니라는 데 있다. Seam 이 원하는 건 AST → IR → slot 을 포함한 HTML 이고, 요청 시에는 프로토콜 해석과 동적 데이터 영역의 injection 만 하면 되니 UI render 쪽 함수는 더 이상 돌릴 필요가 없다. 그래서 어떤 백엔드든 채워 넣을 수 있고, 마지막 한 층을 residual 화해 버리면 vendor lock in JS5 문제도 함께 풀린다. 그러니 사실 나와 위에 언급한 기존 방식들은 애초에 같은 방향선 위에 있지도 않다(x)
채택
Seam은 하이드레이션을 하지만, 전통적인 방식도 Qwik 방식도 아니다. 전통적인 하이드레이션은 클라이언트가 한 번 더 render 해서 DOM 와 대조하는 것이고, Qwik은 SSR 로 HTML 와 컴포넌트 상태를 내보내고 클라이언트가 resume 하며 replay 하지 않는다. 반면 Seam의 request-time 에서는 UI renderer 조차 사실 돌릴 필요가 없다. 하이드레이션 골격, 첫 화면, hydrate 데이터가 모두 같은 한 번의 injection 에서 나온다. 클라이언트가 받는 것은 이미 맞아떨어진 DOM 와 동일한 데이터이고, 컴파일 타임의 IR 에 따라 이벤트와 client state 를 붙일 뿐이다 adopt. 이는 replay 이 아니며, 당연히 컴포넌트 트리를 resume 하는 것도 아니다.
바로 그래서 「양쪽에서 각각 한 번씩 렌더링한 뒤 맞춰 보기」 같은 mismatch6 는 기본값에서 허용되지 않는 상태로 바뀌었다. 내가 구현이 이미 100% 완성됐다고 주장하는 게 아니라, 이런 어긋남이 모델 안에서 애초에 성립하지 않는다는 뜻이다. 나타났다면 그건 내 compiler / injector 구현 안의 bug이고 fix 될 수 있다. 전형적인 예를 들면 (골격이 HTML parser를 거치지 않았거나, 이스케이프가 맞지 않거나, IR과 골격이 어긋난 경우).
물론 skeleton 자체가 parse-stable7 이어야 한다. table 안에 comment 를 아무렇게나 쑤셔 넣는 건 구현을 제대로 못 한 것이지 내 모델 탓으로 돌릴 일이 아니다. 빌드 타임이 계산하는 건 하이드레이션 매핑(무엇이 정적인지, 무엇이 slot 인지, 무엇을 바인딩하는지)이지 데이터를 미리 확정해 버리는 게 아니다. 다만 이 slot 의 데이터 타입은 확실히 고정된다. 데이터 값 자체는 리퀘스트 타임에 얼마든지 바꿀 수 있다. 단지 HTML 과 payload 이 같은 하나여야 할 뿐이다.
같은 그래프?
Astro 은 첫 화면도 마찬가지로 문서이고 그 뒤에 ClientRouter 도 되지만, 차이는 동적인 부분이 여전히 islands 가 각각 render 한다는 데 있다. ClientRouter 는 본질적으로 하이드레이션 이후에도 살아 있는 같은 컴포넌트 그래프라기보다 document morph 에 더 가깝다. 그래서 Astro 의 기술 스택은 애초에 이런 걸 하기에 맞지 않는다. 그 자체가 틀린 게 아니라, 너처럼 쓰라고 설계된 게 아닐 뿐이다. 물론 View Transitions API8 가 있다고 말할 수는 있다. 그럼 내가 되묻겠다, View Transitions API 가 밥 먹여 주나, 그걸로 페이지를 넘나들며 Motion 애니메이션9 을 씌울 수 있나… 얘기가 좀 샜다.
어차피 하이드레이션 전에는 누구도 아무것도 움직일 수 없고, JS가 뜨기 전 첫 프레임10에 애니메이션이 없는 건 애초에 누구의 잘못도 아니다. Seam 역시 injection이 맞고, document가 맞고, client 진입점이 맞다는 것만 보장할 뿐이다. 진짜 제약은 하이드레이션 이후에 있다. client 내비게이션은 아직 살아 있는 그 그래프에 데이터를 꽂아 넣는 것이지 (layout 유지), injection를 다시 한 번 거쳐 document을 갈아치우는 게 아니다. 요컨대 Seam이 하이드레이션 후에 이어받는 것은 동일한 정적 컴포넌트 그래프다.
사실 여기에 Marko 6도 있고, 내 생각에 꽤 가까이 와 있다(템플릿 AST, 컴파일 타임의 세밀한 단위, replay가 아닌 resume). 다만 Marko과 비교했을 때 기술적으로 그을 수 있는 선은 크게 두 가지다. 하나는 request-time이 여전히 JS 컴파일 결과물을 돌리고 있어서 체인 전체가 JS 런타임에 묶여 있다는 것, 다른 하나는 저자가 쓰는 언어가 Svelte가 아니라 Marko라는 것. 대부분의 사람은 프론트엔드를 이야기할 때 2026년이 되어도 React, Vue, Svelte, Solid 정도만 어느 정도 주류인 UI Stack으로 꼽을 거라고 본다. 내가 말하지 않으면 Marko이 뭔지조차 모르는 사람도 많을 것이다. 그러니 Svelte 위에 얹는다는 건 사실 「원리를 하나 복사해 놓고 backend를 붙이는 것」이라기보다는, 누군가 쓰고 누군가 사용하고 누군가 계속 버그를 고치며 다듬어 온 UI를 빌려 쓰는 것에 가깝다고 설명하고 싶다. 전부 다들 익숙한 것들이다.
그리고 하나 더, Qwik의 「하이드레이션 없음」이 아껴 주는 건 클라이언트 쪽의 replay이지, 서버에서 한 번 도는 SSR11(?)까지 아껴 주지는 않는다. 그래서 아쉽다. 지금으로선 나를 만족시키는 프레임워크가 하나도 없고, 내가 뭔가를 덧붙일 수 있을 만큼 충분한 API를 주는 것도 없다. 몇 달 전에 SeamJS라는 걸 만들기 시작한 이유가 바로 그것이고, 오늘은 사실 그것의 roadmap을 다시 설계해 보려는 것이다.
지금의 나는 「Svelte: for building, not frameworking.12」에 깊이 공감한다.
React
먼저 React 얘기부터 하자. Vercel Next.js 에 상업적으로 묶여 있다는 점과 자체 roadmap 를 갖고 있다는 점은 제쳐두더라도, 우선 CVE가 사방에 날아다닌다. 이것 하나만으로도 새 취약점이 보고되고 공개되지는 않았는지 매 순간 소셜 미디어를 들여다봐야 하는 신세가 확정된다. 다른 곳의 라우터는 대체로쓸 수 없는 상태이고 특히 Remix가 그렇다13. 그런데 2026년에는 아주 특별한 선택지가 하나 있는데, 바로 TanStack Start다. 나는 TanStack Router를 꽤 오래전부터 써 왔고, 그래서 이렇게 말할 수 있다. 네이밍 스타일과 자료 구조가 그리 예쁘지 않다는 점14을 빼면 기능적으로는 내가 인정하는 유일한 선택지다. 새롭기는 하지만, Next.js 16의 next-server 프로세스가 지금까지도 해결하지 못한 콜드 스타트 후 첫 페이지 접속 속도 문제를 해결했다. Next.js는 사실 늘 영문 모를 이 버벅임이 있었고, 서버 성능이 모자라서가 아니다. 나는 원인을 이렇게 본다. 시작 단계의 로딩이 아직 끝나지 않았거나, 아니면 runtime 가 여전히 무언가를 컴파일하고 있거나. 게다가 이런 구조는 Cloudflare Worker처럼 여러 지역에 인스턴스를 두고 가까운 곳에서 띄우는 원칙과는 애초에 잘 맞지 않는다. 오직 Vercel 같은 전통적인 Node 상주 프로세스15만이 이 문제를 겪지 않는다(?). 얘기가 좀 샜는데, Next.js에 대해서는 나중에 따로 한 편을 써서 실컷 불평할 수 있겠다. 하지만 TanStack16 은 정말로 상업적으로 써도 될 만큼 성숙했다고 본다. 적어도 나는 어떤 프로젝트의 판매 사이트에 실제로 적용했다. 하지만 이건 단 둘뿐인 선택지 중 하나일 뿐이다(x)
Vue
Vue의 경우, 저 개인적으로 Vue 2에서 Vue 3로 옮겨갈 때부터 문법 같은 메커니즘에 그다지 동의할 수 없었고, 제 마음속의 Vue는 영원히 Vue 2입니다. Vue 3에 훌륭한 feature이 많다는 건 부정할 수 없지만, Vue 2와 비교하면 그 핵심이 제게는 달라진 느낌이고, 그것도 아주 크게 달라졌습니다. 또 비즈니스 관점에서 따져 보면, Vue은 점점 더 meta 계층에 완전히 묶이고 생태계에 통제당하고 있는 것 아닐까요? 처지가 Svelte와 매우 비슷하게 느껴집니다. 둘 다 오픈 소스지만, 제3자가 나서서 두 번째 구현을 만들어 주기란 사실상 거의 불가능하죠. TanStack Start의 합류가 만든 판 흔들기가 Next.js에 정말 큰 타격이 된 이유도 바로 이것이고, Tanner Linsley는 반드시 칭찬받아야 합니다. 그리고 Vue에는 무시할 수 없는 문제가 하나 더 있습니다. 초기의 Vue는 분명 하나의 혁신이었지만, 어떤 UI Stack이든 생태계가 커지고 나면 생태계 문제를 만나게 됩니다. 즉 호환성을 위해 일정한 타협이 생긴다는 것? 특히 이 문제는 React에서 정말 두드러지고, 지금의 Vue도 예외가 아닙니다. 이런내용17이 나오기 시작하면 저는 보통 Slop으로 여기게 됩니다. 그렇다고 나쁘다고 말할 수도 없지만요, Nuxt가 다음 '엔터프라이즈급' 프레임워크가 된 이상, 최우선 고려 사항은 이미 기술적으로 옳은지, 우아한지가 아니라 일정한 하위 호환성과 안정성이니까요(x
그러니까 Evan You가 뭘 잘못한 건 아니다. 오히려 누군가는 그 역할을 맡아야 했고, 그가 마침 가장 적합한 사람이었으며, 가장 적합한 일을 했을 뿐이라고 말할 수도 있다…
Solid
SolidJS 프레임워크는 훌륭하고 코어도 충분히 안정적이다, 그런데 아무도 그 생태계를 만들어주려 하지 않는다. Solid는 사실 초창기의 Rust과 같은 처지인데, Rust는 그 시기를 버텨냈다. Rust도 프로그래밍 영역에서는 생태계가 빈약해 뭐든 직접 바퀴를 깎아야 하는 비슷한 상황이지만, Rust의 툴체인은 그 바퀴를 매끄럽게 깎아내고 최소 동작 단위를 굴러가게 해준다. 바퀴를 다시 만드는 난이도가 사실 그렇게 높지는 않다는 뜻이다. 그런데 안타깝게도 SolidJS는 아직 이 단계를 넘기지 못한 채 AI 겨울을 맞았다. 특히 26년 이후로는 훨씬 더 힘들어질 것이다. Solid 2.0의 코어는 이미 아주 좋고 안정적이지만 주변부가 굴러가질 않았다. 발상은 대단히 옳았고, 다만 때를 잘못 만났다. 주변 프로젝트가 몇 개 있다 해도 한두 갈래에 흩어져 있을 뿐이라 (예를 들어 TanStack에는 Solid용 실험적 Router가 있다) 온전한 생태계로 이어붙지 못한다. 그래서 Solid는 지금도, 앞으로도 애매한 자리에 놓인다. 완전한 생태계가 없고, Agent가 폭발하는 지금 같은 시대에 눈덩이가 구르기 시작하지 않았다면 Agent는 그것을 우선 선택하지 않는다. AI조차 선호하지 않는다는 건 기여를 받을 기회가 그만큼 더 줄어든다는 뜻이다. 하물며 Linus Torvalds마저 를 시작한 시대에는 더욱 그렇다. Solid가 참 아깝다. 발상도 좋고 기술도 옳고 구현도 우아한데, 정말 때를 잘못 만났다…
「예전의 나였다면 이걸 문제라고 여기지 않았을지도 모른다. 전부 직접 만들어도 상관없다고 생각했을 것이다. 하지만 아쉽게도 지금의 나는 그렇게 생각하지 않는다」
균형점
그렇다면 이 길에서 정확히 가장 완벽한 균형점을 밟고 있는 건 사실 Svelte 뿐입니다. SolidJS 보다 생태계가 좋고 (태어난 시대도 더 좋고), 성능도 크게 뒤지지 않으며, 심지어 때로는 컴파일러 덕분에 SolidJS 보다 더 빠르고 더 나은 경우도 많습니다?! 물론 여기서 누군가는 이렇게 말하겠죠. 페이지 수가 일정 규모까지 올라가거나 사용자가 더 많이 둘러볼수록 Svelte 의 리소스 비용18이 SolidJS 대비 점점 커지지 않느냐고, 네트워크 요청이 점점 많아지지 않느냐고. 음, 이론적으로는 사실입니다. 하지만 저는 실제 웹페이지와 함께 봐야 한다고 생각합니다. 99.9% 의 개인 사이트나 콘텐츠 사이트에서 방문자는 기본적으로 오래 머물지 않고, 오래 머무는 건 오히려 매우 드문 경우로 볼 수 있으니까요. 적어도 제 사이트에서는 지금까지 깊이 파고들며 계속 클릭하는 경우가 극히 적어서, 「많은 페이지를 넘긴다 = Svelte 가 더 손해」는 저에게 문제가 되지 않습니다. 하지만 이게 Svelte 를 고른 이유는 아닙니다. 핵심은 역시 markup AST 입니다.
그리고 Dashboard처럼 페이지를 하나하나 열어 보게 되는 것도 있는데, 이 통계는 거기엔 도움이 되지 않는다. 다만 Dashboard 사용자는 진짜 콘텐츠 독자에 비하면 어차피 소수 아닌가. 그래서 "페이지를 아주 많이 보면 오버헤드가 커진다"는 말은 본질적으로 가짜 명제라고 생각한다. 적어도 지금은 그렇고, 나중에는 생각이 달라질지도 모르지만, 이 빠른 정보의 시대에 그런 사용자가 극히 드물다는 건 인정하지 않을 수 없다. 설령 흥미가 생겨 깊이 파고들다 오래가는 팬이 되는 사람이 있다 해도, 그 탐색은 대체로 첫 방문에서만 일어나고 이후에는 새로 올라온 글이나 보러 올 뿐이다. 통계적 수익으로 따지면 이런 극단적인 경우는 비례하지 않는다고 할 수 있다. 그래서 나는 이렇게 믿는 쪽이다: 대부분의 상황에서 Svelte가 더 나은 종합 성능을 낸다.
Svelte 는 위의 세 프레임워크에 비해 처지와 위치가 더 알맞다는 점 말고도, 사실 날것의 HTML 를 쓰는 감각에 아주 가깝다. 심지어 Svelte 3 가 막 퍼지기 시작하고 .svelte 확장자가 막 정해졌던 초창기, 공식 VSCode 확장이 나오기 전까지는 공식적으로 이렇게 하라고 권했을 정도라, 지금도 Svelte 의 확장 페이지에서 이런 문장을 찾을 수 있다:
VSCode 설정에
"files.associations": {"*.svelte": "html" }을 추가했다면, 지우세요.
그리고 Svelte 는 그 밖에도 나에게 여러 이점을 준다. 우선 UI 를 JSX 에 JS function19 를 잔뜩 얹는 쪽으로 밀지 않는다는 점, 그리고 <style> 가 runtime 가 아니라 컴파일 타임 산물이라는 점이다. 앞쪽은 구조를 markup AST 안에 남겨 두고, 뒤쪽은 CSS 가 곧바로 IR 로 들어가게 해 주는데, Tailwind 는 마침 양쪽 모두에 잘 붙는다(
블랙박스
지난 글에서 썼듯이, Seam은 무엇보다 하나의 프로토콜이지 또 하나의 SSR 런타임이 아니다. 빌드 타임에 slot을 담은 HTML skeleton를 만들어 두고, 요청 시점에는 injection만 한다. if / each / match 역시프로토콜 노드20
아쉽게도 예전에 UI를 React로 고르고 손을 대기 시작했는데, 불행히도 React 위에서 페이지 구조를 포착해 변형을 만들어 내는 건 대단히 어렵고, 컴파일러를 손보자니 그 작업량이 하나 새로 짜는 것과 다를 바 없다…
고치지 않는다면 React 컴파일러를 블랙박스로 두고, render + diff 를 잔뜩 실제로 돌려서 구조를 추측해 쓰는 것도 가능하다. nullable / enum 같은 유한한 분기라면 아직 전수로 나열할 수 있지만, price > 10 같은 술어를 만나는 순간 타입의 값 공간은 도저히 곱해 낼 수가 없다. V1 에서는 이런 경우 mock 을 손으로 직접 넣게 할 뿐이다, 예컨대 price < 10 / = 10 / > 10 로 세 번 잘라 주는 식으로;
그런데 문제도 바로 여기서 생긴다(?) 이렇게 정확히 3등분을 하려면 자르는 지점이 10이라는 걸 알아야 하는데, 나에게 React 컴파일러는 불투명해서 조건이 무엇인지 알 수가 없다. 그래서 TypeSafe21의 대가는 결국 사용자가 컴파일러의 일을 대신하는 것이 된다.
이론적으로 이건 사용자에게 떠넘길 일이 아니다. 안 그러면 결과는 escape 이 사방에 날아다니는 것22이고, CTR23 이 커버하는 것도 여전히 그 몇 안 되는 nullable / enum 뿐이라, 구조 발견은 하지 않은 것이나 마찬가지가 되고, 그러면 대체 무슨 의미가 남는지 모르겠다.
하지만 이번에는 프로토콜 자체를 바꾸자는 얘기가 아니다. 내가 원래 말했던 skeleton, slot, injection는 사실 그대로 둬도 된다고 보고, 백엔드도 여전히 UI 코드를 돌리지 않아도 된다. 정작 바꿔야 하는 건 프로토콜 앞단의 그 계층, 즉 답변 페이지가 어떻게 이 노드들로 컴파일되는가 하는 문제다.
그래서 여기서는 Svelte이(가) 내게 준 가장 큰 선물에 감사하지 않을 수 없다 -- markup
마크업의 구조는 내가 추측할 필요가 아예 없으니까!!!
보이는 AST
마크업이 정말로 마크업이라는 사실 하나만으로도, 페이지 구조는 런타임 JS가 잔뜩 실행되어 만들어낸 컴포넌트 트리가 아니라 컴파일러가 읽을 수 있는 무언가가 될 기회를 충분히 얻는다. 그러니까 내 말은, 멀쩡한 UI를 왜 굳이 임의로 실행해 가며 그려야 하느냐는 것이다.이 관점을 부정하려는 건 아니고, 내가 하고 싶은 말은 적어도 97% 이상의 시간에는 임의 실행 렌더링이 쓰일 일이 없다는 것이다, 자세한 내용은 여기를 참고
반대 사례를 하나 들면, React 안의
function Card({ user }) {
return user
? <div className="card">
<Avatar user={user} />
<span>{user.name}</span>
</div>
: null
}본질적으로는 여전히 JavaScript 함수 하나를 실행해서, 그것이 무엇을 반환하는지 보는 것이다. 컴파일러가 아무리 똑똑해도, 그것이 마주하는 기본 모델은 여전히 JS execution → JSX expression → element tree 이다. 그런데 Svelte 안에서는 사정이 아주 다르다.
{#if user}
<div class="card">
<Avatar {user} />
<span>{user.name}</span>
</div>
{/if}그것이 컴파일러에 곧바로 건네주는 것은
Component
├── IfBlock
│ └── Element div
│ ├── Component Avatar
│ └── Element span
│ └── Expression user.name평범한 Kit 애플리케이션에게는 이것만으로도 충분히 훌륭하지만, server rendering 을 compile-time skeleton + CTR + SSR fallback 으로 쪼개려는 Seam 같은 것에게는 그 의미가 정말 조금 더 큰 정도가 아니다.
이 트리가 나에게 두 층의 정보를 동시에 줬기 때문이다. 첫째는 값의 동적 변화 → slot, 하지만 이건 V1에서도 이미 할 수 있던 것이고, 더 중요한 건 사실 두 번째다. 그건 나에게 구조의 동적 변화 → 프로토콜 차원의 제어 흐름 노드를 줬다. React 컴파일러라는 블랙박스를 추측하느니, Svelte의 컴파일러는 제대로 된 프런트엔드 산출물을 그대로 드러내 읽게 해준다. 예를 들어 {user.name}은 전자이고, {#if user}라야 후자다.
그리고 예전 프로토콜의 if / each / match 는 여전히 유효하다. 다만 예전 글에서 말한 데카르트 곱은 한정을 좀 붙여야 할 것 같다. 성립 조건은 사실 값 공간이 아니라 분기 공간이다. nullable / enum / bool 같은 유한 결정은 조합이 유한하니 compile-time 로 돈을 들여 전부 열거하는 게 수학적으로 성립한다. 하지만 JTD 의 string / number / timestamp 은 그 자체가 열거 불가능하고, price > 10, inventory < 5, items.length === 0 같은 술어가 잘라내는 분기는 타입 값의 데카르트 곱만으로는 결코 찾아낼 수 없다. sentinel 에 아무 숫자나 넣어봐야 언제나 같은 쪽으로만 간다.
정말로 이것들을 유한한 결정으로 줄이려면 애초에 derive을 먼저 bool / enum로 바꿔야 하는데, 그 단계는 JTD가 공짜로 주는 게 아니다(ㅋ). 그래서 V1은 nullable / enum에서만 sound하고, 임의 필드의 값 공간에 대해서는 성립하지 않는다. 진짜 엔지니어링 문제는 오히려 그 블록들이 diff가 추측해낸 것이고, IR는 스스로를 설명하지 못한다는 점이다.
로워링
하지만 Svelte 시대에는 이 노드들을 AST에서 직접 생성할 수 있다. 그렇게 하는 이유는 스택을 바꿔 몇 밀리초를 아끼려는 것이 아니라, 제어 흐름을관측24에서 lowering으로 바꾸기 위해서다 — 구조 발견은 AST에 맡기고, 제한된 결정은 프로토콜 노드로 처리하며, 더 이상 N개의 HTML를 곱해 낼 필요가 없다. 타입은 payload을 제약할 뿐, 트리를 발견하지 않는다. 그래서 결국 Svelte 안에서 values는 이 Layer 위에서 아래와 같이 보인다 ↓
STATIC
<div class="card">
DYNAMIC
{user.name}
STATIC
</div>이런 DYNAMIC은 대개 React 수준의 「진짜 렌더링25」까지는 필요하지 않고, slot을 유지하는 것만으로 충분하다.
좀 더 세밀하게 보면, Svelte는 element 하나를 이렇게까지 쪼갤 수도 있다
structure: static
attributes: static
text node #0: dynamic바로 이것이 Seam 이 가장 필요로 하고 또 가장 좋아하는 것이다. React 안에서 attributes 가 static 인지 알아내려면 사실상 무차별 전수 조사밖에 없다. 가능한 입력을 전부 한 번씩 돌려보고, attribute 가 그대로면 static 이고, 바뀌면 거기에 slot 를 붙여 준다.
구조 분기는 더 심각하다. 「여기에 이 DOM 조각이 있는가」조차 두 번 렌더링한 뒤 diff 해야 알 수 있다. 그런데 Svelte의 IfBlock는 바로 그 뒷일을 관측 가능한 것에서 생성 가능한 것으로 바꿔놓았고, 이것이야말로 질적인 변화다.
그런데 사실 이렇게 말하면 완전히 맞지도 않고, 너무 단순화한 것이기도 하다. 좀 더 자세히 말하면, 하나의 element 는 더 쪼갤 수 있고, attributes 도 처음부터 반드시 static 인 것은 아니며, 결국 어떻게 작성하느냐에 달려 있다.
class="card" → attributes: static
class:active={on} → name static, value slot
{...rest} → opaque, escape hatch
text node {user.name} → slot예를 들어 여기서 첫 줄이 skeleton으로 들어가는 건 이견이 없지만, 두 번째 줄이야말로 Svelte가 정말 쓸 만한 지점이다: class: / style: 는 attr 라는 이름을 AST 에 남겨 두고 값만 따로 slot 로 바뀌므로, element 전체를 동적으로 찍어 버릴 필요가 없다. 세 번째 줄은 React 의 {...props} 와 본질적인 차이가 없어서 Seam은 그냥 명시적인 hatch 로 취급하고, 더 이상 분석하는 척할 필요가 없다. React 에서도 리터럴 className="card" 는 사실 전부 열거할 필요가 없다. 열거해야 하는 건 "props를 다른 걸로 바꿨을 때 이 attr이 변하느냐"다. Svelte에 마침 없는 게 바로 이 마지막 종류의 추측이다 — 이름이 트리에 남아 있는 한, 두 번 렌더링해 봐야 알 수 있는 일이 아니니까 🫠
아무튼 React는 Seam이 많은 것을 다시 런타임 문제로 취급하도록 강요한다, 예를 들면 ↓
const Wrapper = cond ? A : B;
return Wrapper({
children: foo.map(renderItem)
});혹은
return foo && bar
? getLayout()(data)
: something();물론 요즘의 React Compiler는 그중 상당 부분을 분석해 낼 수 있지만, 그걸 뜯어고친다는 건 결국 그 개조를 계속 유지보수해야 한다는 뜻이고, 그 비용은 체력으로 보든 시간으로 보든 어마어마하다고 할 수 있다... Svelte 쪽을 고친다면, 여기서 나는 compiler를 fork할 생각은 없다, 이론상 svelte/compiler 가 이미 AST 를 드러내 주고 있다.
Seam이 할 일은 이 트리를 읽고 자기만의 IR을 생성하는 것이다; 공식 DOM / SSR 코드 생성은 사실 그대로 두고 fallback으로 써도 되고, 이론상 유지보수해야 하는 건 IR lowering뿐이지 Svelte 전체가 아니다, 그래서 큰 방향이 일치하니 충돌도 적을 것이다 — 다만 일치하는 건 「구조가 컴파일 타임에 보인다」는 점이지 실행 모델이 아니다; 바로 그래서 Svelte / Kit의 SSR은 여전히 요청마다 생성된 render 함수를 한 번씩 돌린다; 하지만 내 Seam에서는 request-time이 이 함수를 돌리지 않고 프로토콜만 해석했으면 한다 (slot + if / each / match)
Svelte를 고른 데에는 사실 또 하나의 고려가 있었다. Next.js와 React가 이미 완전히26 Vercel에 장악되어 시장 프레임워크가 되어 버린 상황을 전제로 하면, Seam이 React 시맨틱과의 호환을 약속한다면 내 아키텍처는 결국 arbitrary JavaScript → determine tree structure을 허용할 수밖에 없다.
그래서 Seam 의 IR 은 아주 보수적으로 짤 수밖에 없다(x). 결국 십중팔구는 정적 분석이 되는 부분은 최적화하고, 안 되는 부분은 SSR 로 넘기는 형태가 될 것이다. 이것도 CTR + SSR 이 섞인 페이지이긴 하지만, 문제는 바로 그 "분석이 안 되는" 지점에 있다. React 의 세계에는 "확정할 수 없는" 곳이 너무나도 많으니까…
React 컴파일러를 고치지 않는다면 V1 때와 똑같은 처지에 머무를 수밖에 없다. 컴파일러를 블랙박스로 두고 그 산출물을 어림짐작하는 것인데, 그건 대단히 취약하고 우아하지도 않으며, 엣지 케이스가 사방에서 튀어나오기 십상이다.
뒤집기
그래서 지금 Seam 의 컴포넌트 언어가 곧 Svelte 라고 말한다면, 컴파일러는 오히려 거꾸로 갈 수도 있다. 우선 기본은 structure known, 국소적으로는 expression dynamic, 명확한 곳은 escape hatch, 그다음에 runtime 으로 들어가고, 도저히 처리할 수 없을 때에야 마지막으로 SSR 로 물러난다.
<script>
let { product } = $props();
</script>
<article class="product">
<h1>{product.name}</h1>
<div class="price">${product.price}</div>
<button>Buy</button>
</article>Seam은 아래와 같은 형태로 이해해도 전혀 무리가 없다
<article class="product">
<h1><!-- slot 0 --></h1>
<div class="price">$<!-- slot 1 --></div>
<button>Buy</button>
</article>그러면 이론적으로 slot0 = product.name, slot1 = product.price 이다. 이것이 바로 내가 전부터 해오던 잔여화(residualization)이고, 핵심 아이디어는 페이지 전체가 서버 사이드 렌더링을 거칠 필요 없이 진짜로 동적인 잔여 부분만 계산한다는 것이다.
슬롯과 분기
하지만 React에서 Svelte로 바꿔서 실제로 이득을 보는 건 사실 그다음 층이다. 즉, 구조가 바뀔 때도 두 번 렌더링해서 추측할 필요가 없어진다, 구조가 컴파일 타임에 이미 확정되어 있으니 그에 맞는 IR을 바로 생성하면 되기 때문이다
<article class="product">
<h1>{product.name}</h1>
<div class="price">${product.price}</div>
{#if product.available}
<button>Buy</button>
{:else}
<p>Sold out</p>
{/if}
</article><article class="product">
<h1><!-- slot 0 --></h1>
<div class="price">$<!-- slot 1 --></div>
<!--seam:if:product.available-->
<button>Buy</button>
<!--seam:else-->
<p>Sold out</p>
<!--seam:endif-->
</article>값은 여전히 slot이고, 분기는 옛 프로토콜에 있던 if 블록이다. 그런데 React 버전에서는 이 블록을 얻으려면 diff을 여러 번 해야 한다. 반면 Svelte 에서는 여기서 IfBlock로부터 곧바로 도출해 생성할 수 있다.
이스케이프
물론 JSX로 못 한다는 얘기는 아니다. 다만 실제로 써보면 구조를 분기 트리에서 눈에 잘 띄는 자리에 남겨두는 사람은 거의 없고, 대개 아래와 같은 모양이 된다(ㅋ)
const price = formatPrice(product);
const body = product.available
? getAvailableView(product)
: getSoldOutView(product);
return <Layout>{body}</Layout>;그리고 아주 중요한 점이 하나 더 있는데, 네가 이렇게 쓸 수 있다 해도 React 의 주력 집단이 이렇게 쓰지 않고, 학습된 LLM 도 이렇게 쓰지 않는다면, 마이그레이션 비용과 이후 개발 비용은 무조건 기하급수적으로 늘어난다.
하지만 Svelte 쪽은 여기서 또 이야기가 다르다
UI structure ≈ template AST, JavaScript ≈ values + behavior 는 여기서 이미 일상이다
심지어 Svelte 공식이 가장 권장하는 방식이기도 하다. 이 차이는 Seam 이 IR 을 뽑아내는 데 있어 구조적인 문제다. 다만 Svelte 에 동적 구조가 없다는 뜻으로 받아들이지는 말자. 실제로는 그에 해당하는 탈출구가 있고, 그것도 주요 문법 중 하나다
{@render children()}
<svelte:component this={layout} />
<svelte:element this={tag}>
{@html html}Wrapper = cond ? A : B 은 여기서 그냥 사라지는 게 아니라, "임의의 JS 표현식"에서 이 몇 개의 노드로 좁혀진 것이다. Seam 입장에서는 아주 간단해진다. 기본적으로 AST lowering 을 따르기만 하면 되고, 정적으로 보이는 hole 안에 있는 것이 곧 그 주인이다. 가장 흔한 것은 {@render} 을 compose 로 쓰는 경우이고(children 를 감싸는 layout, Card 도 모두 이 부류다), <svelte:component> / <svelte:element> / 동적 {@render} 이야말로 명확한 hatch 이며 runtime 이나 SSR fallback 로 들어간다. 그리고 {@html} 의 경우에는 내가 앞서 설계해 둔 raw HTML slot 을 그대로 쓰면 된다.
그러니까 실제 장점은 「구조를 항상 알 수 있다」가 아니라 「모르는 구조에 대한 참조 목록이 있다」인 것이다 (x
React에서 미지의 영역은 JS 전체다. Svelte에서 미지의 영역은 위에서 말한 몇 가지 Escape가 남긴 틈뿐이고, 그게 전부다. 이를 적용하면 CTR이 분석해야 할 범위는 분명히 눈에 띄게 줄어든다.
CTR
Svelte를 쓸 수 있다고 해서 반드시 CTR도 쓸 수 있는 건 아니지만, 여기에 참고할 만한 기준이 하나 있다 ↓
그래야 정적 참조가 compose가 된다. 그래도 아래의 hatch을 거친다. 여기서 CTR의 단위는 개별 파일이 아니라 정적 컴포넌트 그래프라는 결론이 나온다. 대부분은 기본값인 structure known이고, 일부만 명시적으로 escape hatch이며, SSR fallback는 평소에 거의 마주칠 일이 없다. 표에 없는 나머지는 전부 opaque로 처리하면 되니 추측할 필요가 전혀 없다. CTR는 이 부분집합 위의 execution model일 뿐이고, 내가 Svelte로 옮겨간다 해도Svelte 전체 범위27(ㅋ)
CSS
또 하나의 큰 이득은 사실 CSS인데, Svelte에서는 이렇게 쓸 수 있다:
<div class="card">...</div>
<style>
.card { border-radius: 8px; }
</style>여기서 Seam이 컴포넌트 하나를 컴파일할 때, 동시에 이미 markup dependency + script dependency + style dependency를 모두 손에 쥐고 있고, 게다가 스타일은 정적 산출물이다. 그러면 아주 공격적으로 판단해 낼 수 있다
심지어 한 걸음 더 나아가 흐름을 이렇게 만들 수도 있다 ↓
위의 전체 과정은 JS 런타임이 전혀 관여할 필요가 없다.
CSS-in-JS 없이
CSS-in-JS도 나는 받아들이지 않는데, 그건 React가 아래와 같은 표현을 지원하라고 나를 몰아붙여서가 아니다
const Button = styled.button`
color: ${p => p.primary ? "red" : "black"};
`;또는
<div css={theme => ({
color: theme.colors.primary
})}>다만 위 구조는 사실 V1에서도 이미 Tailwind로 우회할 수 있었다. CSS-in-JS를 붙이지 않는 주된 이유는, 일단 붙이는 순간 CSS가 다시 JS 실행 결과로 돌아가고, 그러면 스타일 런타임, 레지스트리, SSR 수집, 해싱, hydration 일관성, 테마 컨텍스트, 삽입, 순서, 중복 제거까지 전부 다시 불러들여야 하기 때문이다. 평범한 프레임워크라면 삼킬 수 있을 것이다. 하지만 Seam 그녀는 다르잖아! 내가 없애려는 게 바로 이런 서버/런타임 작업인데, 생태계에 대한 예의 때문에 방금 쫓아낸 것을 다시 불러들일 이유는 전혀 없다 (전혀 말이 안 된다
게다가 CSS-in-JS가 등장하던 시절에 풀려던 문제는 지금 Tailwind CSS와 Motion이 하고 있는 일 그 자체다. 지금 Tailwind CSS와 Motion이 그걸 충분히, 심지어 더 낫고 더 우아하게 해결한다면 굳이 시계를 거꾸로 돌릴 이유가 있을까? 앞에서 말한 그대로다. "무엇이든 생태계가 자리를 잡고 나면 타협28이 생기고, 그걸 갈아엎는 breaking 리팩터링은 좀처럼 오지 않는다." 그렇다면 Seam은 아직 생태계가 만들어지지도 않았는데, 끝까지 과감하게 가지 않을 이유가 어디 있나?
다만 여기서 "급진적"이라는 말이 맞는 표현인지는 모르겠다. 맞다고 할 만한 쪽은, 기존 프레임워크에 비해 서버 사이드의 그 무거운 JS 런타임(Node 혹은 Bun)을 실제로 버렸다는 점이다. 아니라고 할 만한 쪽은, 예컨대 CSS-in-JS 같은 것은 아예 완전히 버려도 사실 별문제가 없다는 점이다. 나는 그것을 이제 로 보는 편이 낫다고 생각한다. 한때 가장 유명했던 CSS-in-JS 라이브러리들을 보면, Stitches는 2023년 6월에 더 이상 적극적으로 유지보수하지 않는다고 공식 발표했고, styled-components도 2025년 3월에 휴면 상태에 들어갔으며, Emotion 역시 기본적으로 같은 상황이다.
그렇다면 Svelte의 <style>는 칭찬하지 않을 수 없다. CSS → compile-time artifact는 Svelte 안에서는 이미 이론의 여지가 없는 수준이다 (적어도 나는 그렇게 생각한다, 동의하지 않는다면 네가 맞는 걸로)
JS → execute → generate CSS → collect → serialize → hydrate 이런 식만 아니라면 차이는 아주 크게 벌어질 거고, 더 중요한 건 어쩌면 CSS 의존성이 곧바로 Seam IR로 들어갈 수 있다는 점이다 🤔
예를 들어 컴포넌트 컴파일:
ComponentIR {
skeleton,
dynamic_slots,
css,
client_behavior,
server_dependencies
}이건 별개의 렌더링 모델을 새로 만드는 게 아니라는 점에 유의하자. V1에서 skeleton 과 dynamic_slots 을 합쳐 컴파일하면 결국 예전 프로토콜의 그 HTML 가 나온다. 다만 skeleton 과 dynamic_slots 가 어떻게 만들어지는지가 나에게 훨씬 투명해졌을 뿐이다.
아래와 같이 처리한다고 가정해 보자 ↓
<script>
let { name } = $props();
</script>
<div class="user">Hello {name}</div>
<style>
.user { font-weight: 500; }
</style>그러면 결국 반드시 도달하게 된다
ComponentIR
HTML: <div class="user svelte-x">Hello SLOT(0)</div>
CTR: slot(0) = props.name
CSS: .user.svelte-x { ... }
Client: none
SSR: none다만 위에서 본 것은 잎 노드일 뿐이고, 그것도 꽤 이상적인 단면이라는 점에 유의해야 한다. 하위 컴포넌트도 없고, 구조 분기도 없고, 클라이언트도 없다. 페이지 수준의 skeleton 은 정적 컴포넌트 그래프를 합성해서 만들어진다. 서브트리에 $state 이나 hatch 가 하나라도 들어오는 순간, 중간 표현 안의 클라이언트 / 서버 렌더링은 none 이 아니게 된다.
하지만 이것만으로도 내가 Seam에서 이루고 싶은 이상적인 상태에 상당히 가깝다. 즉 Svelte 컴포넌트가 더 이상 "실행해야 하는 렌더러 하나"에 대응하지 않고, Svelte 5 컴파일러가 분해할 수 있는 리소스가 되는 것이다. 이것만 구현해도 나에게는 이미 대단히 큰 개념적 진전이다.
소유권
그리고 "전역 상태를 밀지 않는다"는 것이 Seam에 갖는 의미는 개발자 경험보다 더 깊다. Next.js 프로젝트에서든 다른 어떤 React 풀스택 프레임워크에서든, 아래의 이 덩어리를 이미 본 적이 있으리라 확신한다 ↓
<ThemeProvider>
<AuthProvider>
<QueryClientProvider>
<I18nProvider>
<RouterProvider>
<App />
</RouterProvider>
</I18nProvider>
</QueryClientProvider>
</AuthProvider>
</ThemeProvider>물론 해결책이 없는 건 아니다. 이런 "피라미드" 형태의 Provider 중첩은 사실 Compose Providers 방식으로 바꿔 쓸 수 있다. 대략 아래와 같은 모습이 된다.
구체적으로는 원래 층층이 중첩돼 있던 Provider 들을 배열에 던져 넣고, 조합 함수를 하나 정의한 다음, 마지막에 그 한 겹만 먹으면 된다. 그런데 나한테는 이게 사실 자기기만이다, 본질적으로는 중첩을 "숨긴" 것일 뿐 정말로 없앤 게 아니다. 시각적인 중첩이 논리적인 중첩으로 바뀐 것뿐이고, 어떤 의미에서는 인 것이다. 순도 100% 자기 위안.
const AppProviders = composeProviders([
ThemeProvider,
AuthProvider,
QueryClientProvider,
I18nProvider,
RouterProvider,
]);
function composeProviders(providers: React.FC<{ children: React.ReactNode }>[]) {
return ({ children }: { children: React.ReactNode }) =>
providers.reduceRight(
(acc, Provider) => <Provider>{acc}</Provider>,
children
);
}
function Root() {
return (
<AppProviders>
<App />
</AppProviders>
);
}React 프로젝트는 마지막에 정말 쉽게, 아니 100% 이렇게 되어 버리고, 그러면 아주 성가신 이음매 문제가 생긴다. 바로 "컴포넌트의 입력은 대체 무엇인가?"라는 문제다. 겉으로는 <ProductCard product={product}/>이라고, 마치 product 자체에만 의존하는 것처럼 말할 수 있다? 하지만 실제로는
depends on:
product
theme
locale
queryClient
router
auth
featureFlags
...그래서 component dependency graph 가 다시 암묵적인 것이 되어 버린다. traditional SSR 이라면 큰 문제는 아니다. 최악의 경우라도 "React tree 전체를 한 번 돌리면" 그만이니까. 하지만 Seam 이 마지막으로 묻고 싶은 것은 결국 ProductCard 가 독립적으로 CTR 을 할 수 있느냐이고, 그 지점에서는 정말로 골치가 아파진다. 심지어 React 컴파일러 전체의 동작과 사용자의 DX, 그리고 습관까지 다 뜯어고쳐야 한다고까지 말할 수 있는데, 그럼 굳이 왜...
Next.js 를 쓰는 사람들은 기본적으로 이미 성능에관심이 없습니다29, 이 말은 내가 책임지고 할 수 있다. 그들은 오히려 Vercel 의 부대 인프라에 돈을 내는 쪽에 가깝고, Next.js 를 쓰면서 Vercel 에 호스팅하지 않는다면 정말 아무런 이득도 못 챙긴다 😡
국소적 소유권
반면 Svelte는 지역적인 소유권을 아주 쉽게 형성한다;
<script>
let { product } = $props();
let quantity = $state(1);
</script>Svelte와 Seam Compiler의 관점에서 보면 이건 정말 편안하다.
어차피 「입력: product + 로컬 클라이언트 상태: quantity」소유권이 정말 아주 명확해졌다
product.name → server CTR
quantity → client
button onclick → client
rest → static하지만 이건 어디까지나 기본 동작일 뿐, 사실 보장이라고 할 수는 없다. getContext('theme')과 .svelte.ts 안의 rune module는 여전히 암묵적 입력이고, QueryClient도 저절로 사라지지는 않으니까. Seam의 태도는 앞의 hatch 목록과 같으리라 본다. 보이는 것은 IR에 표시하고(server 의존이나 client 소유권으로), 분석되지 않는 것은 opaque으로 취급하고 -- 어쨌든 "Svelte에는 Provider 같은 건 없다"는 척은 하지 않는 편이 낫다. 물론 꼭 그렇다는 건 아니고, 조금 더 연구해 보면 Provider를 다룰 방법을 찾을 수 있을지도 모른다
React 시대에 Seam의 가장 큰 문제는 IR이 Component → execute renderer → get output 순서로 흘렀다는 점이고, 그러면 내가 마지막에 말하던 seam compiler은 사실상 어떤 의미에서 앞당겨진 SSR이 되어 버리거나, 아예 이렇게 불러도 된다
「범용 SSR 오케스트레이션 계층」
프로토콜은 그대로
그래서 이제부터는 UI는 곧 Svelte라고 말할 수 있지만, seam 프로토콜 자체는 여전히 프로토콜이고 여전히 slot, injection가 중심이다, JTD가 정말 적합한지는 좀 더 고민해 봐야 한다; 실행 결정 측면에서 보면 지금은 이미 완전히 계층이 분리되어 있고, 슬롯은 이미 residual화되어 있어서 Rust / Go / TS server는 그냥 채워 넣기만 하면 되며 컴포넌트 코드를 전혀 이해할 필요가 없다, 진정한 제로 JS다; 동시에 컴포넌트 안에 순수 파생이 남아 있다면 generated JS / QuickJS runtime을 하나 넣는 것도 고려해 볼 수 있다, (여기서 돌리는 건 데이터이지 UI가 아니라는 점에 주의)
hatch 이 붙은 것만 SSR fallback 되니, 진짜 SSR이 필요 없는 경우라면 백엔드는 UI 코드를 아예 실행하지 않아도 되고, 그러면 자연히 Node 나 Bun 에 묶일 일도 없다. 다만 (Go Server는 유지보수를 중단할 계획이다. 내 개인 역량이 부족해서인데, 시간 있는 분이 계시면 넘겨받아 장기적으로 맡아주셔도 좋다. 아키텍처가 백엔드 독립성을 더는 지원하지 못해서가 아니다)
다시 말해, 나는 UI만 잘라냈고 누구든 다 받아준다, 이런 고생스럽고 별로 티도 안 나는 일을(?) 변하지 않는 부분은 여전히 「Rendering Protocol」로 쓸 수 있고, 백엔드는 JS/TS에 국한되지 않는다.
Runes 의미론
또 하나 아주 중요한 점은 Svelte 5의 runes가 Seam과 무척 잘 맞는다는 것이다. Svelte 5부터 조금 더 JS에 가까워지기는 했지만.
let count = $state(0);
let doubled = $derived(count * 2);그러나여기서 말하는 JS는 그 JS가 아니다30, useState / useMemo / useContext / useEffect 와의 차이는 이것이다. runes 는 여전히 compiler-recognized semantics 이고, 그래서 Seam 은 $state 가 client 소유이며 $derived 는 파생값이고 $effect 는 client runtime 이라는 것을 알 수 있다. 이것은 const foo = someLibraryHook() 와 같은 차원의 이야기가 아니다.
데이터 출처
여기서 아주 중요한 점이 하나 있는데, 「알아볼 수 있다」는 것이 곧 「CTR이 가능하다」는 뜻은 아니라는 것이다. 특히 $derived을 봤다고 해서 그것이 skeleton에 들어간다고 넘겨짚으면 안 된다. 위의 이 count * 2이 바로 그 반례 중 하나인데, 의존 대상이 $state이기 때문에 이것은 client 쪽 파생물이다. 실제로 CTR이 가능한 $derived이 어떻게 생겼는지 보자:
let { product } = $props();
let price = $derived(formatPrice(product.price));product 은 $props 에서 오고, formatPrice 는 순수하면서 동시에 보이는 것이어야 한다. 앞의 표에 이어 붙이면 이런 모양이 된다.
$state / $effect / onclick → client
$derived
deps ⊆ server data, pure, visible → CTR derive
deps include $state → client
impure or opaque callee → QuickJS or SSR fallback
markup → follow the table above; runes are not a free CTR pass그림으로 그려서 보면 더 이해하기 쉬울지도(?)
어쨌든 Runes가 친절한 건 소유권이 문법 차원에 표시되기 때문이지, $derived 가 CTR 자격을 기본으로 갖고 있어서가 아니다. 판단의 유일한 기준은 결국 아래 경로가 보여주는 데이터가 어디서 왔는지, 순수한지, 보이는지다 (웃음)
그리고 그 옆에 <style> → static CSS dependency graph 하나를 더 붙이면, 세계관 전체가 아주 일관돼진다. HTML은 곧 구조, CSS는 스타일 산출물, JS는 반드시 계산과 동작.
현대 React 가 아주 쉽게 빠져드는 전부 JS 로31 쪽이 아니라. React 자신에게는 이게 꼭 나쁜 일은 아닐지도 모르지만, 아쉽게도 모든 것이 JavaScript 인 세계야말로 내가 공격적인 컴파일 타임 분해를 하기에 가장 불리한 세계다
무엇이 출시될까? 32
이렇게 말은 많이 했지만, 결국 실제로 실현될 내용은 무엇일까(웃음). 우선 의심할 여지 없이 UI 스택을 Svelte로 교체하는 것이고, 그 작업이 끝나면 컴파일러의 관측 가능성이 눈에 띄게 높아질 것으로 기대할 수 있다. 가장 큰 이유는 역시 Svelte가 structure을 임의의 JS 제어 흐름에 묻어 두는 대신 markup AST 안에 남겨 두도록 강제하고 또 권장하기 때문이다. 이렇게 하면 skeleton / CTR의 분석 가능 여지가 곧바로 넓어지고, CSS도 runtime concern에서 다시 build artifact으로 돌아온다.
이 말은 Seam 이 컴포넌트 단위 CSS 의존성, 크리티컬 CSS, 트리 셰이킹, 지연 로딩 CSS 를 해볼 기회를 얻는다는 뜻이고, 그것도 server / client 용 style runtime 를 따로 관리하지 않고서 말이다.
그리고 컴포넌트 소유권이 완전히 깔끔해진다. Provider / Context / global runtime dependency 가 줄어든다는 건, 어떤 값이 서버에서 오는지, 어디서 CTR 전달이 가능한지, 누가 client boundary 를 필요로 하는지(어떤 상태가 클라이언트의 것인지), 그리고 이 component 가 정말 hydrate 를 필요로 하는지를 내가 남김없이 답할 기회를 얻는다는 뜻이다…
이 셋 중에서 앞의 둘은 CTR이 그저 하나의 최적화에 그칠지, 아니면 정말로 SeamJS의 주된 실행 모델33이 될 수 있을지를 곧바로 결정한다. 그래서 나에게 React를 버리는 일은 꼭 기술적인 타협만은 아니고, 뒤집어 보면 구조적인 파괴적 전환이기도 하다. 아무튼 나는 여전히 같은 말을 한다
무엇이든 생태계가 자리를 잡고 나면 타협이 생기고,
호환을 깨뜨리는 리팩터링으로 혁신하는 일은 드물다
아직 제대로 자리를 잡지 못했으니, 나는 당연히 얼마든지 갈아엎을 기회가 있고 당분간은 마이그레이션 비용을 생각하지 않아도 된다. 그리고 나는 언젠가 무수한 갈아엎기 끝에 진짜 내 자리를 찾을 수 있기를 정말로 바란다.
끝내 나 혼자만 쓰게 되더라도, 이것은 오롯이 나의 「실험 🧪」