최근 계속 프론트엔드 개발자로 최적화된 사용자 경험을 위한 기술과 렌더링 방식에 대해 공부하며, 관련 개념을 정리하고 있다.
동시성 기능, Server Component, SSR 을 넘어 이번에는 Streaming SSR에 대해 알아보려고 한다.
Web 생태계가 발전함에 따라 React를 중심으로 CSR(Client Side Rendering) 이 큰 유행을 타며 가장 인기있는 방식으로 발전해왔다.
이 방식은 발 그대로 “클라이언트” 측에서 렌더링을 수행하게 하는 방식으로, 기본적인 HTML만 받아오고, 나머지 내용은 JS 번들 파일을 다운 받아 화면을 렌더링하도록 한다.
이 방법은 Native 앱과 같이 아주 부드러운 화면 전환과, 인터렉션을 제공할 수 있다는 장점이 있어 많이 사용하였다.

하지만, 앱의 크기가 커지면 커질 수 록 그리고, Web 환경이 발전할 수 록 여러 문제들이 제기 되었다.
이때 FCP를 개선하기 위해 Code Splitting 과 같은 방식이 등장하기 도했지만, SEO 문제는 여전히 해결 할 수 없었다.
그래서 다시 등장한 방식이 SSR(Server Side Rendering) 이다.
CSR과 큰 차이점은 이름에서 알 수 있듯 “렌더링을 수행하는 주체”가 서버로 옮겨간 방식이다.
페이지에 접속하면, 서버에서 화면 구성과 API 요청 같은 모든 동작을 수행하여 “의미있는 HTML” 을 클라이언트에 전달해준다.
SSR 방식을 통해서 SEO 최적화와 FCP 문제를 어느 정도 해결 해줄 있었다.

하지만, HTML을 전달 후에 인터렉션을 입히는 Hydration 과정 (JS 파일을 다운 받는 과정)이 필요했다.
(hydration 과정의 JS 파일의 사이즈는 결코 작지 않다.)
그렇기 때문에 FCP, LCP 같은 성능은 개선이 되더라도, 유저가 실제로 상호작용한 시점은 Hydration 과정 이후가 되기 때문에 느린 TTI(Time To Interaction)를 가지게 된다는 단점이 있다.

