
더 빠른 웹 애플리케이션을 만들기 위해, 프론트엔드 생태계는 오랜 시간 동안
여러 가지 렌더링 방식과 Hydration 전략을 발전시켜 왔습니다.각각의 방식은 성능을 개선한다는 공통된 목표를 가지고 있지만,
그만큼 서로 다른 장점과 한계도 함께 존재합니다.이번 글에서는 지금까지 등장한 다양한 렌더링 및 Hydration 전략들이 어떤 배경에서 만들어졌는지,
그리고 각각 어떤 특징과 트레이드오프를 가지고 있는지 하나씩 살펴보겠습니다.원문 : https://neciudan.dev/hydration-and-rendering-strategies
월드컵 경기 도중 선수들이 수분을 보충하기 위해 갖는 이른바 ‘Hydration Break’ 이야기를 하려는 건 아닙니다.
이번 글에서 이야기할 주제는 서버 사이드 렌더링 환경에서 이루어지는 Hydration 과정입니다.
그리고 다른 프레임워크들은 이 문제를 어떻게 해결하고 있는지, 더 나아가 웹 애플리케이션의 성능을 높이기 위해 어떤 렌더링 전략들이 등장했는지도 함께 살펴보려고 합니다.
먼저, 성능이 느린 스마트폰에서 서버 렌더링된 웹 페이지를 하나 열어봅시다.
페이지의 콘텐츠는 거의 즉시 화면에 표시되고, 겉보기에는 모든 것이 이미 준비된 것처럼 보입니다.
그런데 버튼을 눌러보면 1~2초 정도 아무 반응이 없습니다.
그 짧은 순간 동안 브라우저는 보이지 않는 곳에서 자바스크립트를 다운로드하고 실행하면서, 지금 화면에 이미 보이고 있는 UI를 다시 한 번 내부적으로 구성하고 있습니다.
이처럼 페이지는 이미 준비된 것처럼 보이지만(HTML은 렌더링 완료), 실제로는 아직 동작하지 않는 상태(JavaScript가 아직 인터랙션을 연결하지 못한 상태), 그 사이의 시간을 우리는 Hydration이라고 부릅니다.
최근 등장한 대부분의 렌더링 전략들은 결국 이 간격을 얼마나 줄일 수 있는지에 초점을 맞추고 발전해왔다고 볼 수 있습니다.
Hydration을 줄이는 방법을 보기 전에, 먼저 다른 선택지부터 살펴보겠습니다.
많은 웹사이트에서는 매 요청마다 HTML을 렌더링하는 대신, 미리 한 번 렌더링해 두는 방식이 가장 좋은 접근입니다.
만약 페이지가 모든 사용자에게 동일하다면, 예를 들어 블로그 글, 문서 페이지, 마케팅 랜딩 페이지처럼 말입니다. 이런 경우 동일한 HTML을 수천 번 반복해서 다시 생성하고 있는 셈입니다.
정적 생성은 빌드 시점에 각 페이지를 한 번 렌더링하고, 그 결과를 HTML 파일로 저장한 뒤 CDN을 통해 제공합니다.
서버는 요청이 들어올 때마다 파일을 전달하는 것 외에는 아무 작업도 하지 않습니다.
// Next.js: 이 코드는 각 요청마다가 아니라 BUILD 시점에 실행됩니다.
export async function getStaticProps() {
const posts = await loadPostsFromCMS();
return { props: { posts } };
}
가장 명확한 한계는 freshness입니다.
HTML이 한 시간 전에 생성되었다면, 그 HTML 역시 한 시간 전 상태를 유지합니다. 블로그라면 문제가 없지만, 실시간 가격이 반영되어야 하는 상품 페이지라면 적합하지 않습니다.
그리고 규모가 커질수록 build 과정 자체가 병목이 됩니다. 블로그 글 50개는 몇 초 안에 빌드할 수 있지만, 상품 페이지가 10만 개라면 몇 시간이 걸릴 수 있으며, 콘텐츠가 변경될 때마다 다시 빌드해야 합니다.
이 freshness 문제를 해결하기 위해 등장한 것이 Incremental Static Regeneration(ISR)입니다.
기본적으로는 이전과 동일하게 static 페이지를 제공하지만, 일정 시간이 지나거나 실제 콘텐츠가 변경되었을 때 프레임워크가 백그라운드에서 페이지를 다시 생성하도록 설정할 수 있습니다.
export async function getStaticProps() {
const posts = await loadPostsFromCMS();
// 캐시된 페이지를 제공하고, 최대 60초마다 한 번만 다시 빌드합니다.
return { props: { posts }, revalidate: 60 };
}
설정한 시간이 지난 후 처음 방문한 사용자가 조용히 rebuild를 트리거하게 되고, 새로운 버전이 준비될 때까지 다른 모든 사용자는 기존에 정상적으로 제공되던 페이지를 계속 보게 됩니다.
즉, 정적 페이지가 가지는 빠른 전달 속도를 유지하면서도, 콘텐츠는 최대 1~2분 이상 오래된 상태가 되지 않도록 만들 수 있습니다.
추가로 자주 보게 되는 또 다른 방식은 edge rendering입니다.
이것은 다른 hydration 모델이 아닙니다. 단순히 SSR이나 ISR을 사용자와 더 가까운 위치로 옮긴 방식입니다. 하나의 origin 서버에서 실행하는 대신, 전 세계에 분산된 CDN edge 서버에서 실행됩니다.
이 방식을 사용하는 이유는 latency를 줄이기 위해서입니다. 동적인 HTML이 대륙 반대편 서버가 아니라 사용자의 가까운 위치에서 몇 밀리초 안에 생성됩니다. 대신 edge runtime은 할 수 있는 작업에 제약이 더 많습니다. 사용할 수 있는 Node API가 제한적이며, 실행 시간과 메모리 제한 역시 더 엄격합니다.
이 방식은 정적인 콘텐츠에 매우 적합합니다.
하지만 만약 우리의 콘텐츠가 동적이라면 어떻게 해야 할까요?
가장 단순한 접근 방식은 서버에서 초기 HTML을 렌더링하지 않는 것입니다.
서버는 거의 비어 있는 HTML shell만 전달합니다. 이것은 단순히 HTML 페이지의 기본 구조일 뿐이며, 애플리케이션이 들어갈 공간으로 <div id="root"> 같은 영역과 JavaScript 코드를 가리키는 script 태그만 포함합니다.
그 다음 브라우저는 bundle을 다운로드합니다. bundle은 애플리케이션의 모든 코드를 하나의 파일로 묶은 것이며, 브라우저는 이를 실행하고, 필요한 데이터를 가져온 뒤, 전체 사용자 인터페이스를 브라우저 안에서 직접 구성합니다.
여기에는 hydration 과정이 존재하지 않습니다. hydration 할 대상 자체가 없기 때문입니다. DOM은 서버에서 렌더링된 적이 없으며, 처음부터 클라이언트에서 새롭게 생성됩니다.
React는 한 번의 과정 안에서 UI를 만들고 바로 연결합니다.
import { createRoot } from 'react-dom/client';
import App from './App';
createRoot(document.getElementById('root')).render(<App />);
이러한 애플리케이션을 Single Page Application, 줄여서 SPA라고 부릅니다.
이 방식의 가장 큰 장점은 단순함입니다. 개발자는 오직 브라우저 환경만 생각하면 됩니다.
단점은 속도입니다.
중간 성능의 Android 휴대폰을 사용하는 사용자가 호텔 Wi-Fi 환경에서 처음으로 여러분의 링크를 눌렀다고 가정해보겠습니다. 사용자는 아무것도 없는 흰 화면을 보게 됩니다.
그리고 그 화면은 bundle이 다운로드되는 동안 계속 유지됩니다. 그 다음에는 parsing이 이루어지고, 이후 코드가 실행됩니다.
그 다음에는 페이지에 필요한 데이터를 가져오는 과정을 기다려야 합니다. 이 모든 과정이 끝난 이후에야 비로소 화면이 동작하기 시작합니다.
좋은 Wi-Fi 환경의 노트북에서는 이런 지연을 거의 느끼지 못할 수도 있습니다.
하지만 휴대폰 환경에서는, 애플리케이션이 화면에 단 하나의 픽셀도 그리기 전에 사용자가 탭을 닫아버릴 만큼 충분히 긴 시간이 될 수 있습니다.
검색 엔진도 같은 문제를 겪습니다. Google crawler는 페이지를 요청하지만, 비어 있는 <div id="root"> 만 받게 되며, 이는 SEO 측면에서도 상당한 영향을 줄 수 있습니다.
그래서 초기 HTML 안에 실제 콘텐츠가 존재해야 합니다.
그리고 이를 위해서는 서버가 필요합니다.
서버에서 HTML을 렌더링하고, 이를 브라우저로 전달한 뒤, 브라우저가 이를 React 애플리케이션으로 hydration 하도록 만드는 방식입니다.
사용자는 실제 콘텐츠를 즉시 볼 수 있게 되며, 이를 통해 CSR이 가진 두 가지 문제를 해결할 수 있습니다. JavaScript가 로드되는 동안 페이지가 비어 있지 않으며, crawler 역시 실제 콘텐츠를 확인할 수 있습니다.
import { renderToString } from 'react-dom/server';
import App from './App';
const html = renderToString(<App />);
// html을 document shell 안에 감싸서 전달합니다.
클라이언트에서는 다음과 같이 동작합니다.
import { hydrateRoot } from 'react-dom/client';
hydrateRoot(document.getElementById('root'), <App />);
이 방식은 검색 엔진에 노출되어야 하면서 동시에 상호작용도 필요한 콘텐츠에 적합합니다. 예를 들어 뉴스 사이트, 블로그, 회원가입 폼이 포함된 마케팅 페이지, 혹은 SEO가 중요한 상품 페이지가 이에 해당합니다.
단점은 동일한 작업을 두 번 반복한다는 점입니다.
서버는 HTML을 렌더링하고, 클라이언트는 React를 다운로드한 뒤 다시 렌더링하며, 새롭게 생성된 HTML과 기존 HTML을 비교합니다. 그리고 이 두 번째 과정이 끝나기 전까지 페이지의 어떤 요소도 상호작용할 수 없습니다.
기존 hydration 방식은 트리를 위에서 아래 방향으로 순차적으로 처리합니다. 만약 무거운 Comments 섹션이 상단에 있고 LikeButton이 그 아래에 있다면, hydration은 먼저 comments부터 처리합니다.
Like button은 hydration이 모두 끝날 때까지 동작하지 않습니다.
여기에는 또 하나의 큰 문제가 있습니다. 바로 hydration mismatch입니다.
만약 서버에서 생성된 HTML과 클라이언트에서 생성된 HTML이 어떤 방식으로든 다르다면, React는 이를 감지합니다.
최악의 경우 React는 서버에서 전달된 HTML을 버리고, 클라이언트에서 전체를 다시 렌더링합니다. 결국 SSR이 해결하려고 했던 빈 화면 문제를 다시 만들어내게 되고, SSR의 모든 장점이 사라지게 됩니다.
이 문제는 겉보기에는 평범한 코드 때문에 자주 발생합니다.
// 서버와 브라우저에서 서로 다른 값을 렌더링합니다.
<span>{new Date().toLocaleTimeString()}</span>
// 서버에는 존재하지 않는 브라우저 전용 API를 읽습니다.
<div>{window.innerWidth > 768 ? 'desktop' : 'mobile'}</div>
두 코드 모두 겉보기에는 문제가 없어 보이지만, 서버와 클라이언트에서 서로 다른 결과를 만들기 때문에 hydration warning이 발생합니다.
만약 이런 차이가 의도된 것이라면, 예를 들어 timestamp처럼 말입니다. React는 이를 위한 escape 방법을 제공합니다. suppressHydrationWarning 을 추가하면, React는 해당 node에 대해 warning을 발생시키지 않습니다.
첫 번째 개선 방식은 hydration은 그대로 유지하면서도, 페이지에서 가장 느린 부분이 준비될 때까지 사용자가 아무것도 보지 못한 채 기다리게 만드는 문제를 해결합니다.
전체 트리를 하나의 문자열로 렌더링한 뒤 모든 작업이 끝났을 때 한 번에 보내는 대신, 서버는 각 부분이 준비되는 즉시 HTML을 여러 chunk로 나누어 스트리밍합니다.
이를 가능하게 하는 도구가 Suspense입니다. Suspense는 느린 영역을 감싸는 wrapper이며, React에게 “이 안의 모든 것이 준비될 때까지 fallback을 먼저 보여달라”라고 알려주는 역할을 합니다.
import { Suspense } from 'react';
function ProductPage() {
return (
<div>
<ProductDetails />
<Suspense fallback={<ReviewsSkeleton />}>
<Reviews />
</Suspense>
</div>
);
}
ProductDetails 컴포넌트는 즉시 렌더링됩니다.
느린 query를 기다리고 있을 수 있는 Reviews 컴포넌트는 skeleton UI를 먼저 보여주고, 데이터가 준비되면 실제 reviews가 스트리밍되어 들어옵니다.
사용자는 reviews가 아직 로딩되는 동안에도 product 정보를 먼저 읽을 수 있습니다.
이제 React는 더 이상 위에서 아래 방향으로 hydration을 진행할 필요가 없습니다. 각각의 Suspense boundary는 독립적으로 hydration 될 수 있는 하나의 단위가 되며, React는 사용자의 현재 상호작용을 기준으로 우선순위를 결정합니다.
아직 hydration 되지 않은 영역 안의 버튼을 클릭하면, React는 사용자가 건드리지 않은 다른 boundary보다 먼저 해당 boundary의 hydration을 우선 처리하여 이벤트 핸들러가 바로 실행될 수 있도록 만듭니다.
이 방식의 가장 큰 장점은 사용자가 체감하는 로딩 시간이 더 이상 가장 느린 컴포넌트에 종속되지 않는다는 점입니다.
다시 product 페이지를 생각해보겠습니다. streaming이 없다면 recommendations query가 3초 걸리는 경우, 사용자는 product 정보를 보기까지도 3초를 기다려야 합니다.
하지만 이것이 완벽한 해결책은 아닙니다. Streaming은 작업이 수행되는 순서와 시점만 바꿀 뿐, 실제 작업량 자체를 줄이지는 않습니다.
페이지 안의 모든 interactive 컴포넌트는 여전히 클라이언트에서 다운로드되고 다시 실행됩니다. 즉, 페이지가 더 빠르게 느껴지도록 만들었을 뿐, JavaScript가 전송되는 양은 단 1바이트도 줄지 않았습니다.
또한 Suspense를 너무 넓은 영역에 감싸면 오히려 상황이 더 나빠질 수도 있습니다. 하나의 느린 child가 전체 영역을 함께 지연시키기 때문입니다.
// 전체를 하나의 boundary로 감쌉니다.
// 빠른 summary도 느린 chart를 기다리게 됩니다.
// 둘이 동일한 fallback을 공유하기 때문입니다.
<Suspense fallback={<PageSkeleton />}>
<Summary /> {/* 빠름 */}
<SlowChart /> {/* 느림 */}
</Suspense>
// boundary를 분리합니다.
// summary는 즉시 스트리밍됩니다.
// chart는 자신의 속도에 맞춰 따로 로드됩니다.
<Summary />
<Suspense fallback={<ChartSkeleton />}>
<SlowChart />
</Suspense>
여러 프레임워크에서 볼 수 있는 또 다른 방식도 있습니다. 바로 progressive hydration입니다.
각 boundary를 코드가 도착하는 즉시 hydration 하는 대신, 실제로 활성화할 이유가 생길 때까지 해당 컴포넌트의 hydration을 지연시키는 방식입니다. 예를 들어 화면 안으로 스크롤되어 들어오거나, 사용자가 처음 상호작용할 때까지 기다립니다.
페이지 아래쪽 세 화면 정도 내려가야 보이는 footer newsletter form은 초기 로딩 단계에서 hydration 될 필요가 없습니다. 사용자가 해당 영역 근처까지 스크롤할 때까지 기다려도 충분합니다.
이 방식 역시 streaming처럼 결국 모든 요소를 hydration 하게 됩니다. 하지만 작업을 시간에 따라 분산시키고, 사용자가 끝내 접근하지 않는 영역은 아예 실행하지 않도록 만들 수 있습니다.
일반적인 웹페이지 대부분은 사실 상호작용이 필요하지 않습니다.
블로그 글은 텍스트, 제목, 이미지로 구성되어 있고, 많아야 댓글 위젯이나 뉴스레터 구독 폼 정도가 추가될 뿐입니다.
문서 페이지 역시 거의 대부분이 정적인 콘텐츠이고 검색창 하나 정도만 존재합니다. 그렇다면 단순히 콘텐츠로 존재하는 95% 영역을 위해 왜 JavaScript runtime 전체를 전송하고 hydration 해야 할까요?
Islands는 SSR과 반대 방식으로 동작합니다. 페이지 전체는 정적인 HTML로 렌더링하고, 실제로 동작해야 하는 부분만 명시적으로 활성화합니다.
각각의 상호작용 영역은 정적인 markup 바다 위에 떠 있는 하나의 “island”가 되며, 각각 독립적으로 hydration 되면서 자신에게 필요한 JavaScript만 개별적으로 로드합니다.
이 개념을 대중화시킨 프레임워크는 Astro 이며, directive를 통해 이 모델을 매우 직관적으로 보여줍니다.
기본적으로 컴포넌트는 static HTML로 렌더링되며 JavaScript는 전혀 전송되지 않습니다. 상호작용은 컴포넌트 단위로 직접 opt-in 해야 합니다.
---
import Header from '../components/Header.astro';
import Newsletter from '../components/Newsletter.tsx';
import Comments from '../components/Comments.tsx';
---
<Header />
<article>
<h1>My post</h1>
<p>이 부분은 단순한 텍스트입니다. HTML만 전송되며 JavaScript는 없습니다.</p>
</article>
<Newsletter client:visible />
<Comments client:visible />
여기서 client:visible 은 loading contract입니다. 그리고 이것은 앞에서 살펴본 progressive hydration 개념과 동일합니다.
Astro에게 먼저 해당 컴포넌트를 static HTML로 렌더링하고, 화면에 실제로 나타났을 때만 hydration 하도록 지시하는 것입니다.
directive는 일반적으로 필요한 대부분의 상황을 지원합니다. client:load 는 화면 상단처럼 즉시 상호작용이 필요한 영역을 바로 hydration 하고, client:idle 은 브라우저가 idle 상태가 될 때까지 기다리며, client:visible 은 해당 컴포넌트가 viewport 안으로 들어왔을 때 hydration 합니다. 그리고 client:only 는 브라우저 전용 API에 의존하는 컴포넌트를 위해 서버 렌더링 자체를 건너뜁니다.
Islands는 progressive hydration 개념에 가장 중요한 두 번째 요소를 추가합니다. 개발자가 표시하지 않은 부분은 영원히 static 상태로 남고, JavaScript 자체를 전혀 전송하지 않는다는 점입니다.
사용자가 comments 영역까지 끝내 스크롤하지 않는다면, comment widget에 필요한 JavaScript 역시 절대 로드되지 않습니다.
이 방식은 콘텐츠 중심 사이트에 거의 완벽하게 들어맞습니다.
블로그, 문서 사이트, 마케팅 페이지, 뉴스 사이트, 그리고 이커머스 카테고리 페이지가 대표적입니다. 즉, 페이지 대부분이 콘텐츠이고 일부 영역만 상호작용을 가지는 구조에 적합합니다.
이 개념을 중심으로 만들어진 Astro는 State of JavaScript 2025 meta-framework 만족도 조사에서 가장 높은 순위를 기록했습니다. 그리고 2026년 1월에는 Cloudflare 가 Astro를 인수했습니다. 그리고 이전 글을 읽어보셨다면 아시겠지만, 이 블로그 역시 Astro를 사용하고 있으며, 이전 글에서는 Astro 6으로 업그레이드하면서 얻은 여러 장점들도 다뤘습니다.
실질적으로 얻는 결과는 매우 명확합니다. 특별한 최적화를 하지 않아도 Lighthouse 점수가 90점대를 쉽게 넘습니다. 브라우저가 main thread에서 해야 할 작업 자체가 거의 없기 때문입니다.
그리고 각각의 island는 독립적으로 동작하기 때문에 하나의 프레임워크에 묶일 필요도 없습니다. 같은 페이지 안에서 React island 옆에 Svelte island를 함께 배치할 수도 있습니다.
동일한 아이디어에는 서버 사이드 버전도 존재합니다. Astro의 server:defer 는 컴포넌트를 server island로 만들어 줍니다. 정적인 shell은 즉시 전송되고 적극적으로 cache 되지만, 로그인한 사용자의 avatar나 장바구니처럼 개인화된 영역은 요청마다 서버에서 렌더링되어 나머지 페이지를 기다리게 하지 않고 별도로 삽입됩니다.
결과적으로 모든 사용자에게 동일한 95% 영역은 cache 할 수 있으면서도, 나머지 5%의 개인화된 영역은 동적으로 제공할 수 있습니다.
하지만 islands 방식은 페이지 대부분이 정적이고, 일부에만 상호작용이 존재한다는 가정을 기반으로 합니다. 그리고 이 가정이 깨지는 순간 한계가 드러납니다.
예를 들어 Figma 같은 서비스를 만든다고 생각해보겠습니다. 혹은 트레이딩 터미널처럼 화면 거의 모든 부분이 상호작용해야 하거나, 여러 UI 요소가 전체 layout에서 동일한 state를 공유해야 하는 애플리케이션도 마찬가지입니다.
이런 경우 결국 전체 페이지를 하나의 거대한 island로 만들어야 하며, 원래 독립적으로 설계된 island들 사이에서 공유 state를 계속 연결해야 하고, 아키텍처 자체와 계속 싸우게 됩니다.
결국 그 시점이 되면, extra step이 추가된 SSR을 다시 만들어내고 있는 것과 다르지 않게 됩니다.
결국 islands는 상호작용이 예외적인 경우에 가장 강력합니다.
반대로 상호작용이 기본 전제가 되는 애플리케이션이라면, 다른 접근 방식이 필요합니다.
Server Component는 서버에서만 실행됩니다. 즉, 해당 컴포넌트의 코드는 브라우저로 전송되지 않습니다.
이 컴포넌트는 데이터베이스에 직접 접근할 수 있고, 파일 시스템을 읽을 수도 있으며, secret 값도 사용할 수 있습니다. 그리고 클라이언트로 전달되는 것은 JavaScript가 아니라, 컴포넌트가 만들어낸 UI를 직렬화한 결과물이며, React는 이 포맷을 Flight라고 부릅니다.
이제 여기에는 두 개의 stream이 존재합니다. 앞에서 살펴본 streaming 섹션의 HTML stream이 있고, 이것은 브라우저가 실제로 화면에 그리는 markup입니다. 그리고 또 하나는 Flight stream이며, 이것은 클라이언트가 다시 구성하는 직렬화된 component tree입니다.
RSC는 이 두 가지를 모두 사용합니다. 서버에서 실행된 부분은 클라이언트에 다시 실행할 컴포넌트 코드 자체가 존재하지 않기 때문에 hydration 할 대상이 없습니다.
서버에서만 동작하는 컴포넌트는 클라이언트 입장에서 아무런 무게를 가지지 않습니다. 반면 "use client" 로 표시된 컴포넌트만 JavaScript가 전송되고 hydration 됩니다.
// Server Component입니다.
// 서버에서만 실행되며 JavaScript는 전송되지 않습니다.
async function ProductPage({ id }) {
const product = await db.product.findUnique({ where: { id } });
return (
<div>
<h1>{product.name}</h1>
<p>{product.description}</p>
<AddToCartButton productId={product.id} />
</div>
);
}
'use client';
// Client Component입니다.
// 실제로 JavaScript가 전송되고 hydration 되는 부분입니다.
function AddToCartButton({ productId }) {
const [adding, setAdding] = useState(false);
// ...
}
상품 이름과 설명은 서버에서 렌더링되며, 클라이언트에서는 절대 JavaScript가 되지 않습니다.
브라우저에서 실제로 존재하는 것은 AddToCartButton 뿐입니다.
이것이 현재 Next.js 가 기반으로 하고 있는 모델이며, 동시에 서버에서 생성되는 비상호작용 콘텐츠가 많은 전체 애플리케이션 구조도 지원할 수 있습니다.
가장 큰 장점은 브라우저가 절대 사용하지 않을 코드를 더 이상 전송하지 않아도 된다는 점입니다.
앞선 예시에서는 데이터베이스 호출과 그것이 만들어낸 markup이 모두 서버에만 남아 있습니다. 실제로 사용자에게 전달되는 JavaScript는 AddToCartButton 하나뿐입니다.
또한 컴포넌트 내부에서 바로 데이터베이스를 조회할 수 있습니다. 별도로 /api/products/:id 같은 API route를 만들 필요도 없고, fetch 연결 작업도 필요 없으며, loading state를 직접 관리할 필요도 없습니다.
여기에 streaming과 selective hydration까지 함께 적용하면, 그리고 RSC는 이것을 기본적으로 제공합니다. 빠른 first paint, 작은 bundle 크기, 그리고 직접적인 데이터 접근을 하나의 모델 안에서 모두 얻을 수 있습니다.
React 19 19.2는 이 영역의 기능들을 더욱 발전시켰습니다. Partial Pre-Rendering은 build 시점에 static shell을 렌더링하고, 이를 CDN edge에 cache 한 뒤, 요청이 들어올 때마다 dynamic hole만 스트리밍할 수 있게 만듭니다. 즉 하나의 페이지 대부분은 정적으로 cache 하면서도, 일부 개인화된 영역만 동적으로 처리할 수 있습니다.
Suspense reveal은 이제 클라이언트 동작 방식에 맞춰 batch 처리되며, hydration mismatch 에러 역시 더 이상 이해하기 어려운 warning만 보여주지 않고 실제로 어떤 컴포넌트가 문제를 발생시켰는지 알려주기 시작했습니다.
하지만 서버와 클라이언트 사이의 경계는 결코 공짜가 아닙니다. 잘못된 위치에 "use client" 하나만 추가되어도 전체 subtree가 조용히 브라우저 bundle 안으로 들어가게 됩니다.
예를 들면 다음과 같습니다.
'use client';
import { HeavyChart } from './HeavyChart';
// 이 컴포넌트 역시 함께 client code가 됩니다.
단순히 버튼 하나 때문에 "use client" 를 추가했지만, client 파일은 자신이 import 하는 모든 요소를 client graph 안으로 함께 끌고 들어오기 때문에, 원래 서버에 남겨두고 싶었던 무거운 chart까지 bundle에 포함되게 됩니다. 이것이 실제 프로덕션 코드베이스에서 자주 발생하는 “use client creep” 문제입니다.
그리고 RSC는 특정 프레임워크와 이를 지원하는 서버 runtime에 의존합니다. 이미 존재하는 일반적인 SPA에 부분적으로 붙여서 사용할 수 있는 기능이 아닙니다.
추가로 렌더링을 서버로 옮기는 것은 보안 측면에서도 공격 범위를 넓히게 됩니다.
Server Component는 서버에서 실행되고, payload를 직렬화하며, 클라이언트는 이를 다시 역직렬화합니다. 이제 이 모든 과정은 데이터베이스와 secret 값에 접근 가능한 privileged 환경에서 실행됩니다.
실제로 2025년 말에는 심각한 RSC 역직렬화 취약점인 CVE-2025-55182 가 발견되었고, 실제 공격 사례까지 확인되면서 여러 React와 Next.js 버전에서 긴급 패치가 이루어졌습니다.
즉, 서버에서 이러한 방식을 사용할 경우 계속해서 주의 깊게 살펴봐야 할 보안 취약점이 상당히 많을 수 있습니다.
Next.js 의 모델은 server-first 입니다.
TanStack Start 는 이 구조를 완전히 뒤집습니다. 클라이언트가 계속 주도권을 가지며, RSC는 하나의 패러다임이 아니라 단순한 데이터 타입으로 내려오게 됩니다.
핵심 아이디어는 RSC payload가 결국 하나의 stream일 뿐이라는 점입니다. 정확히 말하면 React Flight stream이며, 이것은 서버가 생성한 UI를 직렬화한 결과물입니다.
TanStack Start는 이것을 문자 그대로 그렇게 다룹니다. 프레임워크가 기본적으로 넘겨주는 서버 소유 tree가 아니라, 필요할 때마다 클라이언트가 HTTP를 통해 직접 가져오는 byte 데이터로 취급합니다.
개발자는 server function 안에서 서버 렌더링 UI를 만들고, route loader를 통해 이를 불러옵니다.
더 이상 서버를 빠져나오기 위해 "use client" 를 표시할 필요가 없습니다. 오히려 정말 필요한 곳에만 직접 서버 사용을 opt-in 하면 됩니다.
import { createServerFn } from '@tanstack/react-start';
import { renderServerComponent } from '@tanstack/react-start/rsc';
function Greeting() {
return <h1>Hello from the server</h1>;
}
const getGreeting = createServerFn().handler(async () => {
return { Greeting: await renderServerComponent(<Greeting />) };
});
payload는 결국 단순한 데이터이기 때문에, 기존에 이미 사용하던 caching 도구들과 그대로 연결할 수 있습니다. 별도의 특별한 “RSC mode” 같은 것은 존재하지 않습니다.
server function을 TanStack Query 호출로 감싸기만 하면, RSC payload 역시 일반 query와 동일하게 cache key, staleTime, background refetching 기능을 그대로 사용할 수 있습니다.
정적인 콘텐츠라면 staleTime: Infinity 로 설정하면 끝입니다.
function Greeting() {
const query = useQuery({
queryKey: ['greeting'],
queryFn: async () => createFromReadableStream(await getGreeting()),
});
return <>{query.data}</>;
}
이것은 RSC가 던졌던 동일한 질문에 대한 완전히 다른 답변입니다. 브라우저가 필요하지 않은 컴포넌트 코드를 어떻게 사용자에게 보내지 않을 것인가에 대한 답입니다.
Next.js는 프레임워크 수준에서 답합니다. server-first 방식이며, 전체 구조를 모두 받아들여야 합니다. TanStack Start는 데이터 수준에서 답합니다. client-first 방식이며, 필요한 부분만 opt-in 하는 구조입니다.
이 방식은 server-first 모델이 잘 처리하지 못하는 상황에 특히 잘 맞습니다. 이미 존재하는 client-side 애플리케이션, admin portal, 혹은 SaaS dashboard처럼 새로운 패러다임으로 전체 아키텍처를 다시 작성하지 않고도 무거운 렌더링 일부만 서버로 옮기고 싶은 경우입니다.
예를 들어 markdown parsing, syntax highlighting, search index처럼 무겁고 정적이며 굳이 클라이언트로 보내고 싶지 않은 작업들이 여기에 해당합니다.
이럴 때 필요한 곳에만 RSC를 부분적으로 추가하고, 나머지 SPA 구조는 그대로 유지할 수 있습니다.
실제로 TanStack 가 자신들의 docs 사이트에서 콘텐츠 비중이 큰 blog와 docs 페이지를 이 방식으로 이전했을 때, 각각 클라이언트 JavaScript graph에서 gzip 기준 약 153KB가 줄었고, Total Blocking Time은 약 1200ms에서 260ms까지 감소했습니다.
RSC를 사용하기 위해 애플리케이션 전체를 다시 작성할 필요는 없습니다. 하지만 그 대신 상당히 많은 wiring 작업을 직접 해야 합니다.
server-first 모델이 모든 것을 하나로 제공하는 특성 덕분에 streaming, boundary, 그리고 colocated server 작업이 기본 기능처럼 자연스럽게 동작합니다. 반면 각각을 직접 opt-in 하는 구조에서는 Next.js가 기본 제공하던 composition 구조를 스스로 관리해야 합니다.
그리고 TanStack Start는 아직 비교적 새로운 프레임워크입니다. RSC 지원 역시 아직 1.0을 향해 안정화되는 과정에 있습니다. 따라서 대규모 프로덕션 애플리케이션에서는 Next.js보다 덜 검증된 선택지를 받아들이는 대신, 그만큼 더 높은 유연성을 얻는 구조입니다.
RSC는 어떤 방식으로 ownership을 나누든 hydration이 필요한 영역을 interactive leaves 수준까지 크게 줄여줍니다.
하지만 그 leaves 역시 결국 hydration 됩니다. 여전히 클라이언트에서 다운로드되고, 다시 실행되며, 자신이 담당하는 tree 일부를 다시 구성합니다.
그리고 마지막으로 하나의 질문이 남습니다.
정말 그것마저 필요한가 입니다.
그리고 이 질문에는 서로 다른 두 가지 답이 존재합니다.
React 는 coarse-grained 방식입니다. state가 변경되면 해당 state를 소유한 component가 다시 실행되고, React는 그 지점부터 아래로 내려가면서 virtual DOM을 비교해 실제 DOM에서 무엇이 바뀌었는지 확인합니다.
Hydration 비용이 큰 이유 중 하나도 바로 이 동일한 re-render 메커니즘이 전체 tree를 대상으로 처음부터 한 번 더 실행되기 때문입니다.
SolidJS 와 Svelte 는 완전히 다른 방식으로 동작합니다.
virtual DOM이 존재하지 않고, re-render 자체도 없습니다. component는 단 한 번만 실행되어 reactive graph를 구성하고, 각각의 state를 자신이 제어하는 특정 DOM node에 직접 연결합니다.
그리고 state가 변경되면 framework는 해당 text node 혹은 attribute 하나만 업데이트합니다.
import { createSignal } from 'solid-js';
function Counter() {
const [count, setCount] = createSignal(0);
// 이 함수 body는 단 한 번만 실행됩니다.
// signal은 바로 아래 text node와 직접 연결됩니다.
return <button onClick={() => setCount(count() + 1)}>Clicked {count()} times</button>;
}
이 Counter 함수는 setup 단계에서 단 한 번만 실행됩니다. 버튼을 클릭하면 count signal이 업데이트되고, 그 결과 정확히 하나의 text node만 업데이트됩니다.
component는 다시 실행되지 않습니다.
re-render 메커니즘 자체가 없기 때문에 hydration 비용 역시 극적으로 줄어듭니다. Solid 역시 reactive graph를 서버에서 렌더링된 DOM과 연결하기 위한 setup 비용은 존재합니다. 하지만 전체 component tree를 다시 실행하고 diffing 하는 과정이 없기 때문에, React가 hydration 과정에서 수행하는 작업량에 비하면 극히 일부 수준에 불과합니다.
Svelte 5 역시 같은 영역에 있습니다. 새로운 runes 시스템인 $state, $derived 를 통해 Svelte는 signal 기반의 fine-grained reactivity 모델로 이동했으며, 구조적으로 Solid와 매우 유사해졌습니다. 그리고 State of JavaScript 2025 는 바로 이 변화가 올해 가장 뛰어난 developer experience 개선 사례라고 평가했습니다.
이 계열에서 가장 최근 등장한 것은 Ripple입니다. 이것은 Dominic Gannaway 의 실험 프로젝트입니다. 그는 과거 Meta 에서 React Hooks 작업에 참여했고, Svelte 5 core team에도 속해 있었습니다.
Ripple은 TypeScript-first 컴파일 언어이며, 동일하게 virtual DOM이 존재하지 않고, 필요한 부분만 정밀하게 업데이트하는 surgical-update 모델을 사용합니다. reactive 시스템은 track() primitive 위에서 동작합니다.
이러한 framework들은 성능이 매우 중요한 interactive UI에 적합합니다. 특히 상태 변화가 지속적으로 발생하는 환경에서 강점을 가집니다.
수백 개의 실시간 데이터가 계속 갱신되는 real-time dashboard, trading interface, data grid, 그리고 안정적으로 60fps를 유지해야 하는 animation-heavy application이 대표적인 예시입니다.
즉, 화면이 매우 바쁘게 동작하고 React의 re-render와 diff 비용이 업데이트마다 계속 누적되는 모든 환경에 적합합니다.
물론 React의 다운로드 수는 이러한 새로운 framework들을 압도합니다. 그리고 그 차이는 결국 ecosystem, library 수, AI 기반 도움 자료, 그리고 다음에 채용할 개발자가 이미 해당 framework를 알고 있을 가능성으로 이어집니다.
meta-framework 생태계 역시 아직 완전히 안정되었다고 보기는 어렵습니다. SvelteKit 는 이미 안정적으로 production 환경에서 사용 가능하지만, SolidStart 는 이제 막 1.0에 도달했고, 아직 deployment adapter나 공식 문서 패턴들이 계속 채워지고 있는 단계입니다. 따라서 예상하지 못한 edge case를 처음 마주하는 사람이 될 가능성이 훨씬 높습니다.
그리고 mental model 자체도 React와 다릅니다.
// React에서는 이 함수가 매 render마다 다시 실행됩니다.
// 따라서 expensive 값 역시 매번 다시 계산됩니다.
function Row({ value }) {
const expensive = computeStuff(value);
return <td>{expensive}</td>;
}
// Solid에서는 이 함수가 단 한 번만 실행됩니다.
// 만약 React 방식 그대로 작성한다면
// expensive는 한 번만 계산되고 이후에는 절대 업데이트되지 않습니다.
// value가 변경될 때는 derived signal을 별도로 사용해야 합니다.
코드는 React처럼 보입니다.
하지만 동작 방식은 React가 아닙니다.
Fine-grained reactivity는 hydration 비용을 매우 낮게 만들어줍니다. 하지만 여전히 hydration은 존재합니다. 사용자가 상호작용할 수 있기 전에, client에서는 reactive graph를 DOM과 연결하기 위한 setup pass가 여전히 한 번 실행되어야 합니다.
그리고 마지막 전략은 여기서 한 가지 질문을 던집니다.
과연 이것조차 생략할 수 있을까 입니다.
마지막 접근 방식은 조금 특이합니다. 그리고 이것은 단순히 더 빠른 hydration이 아니기 때문에, 이를 설명하기 위해 새로운 단어가 필요했습니다. 이것은 hydration이 더 빨라진 것이 아니라, hydration 자체가 존재하지 않는 방식입니다.
지금까지 살펴본 모든 전략은, 아무리 가벼운 island 구조라고 하더라도 하나의 공통된 가정을 가지고 있습니다. 서버에서 렌더링된 HTML을 상호작용 가능하게 만들기 위해서는 클라이언트가 반드시 일부 JavaScript를 실행해야 한다는 점입니다.
컴포넌트를 다시 실행하거나, 최소한 reactive graph를 연결하기 위한 초기 설정 과정을 한 번 수행해야 합니다.
Qwik 의 주장은 바로 그 작업을 버리는 것 자체가 근본적인 실수라는 것입니다. HTML만 직렬화한 뒤 나머지를 클라이언트에서 다시 구성하는 대신, 프레임워크의 전체 실행 상태, 컴포넌트 경계, 이벤트 리스너 위치, 그리고 reactivity graph 전체를 HTML 안에 직접 직렬화합니다.
클라이언트는 아무것도 다시 구성하지 않습니다. 서버가 멈춘 바로 그 지점부터 그대로 이어서 실행합니다.
이 메커니즘은 실제 markup 안에서 확인할 수 있습니다. 이벤트 핸들러는 초기 실행 과정에서 attach 되는 것이 아니라, lazy loading 가능한 chunk를 가리키는 attribute 형태로 직렬화됩니다.
<button on:click="./chunk-abc.js#handler">Add to cart</button>
초기 실행 시 동작하는 것은 Qwikloader 라는 매우 작은 스크립트뿐입니다. 크기는 1KB도 되지 않으며, 하나의 전역 이벤트 리스너를 등록한 뒤 사용자가 상호작용하기 전까지는 아무 작업도 하지 않습니다.
사용자가 해당 버튼을 클릭하는 순간, 그리고 그때에만 Qwikloader는 해당 attribute를 확인하고, 필요한 handler chunk를 다운로드하며, 해당 handler가 필요로 하는 상태만 다시 복원한 뒤 실행합니다.
즉, 특정 상호작용에 필요한 코드는 미리 로드되는 것이 아니라, 실제 상호작용이 발생했을 때 로드됩니다.
하지만 실행 상태 전체를 HTML에 직렬화하는 방식은 직렬화할 수 있는 대상에 제약을 만들고, 이러한 제약은 컴포넌트를 작성하는 방식에도 영향을 줍니다. 아무 값이나 closure 안에 넣고 그대로 resume 될 것이라고 기대할 수는 없습니다.
또한 상호작용마다 코드를 개별적으로 로드한다는 것은, 처음부터 하나의 큰 bundle을 내려받는 대신 더 많은 작은 요청이 발생한다는 의미이기도 합니다. speculative prefetching 과 HTTP/2 는 이러한 비용을 숨기도록 설계되어 있지만, 이는 기존과 다른 성능 특성이며 무시할 수 있는 것이 아니라 직접 이해하고 조정해야 하는 영역입니다.
그리고 Qwik은 이 아이디어 전체를 기반으로 설계된 유일한 프레임워크입니다. 그렇기 때문에 이 글에서 소개하는 프레임워크 중 가장 새로운 선택지이며, 실제 프로덕션 환경에서의 검증도 가장 적고, 활용할 수 있는 생태계 역시 가장 작습니다.
그리고 이 방식의 장점이 크게 의미가 없는 경우도 존재합니다.
예를 들어 비디오 편집기나 실시간 대시보드를 만든다고 가정해보겠습니다. 사용자는 거의 모든 기능과 매우 빠르게 상호작용하게 되므로, 결국 대부분의 코드를 처음 몇 초 안에 모두 로드하게 됩니다.
Resumability의 핵심 장점은 사용자가 끝내 접근하지 않을 수도 있는 코드를 최대한 뒤로 미루는 데 있습니다. 하지만 사용자가 결국 모든 코드에 접근하게 된다면, 복잡성은 늘어나지만 얻는 이점은 거의 없게 됩니다.
지금까지의 모든 이야기는 첫 번째 로딩에 관한 것이었습니다. 페이지가 얼마나 빠르게 그려지는지, 그리고 상호작용 가능 상태가 되기까지 얼마나 많은 비용이 드는지에 대한 이야기입니다.
하지만 애플리케이션이 정말 앱처럼 느껴질지를 결정하는 두 번째 순간이 있습니다. 그리고 이것은 서버 중심 모델이 항상 가장 약했던 부분이기도 합니다. 바로 다음 페이지로 이동하기 위해 링크를 클릭했을 때 무엇이 발생하는가입니다.
기존의 서버 중심 애플리케이션에서는, 이 클릭이 곧 네트워크 왕복을 의미합니다.
클릭합니다. 아무 일도 일어나지 않습니다. 서버가 응답합니다. 그리고 다음 페이지가 나타납니다.
블로그라면 이것은 충분히 받아들일 수 있습니다. 하지만 소프트웨어처럼 느껴져야 하는 애플리케이션에서는, 바로 이 지연이 사람들이 서버 기반 애플리케이션을 두고 “앱이 아니라 웹사이트 같다”라고 말하게 만드는 이유입니다.
클라이언트 중심 SPA는 이 지연을 감춥니다. 클릭하면 즉시 다음 페이지의 shell이 먼저 보이고, 데이터는 여전히 로딩되는 동안 나머지 내용이 이후 채워집니다.
이 즉각적으로 보이는 shell이야말로, 서버 중심 모델이 다른 모든 측면에서 더 적합하더라도 사람들이 SPA를 선택하는 가장 큰 이유 중 하나입니다.
Next.js 16.3은 서버 중심 모델에서도 동일한 instant-shell 경험을 제공하는 것을 목표로 합니다. 그리고 이를 구현하는 방식은 이 글에서 이미 다뤘던 두 가지 개념, Partial Pre-Rendering과 RSC가 클라이언트로 스트리밍하는 Flight payload를 연결하는 방식입니다.
현재 이 기능 전체는 하나의 flag 뒤에 숨겨져 있습니다.
// next.config.ts
const nextConfig = {
cacheComponents: true,
};
Cache Components를 활성화하면 렌더링 모델이 바뀝니다.
모든 route는 기본적으로 dynamic이 되며, 암묵적인 caching은 존재하지 않게 됩니다. 그리고 PPR은 더 이상 route 단위의 experimental opt-in 기능이 아니라 기본 동작이 됩니다.
그 이후부터 route가 서버에서 데이터를 await 할 때마다, 해당 페이지의 그 부분에 대해 세 가지 선택지 중 하나를 결정하게 됩니다.
느린 부분을 <Suspense> 로 감싸서 stream 할 수 있습니다. 그러면 사용자는 해당 위치에 loading 상태가 표시된 정적인 shell을 즉시 보게 됩니다.
또는 use cache 를 사용해 cache 할 수 있습니다. 그러면 사용자는 요청 간 재사용되는 이전에 cache된 UI를 즉시 보게 됩니다.
async function ProductList() {
'use cache';
cacheLife('hours');
const products = await getProducts();
return <ProductGrid products={products} />;
}
이 두 가지 방식 모두 navigation을 즉각적으로 느껴지게 만듭니다. 사용자가 기다리는 어떤 요소도 shell을 막지 않기 때문입니다.
세 번째 선택지는 의도적으로 block 하는 것입니다. 어떤 route는 loading shell을 보여주면 안 됩니다. 예를 들어 skeleton UI보다 전체가 한 번에 도착해야 하는 블로그 포스트 같은 경우입니다. 이 경우 명시적으로 해당 route를 제외합니다.
// page.tsx
export const instant = false;
Next.js 16.3은 또 하나의 새로운 방식을 도입했습니다. 기존처럼 링크마다 각각 페이지를 prefetch 하는 것이 아니라, route마다 재사용 가능한 하나의 shell을 prefetch 하고 이를 session 동안 클라이언트에 cache 합니다.
예를 들어 /chat/[id] 로 연결되는 20개의 링크가 있다면, 이제 각각을 prefetch 하지 않고 하나의 /chat/[id] shell만 prefetch 합니다. 이는 SPA가 route 단위 코드 하나를 내려보내고 모든 항목에서 재사용하는 방식과 같습니다.
이 기능은 Cache Components와 함께 활성화할 수 있습니다.
// next.config.ts
const nextConfig = {
cacheComponents: true,
partialPrefetching: true,
};
이 shell은 route의 정적이며 cache 가능한 부분입니다. 그리고 이것은 PPR이 이미 pre-render 할 수 있다고 판단한 부분이기도 합니다.
결국 이 두 개의 flag는 서로 다른 방향에서 바라본 하나의 동일한 개념입니다. PPR은 route 중 어떤 부분이 static shell인지 결정하고, Partial Prefetching은 바로 그 shell을 클릭 전에 클라이언트로 미리 전달합니다.
shell이 기준점 역할을 하기 때문에, 이제 prefetching은 더 이상 all-or-nothing 방식이 아닙니다.
특정 링크에서 단순한 shell보다 더 많은 내용을 즉시 보여주고 싶다면, 예를 들어 채팅 헤더가 즉시 나타나야 하는 경우처럼, 해당 링크에 대해 더 깊은 prefetching을 선택할 수 있습니다. 그러면 Next.js는 synchronous 하거나 'use cache' 로 표시된 지점까지 렌더링합니다.
<Link href={`/chat/${id}`} prefetch={true}>
{title}
</Link>
이 방식은 이전까지 서버 중심 모델과 잘 맞지 않았던 애플리케이션 유형에 정확히 들어맞습니다. 대시보드, 채팅 애플리케이션, 혹은 링크가 빽빽하게 존재해서 클릭할 때마다 눈에 보이는 지연이 발생하던 모든 인터페이스가 이에 해당합니다.
Vercel 은 이미 v0 에서 이 기능을 사용하고 있습니다. 풍부한 클라이언트 기능 때문에 navigation이 느리게 느껴졌던 문제를 해결하기 위해, 새로운 개발 단계 인사이트 도구를 활용해 즉시 navigation 되지 않는 route를 찾아내고 있습니다.
이제 서버 중심 모델, 작은 클라이언트 bundle, 직접적인 데이터 접근, 검색 엔진이 크롤링 가능한 첫 로딩 등, 앞서 RSC 섹션에서 이야기했던 모든 장점을 포기하지 않으면서도 SPA처럼 즉시 클릭되는 경험을 얻을 수 있습니다.
문제는 이제 “instant”가 더 이상 자동으로 따라오는 것이 아니라, 직접 관리해야 하는 대상이 된다는 점입니다.
이제 모든 route의 모든 dynamic 부분에 대해 Stream, Cache, Block 중 하나를 계속 결정해야 하며, 애플리케이션이 변경될 때마다 이를 올바르게 유지해야 합니다. 예를 들어 refactor 과정에서 cookies() 호출이 <Suspense> boundary 밖으로 이동하면, 아무 경고 없이 instant route가 blocking route로 바뀔 수 있습니다.
Next.js는 이를 유지하기 위해 tooling에 크게 의존합니다. 개발 단계 에러 메시지, shell 단계에서 navigation을 멈춰 어떤 부분이 prefetched 되었는지 보여주는 Navigation Inspector, 그리고 네트워크 응답 전에 무엇이 보여야 하는지 검증하는 instant() Playwright helper가 이에 포함됩니다.
import { instant } from '@next/playwright';
test('next page shell is instant', async ({ page }) => {
await page.goto('/products/shoes');
await instant(page, async () => {
await page.click('a[href="/products/hats"]');
await expect(page.locator('h1')).toContainText('Baseball Cap');
});
});
이 기능은 2026년 중반 기준 아직 preview 단계이며 flag 기반으로 동작합니다. 그리고 cacheComponents 와 partialPrefetching 모두 향후 주요 버전에서 기본값이 될 예정입니다.
정말 중요한 것은 단 하나입니다. HTML이 화면에 그려진 시점부터 페이지가 상호작용 가능해질 때까지, 클라이언트에서 얼마나 많은 작업이 발생하는가 입니다.
대부분의 사람들에게, 그리고 대부분의 경우에, 솔직한 답은 생각보다 단순합니다.
콘텐츠 중심 사이트를 만든다면 static generation과 islands 방식을 선택하면 됩니다. Astro 는 거의 추가적인 노력 없이 매우 이상적인 성능을 제공합니다.
애플리케이션을 만든다면, React 와 React Server Components 를 Next.js 같은 프레임워크와 함께 사용하는 것만으로도 상당히 오랫동안 충분합니다. 그리고 streaming과 selective hydration 메커니즘도 별다른 추가 작업 없이 함께 따라옵니다.
그 외의 것들은 특정 문제가 실제로 발생했을 때 선택하는 도구입니다.
Fine-grained reactivity는 복잡한 화면에서 지속적인 업데이트 성능이 문제가 될 때 선택합니다.
Resumability는 애플리케이션 규모가 커져 hydration 비용 자체가 병목이 되었고, 첫 번째 상호작용이 반드시 즉시 이루어져야 할 때 선택합니다.
CSR은 페이지가 로그인 뒤에 존재하고, 첫 번째 화면 표시 속도가 중요하지 않을 때 선택합니다.
어떤 사이트도 HTML을 서버에서 렌더링하고 hydration 했기 때문에 느려진 적은 없습니다.
항상 문제는 반대 방향에서 발생합니다. 콘텐츠 중심 블로그가 전체 앱 런타임을 전송하거나, 애플리케이션이 측정조차 하지 않은 hydration 비용과 싸우거나, 마케팅 페이지가 bundle이 로드되는 동안 2초 동안 빈 화면으로 남아 있는 경우입니다.
자신의 페이지에서 실제로 얼마나 많은 부분이 상호작용을 필요로 하는지에 맞춰 전략을 선택해야 합니다. 그리고 더 복잡한 방식을 선택하는 것은, 그것이 해결하는 문제가 무엇인지 명확하게 설명할 수 있을 때만 고려하면 됩니다.
Resumable vs. Hydration — Qwik 공식 문서입니다. 실행 상태를 다시 실행하는 대신 직렬화하는 방식에 대해 다루고 있습니다.
Islands Architecture — Astro 공식 문서입니다. client islands와 server islands 구조를 설명하고 있습니다.
Client Directives — Astro 공식 문서입니다. client:* 로딩 계약 방식에 대해 다루고 있습니다.
Resumability vs Hydration — Builder.io 에서 정리한 자료입니다. React, Solid, Qwik을 하나의 스펙트럼 위에서 비교하고 있습니다.
New Suspense SSR Architecture in React 18 — React 18 working group 자료입니다. streaming과 selective hydration 구조를 다루고 있습니다.
How Wix Solved React’s Hydration Problem — Wix Engineering 자료입니다. 실제 프로덕션 환경에서 selective hydration을 적용한 사례를 설명하고 있습니다.
Server Components — React Server Components 공식 문서입니다.
React Server Components Your Way — TanStack 블로그입니다. client-first 구조와 RSC를 stream으로 다루는 모델을 설명하고 있습니다.
How to choose the best rendering strategy — Vercel 자료입니다. SSG, ISR, SSR, CSR 그리고 route 단위 조합 전략을 다루고 있습니다.
Ripple — 가장 최근 등장한 fine-grained 방식의 compiler 기반 TypeScript 프레임워크입니다.
State of JavaScript 2025: Meta-Frameworks — State of JavaScript 2025 조사 자료입니다. Astro, Next.js를 포함한 다양한 meta-framework 만족도 순위를 정리하고 있습니다.