기본적으로 리액트는 브라우저 JS 환경에서 렌더링할 수 있는 방법을 제공하는 프론트엔드 라이브러리이다. 하지만 리액트 애플리케이션을 서버에서 렌더링할 수 있는 API 또한 제공하고 있다. 해당 API는 당연히 Node.js와 같은 서버 환경에서만 실행할 수 있다.
현재 리액트 18이 릴리스되며 react-dom/server에 renderTopipeableStream이 추가됐고, 나머지는 대부분 지원 중단되는 등 큰 변화가 있다. 이 내용은 추후에 다루기로 하고 기존의 함수들에 대해 알아보자.
renderToString
renderToString은 인수로 넘겨받은 리액트 컴포넌트를 렌더링해 HTML 문자열로 반환하는 함수이다.
SSR을 구현하는 데 가장 기초적인 API이다.
import React, { useEffect } from "react";
function ChildrenComponent({ fruits }: { fruits: string[] }) {
useEffect(() => {
console.log(fruits);
}, [fruits]);
function handleClick() {
console.log("hello");
}
return (
<ul>
{fruits.map((fruit) => (
<li key={fruit} onClick={handleClick}>
{fruit}
</li>
))}
</ul>
);
}
const MyComponent = () => {
return (
<>
<div>hello</div>
<ChildrenComponent
fruits={["apple", "banana", "peach"]}
></ChildrenComponent>
</>
);
};
const result = ReactDOMServer.renderToString(
React.createElement("div", { id: "root" }, <MyComponent />)
);
위의 result는 다음과 같은 문자열을 반환한다.
<div id = "root" data-reactroot="">
<div>hello</div>
<ul>
<li>apple</li>
<li>banana</li>
<li>peach</li>
</ul>
</div>
하지만 ChildrenComponent에 있는 useEffect와 같은 훅과 handleClick과 같은 이벤트 핸들러는 결과물에 포함되지 않는다. 이는 의도된 것으로, renderToString은 인수로 주어진 리액트 컴포넌트를 기준으로 빠르게 브라우저가 렌더링할 수 있는 HTML을 제공하는 데 목적이 있는 함수일 뿐이다.
renderToString을 사용하면 완성된 HTML을 서버에서 제공할 수 있기에 초기 렌더링에 좋은 성능을 보일 것이다.
또한 메타 정보도 renderToString에서 미리 준비한 채로 제공할 수 있다.
필요한 JS 코드는 생성된 HTML과는 별도로 제공해 브라우저에 제공해야 한다.
사용자와 인터랙션할 준비가 되기 위해서는 이와 관련된 별도의 JS 코드를 모두 다운로드, 파싱, 실행하는 과정을 거쳐야 한다.
또한 div#root에 존재하는 data-reactroot 속성을 주목해야 한다. 해당 속성은 리액트 컴포넌트의 루트 엘리먼트가 무엇인지 식별하게 해준다. 이 속성으로 이후 JS를 실행하기 위한 hydrate 함수에서 루트를 식별할 수 있다.
renderToStaticMarkup
renderToStaticMarkup은 renderToString과 매우 유사한 함수이다. 하지만 data-reactroot와 같은 리액트에서만 사용하는 추가적인 DOM 속성을 만들지 않는것이 차이점이다.
리액트에서만 사용하는 속성을 제거하면 결과물인 HTML의 크기를 아주 약간이라도 줄일 수 있다는 장점이 있다.
// 위와 동일
const result = ReactDOMServer.renderToStaticMarkup(
React.createElement('div',{id:'root'},<SampleComponent/>,
)
<div>
<div>hello</div>
<ul>
<li>apple</li>
<li>banana</li>
<li>peach</li>
</ul>
</div>
이 함수를 실행한 결과로 렌더링을 수행하면 클라이언트에서는 리액트에서 제공하는 useEffect와 같은 API를 절대로 실행할 수 없다.
이 문제의 원인은 renderToStaticMarkup 함수는 순수한 HTML만 반환하기 때문이다. hydrate를 수행하면 서버와 클라이언트의 내용이 맞지 않다는 에러가 발생한다.
data-reactroot가 없으니 hydrate하는 과정에서 에러가 발생한다.
renderToStaticMarkup은 리엑트의 이벤트 리스너가 필요없는 완전히 순수한 HTML을 만들 때만 사용하면 된다.
renderToNodeStream
이 함수 역시 renderToString과 결과물이 동일하지만 두 가지 차이점이 있다.
- renderToString과 renderToStaticMarkup과는 달리 브라우저에서 사용하는 것이 완전히 불가능하다. 오직 Node.js 환경에 의존한다.
- 결과 반환형이 utf-8 인코딩된 바이트 스트림이며 string을 얻기 위해서는 추가적인 처리가 필요하다.
해당 함수의 필요성을 예시를 통해 알아보자.
유튜브와 같이 동영상을 제공하는 서비스는 영상을 시청하기 위해 영상 전체가 다운로드될 때까지 기다리지 않고 일부분을 먼저 다운로드 하고 그 부분을 보여주며 나머지를 다운로드 한다.
스트림
큰 데이터를 다룰 때 데이터를 청크(chunk, 작은 단위)로 분할해 조금씩 가져오는 방식을 의미
이 처럼 HTML의 크기가 매우 크면 스트림을 활용해 청크 단위로 분리해 순차적으로 처리할 수 있다는 장점이 있다. 대부분 알려진 리액트 SSR 프레임워크는 모두 renderToNodeStream을 채택하고 있다.
renderToStaticNodeStream
renderToNodeStream과 제공하는 결과물은 동일하나 리액트 JS에 필요한 리액트 속성을 제공하지 않는다.
hydrate할 필요가 없는 순수 HTML 결과물이 필요할 때 사용하는 메서드.
hydrate
앞서 renderToString, renderToNodeStream으로 생성된 HTML 콘텐츠에 JS 핸들러나 이벤트를 붙이는 역활을 한다.
hydrate를 보기전에 이와 비슷한 render 함수를 살펴보자.
import * as ReactDOM from 'react-dom'
import App from './App'
const rootElement = document.getElementById('root')
ReactDOM.render(<App/>,rootElement)
render함수는 컴포넌트와 HTML의 요소를 인수로 받으며 HTML의 요소에 컴포넌트를 렌더링하며 이벤트 핸들러를 붙이는 작업까지 한번에 수행한다.
hydrate는 render와 인수를 넘기는 것이 거의 유사하다.
import * as ReactDOM from 'react-dom'
import App from './App'
//containerId는 서버에서 렌더링된 HTML의 특정 위치
const element = document.getElementById(containerId)
ReactDOM.hydrate(<App/>,element)
render와의 차이점은 기본적으로 이미 렌더링된 HTML이 있다는 가정하에 작업이 수행되고, 이벤트를 붙이는 작업만 실행한다는 점이다. 따라서 hydrate의 두 번째 인수로 리액트 관련 정보가 없는 순수한 HTML 정보를 넘겨주면 경고가 발생한다.
import * as ReactDOM from 'react-dom'
import App from './App'
const rootElement = document.getElementById('root')
ReactDOM.hydrate(<App/>,rootElement)
//CRA에서 root안에는 아무런 HTML이 없다.
경고가 발생하지는 않지만 hydrate가 수행한 렌더링 결과물 HTML과 넘겨받은 HTML을 비교하여 다시 렌더링을 해주기 때문에 에러가 발생하진 않는다.
물론 이런 방법은 두 번 렌더링하며 SSR의 장점을 포기하는 것이기 때문에 좋은 방법은 아니다.
서버 사이드 렌더링 예제 프로젝트
간단한 리액트 SSR 예제 애플리케이션을 만들어 보자.
https://github.com/wikibook/react-deep-dive-example/tree/main/chapter4/ssr-example
react-deep-dive-example/chapter4/ssr-example at main · wikibook/react-deep-dive-example
《모던 리액트 Deep Dive》 예제 코드. Contribute to wikibook/react-deep-dive-example development by creating an account on GitHub.
github.com
리액트 팀에서도 실제 프로젝트를 진행하며 SSR을 적용하는 경우 Next.js와 같은 프레임워크를 사용하는 것을 권장한다. 아래의 예제는 SSR의 개념을 학습하기 위하는 것을 생각하며 진행한다.
이 예제는 특정 /api에서 할 일 목록을 가져오고, 각 할 일을 클릭하여 useState로 완료 여부를 변경할 수 있는 간단한 구조를 설계할 것이다.
index.tsx
import React from 'react'
import { hydrate } from 'react-dom'
import App from './components/App'
import { fetchTodo } from './fetch'
async function main() {
const result = await fetchTodo()
const app = <App todos={result} />
const el = document.getElementById('root')
hydrate(app, el)
}
main()
이 파일은 목적은 서버로부터 받은 HTML을 hydrate를 통해 완성된 웹 애플리케이션으로 만든다. 중요하게 봐야할 점은 fetchTodo를 호출하여 필요한 데이터를 주입한다는 점이다.
App.tsx
일반적으로 사용자가 만드는 리액트 애플리케이션의 시작점이다. todos는 서버에서 받아 props로 전달된다는 점을 알아두자.
위의 Index.tsx에서 보다시피 넘겨주는 것을 확인할 수 있다.
import React, { useEffect } from 'react'
import { TodoResponse } from '../fetch'
import { Todo } from './Todo'
export default function App({ todos }: { todos: Array<TodoResponse> }) {
useEffect(() => {
console.log('하이!') // eslint-disable-line no-console
}, [])
return (
<>
<h1>나의 할일!</h1>
<ul>
{todos.map((todo, index) => (
<Todo key={index} todo={todo} />
))}
</ul>
</>
)
}
Todo.tsx
todo를 받아서 렌더링 하는 역활을 해준다.
import React, { useState } from 'react'
import { TodoResponse } from '../fetch'
export function Todo({ todo }: { todo: TodoResponse }) {
const { title, completed, userId, id } = todo
const [finished, setFinished] = useState(completed)
function handleClick() {
setFinished((prev) => !prev)
}
return (
<li>
<span>
{userId}-{id}) {title} {finished ? '완료' : '미완료'}
<button onClick={handleClick}>토글</button>
</span>
</li>
)
}
index.html
서버 사이드 렌더링을 수행하는 경우 기본이 되는 HTML 템플릿이다.
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>SSR Example</title>
</head>
<body>
__placeholder__
<script src="https://unpkg.com/react@17.0.2/umd/react.development.js"></script>
<script src="https://unpkg.com/react-dom@17.0.2/umd/react-dom.development.js"></script>
<script src="/browser.js"></script>
</body>
</html>
__placeholder__ : 서버에서 리액트 컴포넌트를 기반으로 만든 HTML 코드를 사입하는 자리이다. 단순히 이 부분을 결과물로 대체하여 리액트에서 만든 HTML을 삽입한다.
unpkg : npm 라이브러리를 CDN( Content Delivery Network )으로 제공하는 웹 서비스이다. 원래 일반적인 프래임워크 라면 클라이언트에서 필요한 react와 react-dom을 웹팩과 같은 도구로 번들링해 제공하는 것이 일반적이다. 하지만 예제는 목적에 집중하기 위해서 간단하게 처리했다.
browser.js : 리액트 애플리케이션 코드를 번들링 했을때 제공되는 리액트 JS 코드
server.js
서버에서 사용자의 요청 주소에 따라 어떠한 리소스를 내려줄지 결정하는 역할을 한다.
import { createServer, IncomingMessage, ServerResponse } from 'http'
import { createReadStream } from 'fs'
import { renderToNodeStream, renderToString } from 'react-dom/server'
import { createElement } from 'react'
import html from '../public/index.html'
import indexFront from '../public/index-front.html'
import indexEnd from '../public/index-end.html'
import App from './components/App'
import { fetchTodo } from './fetch'
const PORT = process.env.PORT || 3000
async function serverHandler(req: IncomingMessage, res: ServerResponse) {
const { url } = req
switch (url) {
case '/': {
const result = await fetchTodo()
const rootElement = createElement(
'div',
{ id: 'root' },
createElement(App, { todos: result }),
)
const renderResult = renderToString(rootElement)
const htmlResult = html.replace('__placeholder__', renderResult)
res.setHeader('Content-Type', 'text/html')
res.write(htmlResult)
res.end()
return
}
case '/stream': {
res.setHeader('Content-Type', 'text/html')
res.write(indexFront)
const result = await fetchTodo()
const rootElement = createElement(
'div',
{ id: 'root' },
createElement(App, { todos: result }),
)
const stream = renderToNodeStream(rootElement)
stream.pipe(res, { end: false })
stream.on('end', () => {
res.write(indexEnd)
res.end()
})
return
}
case '/browser.js': {
res.setHeader('Content-Type', 'application/javascript')
createReadStream(`./dist/browser.js`).pipe(res)
return
}
case '/browser.js.map': {
res.setHeader('Content-Type', 'application/javascript')
createReadStream(`./dist/browser.js.map`).pipe(res)
return
}
default: {
res.statusCode = 404
res.end('404 Not Found')
}
}
}
function main() {
createServer(serverHandler).listen(PORT, () => {
console.log(`Server has been started ${PORT}...`) // eslint-disable-line no-console
})
}
main()
코드를 하나씩 살펴보자.
createServer
function main() {
/* createServer : http 모듈을 이용해 간단한 서버를 만들 수 있는 Node.js 기본 라이브러리 */
createServer(serverHandler).listen(PORT, () => {
console.log(`Server has been started ${PORT}...`) // eslint-disable-line no-console
})
}
serverHandler
// createServer로 넘겨주는 인수로 HTTP 서버가 라우트별로 어떻게 작동할지를 정의
async function serverHandler(req: IncomingMessage, res: ServerResponse) {
const { url } = req
switch (url) {
// ... 생략
server.ts의 루트 라우터
case '/': {
const result = await fetchTodo()
const rootElement = createElement(
'div',
{ id: 'root' },
createElement(App, { todos: result }),
)
const renderResult = renderToString(rootElement)
// __placeholder__ 를 대체하여 renderToString의 결과를 넣는다.
// 이 결과는 온전히 서버에서만 만들어진 페이지가 된다.
const htmlResult = html.replace('__placeholder__', renderResult)
res.setHeader('Content-Type', 'text/html')
res.write(htmlResult)
res.end()
return
}
결과물
server.ts의 /stream 라우터
// 루트 라우터와 rootElement를 만드는 과정까지는 동일하다.
case '/stream': {
res.setHeader('Content-Type', 'text/html')
res.write(indexFront)
const result = await fetchTodo()
const rootElement = createElement(
'div',
{ id: 'root' },
createElement(App, { todos: result }),
)
const stream = renderToNodeStream(rootElement)
stream.pipe(res, { end: false })
stream.on('end', () => {
res.write(indexEnd)
res.end()
})
return
}
코드에서 res.write (indexFront), res.write(indexEnd)로 분리되어 있고, 그 사이에 renderNodeStream이 있는 것을 확인할 수 있다.
<!-- index-front.html -->
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>SSR Example</title>
</head>
<body>
<!-- index-end -->
<script src="https://unpkg.com/react@17.0.2/umd/react.development.js"></script>
<script src="https://unpkg.com/react-dom@17.0.2/umd/react-dom.development.js"></script>
<script src="/browser.js"></script>
</body>
</html>
indexFront와 indexEnd는 index.html의 __placeholder__ 부분을 반으로 나눈 코드이다.
index.html의 앞 절반을 우선 응답으로 기록하고 나머지 부분은 청크 단위로 스트림을 생성하며, 청크가 생성될 때마다 res에 기록하며 브라우저에게 최종 결과물을 보여줄 수 있다.
/stream으로 접속해도 renderToString과 같은 결과물을 확인할 수 있다.
어디까지나 renderNodeStream과 renderToString의 차이는 서버에서만 존재한다는 것을 염두하자.
그 밖의 라우터
case '/browser.js': {
res.setHeader('Content-Type', 'application/javascript')
createReadStream(`./dist/browser.js`).pipe(res)
return
}
case '/browser.js.map': {
res.setHeader('Content-Type', 'application/javascript')
createReadStream(`./dist/browser.js.map`).pipe(res)
return
}
애플리케이션에서 작성한 리액트 및 관련 코드를 제공하며 웹팩이 생성된다.
browser.js.map은 browser.js와 관련된 소스맵 파일로서 디버깅 용도로 사용된다.
webpack.config.js
// @ts-check
/** @typedef {import('webpack').Configuration} WebpackConfig **/
const path = require('path')
const nodeExternals = require('webpack-node-externals')
/** @type WebpackConfig[] */
const configs = [
// 리액트 파일 설정
{
// entry를 설정
entry: {
browser: './src/index.tsx',
},
// 결과물이 저장되는 위치
output: {
path: path.join(__dirname, '/dist'),
filename: '[name].js',
},
// 번들링에 포함해야 하는 파일들 설정
resolve: {
extensions: ['.ts', '.tsx'],
},
devtool: 'source-map',
// ts파일을 읽기위해 loader 추가
module: {
rules: [
{
test: /\.tsx?$/,
loader: 'ts-loader',
},
],
},
// react , react-dom 은 외부 CDN을 사용하기 위해 제외
externals: {
react: 'React',
'react-dom': 'ReactDOM',
},
},
// 서버 파일 설정
{
entry: {
server: './src/server.ts',
},
output: {
path: path.join(__dirname, '/dist'),
filename: '[name].js',
},
resolve: {
extensions: ['.ts', '.tsx'],
},
devtool: 'source-map',
module: {
rules: [
{
test: /\.tsx?$/,
loader: 'ts-loader',
},
{
test: /\.html$/,
use: 'raw-loader',
},
],
},
target: 'node',
externals: [nodeExternals()],
},
]
module.exports = configs
CRA를 사용하면 webpack.config.js를 볼 수 없는 이유는 리액트에서 막아두었기 때문이다.
webpack.config.js를 확인하고 싶다면 터미널에서 eject를 실행하면 볼 수 있다.
정리
서버 사이드 렌더링은 사용자에게 더 빠른 웹 페이지 결과물을 제공할 수 있다는 장점이 있다.
하지만 서버라는 존재 자체가 개발자에게 부담이 된다. 서버에서 HTML을 제공하는 것 뿐만 아니라 번들링된 JS 소스도 제공해야 하며, 적절한 캐시도 사용해야하는 것처럼 여러가지 복잡한 부분이다.
리액트 18이 도입되며 suspense, concurrent, ServerComponent 등 새로운 개념이 도입되며 서버에서 렌더링 하는 것이 더욱 복잡해졌기에 저자는 SSR 자체만으로 개발자에게는 큰 도전이라는 말을 하고 있다.
이번 학습을 통해 개념만으로 알고있던 SSR에 대해 실제 사용 예시를 만들며 깊게 학습하며 더욱 확실하게 이해되는 시간이였다.
'React > 모던 리액트 Deep Dive' 카테고리의 다른 글
[React Deep Dive] 리액트와 상태 관리 라이브러리 (1) | 2025.06.10 |
---|---|
[React Deep Dive] Next.js (1) | 2025.06.06 |
서버 사이드 렌더링 (1) | 2025.04.30 |
사용자 정의 훅과 고차 컴포넌트 (0) | 2025.04.28 |
useContext와 useReducer, useImperativeHandler, useLayoutEffect, useDebugValue (0) | 2025.04.22 |