또한 구조적으로 서버에서 렌더링을 하는 하고, 유저에게 전달해주기 때문에 CSR 보다는 느린 TTFB(Time To First Byte) 를 가지게 된다.
CSR의 단점을 보완하기 위해 SSR이 등장했지만, SSR 방식에서도 여전히 해결되지 못하는 단점들이 존재했다.
그래서 여기서 더 나아가 최적화된 렌더링 방식으로 제안한 것이 바로 Streaming SSR 이다.
Streaming SSR 은 “SSR 방식의 느린 TTFB를 개선하는 것이 우선 적인 목표”를 가지고 HTML Streaming 방식을 도입한 렌더링 방식이다.
서버에서 렌더링, 즉 HTML을 완성하는 과정에서 이를 Stream 형태로 반환하여, HTML을 모두 완성하기 전부터 클라이언트에 HTML에 대한 정보를 내려주기 시작하는 방식이다.
Stream ❓
“데이터를 여러 조각(chunk)로 쪼개어 순차적으로 보내는 데이터의 흐름”
넷플릭스나, 유튜브 같은 플랫폼에서 영상을 시청할 때 Streaming 방식을 사용한다는 말을 들어본적이 있을 것이다.
대용량의 파일을 한번에 다운 받는 것이 아니라, 점진적으로 다운 받아 사용하는 이러한 방식을 렌더링 과정에 도입한 것이라고 이해하면 된다.
Streaming SSR을 이용하면 다음과 같은 이점을 가져올 수 있다.
이런 Streaming 방식을 가능 하게 하는 방법은 HTTP에 있다.
HTTP로 어떻게 Streaming 을 구현할 수 있을까 의문을 품는 사람들은 HTTP는 Stateless 의 특징을 가지고 있다는 것을 잘 알고 있기 때문일 것이다.
여기서 등장하는 개념이 바로 HTTP의 청크 전송 인코딩(chunked transfer encoding) 방식이다.
Transfer-Encoding - HTTP | MDN
청크 전송 인코딩 방식은 HTTP의 Response Header에 Transfer-Encoding 을 chunked 로 전송 할 수 있도록 제공한다.
이 방식을 사용하여 데이터를 전송하게 되면, 대용량의 데이터를 Stream 형태로 보내줄 수 있어. Streaming SSR 을 구현할 수 있게 된다.
HTTP의 청크 전송 인코딩을 사용하기 위해서는 “서버”에서도 데이터를 Stream 형태로 보내줄 수 있어야 한다.
이러한 방식을 가능하게 하는 방법은 Node.js의 Stream API를 사용하는 것이다.
이 Stream API를 활용하면 HTML을 작은 chunk 형태로 처리하여, Stream 형태로 내려줄 수 있다.
이것을 직접 구현하기에는 복잡함이 있기 때문에 React 에서는 renderToReadableStream ****메서드를 제공하여, 이를 구현할 수 있게 해준다.
renderToReadableStream()
“React 18 이후 브라우저/서버 환경에서 Streaming SSR을 지원하기 위해 추가된 함수”
- 조각(chunk) 단위로 HTML을 Streming 해주는 것이 목적
- React Element를 Web Streams API의
ReadableStream로 변환- 이 함수를 사용하기 위해선 Web Streams API를 제공해야 함
import { renderToReadableStream } from "react-dom/server";
먼저 클라이언트에서 특정 서비스에 접속하여 요청을 보냈다고 해보자.
https://example.com
그러면 서버에서는 해당 요청에 대해 renderToReadableStream을 사용해서 React 앱을 스트리밍 응답으로 변환한다.
// server.ts (Edge 환경 예시)
import { renderToReadableStream } from "react-dom/server";
import App from "./App";
export default {
async fetch(req: Request) {
// React App을 ReadableStream으로 변환
const stream = await renderToReadableStream(<App />, {
bootstrapScripts: ["/client.js"], // hydration을 위한 JS
});
// HTML 스트림을 Response로 반환
return new Response(stream, {
headers: { "Content-Type": "text/html" },
});
},
};
renderToReadableStream 함수의 첫번째 매개변수로 전달된 <App/> 컴포넌트를 HTML Stream으로 변환한다.
이때 변환된 stream은 Web Stream API의 ReadableStream 으로 사용된다.
export default function App() {
return (
<div>
<h1>Hello Streaming SSR</h1>
<Suspense fallback={<p>Loading...</p>}>
<SlowComponent />
</Suspense>
</div>
);
}
React 의 <App/>에서는 동기적으로 변환이 가능한 부분은 즉시 HTML로 변환하여 stream에 담겨 전송이된다.
비동기 부분은 Suspense를 통해 fallback UI가 먼저 stream에 담겨 전송이 되고, 이후 resolve를 통해 완료가 되면 그때 stream으로 전송이 되게 된다.
참고로 이때 서버가 브라우저에 보내주는 Response 의 내용은 다음과 같이 전달이 된다.
HTTP/1.1 200 OK
Content-Type: text/html; charset=utf-8
Transfer-Encoding: chunked
조금 더 직관적으로 chunk 파일이 보내지는 내용을 확인해 보자.
chuck #1
<!DOCTYPE html>
<html>
<head>
<title>My Streaming App</title>
</head>
<body>
<div id="root">
<h1>Hello Streaming SSR</h1>

chuck #2
SlowComponent Promise가 아직 resolve되지 않아 fallback HTML 전송 <p>Loading...</p>

chuck #3
</div>
<script src="/_next/static/chunks/app.js"></script>
</body>
</html>
모든 비동기 렌더링이 끝난 후, 서버는 마지막으로 HTML 마무리 태그와 Hydration에 필요한 JS 번들을 브라우저로 전송한다.
chuck #4
response.end() 호출 → 서버 스트림 종료 <p>실제 데이터</p>
<ul>
<li>Item A</li>
<li>Item B</li>
</ul>

CSR
요청 → HTML 즉시 반환 → JS 번들 다운 → 렌더링 → 완료
SSR
요청 → 서버에서 HTML 렌더링 → HTML 반환 → Hydration → 완료
Streaming SSR
요청 → 서버에서 HTML Strem 반환 → Hydration → 완료
Streaming Server Side Rendering