애플리케이션이 성장할수록 과제도 늘어납니다. 앞서 나가려면 원활한 고성능 사용자 경험을 제공하기 위해 고급 SSR 기술을 익히는 것이 필수적입니다.
이전 기사에서 React 프로젝트에서 서버 측 렌더링을 위한 기반을 구축한 후 프로젝트 확장성을 유지하고, 서버에서 클라이언트로 데이터를 효율적으로 로드하고, 하이드레이션 문제를 해결하는 데 도움이 되는 기능을 공유하게 되어 기쁩니다.
서버 측 렌더링(SSR) 스트리밍은 전체 페이지가 준비될 때까지 기다리지 않고 서버가 생성되는 HTML 페이지의 일부를 청크로 브라우저에 보내는 기술입니다. 배달하기 전에. 이를 통해 브라우저는 콘텐츠 렌더링을 즉시 시작할 수 있어 로드 시간과 사용자 성능이 향상됩니다.
스트리밍은 다음과 같은 경우에 특히 효과적입니다.
스트리밍은 기존 SSR과 최신 클라이언트측 상호 작용 간의 격차를 해소하여 사용자가 성능 저하 없이 의미 있는 콘텐츠를 더 빠르게 볼 수 있도록 합니다.
지연 로딩은 구성요소나 모듈이 실제로 필요할 때까지 로딩을 연기하여 초기 로딩 시간을 줄이고 성능을 향상시키는 기술입니다. SSR과 결합하면 지연 로딩이 서버와 클라이언트 워크로드를 모두 크게 최적화할 수 있습니다.
지연 로딩은 구성 요소를 Promise로 동적으로 가져오는 React.lazy를 사용합니다. 기존 SSR에서는 렌더링이 동기식입니다. 즉, 전체 HTML을 생성하여 브라우저에 보내기 전에 서버가 모든 약속을 해결해야 합니다.
스트리밍은 구성 요소가 렌더링될 때 서버가 HTML을 청크로 전송할 수 있도록 하여 이러한 문제를 해결합니다. 이 접근 방식을 사용하면 Suspense 폴백이 즉시 브라우저로 전송되어 사용자가 의미 있는 콘텐츠를 조기에 볼 수 있습니다. 지연 로드된 구성 요소가 해결되면 렌더링된 HTML이 점진적으로 브라우저로 스트리밍되어 대체 콘텐츠가 원활하게 대체됩니다. 이렇게 하면 렌더링 프로세스가 차단되는 것을 방지하고, 지연을 줄이고, 인지된 로드 시간을 개선할 수 있습니다.
이 가이드는 이전 기사인 생산 가능한 SSR React 애플리케이션 구축에서 소개된 개념을 기반으로 하며, 해당 기사는 하단에 링크되어 있습니다. React에서 SSR을 활성화하고 지연 로드 구성 요소를 지원하기 위해 React 구성 요소와 서버 모두에 몇 가지 업데이트를 적용할 예정입니다.
React의 renderToString 메서드는 SSR에 일반적으로 사용되지만 전체 HTML 콘텐츠가 준비될 때까지 기다렸다가 브라우저에 보냅니다. renderToPipeableStream으로 전환하면 생성되는 HTML의 일부를 보내는 스트리밍을 활성화할 수 있습니다.
// ./src/entry-server.tsx import { renderToPipeableStream, RenderToPipeableStreamOptions } from 'react-dom/server' import App from './App' export function render(options?: RenderToPipeableStreamOptions) { return renderToPipeableStream(<App />, options) }
이 예에서는 개념을 보여주기 위해 간단한 카드 구성 요소를 만들어 보겠습니다. 프로덕션 애플리케이션에서 이 기술은 일반적으로 성능을 최적화하기 위해 더 큰 모듈이나 전체 페이지에 사용됩니다.
// ./src/Card.tsx import { useState } from 'react' function Card() { const [count, setCount] = useState(0) return ( <div className="card"> <button onClick={() => setCount((count) => count + 1)}> count is {count} </button> <p> Edit <code>src/App.tsx</code> and save to test HMR </p> </div> ) } export default Card
지연 로드 구성 요소를 사용하려면 React.lazy를 사용하여 동적으로 가져오고 Suspense로 래핑하여 로드 중에 대체 UI를 제공하세요
// ./src/App.tsx import { lazy, Suspense } from 'react' import reactLogo from './assets/react.svg' import viteLogo from '/vite.svg' import './App.css' const Card = lazy(() => import('./Card')) function App() { return ( <> <div> <a href="https://vite.dev" target="_blank"> <img src={viteLogo} className="logo" alt="Vite logo" /> </a> <a href="https://react.dev" target="_blank"> <img src={reactLogo} className="logo react" alt="React logo" /> </a> </div> <h1>Vite + React</h1> <Suspense fallback='Loading...'> <Card /> </Suspense> <p className="read-the-docs"> Click on the Vite and React logos to learn more </p> </> ) } export default App
스트리밍을 활성화하려면 개발 및 프로덕션 설정 모두 일관된 HTML 렌더링 프로세스를 지원해야 합니다. 두 환경 모두 프로세스가 동일하므로 재사용 가능한 단일 기능을 생성하여 스트리밍 콘텐츠를 효과적으로 처리할 수 있습니다.
// ./server/constants.ts export const ABORT_DELAY = 5000
streamContent 함수는 렌더링 프로세스를 시작하고, HTML의 증분 청크를 응답에 쓰고, 적절한 오류 처리를 보장합니다.
// ./server/streamContent.ts import { Transform } from 'node:stream' import { Request, Response, NextFunction } from 'express' import { ABORT_DELAY, HTML_KEY } from './constants' import type { render } from '../src/entry-server' export type StreamContentArgs = { render: typeof render html: string req: Request res: Response next: NextFunction } export function streamContent({ render, html, res }: StreamContentArgs) { let renderFailed = false // Initiates the streaming process by calling the render function const { pipe, abort } = render({ // Handles errors that occur before the shell is ready onShellError() { res.status(500).set({ 'Content-Type': 'text/html' }).send('<pre class="brush:php;toolbar:false">Something went wrong') }, // Called when the shell (initial HTML) is ready for streaming onShellReady() { res.status(renderFailed ? 500 : 200).set({ 'Content-Type': 'text/html' }) // Split the HTML into two parts using the placeholder const [htmlStart, htmlEnd] = html.split(HTML_KEY) // Write the starting part of the HTML to the response res.write(htmlStart) // Create a transform stream to handle the chunks of HTML from the renderer const transformStream = new Transform({ transform(chunk, encoding, callback) { // Write each chunk to the response res.write(chunk, encoding) callback() }, }) // When the streaming is finished, write the closing part of the HTML transformStream.on('finish', () => { res.end(htmlEnd) }) // Pipe the render output through the transform stream pipe(transformStream) }, onError(error) { // Logs errors encountered during rendering renderFailed = true console.error((error as Error).stack) }, }) // Abort the rendering process after a delay to avoid hanging requests setTimeout(abort, ABORT_DELAY) }
// ./server/dev.ts import { Application } from 'express' import fs from 'fs' import path from 'path' import { StreamContentArgs } from './streamContent' const HTML_PATH = path.resolve(process.cwd(), 'index.html') const ENTRY_SERVER_PATH = path.resolve(process.cwd(), 'src/entry-server.tsx') // Add to args the streamContent callback export async function setupDev(app: Application, streamContent: (args: StreamContentArgs) => void) { const vite = await ( await import('vite') ).createServer({ root: process.cwd(), server: { middlewareMode: true }, appType: 'custom', }) app.use(vite.middlewares) app.get('*', async (req, res, next) => { try { let html = fs.readFileSync(HTML_PATH, 'utf-8') html = await vite.transformIndexHtml(req.originalUrl, html) const { render } = await vite.ssrLoadModule(ENTRY_SERVER_PATH) // Use the same callback for production and development process streamContent({ render, html, req, res, next }) } catch (e) { vite.ssrFixStacktrace(e as Error) console.error((e as Error).stack) next(e) } }) }
// ./server/prod.ts import { Application } from 'express' import fs from 'fs' import path from 'path' import compression from 'compression' import sirv from 'sirv' import { StreamContentArgs } from './streamContent' const CLIENT_PATH = path.resolve(process.cwd(), 'dist/client') const HTML_PATH = path.resolve(process.cwd(), 'dist/client/index.html') const ENTRY_SERVER_PATH = path.resolve(process.cwd(), 'dist/ssr/entry-server.js') // Add to Args the streamContent callback export async function setupProd(app: Application, streamContent: (args: StreamContentArgs) => void) { app.use(compression()) app.use(sirv(CLIENT_PATH, { extensions: [] })) app.get('*', async (req, res, next) => { try { const html = fs.readFileSync(HTML_PATH, 'utf-8') const { render } = await import(ENTRY_SERVER_PATH) // Use the same callback for production and development process streamContent({ render, html, req, res, next }) } catch (e) { console.error((e as Error).stack) next(e) } }) }
streamContent 함수를 각 구성에 전달합니다.
// ./server/app.ts import express from 'express' import { PROD, APP_PORT } from './constants' import { setupProd } from './prod' import { setupDev } from './dev' import { streamContent } from './streamContent' export async function createServer() { const app = express() if (PROD) { await setupProd(app, streamContent) } else { await setupDev(app, streamContent) } app.listen(APP_PORT, () => { console.log(`http://localhost:${APP_PORT}`) }) } createServer()
이러한 변경 사항을 구현한 후 서버는 다음을 수행합니다.
HTML을 클라이언트에 보내기 전에 서버에서 생성된 HTML을 완전히 제어할 수 있습니다. 이를 통해 필요에 따라 태그, 스타일, 링크 또는 기타 요소를 추가하여 구조를 동적으로 수정할 수 있습니다.
특히 강력한 기술 중 하나는