이 글은 모든 독자에게 적합하지 않을 수 있습니다. 내용을 이해하려면 적어도 예비 프런트엔드 개발자나 풀스택 개발자여야 하고, SSR, SSG, ISR 같은 개념을 기본적으로 이해하며 Next.js를 한 번 이상 사용해 본 경험이 있어야 합니다.
서문
아마도 나는 타고나길 사물의 근본 원리에 호기심이 아주 많고, 때로는 왜 그렇게 하면 안 되는지 끝까지 따져 묻는 데 집착하기 때문인지도 모르겠습니다. 아마 그런 이유로 가끔 독특한 아이디어를 떠올릴 수 있는 것 같은데, 이것도 그중 하나입니다. 이 생각은 일찍이 2025년 11월에 떠올랐고 당시 주변의 많은 친구와 이야기를 나눴지만, 진행 중이던 프로젝트들과 일정이 겹치는 바람에 계속 미루다가 2026 년 새해 무렵이 되어서야 실제로 첫 버전을 만들었습니다. 그러고는 다시 묵혀 두었습니다. 웹사이트를 만들러 갔기 때문입니다. 블로그도 아직 완전히 갖춰지지는 않았지만, 그래도 무언가를 기록할 만한 공간은 생겼으니 이 이야기를 다시 꺼내 보려고 합니다.
먼저 한 가지 분명히 해두고 싶습니다. 나는 SSR 자체가 나쁘다고 말하는 것이 아닙니다. 다만 내가 지켜본 흐름으로 판단하면 너무 많은 사람이 이를 남용하고 있으며, 전체적인 방향도 시류에 휩쓸린 채 어떤 문제를 회피하는 쪽으로 가고 있다고 생각합니다. 이 문제를 여러 사람과 논의해 보았지만 돌아오는 답은 대부분 확신이 없었습니다. 내 구상도 이야기해 보았지만 상대는 늘 그 안에 온갖 문제와 경계 조건이 있을까 봐 걱정했습니다. 게다가 솔직히 말해 매번 전체 사고 과정을 처음부터 끝까지 완전히 설명하고 싶지도 않습니다. 그래서 오늘은 아예 이를 자세히 글로 정리하면서 몇 가지 의문과 기술적 원리를 명확히 설명해 보려고 합니다.
SSR 남용
제가 웹 프런트엔드 개발에 막 입문했을 때 처음 접한 풀스택 프레임워크는 아마 Next.js였을 것입니다. 매우 훌륭한 프레임워크라는 점은 인정합니다. 성능 문제가 다소 있지만, 그것은 확실한 개발자 경험을 얻는 대가이며 충분히 납득할 수 있습니다. 하지만 프런트엔드의 여러 개념과 지식을 점차 깊이 이해하면서, 이 프레임워크가 갈수록 저와 맞지 않을지도 모른다고 느꼈습니다. 저는 Rust로 코드를 작성하는 것을 무척 좋아하고 저수준 기술을 연구하는 것도 즐기며 임베디드 시스템도 다뤄 봤기 때문에 성능에 유난한 집착이 있기 때문입니다. 그렇다고 모든 것을 극한까지 끌어올려야 한다는 뜻은 아닙니다. 이미 성능이 눈에 띄게 더 좋으면서도 어쩌면 아주 적은 노력만으로 구현할 수 있는 방법이 있는데, 왜 모두가 그렇게 하지 않는지 의문이라는 뜻입니다. 그래서 Next를 사용할 때마다 이런 생각이 듭니다. 아주 단순한 페이지이고 대부분은 고정되어 있으며 단 한 곳만 바뀌면 되는데도 왜 페이지 전체를 다시 렌더링해야 할까요? 왜일까요? 제 생각에는 백엔드가 Rust로 작성되지 않았더라도, 극한의 성능을 추구하지 않더라도, 심지어 JavaScript 해석기 위에서 실행되더라도 실제로 바뀌는 그 적은 양의 데이터에는 비교적 작은 비용만 들어야 합니다. 페이지 전체를 다시 렌더링하는 대신 아주 낮은 비용으로 “렌더링”을 구현할 수 있어야 합니다.
그러다 보니 나중에는 그것이 내 마음속의 걸림돌이자 반드시 생각해 봐야 하는 문제가 되었습니다. 하지만 아이러니하게도 지난 10년 가까운 프런트엔드의 발전과 Vue, Svelte, Solid, 그리고 그 프레임워크인 Next.js, TanStack Start, Remix, Nuxt, Sveltekit, Soild Start를 살펴보고 나니, 모두가 방향을 잘못 잡은 듯했습니다. 특히 React의 RSC는 잘못이지만, 그것이 존재하는 데에는 사실 나름의 이유가 있습니다. 이 점은 나중에 기회가 되면 자세히 이야기해 볼지도 모르겠습니다...
내가 보기에는 RSC와 수많은 SSR 프레임워크가 사실상 render을 남용하고 있습니다. SSG은 내가 원하던 방식, 즉 빌드할 때 페이지를 미리 생성해 두고 요청 시 다시 렌더링할 필요가 없는 방식을 구현하기는 했지만, 진정한 동적 기능은 없습니다 (x). 한편 ISR은 SSG와 SSR 사이에서 균형점을 찾아 어느 정도 유연성을 확보한 것처럼 보이지만, 본질적으로는 내 기대에 미치지 못합니다. 내 생각에 ISR은 사실 SSR의 낮은 성능을 보완하기 위해 등장했으며, 이후의 여러 CDN 캐시 전략 같은 조치도 결국 일종의 우회책일 뿐 진정으로 우아한 해결책은 아닙니다.
물론 SSR 자체가 잘못된 것은 아닙니다. SSR은 수많은 실제 사용 사례를 해결하는 훌륭한 기술입니다. 문제는 이러한 프레임워크가 SSR을 지나칠 정도로 기본 선택지로 내세운 탓에, 개발자가 실제로 SSR을 사용하는 상황의 약 95%에서는 사실 SSR이 전혀 필요하지 않을 수 있다는 점입니다. 페이지 콘텐츠의 대부분은 고정되어 있고 실제로 동적인 부분은 극히 적지만, 요청이 들어올 때마다 여전히 전체 컴포넌트 트리를 실행합니다.
그래도 최근 몇 년 사이 React는 다소 개선되어 React 19.2에 PPR이 도입되었습니다. 하지만 이것도 런타임에 비용이 큰 renderToString()이 여전히 필요하므로 진정한 해결책이라기보다는 우회책에 가깝습니다. 그래서 대부분의 내용이 빌드할 때 이미 정해진다면 렌더링을 아예 컴파일할 때 수행하면 되지 않을까 생각했습니다.
컴파일 타임 렌더링?
렌더링을 컴파일 시점으로 옮긴다는 것은 상당히 터무니없는 이야기처럼 들립니다. 서버 사이드 렌더링이 존재하는 핵심 이유부터가 어떤 값이나 조건은 런타임의 바로 그 시점이 되기 전까지는 도저히 알아낼 수 없고, 그것이 무엇인지 모르니 판단도 내릴 수 없기 때문입니다. 그러니 대부분의 사람이 내 생각을 듣자마자 부정하는 것도 나름의 이유가 있으며 충분히 이해할 만합니다.
하지만 사실 이 일을 구현할 꽤나 정교한 Pipeline 을 생각해 냈고, 바로 그래서 이것을 일종의 Protocol 이라고 불러야 합니다. 또 한편으로 나는 풀스택 프레임워크가 프런트엔드와 백엔드의 경계를 뒤섞는 것이 늘 아주 어리석다고 생각해 왔습니다. (다만 최근 몇 년간 Next.js가 이 점에서 훌륭한 개발자 경험을 제공해 많은 초보자가 풀스택 애플리케이션을 만드는 일을 매우 간단하게 여기게 했다는 사실은 인정할 수밖에 없습니다. 하지만 실제로는 이 때문에 많은 보안 위험도 생깁니다—이야기가 옆길로 샜습니다.)
컴포넌트 안에서 데이터를 가져오는 것도 마찬가지입니다. 나는 경계가 명확한 방식을 선호합니다. 즉, 컴포넌트는 순수 컴포넌트여야 하고 데이터 가져오기는 완전히 분리되어야 합니다. 이 전제를 받아들여 순수 컴포넌트와 데이터를 분리하고 나면 흥미로운 점이 드러납니다. 데이터 자체도 사실 분류할 수 있다는 것입니다.
이런 영감을 준 TypeScript에도 감사해야 합니다. 이른바 ‘값’이 컴파일 시점에 실제로 무엇인지는 중요하지 않고, 중요한 것은 그 타입입니다. slot 안에 어떤 내용을 넣든 Open String 같은 것, 즉 가능한 값이 무한한 문자열만 아니라면 유한한 가능성을 나타내는 타입으로 감쌀 수 있습니다. 예를 들어 대시보드를 만들면서 어떤 영역을 조건부로 렌더링해야 한다면 결국 사용자나 관리자 같은 경우로 나뉘며, 그 타입들은 모두 빠짐없이 정의할 수 있습니다. 현실 세계에서 조건부 렌더링이나 논리적 판단이 필요한 거의 모든 곳은 몇 가지 확정적인 가능성으로 정리할 수 있습니다.
그리고 이러한 가능성을 아주 효과적으로 기술하는 방법이 바로 JTD입니다!
JTD 사양
**JTD(JSON 타입 정의)**는 RFC 8927에 정의되어 있으며, 빈 형식, 참조, 타입(불리언, 문자열, 타임스탬프 및 다양한 정밀도의 숫자 타입), 열거형, 요소, 속성, 값, 판별자라는 여덟 가지 스키마 형식이 있습니다. 여기에 모든 스키마를 nullable로 표시할 수도 있습니다. JTD의 장점은 언어를 가리지 않는다는 데 있습니다. JavaScript, Rust, Go를 비롯한 거의 모든 언어에서 대응하는 타입 매핑을 찾을 수 있습니다. 덕분에 JTD는 자연스럽게 프런트엔드와 백엔드를 잇는 다리가 되며, 그 매개체인 JSON 자체가 원래 프런트엔드와 백엔드의 최소 공통분모이기도 합니다. 현실 세계의 웹 애플리케이션 중 **95%**는 문자열이나 숫자 필드를 표시하거나 불리언 값으로 판단하는 정도에 불과하며, 이 모든 것이 해당 범위에 들어갑니다. 이 점이 명확해지면 실제로 활용할 수 있는 여지는 매우 커집니다. 물론 JTD도 완벽하지 않아 Markdown 같은 예외가 있지만, 이에 대해서는 뒤에서 자세히 이야기하겠습니다. 사실 바로 이 지점에서 저는 SSR의 진정한 의미를 인정합니다.
센티널 값
우선 쉬운 부분부터 살펴보겠습니다. 모든 동적 값의 형식을 이미 알고 있으므로 컴파일 시점에 renderToString()으로 React 컴포넌트를 한 번 실행할 수 있습니다. 다만 실제 데이터가 아니라 Sentinel으로 만든 모의 데이터를 사용합니다. 이것은 정확히 무슨 뜻일까요?
{ user: { name: "Alice", age: 30 } }데이터가 다음과 같은 형태라고 가정하면, 이를 다음과 같이 바꿀 수 있습니다
{ user: { name: "%%SEAM:user.name%%", age: "%%SEAM:user.age%%" } }이러한 %%SEAM:...%%는 센티널로, 각 동적 값의 자리를 차지합니다. React가 이 센티널 데이터를 사용해 renderToString() 실행을 마치면 출력된 HTML에서 해당 위치들이 표시됩니다. 그런 다음 빌드 파이프라인이 이 센티널들을 HTML 주석 형식의 삽입 위치 표식으로 변환합니다.
<!-- Sentinel -->
<span>%%SEAM:user.name%%</span>
<!-- Slot -->
<span><!--seam:user.name--></span>여기까지 오면 이 slot 들이 **“자료형 표식이 있는 슬롯”**이라는 점을 이미 알아차렸을지도 모릅니다. 이제 서버 측 실행 환경이 할 일은 아주 단순해집니다. 실제 데이터를 가져와 이 구멍들에 값을 채우는, 순수한 문자열 치환일 뿐입니다. renderToString() 도, JavaScript 실행 환경도, vDOM 도 전혀 필요하지 않습니다. HTML 주석을 해석하고 문자열 치환을 수행할 수 있는 언어라면 무엇이든 백엔드로 사용할 수 있습니다. Rust, Go, TypeScript 모두 가능합니다. 이것이 프레임워크가 아니라 프로토콜인 이유입니다.
이쯤에서 조건부 렌더링은 어떻게 해야 하는지 궁금할 수 있습니다. 예를 들어 어떤 필드가 null이면 콘텐츠 블록 전체가 표시되지 않아야 합니다. 하지만 이 역시 런타임 JavaScript로 판단할 필요 없이 프로토콜에서 이러한 경우를 정의할 수 있습니다.
<!--seam:if:user.avatar-->
<img src="<!--seam:user.avatar-->" />
<!--seam:endif:user.avatar-->조건부 렌더링도, 목록 렌더링도 마찬가지입니다.
<!--seam:each:messages-->
<li><!--seam:$.text--></li>
<!--seam:endeach-->패턴 매칭까지도
<!--seam:match:status-->
<!--seam:when:active--><span class="green">Active</span>
<!--seam:when:disabled--><span class="red">Disabled</span>
<!--seam:endmatch-->이것이 주석의 묘미입니다. 여기서 주석을 선택한 이유는 우연히 유효한 HTML이기 때문일 뿐, 그 이상은 아닙니다. 그렇다면 이런 조건 블록과 반복 블록은 빌드 시점에 어떻게 식별하고 생성할까요? 사실 꽤 영리한 방법이 있습니다. 가상 DOM은 없지만 HTML을 두 번 렌더링한 뒤 차이를 비교할 수 있습니다. 조건부 렌더링을 예로 들면, 먼저 완전한 감시자 데이터로 렌더링하고, 다음에는 특정 nullable 필드를 null로 설정한 뒤 다시 렌더링합니다. 두 출력을 비교했을 때 사라진 HTML 부분이 바로 그 필드가 제어하는 조건 블록이므로, 이를 <!--seam:if:...-->로 감싸기만 하면 됩니다. 배열도 같은 방식입니다.
어쩌면 CTR?
그러면 분명 또 누군가는 걱정할 것입니다. 조건부 렌더링의 조합 수가 지수적으로 폭발하지 않을까? 가령 3~5개의 변수가 조건부 렌더링을 제어하고 변수마다 가능한 값이 10개라면, 이를 곱한 수는 확실히 상당히 커 보입니다. 하지만 실제로는 겉보기에만 위협적인 숫자입니다. 우선 문자열형 값은 대부분 실제 내용을 모두 열거할 필요가 없고, 값이 있는지 없는지 nullable 만 확인하면 되므로 실질적으로는 두 가지뿐입니다. 또한 실제로 조건 판단에 쓰이는 필드라면 그 타입은 반드시 열거 가능해야 합니다. 예를 들어 open string 을 가지고 if 판단을 하지는 않을 테니까요. 설령 모든 조합을 정말로 열거하더라도 현대적인 CPU에서는 몇 ms 만에 끝날 수 있습니다. 게다가 이 부담은 전부 컴파일 시점에만 발생합니다. Rust 컴파일처럼 컴파일할 때는 비교적 큰 비용을 치르지만, 실행 시점에는 순수한 문자열 치환만 남습니다. 어떻게 계산해도 남는 장사이며, 여기에는 특별한 마법조차 없습니다. 다만 작은 복선 하나는 깔아 둘 수 있습니다. 뒤에서는 복잡한 추론을 처리하기 위해 아주 작은 내장형 JavaScript 실행 환경을 도입합니다. 이는 일종의 타협이며, 그 이유와 이론적으로는 없어도 되는 까닭을 뒤에서 설명하겠습니다.
이것이 제가 말한 CTR(컴파일 타임 렌더링) 입니다. 제약은 사실 SSR의 제약과 완전히 같습니다. 예를 들어 <!--seam:path-->로 텍스트를 삽입하면 HTML 이스케이프(&, <, > 등)가 자동으로 적용되며, 원본 HTML을 삽입하려면 <!--seam:path:html-->를 명시적으로 사용해야 합니다. 존재하지 않는 데이터 경로는 텍스트 slot에서는 빈 문자열이 되고, 속성 slot에서는 삽입이 생략됩니다. 또한 each 블록에 배열이 아닌 값이 들어오면 해당 블록을 그대로 건너뜁니다. 이런 동작은 SSR 프레임워크에서 마주치는 경계 사례와 본질적으로 다르지 않습니다. 저는 그저 SSR의 렌더링 단계를 컴파일 시점으로 옮겼을 뿐입니다. CTR은 빌드 시점에 모든 조건 변수 타입이 가질 수 있는 값의 데카르트 곱을 순회하면서, 타입에 맞게 자동 생성된 임의의 모의 값이나 사용자가 별도로 덮어쓴 특수 모의 값을 사용합니다(일반적으로 모든 HTML 변형을 생성하는 데 필요한 모의 값을 직접 작성할 필요는 없지만, 원한다면 지정할 수 있습니다. 지정하지 않으면 각 타입에 맞는 값이 자동으로 선택됩니다). 각 조합을 한 번씩 렌더링하고 결과를 비교해 모든 조건 블록과 반복 블록의 경계를 찾아낸 뒤, 최종적으로 완전히 펼쳐진 HTML 골격을 얻습니다. 수학적으로 런타임에 전달되는 데이터가 이러한 타입 정의를 따르기만 하면 반드시 이 골격에 올바르게 삽입될 수 있습니다. 가능한 모든 분기 경로가 이미 컴파일 시점에 빠짐없이 열거되었기 때문입니다.
일관성은?
그렇다면 일관성은 어떻게 보장할까요? 답은 JTD라는 계약입니다. 프런트엔드와 백엔드가 분리되어 있더라도 둘 다 동일한 JTD 스키마를 준수하면 데이터 타입이 서로 맞아 불일치 문제가 생기지 않습니다.
하지만 다른 프레임워크보다 해결해야 할 과제가 하나 더 있었습니다. 백엔드를 JavaScript 런타임에서 해방했으니 더 이상 프런트엔드와 한 몸일 필요가 없고, 백엔드를 Rust나 Go로 작성해도 됩니다. TypeScript 풀스택 프레임워크에서는 타입만으로 프런트엔드와 백엔드의 일관성을 바로 보장할 수 있지만, 다른 언어는 어떨까요? 실제로 여기서는 코드 생성을 사용했습니다. 백엔드를 어떤 언어로 작성하든 그것을 기준으로 삼아 프런트엔드에서 사용할 수 있는 변수와 타입을 곧바로 TypeScript로 생성해 프런트엔드가 가져오게 합니다. 이렇게 프런트엔드와 백엔드의 경계를 분명히 나누면서도 실제로는 TypeScript 풀스택 프레임워크처럼 한 폴더 안에 둘 수 있어 모노레포 관례에 맞고, 애플리케이션 프로그래밍 인터페이스나 gen OpenAPI 같은 것을 직접 작성하지 않고도 서로 바로 호출할 수 있습니다. 실제로 여기서는 비공개 경로 /_seam/ 을 프레임워크의 기본 엔드포인트로 사용하며(Nuxt처럼 설정할 수 있습니다), JTD Typed-RPC 을 실행하면 데이터 전송과 교차 출처 리소스 공유 문제를 해결할 수 있습니다.
CTR × SSR
그다음은 raw HTML slot인데, 이는 결국 그 **5%**의 예외적 상황으로 다시 돌아가는 문제입니다. 예를 들어 Markdown이나 Rich Text 같은 것은 컴파일 시점에 값을 알아낼 수가 없고, 타입으로 제약하려면 비용도 매우 큽니다. 물론 프로토콜을 확장해 Markdown의 모든 문법을 나열하고 실행 시점에 다시 분석할 수도 있지만, 그러려면 사실상 Markdown 렌더러를 새로 작성하는 것과 비슷한 작업량이 들지 않을까요? 반면 우리는 꼭 필요한 최소한의 타입 몇 가지만 제약하므로, 전체 렌더링 엔진을 다시 작성하는 것과는 작업량 자체가 완전히 다릅니다. 이 역시 처음에 말한 철학, 즉 더 적은 비용과 더 우아한 방식으로 문제를 해결한다는 생각으로 돌아갑니다.
그러니 좋은 소식은 raw HTML slot 덕분에 CTR과 SSR가 실제로 공존할 수 있다는 것입니다. 즉, 페이지 사용자 인터페이스의 대부분을 CTR로 만들어 실행 시간 비용을 거의 없애고, 가장 핵심적인 Markdown 글은 SSR로 렌더링할 수 있습니다. 이제 백엔드의 제약에서 벗어났으므로 TypeScript로 기존 SSR 렌더링 방식을 가져와도 되고, 아예 발상을 넓혀도 됩니다! 백엔드가 Rust라면 당연히 Rust용 Markdown 컴파일러로 렌더링할 수도 있습니다. 어차피 마지막에 HTML 문자열만 만들어 raw slot에 삽입하면 표시됩니다. CTR를 사용한다고 해서 SSR를 사용할 수 없는 것은 아니며, 둘은 얼마든지 공존할 수 있습니다.
PPR의 낙차
이렇게 이해하면 CTR은 본질적으로 **PPR(부분 사전 렌더링)**의 가장 이상적인 형태입니다. 캐시할 수 있는 모든 것을 컴파일 시점에 렌더링해 두므로 실행 시 추가 비용은 전혀 없고, 실제로 바뀌는 소량의 데이터에만 비용이 듭니다. 그렇다면 React 19.2의 PPR과는 무엇이 다를까요? 답은 내 방식이 더 철저하고 더 과감하다는 것입니다. PPR 모든 정적 부분이 캐시되어 있고 작은 동적 콘텐츠 조각만 갱신하면 되는 가장 이상적인 상황에서도, 그 변경이 단순한 문자열 하나의 변경에 불과하더라도 React는 renderToReadableStream()을 다시 실행해야 하며, 그 비용은 결코 작지 않습니다.
반면 CTR 은 이 경계를 명확하게 구분합니다. 문자열, 숫자, 불리언 같은 단순 자료형은 문자열을 직접 치환하고, Markdown처럼 모든 경우를 열거하는 비용이 막대한 복잡한 자료형만 실제 렌더링 과정을 거칩니다. 또한 여기서 말하는 렌더링은 더 이상 전통적인 의미의 SSR이 아니며, 어떤 프로그래밍 언어로 구현한 렌더링 방식이든 사용할 수 있습니다.
그리고 RSC
이제 RSC(React 서버 컴포넌트)에 관해 이야기해 보겠습니다. 앞서 RSC가 잘못된 선택일 수 있다고 말했는데, 프런트엔드와 백엔드 사이의 경계를 지나치게 흐리고, ~~적지 않은 보안 위험을 초래하며~~ 수많은 CVE까지 낳았기 때문입니다. 하지만 사실 이는 Next.js와 다른 SSR 방식의 잘못이 더 큽니다. 사용자에게는 아예 사용하지 않을 선택권이 없고, 동적인 콘텐츠를 원하면 반드시 SSR을 써야 하는 상황이 되었기 때문입니다. 그러나 실제로는 우리가 하는 것과 같은 문자열 치환도 충분히 잘 작동합니다. 또한 RSC가 서버에 매우 중요한 능력, 즉 임의의 React 컴포넌트를 렌더링하는 능력, 다시 말해 임의의 코드를 실행하는 능력을 부여했다는 점은 인정할 수밖에 없습니다.
하지만 어차피 임의의 코드를 실행해야 한다면 이를 타입으로 빠짐없이 열거하는 것은 사실상 불가능합니다. 설령 가능하더라도 그 작업량이 React 컴파일러를 작성하는 것보다 적으리라는 보장도 없습니다. 따라서 RSC의 이런 기능에는 분명 존재할 이유가 있습니다. CTR과 RSC가 공존할 수 있을까요? 답은 ‘그렇다’이며, 둘은 전혀 충돌하지 않습니다. 다만 이는 프레임워크 계층에서 해결해야 할 문제이지 프로토콜 자체의 범위는 아닙니다. 아직 구현하지는 않았지만 TanStack Start의 방식을 참고할 수 있을 듯합니다. html 패키지와 js 패키지를 하나씩 더 보내기만 하면 되므로 이론적으로는 구현도 꽤 쉬우며, 적어도 필요한 작업량은 명확하게 가늠할 수 있습니다.
다시 raw HTML slot 이야기로 돌아가면, 이것이 주로 해결하는 것은 Markdown, Rich Text 같은 상황입니다. 렌더링된 HTML 을 dangerouslySetInnerHTML 로 주입하고, 하이드레이션 이후 이 영역은 상호작용에 전혀 참여하지 않는 "죽은" 부분이 됩니다. 이 부분은 최소한으로 줄여야 하며, 글에 테두리나 스타일을 넣고 싶다면 그건 React 컴포넌트로 작성해야지 이 HTML 안에 섞을 일이 아닙니다. 이 방식의 단점은 하이드레이션 이후에는 바뀔 수 없다는 점이지만, 그 전제 아래에서 "SSR" 비용을 아주 낮게, 거의 제로 코스트에 가깝게 눌러 준다는 것도 사실입니다. 정말로 SSR 이 필요한 경우 중 약 60% 는 이런 정적 HTML 주입이고, 나머지 40% 에 가서야 RSC 처럼 서버에서 임의의 컴포넌트를 실행하는 능력이 필요합니다.
UI 비의존성
마지막으로 아주 매력적인 프로토콜 독립성이 있습니다. 본질적으로는 renderToString라는 단 하나의 핵심만 잡으면 되며, 그 앞에서 어떤 UI 프레임워크를 사용하는지는 프로토콜과 아무런 관계가 없습니다. 그렇다면 이것은 Astro와 무엇이 다를까요? 하지만 안심하세요. 제가 만드는 것은 절대 또 하나의 Astro가 아닙니다. 겉으로 보면 제 개념이 Astro의 아일랜드와 조금 비슷해 보이지만, 실제로는 큰 차이가 있습니다.
저는 하나의 페이지에서 여러 런타임을 하이드레이션하는 것이 아닙니다. 음, 사실 이 기능이 정말 필요한 경우는 매우 제한적이라고 생각합니다. 서로 다른 기술 스택 사이에서 컴포넌트 상태를 주고받는 데 드는 비용이 매우 커지기 때문입니다. 이는 한 기술 스택에서 다른 기술 스택으로 마이그레이션할 때 모든 것을 한 번에 교체할 수 없어 우선 거쳐 가는 과도기적 방안에 가깝습니다. 게다가 Astro는 본질적으로 MPA인 반면, 우리는 하이드레이션 전에는 MPA였다가 하이드레이션 후에는 SPA로 바뀔 수 있습니다. Next.js처럼 클라이언트 측 라우팅을 제공해 페이지 간 애니메이션도 구현할 수 있는데, 이는 Astro가 간절히 바라는 기능입니다.
Astro와 SSG
또한 Astro의 설계는 애초에 여러 기술 스택을 아우르는 상황에서야 진정으로 유용합니다. 속도 때문에 Astro를 쓰면서 그중 한 프레임워크만 사용해, 예를 들어 React만 가져오고 Vue은 가져오지 않는다면, Astro가 말하는 “빠른 속도”는 제 생각에 허위 명제입니다. 첫 화면이 전부 HTML로 로드되는 것은 맞지만, 어떤 상호작용이든 원한다면 하이드레이션해야 하고 그 비용은 React 런타임 전체를 다운로드하는 것입니다. 이는 우리의 하이드레이션과 본질적으로 다르지 않습니다. 물론 나중에는 아일랜드 개념을 도입하고 셸 라우터를 추가해 UI 프레임워크를 넘나드는 SPA 탐색을 구현할 수도 있지만, 이는 이후 로드맵에 있는 일이고 적어도 지금은 서두르지 않습니다.
마지막으로 전통적인 SSG와 비교해 보면, 저는 SSG와 같은 일을 해냈습니다. 본질적으로 컴파일 시점에 확정할 수 있는 것은 모두 렌더링해 둔 것입니다. 하지만 우리는 더 동적입니다. 단순 타입의 slot 값은 런타임에 완전히 교체할 수 있기 때문입니다. 이를 SSG를 일종의 MPA 진입점으로 렌더링한 것이라고 이해하면 됩니다. 하이드레이션 전에는 MPA이고, 하이드레이션 후에는 SPA로 바뀌면서도 “동적” 기능은 그대로 유지됩니다.
하이드레이션 오류?
마지막은 제가 정말 싫어하는 하이드레이션 불일치입니다. 아마 여러분도 좋아하지 않을 겁니다. 하지만 결국 이는 단순히 DOM 상태가 일치하지 않는 문제입니다. Next.js의 App Router 같은 기존 프레임워크는 애플리케이션 전체를 React로 감싸려 하므로, 사용자 측 브라우저가 어떤 마크업이든 삽입하면 하이드레이션 오류가 발생할 수 있습니다. 사실 우리에게도 기존 서버 측 렌더링과 동일한 제약이 있지만, TS를 사용할 때는 __root이라는 하이드레이션 div로도 감쌉니다. 덕분에 하이드레이션 영역이 더 이상 메타데이터 영역까지 덮지 않아 하이드레이션이 한층 견고해집니다. 또한 React 19는 <title>, <meta>, <link> 같은 문서 메타데이터 태그를 기본으로 지원합니다. 전체 <html>이 아니라 페이지 안의 특정 <div>만 하이드레이션하더라도 컴포넌트에서 <title>My Page</title>을 직접 렌더링하면 React가 자동으로 이를 <head> 안으로 끌어올립니다.
하이드레이션 불일치로 돌아가 보면, 컴파일 시점에 각 slot의 타입을 이미 알고 있으므로 CTR 동등성 검사를 한 단계 더 추가할 수 있습니다. 타입 정의에서 도출한 모의 데이터로 완전히 펼쳐진 HTML을 채우고 기존 renderToReadableStream()을 한 번 실행한 다음, 형식 차이는 무시한 채 두 DOM 트리가 의미상 동등한지 비교하는 것입니다. 형식에는 미세한 차이가 있을 수 있지만 DOM 구조가 엄밀하고 완전하게 동등하기만 하면, 실제로는 하이드레이션 불일치 와 완전히 작별할 수 있습니다. 기존 SSR은 왜 이렇게 할 수 없을까요? 이 작업을 런타임에야 수행하는 반면, CTR 구조에서는 이 모든 제약을 컴파일 시점에 확정해야 하기 때문입니다. 물론 any이라는 탈출구도 제공하지만, 적어도 TypeScript와 마찬가지로 any을 사용한다면 그 결과를 스스로 감수해야 합니다. CLI는 컴파일할 때 개방형 문자열 탈출구인 any이 불일치를 일으킬 수 있다고 경고합니다.
서버리스
물론 서버리스라는 선택지도 빼놓을 수 없습니다. 최근 몇 년 사이 서버리스의 사용 경험은 매우 좋아졌고, 제 평가는 “비용이 많이 든다는 것 말고는 별다른 단점이 없다”입니다. 한편 CTR은 태생적으로 이 시나리오에 매우 잘 맞습니다. 런타임에 처리하는 작업이 가볍고 적어서 서버리스 환경에서 매우 빠르게 실행되며, 응답 시간과 오버헤드가 모두 크게 개선됩니다. 개선되지 않는 것은 Markdown 렌더링 같은 경우뿐인데, 이는 전적으로 비즈니스 로직에서 최적화해야 합니다. 예를 들어 Markdown을 미리 렌더링해 저장해 두면 요청할 때마다 다시 렌더링할 필요가 없습니다. 지금 제 웹사이트도 그렇게 하고 있습니다. 이는 비즈니스 계층의 문제이지 프레임워크가 해결할 수 있는 문제는 아니지만, 그 밖의 단순한 로직에서 발생하는 오버헤드는 프레임워크가 거의 0까지 줄여 줄 수 있습니다. 기존 SSR과 비교하면 오버헤드가 더 이상 같은 규모라고 할 수도 없습니다. 얼마나 작으냐면 대략 수백 마이크로초에서 1밀리초 정도로, 전통적인 SSR에서는 상상조차 하기 어려운 수준입니다.
그렇다면 백엔드가 다른 언어로 작성되어 있으면 어떻게 해야 할까요? Cloudflare Workers를 예로 들면, 실제로 많은 서버리스 플랫폼이 WASM을 지원하므로 다른 언어도 WASM 바이너리로 컴파일한 뒤 동일하게 백엔드로 사용할 수 있습니다. 본질적으로는 클라이언트 측 렌더링처럼 프런트엔드를 순수 정적 리소스로 컴파일하는 것이지만, 이 리소스들을 전용 브리지 /_seam/와 결합하면 진정한 풀스택 프레임워크와 같은 동적 기능을 구현할 수 있고, 당연히 서버리스 플랫폼과도 완벽하게 호환됩니다.
Seam과 SeamJS
그래서 Seam 과 SeamJS 의 관계가 헷갈리기 시작했을지도 모르겠습니다. 사실 아주 간단합니다. Seam 은 프로토콜입니다. Sentinel 으로 동적 위치를 어떻게 표시할지, 그것이 어떻게 slot 마커로 변환될지, 조건 블록과 반복 블록의 diff 검출을 어떻게 할지, 그리고 런타임이 AST 를 기반으로 어떻게 데이터를 주입할지를 정의합니다. 프로토콜 자체는 언어에 종속되지 않아서, HTML 주석을 파싱하고 문자열을 치환할 수 있는 백엔드라면 무엇이든 구현할 수 있습니다. SeamJS 는 프레임워크이고, 이 프로토콜을 바탕으로 만든 구체적인 구현체입니다. Vite, TanStack Router, TanStack Query 같은 이미 있는 바퀴들을 붙이고, 그것들이 다루지 못하는 부분, 이를테면 제가 만든 skeleton 추출이나 주입 엔진, CLI 같은 것들을 채워 넣습니다.
하지만 솔직히 말해 SeamJS는 아직 매우 기초적인 단계입니다. 실행하는 데는 별문제가 없지만, 실제 프로젝트를 개발하는 데 사용하려면 앞으로도 오랫동안 다듬어야 합니다. 데이터 전송 경로의 추상화 같은 아키텍처 개편도 더 깊이 고민하고 있지만, 이후 버전에서도 이 방향을 계속 유지할지는 확신하지 못하겠습니다. 프레임워크 차원에서는 TypeScript 풀스택 프레임워크와 Rust를 백엔드로 사용하는 대표 프레임워크를 반드시 만들 생각입니다. 반면 Go 관련 구현은 향후 버전에서 제거할 예정입니다. 제 역량만으로는 이를 계속 유지·보수하기가 벅차다고 느끼기 때문입니다.
Rendering is a protocol, not a render-time computation.
웹 프레임워크 그 이상
그렇다면 이것을 만들어서 어디에 쓸 수 있을까요? 사실 용도는 웹에만 국한되지 않습니다. 예를 들어 Transport 채널까지 추상화하면 나중에 Electron이나 Tauri 같은 데스크톱 환경으로 이식할 수 있습니다. HTTP 전송 파이프라인을 IPC 통신으로 바꾸기만 하면 동일한 Seam 프로토콜이 그대로 실행됩니다. 그러면 Electron 앱을 시작할 때 더 이상 로딩 화면을 표시할 필요가 없고, 많은 요소를 서버 측 렌더링처럼 즉시 로컬에서 렌더링할 수 있습니다. 이것이 바로 CTR의 마법입니다!
JS 런타임이 없는 대가
그렇다면 SeamJS는 왜 결국 JS 런타임을 추가했을까요? 이 방식을 실제로 구현하는 과정에서 한 가지 문제에 부딪혔기 때문입니다. CTR이 지향하는 이상적인 설계는 첫 화면 데이터에 매우 엄격한 제약을 둡니다. 데이터는 완전히 추론할 수 있는 확정적 구조체여야 하며, 계산 로직은 하나도 포함할 수 없고 오직 조건만 허용됩니다. 이는 지나치게 까다로운 제약입니다. 프레임워크의 개발 경험을 생각하면, 기존 방식으로 React를 작성해 온 개발자는 당연히 컴포넌트 안에서 몇 가지 계산을 수행하고 얻은 값을 바로 사용할 수 있기를 기대합니다. CTR의 제약을 엄격히 준수해 ready-to-display 상태에 도달하려면 과정이 매우 번거로워지고, 심지어 같은 컴포넌트를 두 번 작성해야 할 수도 있습니다.
하지만 사실 해결책은 있습니다. Web의 특성상 프런트엔드는 JavaScript에서만 실행될 수 있으므로 컴포넌트도 당연히 JS로만 실행할 수 있습니다. 따라서 이 문제를 해결하려면 백엔드에도 JS 실행 기능이 있어야 합니다. TypeScript 풀스택 프로젝트라면 기존 런타임을 그대로 활용할 수 있고, Rust에서는 QuickJS처럼 매우 작은 JS 런타임 하나만 내장하면 됩니다. 여기서 말하는 JavaScript 런타임은 표준 JS의 일부만 구현하며, 완전한 운영체제 API를 제공하는 Bun이나 Node 같은 런타임과는 전혀 다릅니다. 실제로는 데이터를 추론하고 파생하는 데만 사용됩니다.
이렇게 하면 컴포넌트 안에 일부 계산 로직을 계속 작성할 수 있습니다. 백엔드는 데이터를 받은 뒤 짧은 JavaScript 코드를 실행해 데이터를 ready-to-display 상태로 도출하고, 이를 프런트엔드로 돌려보내 첫 화면을 하이드레이션합니다. CTR은 엄격한 구조의 파생 데이터를 얻으므로 만족하고, 사용자의 개발자 경험도 컴포넌트를 한 번만 작성하면 되므로 좋아집니다. 물론 순수한 타입 제약을 엄격히 따른다면 백엔드를 실제로 JavaScript 런타임 없이 구성할 수 있으므로, 엄밀히 말하면 이 약속은 여전히 유효합니다. 하지만 어느 쪽이든 새로 추가되는 작은 JavaScript 런타임은 성능 부담과 메모리 사용량, 크기가 Node 같은 것보다 훨씬 작습니다. 사실상 200~300KB에 불과하면서도 매우 큰 유연성을 제공합니다.
향후 전망
이렇게나 떠들었는데, 대체 언제쯤 수 있을까요? 아마 아주 오래 걸릴 것입니다. 정말 내가 싶어서가 아니라, 현 단계에서 이 물건이 나를 너무너무 많이 인질로 붙잡고 있어서입니다. 이걸 개발하는 데만 쓸 수 있다면 사실상 애플리케이션은 못 씁니다. 뭘 쓰든 프레임워크가 먼저 발을 겁니다. 게다가 요즘 나는 주로 이 웹사이트 같은 것만 만들고 있었습니다. 그래서 마음을 정했습니다. 차라리 웹사이트를 어느 정도 규모까지 먼저 만들기로 했습니다. 그러면 내 요구사항이 정확히 뭔지 알게 됩니다. 나중에 다시 SeamJS를 쓸 때는 TODO List가 손에 있을 것이고, 기능을 하나씩 제대로 구현하면 쓸 수 있게 됩니다. 그다음엔 마이그레이션이랑 Benchmark로 한 편 더 때울 수도 있고? 자, . 이념은 여기 있습니다. 오늘 당장 쓸 수 있는지는 일단 접어두죠. 적어도 생각 위에서는 프로토타입이 돌아갔습니다. 잘 자요 💤