|
| 1 | +# Streaming SSR 구현하기 |
| 2 | + |
| 3 | +## 목표 |
| 4 | + |
| 5 | +- Hono로 Node.js 서버를 구성합니다. |
| 6 | +- Streaming SSR 구조를 작성하고 결과를 분석해봅니다. |
| 7 | + |
| 8 | +전체적인 구조는 다음과 같습니다. |
| 9 | + |
| 10 | +<img src="./flow.png"> |
| 11 | + |
| 12 | +## 서버 구성하기 |
| 13 | + |
| 14 | +Node.js에서 Hono를 사용해 서버를 실행해봅시다. |
| 15 | + |
| 16 | +```ts |
| 17 | +/** server.ts */ |
| 18 | + |
| 19 | +import { serve } from "@hono/node-server"; |
| 20 | +import { Hono } from "hono"; |
| 21 | + |
| 22 | +const app = new Hono(); |
| 23 | + |
| 24 | +// 3000 포트로 서버를 실행합니다. |
| 25 | +serve(app, (info) => { |
| 26 | + console.log(`Server is running on http://localhost:${info.port}`); |
| 27 | +}); |
| 28 | + |
| 29 | +app.get("/", (ctx) => { |
| 30 | + return ctx.text("Hello World"); |
| 31 | +}); |
| 32 | +``` |
| 33 | + |
| 34 | +개발 서버를 실행한 후, http://localhost:3000에 접속하면 "Hello World" 문자열이 반환됩니다. |
| 35 | + |
| 36 | +## 컴포넌트 작성하기 |
| 37 | + |
| 38 | +서버에서 렌더링 할 컴포넌트를 작성해봅시다. 컴포넌트는 구조는 다음과 같습니다. |
| 39 | + |
| 40 | +- App: 최상위 컴포넌트입니다. |
| 41 | +- TodoList: 비동기로 작성된 컴포넌트입니다. 내부의 비동기 작업은 2초 뒤 종료됩니다. |
| 42 | + |
| 43 | +```tsx |
| 44 | +/** client.ts */ |
| 45 | + |
| 46 | +import React, { Suspense } from "react"; |
| 47 | +import { getTodoList } from "./lib"; |
| 48 | + |
| 49 | +async function TodoList() { |
| 50 | + const todoList = await getTodoList(); |
| 51 | + |
| 52 | + return ( |
| 53 | + <ul> |
| 54 | + {todoList.map((todoItem) => { |
| 55 | + return <li key={todoItem.id}>{todoItem.title}</li>; |
| 56 | + })} |
| 57 | + </ul> |
| 58 | + ); |
| 59 | +} |
| 60 | + |
| 61 | +function App() { |
| 62 | + return ( |
| 63 | + <div> |
| 64 | + <h1>Todo List</h1> |
| 65 | + <Suspense fallback={<div>loading...</div>}> |
| 66 | + <TodoList /> |
| 67 | + </Suspense> |
| 68 | + </div> |
| 69 | + ); |
| 70 | +} |
| 71 | + |
| 72 | +export default App; |
| 73 | +``` |
| 74 | + |
| 75 | +App 컴포넌트를 서버에서 렌더링하면 렌더링 결과는 2단계로 나눌 수 있습니다. |
| 76 | + |
| 77 | +- 초기 렌더링: loading... 문구가 표시됩니다. |
| 78 | +- 최종 렌더링: TodoList 데이터가 표시됩니다. |
| 79 | + |
| 80 | +### 초기 렌더링 |
| 81 | + |
| 82 | +```html |
| 83 | +<!DOCTYPE html> |
| 84 | +<html> |
| 85 | + <body> |
| 86 | + <div> |
| 87 | + <h1>Todo List</h1> |
| 88 | + <div>loading...</div> |
| 89 | + </div> |
| 90 | + </body> |
| 91 | +</html> |
| 92 | +``` |
| 93 | + |
| 94 | +### 최종 렌더링 (TodoList 비동기 작업 종료 후 시점) |
| 95 | + |
| 96 | +```html |
| 97 | +<!DOCTYPE html> |
| 98 | +<html> |
| 99 | + <body> |
| 100 | + <div> |
| 101 | + <h1>Todo List</h1> |
| 102 | + <div> |
| 103 | + <h1>Todo List</h1> |
| 104 | + // [!code --] |
| 105 | + <div>loading...</div> |
| 106 | + // [!code ++] |
| 107 | + <ul> |
| 108 | + // [!code ++] |
| 109 | + <!-- `getTodoList` 기반으로 생성된 li 목록 ... --> |
| 110 | + // [!code ++] |
| 111 | + <li></li> |
| 112 | + // [!code ++] |
| 113 | + </ul> |
| 114 | + </div> |
| 115 | + </div> |
| 116 | + </body> |
| 117 | +</html> |
| 118 | +``` |
| 119 | + |
| 120 | +이제 서버에서 React 컴포넌트를 streaming 렌더링하는 방법을 살펴봅시다. |
| 121 | + |
| 122 | +## 서버에서 렌더링하기 |
| 123 | + |
| 124 | +React는 서버에서 컴포넌트를 렌더링 하기 위해 `react-dom/server` 기반의 [서버 API](https://ko.react.dev/reference/react-dom/server)를 제공합니다. Node.js를 사용하는 경우 [renderToPipeableStream](https://ko.react.dev/reference/react-dom/server/renderToPipeableStream) API를 사용해야 합니다. |
| 125 | + |
| 126 | +### stream 준비하기 |
| 127 | + |
| 128 | +App 컴포넌트를 stream 형태로 렌더링하고, 브라우저로 전송할 준비합니다. |
| 129 | + |
| 130 | +```ts |
| 131 | +/** server.ts */ |
| 132 | + |
| 133 | +import { PassThrough, Readable } from "stream"; |
| 134 | + |
| 135 | +import ReactDomServer from "react-dom/server"; |
| 136 | + |
| 137 | +import App from "./client"; |
| 138 | + |
| 139 | +router.get("/", async () => { |
| 140 | + const element = createElement(App); |
| 141 | + |
| 142 | + const reactAppPassThrough = new PassThrough(); |
| 143 | + const reactAppStream = Readable.toWeb(reactAppPassThrough); |
| 144 | + |
| 145 | + const { pipe, abort } = ReactDomServer.renderToPipeableStream(element, { |
| 146 | + onShellReady() { |
| 147 | + pipe(reactAppPassThrough); |
| 148 | + }, |
| 149 | + onError(error) { |
| 150 | + reactAppPassThrough.destroy(); |
| 151 | + abort(error); |
| 152 | + } |
| 153 | + }); |
| 154 | +}); |
| 155 | +``` |
| 156 | + |
| 157 | +> [!TIP] onShellReady을 무엇인가요? |
| 158 | +> |
| 159 | +> - `onShellReady` 함수는 서버에서 [초기 렌더링 결과(Shell)](https://ko.react.dev/reference/react-dom/server/renderToPipeableStream#specifying-what-goes-into-the-shell)가 생성했을 때 호출되는 콜백입니다. |
| 160 | +> - `onShellReady` 함수가 호출되기 전 HTML의 기본구조 `<!DOCTYPE html>`, `<html>` 등을 chunk로 전송해야 합니다. |
| 161 | +
|
| 162 | +> [!TIP] 왜 PassThrough을 사용하나요? |
| 163 | +> stream 을 직접 제어하려고 [PassThrough](https://nodejs.org/api/stream.html#class-streampassthrough) 기반으로 stream을 중계합니다. |
| 164 | +
|
| 165 | +### 브라우저에 stream 전달하기 |
| 166 | + |
| 167 | +stream 기반의 통신을 위해서 데이터의 디코딩, 인코딩이 필요합니다. 관련 로직을 다음과 같이 작성해봅시다. |
| 168 | + |
| 169 | +```ts |
| 170 | +const textDecoder = new TextDecoder(); |
| 171 | +const textEncoder = new TextEncoder(); |
| 172 | + |
| 173 | +// 인코딩 된 데이터를 디코딩 하는 함수 |
| 174 | +const decodeChunk = (chunk: unknown) => { |
| 175 | + if (chunk instanceof Uint8Array) { |
| 176 | + return textDecoder.decode(chunk); |
| 177 | + } |
| 178 | + |
| 179 | + return String(chunk); |
| 180 | +}; |
| 181 | +``` |
| 182 | + |
| 183 | +브라우저에 전달할 stream 생성하여 브라우저에 반환해봅시다. stream이 데이터를 전송하는 시점은 3가지로 분리할 수 있습니다. |
| 184 | + |
| 185 | +1. 브라우저 stream 생성: HTML 시작 태그 chunk를 전송합니다. |
| 186 | +2. 중간: React 컴포넌트에서 생성된 chunk를 전송합니다. |
| 187 | +3. React stream 종료: HTML 종료 태그 chunk를 전송합니다. |
| 188 | + |
| 189 | +```ts |
| 190 | +const header = `<!DOCTYPE html><html><head><title>Hello World</title></head><body><div id="root"></div>`; |
| 191 | +const trailer = `</body></html>`; |
| 192 | + |
| 193 | +app.get("/", async () => { |
| 194 | + // ... |
| 195 | + |
| 196 | + const onResponseStreamStart = async (controller: ReadableStreamDefaultController<unknown>) => { |
| 197 | + // [1] HTML 시작 태그 chunk 전달 |
| 198 | + // // <!DOCTYPE html><html><head><title>Hello World</title></head><body><div id="root"></div> |
| 199 | + controller.enqueue(textEncoder.encode(header)); |
| 200 | + |
| 201 | + // [2] React Stream chunk 전달 |
| 202 | + try { |
| 203 | + let chunk; |
| 204 | + |
| 205 | + const reader = reactAppStream.getReader(); |
| 206 | + |
| 207 | + // React 스트림 읽기 |
| 208 | + while (true) { |
| 209 | + chunk = await reader.read(); |
| 210 | + |
| 211 | + if (chunk.done) { |
| 212 | + break; |
| 213 | + } |
| 214 | + |
| 215 | + const decodedChunk = decodeChunk(chunk.value); |
| 216 | + controller.enqueue(textEncoder.encode(decodedChunk)); |
| 217 | + } |
| 218 | + } catch (error) { |
| 219 | + controller.error(error); |
| 220 | + } finally { |
| 221 | + // [3] HTML 종료 태그 chunk 전달 |
| 222 | + // </body></html> |
| 223 | + controller.enqueue(textEncoder.encode(trailer)); |
| 224 | + controller.close(); |
| 225 | + } |
| 226 | + }; |
| 227 | + |
| 228 | + const responseStream = new ReadableStream({ start: onResponseStreamStart }); |
| 229 | + |
| 230 | + return new Response(responseStream, { |
| 231 | + headers: { |
| 232 | + "Content-type": "text/html" |
| 233 | + } |
| 234 | + }); |
| 235 | +``` |
| 236 | +
|
| 237 | +## 렌더링 결과 분석하기 |
| 238 | +
|
| 239 | +App 컴포넌트가 Streaming SSR 기반으로 렌더링 된 결과를 살펴봅시다. |
| 240 | +
|
| 241 | +### 초기 렌더링 |
| 242 | +
|
| 243 | +Suspense 영역은 loading... 으로 표기되고, 나머지 영역이 DOM에 반영된 것을 확인할 수 있습니다. |
| 244 | +
|
| 245 | +<img src="./streaming-ssr-init-render.png"> |
| 246 | +
|
| 247 | +### 최종 렌더링 |
| 248 | +
|
| 249 | +초기 렌더링 후 2초가 뒤 TodoList 목록이 DOM에 표시된 것을 확인할 수 있습니다. 추가로 HTML 응답 결과를 확인해보면, `$RC` 함수를 통해 `B:0` 영역의 요소에 `S:0` 요소로 치환된 코드를 확인해볼 수 있습니다. |
| 250 | +
|
| 251 | +<img src="./streaming-ssr-final-render.png"> |
| 252 | +
|
| 253 | +> [!TIP] B:0, S:0, $RC 태그는 무엇인가요? |
| 254 | +> |
| 255 | +> - `B:0`, `S:0`, `$RC` React Fizz에서 사용하는 용어입니다. |
| 256 | +> - Fizz는 서버에서 React 컴포넌트를 스트리밍 렌더링하는 시스템입니다. |
| 257 | +> - Fizz는 초기 HTML과 함께 B:0(Boundary 시작), S:0(Segment 시작), $RC(React Component 렌더링) 같은 명령어를 전송합니다. |
| 258 | +> - 브라우저는 스트림을 따라 준비된 컴포넌트를 점진적으로 렌더링하고, 서버에서도 Suspense를 지원합니다. |
| 259 | +> - 관련 코드는 https://github.com/facebook/react/tree/main/packages/react-server 에서 확인하실 수 있습니다. |
0 commit comments