기본적으로 리액트는 프론트엔드 라이브러리이다. 하지만 리액트 애플리케이션을 서버에서 렌더링 할 수 있게 해주는 API 또한 제공하고 있다. 당연히 해당 API는 window환경이 아닌 Node.js와 같은 서버 환경에서만 실행할 수 있다.
현재 리액트 18이 릴리스되며 react-dom/server 에 renderToPipeableStream이 추가되었고 나머지는 대부분 지원중단 되는등 큰 변화가 있었다. 이 내용들도 따로 다루기로 하고 기존에 있던 함수들에 대해 알아보자.
renderToString
인수로 넘겨받은 리액트 컴포넌트를 렌더링해 HTML문자열로 반환하는 함수
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 />)
);
console.log(result);
위 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을 제공하는것이 목적이기 때문이다.
필요한 JS코드들은 생성된 HTML과는 별도로 제공해줘야 한다. 또한 div#root에 존재하는 data-reactroot 속성을 주목해야 한다. 해당 속성은 리액트 컴포넌트의 루트 엘리먼트가 무엇인지 식별하게 해준다. 이 속성으로 인해 이후 JS를 실행하기 위한 hydrate함수에서 루트를 식별할 수 있다.
renderToStaticMarkup
renderToString과 매우 유사하다. 단 해당 함수는 data-reactroot와 같은 리액트에서만 사용하는 추가적인 DOM 속성을 만들지 않는다.
// 위와 동일
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>
renderToNodeStream
이 함수 역시 renderToString과 결과물이 완전히 동일하다. 하지만 두가지 차이점이 존재한다.
1. renderToString 과 renderToStaticMarkup은 브라우저에서도 실행은 가능하지만 해당 함수는 아예 실행 자체가 불가능하다.
즉 renderToNodeStream은 Node.js환경에 의존하고 있다.
2. 결과 반환형이 utf-8 인코딩된 바이트 스트림이며 string을 얻기 위해서는 추가적인 처리가 필요하다.
해당함수가 왜필요한지는 아래의 예시를 생각하면 받아들일 수 있다.
유튜브와 같은 동영상 제공 서비스는 영상을 보기위해서 전체 영상을 모두 다운로드할때까지 기다리지 않고 사용자가 볼 수 있는 일부라도 먼저 다운로드되면 해당 부분을 먼저 보여준다.
스트림
큰 데이터를 다루는 경우 데이터를 청크 단위로 분리하여 가져오는 방식
마찬가지로 HTML이 매우 큰 경우 스트림을 활용하여 서버의 부담을 줄여줄 수 있다. 대부분 알려진 리액트 서버 사이드 렌더링 프레임워크는 모두 renderToNodeStream을 채택하고 있다.
renderToStaticNodeStream
renderToStaticMarkup처럼 renderToNodeStream과 결과물은 동일하지만 리액트 자바스크립트에 필요한 리액트 속성을 제공하지 않는다.
hydrate
앞서 생성된 renderToString , renderToNodeStream으로 생성된 HTML 콘텐츠에 JS 핸들러나 이벤트를 붙이는 역할을 한다.
hydrate를 보기전에 이와 비슷한 reder함수를 살펴보자.
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이 있다고 가정을 한 이후 이벤트를 붙이는 작업만 실행한다. 따라서 element에 리액트 관련 정보가 없는 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을 비교하여 다시 렌더링을 해주기 때문에 에러가 발생하진 않는다.
물론 이런 방법은 두번 렌더링을 하는, 서버 사이드 렌더링의 장점을 포기하는 방법이기 때문에 옳은 방법은 아니다.
서버 사이드 렌더링 예제 프로젝트
간단하게 리액트 서버 사이드 렌더링
예제 애플리케이션을 만들어 보자.
https://github.com/wikibook/react-deep-dive-example/tree/main/chapter4/ssr-example
당연히 서버 사이드 렌더링의 개념을 알기위한 프로젝트 이므로 실제 프로젝트를 진행하는 경우에는 Next.js와 같은 프레임워크를 사용하는 것을 리액트 팀에서도 권장하고 있다고 한다.
이 예제는 특정 /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는 서버에서 받아와 받아온다는 점을 알아두자.
위 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 : 리액트 애플리케이션 코드를 번들링 했을때 제공되는 리액트 자바스크립트 코드
server.ts
서버에서는 사용자의 요청 주소에 따라 어떠한 리소스를 내려줄지 결정하는 역할을 해준다.
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
}
페이지가 온전히 서버에서 만들어졌기 때문에 소스탭에서 보면 HTML이 잘 나오는 것을 확인할 수 있다.
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>
index-front 와 index-end는 위와같이 body를 기준으로 잘려있다. 따라서 index.html의 앞선 절반을 기록한 후 청크단위로 생성하며 브라우저에게 보여줄 수 있다. 그렇기에 당연히 결과물은 똑같다.
두 방식의 차이점은 서버에서만 존재하지만 페이지를 만드는 순서대로 제공하기 때문에 더욱 효율적이다.
그 밖의 라우터
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파일은 디버깅 용도로 사용되나 본 예제에서는 사용하지 않았다!!
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를 볼 수는 없다. 리액트에서 이를 막아두기 때문
이를 보고 싶다면 eject를 실행해서 볼 수 있다.
서버 사이드 렌더링은 분명 장점이 있다. 사용자에게 더 빠른 웹페이지 결과물을 제공할 수 있다는 장점말이다.
하지만 그 이면에는 서버라는 존재가 있는데 이 서버는 개발자에게 큰 부담이 된다. 서버에서 HTML 뿐 아니라 번들링된 JS소스또한 이썽야 한다.
리액트 18이 도입되면서 suspense,concurrent,ServerComponent등 새로운 개념이 추가되며 서버 사이드 렌더링을 시도하는 것만으로 큰 도전이 될 것이라고 저자는 말한다.
원래 항상 프레임워크를 단순히 "이용"만 하고 SSR , CRA 등 개념을 알고만 있었는데 이렇게 원리를 파고드니 더욱 쉽게 이해가 된 것 같다.
'FrontEnd > Deep Dive' 카테고리의 다른 글
[React] Deep Dive 모던 리액트(15) 리액트와 상태관리 라이브러리 역사 (0) | 2024.01.02 |
---|---|
[React] Deep Dive 모던 리액트(14) Next.js (0) | 2023.12.29 |
[React] Deep Dive 모던 리액트(12) 서버사이드 렌더링(SSR) (0) | 2023.12.26 |
[React] Deep Dive 모던 리액트(11) 사용자 정의 훅 & 고차 컴포넌트 (0) | 2023.12.24 |
[React] Deep Dive 모던 리액트(10) useContext,useReducer,기타 훅들 (1) | 2023.12.23 |