시작하기에 앞서 이 실습은 https://ui.dev/react-router-server-rendering에 작성된 글을 따라 연습한 것이다. 더 자세히 알아보고 싶다면 링크를 들어가서 알아볼 것.
github api를 사용해서 각 언어마다 인기있는 레퍼지토리의 목록을 보여주는 사이트를 만들어보자.
완성된 모습은 아래와 같다.
browserConfig
와 serverConfig
를 따로 나눠서 설정을 한다. const path = require("path");
const webpack = require("webpack");
const nodeExternals = require("webpack-node-externals");
const MiniCssExtractPlugin = require("mini-css-extract-plugin");
const browserConfig = {
mode: "production",
entry: "./src/browser/index.js",
output: {
path: path.resolve(__dirname, "dist"),
filename: "bundle.js",
},
module: {
rules: [
{ test: /\.(js)$/, use: "babel-loader" },
{ test: /\.css$/, use: ["css-loader"] },
],
},
plugins: [
new webpack.DefinePlugin({
__isBrowser__: "true",
}),
],
};
const serverConfig = {
mode: "production",
entry: "./src/server/index.js",
target: "node",
externals: [nodeExternals()],
output: {
path: path.resolve(__dirname, "dist"),
filename: "server.js",
},
module: {
rules: [
{ test: /\.(js)$/, use: "babel-loader" },
{
test: /\.css$/,
use: [MiniCssExtractPlugin.loader, "css-loader"],
},
],
},
plugins: [
new MiniCssExtractPlugin(),
new webpack.DefinePlugin({
__isBrowser__: "false",
}),
],
};
module.exports = [browserConfig, serverConfig];
browserConfig
는 /src/browser/index.js
를 babel-loader
와ccs-loader
를 통해서 실행하고 그 결과물은 /dist/bundle.js
에 저장된다.
또한 DefinePlugin
을 사용해서 __isBrowser__
속성을 추가하여 언제 브라우저 안에 들어왔는지를 알수 있게 된다.
serverConfig
는 비슷하지만 src/server/indes.js
의 파일을 실행하며 결과물은 /dist/server.js
에 저장된다.
externals
은 node_modules
가 결과 내용에 포함되지 않게 해준다. 외부 모듈의 의존성없이 자신이 작성한 것만 번들링을 하기 위해서이다.
target
은 externals
가 어떤 모듈을 무시해도 되는지를 알려준다.
MiniCssExtractPlugin
은 모든 css를 하나의 파일로 만들어서 마치 main.css처럼 결과물을 만들고 dist
폴더 안에 저장된다.
따라서 클라이언트 코드는 dist/bundle.js
에, 서버 코드는 dist/server.js
에 저장된다.
{
"name": "react-router-server-rendering",
"description": "Server rendering with React Router.",
"scripts": {
"build": "webpack",
"start": "node dist/server.js",
"dev": "webpack && node dist/server.js"
},
"babel": {
"presets": ["@babel/preset-env", "@babel/preset-react"],
"plugins": ["@babel/plugin-proposal-object-rest-spread"]
},
"devDependencies": {
"@babel/core": "^7.14.6",
"@babel/plugin-proposal-object-rest-spread": "^7.14.7",
"@babel/preset-env": "^7.14.7",
"@babel/preset-react": "^7.14.5",
"babel-loader": "^8.2.2",
"css-loader": "^5.2.6",
"mini-css-extract-plugin": "^2.0.0",
"webpack": "^5.42.0",
"webpack-cli": "^4.7.2",
"webpack-node-externals": "^3.0.0"
},
"dependencies": {
"cors": "^2.8.5",
"express": "^4.17.1",
"history": "^5.0.0",
"isomorphic-fetch": "^3.0.0",
"react": "^17.0.2",
"react-dom": "^17.0.2",
"react-router-dom": "^6.0.0-beta.0",
"serialize-javascript": "^6.0.0"
},
"version": "1.0.0",
"main": "index.js"
}
scripts
항목에서 dev
를 추가했다. webpack을 통해서 dist/server.js
를 번들하고 서버를 시작하게 된다.
서버사이드 렌더링에서 필요한 것은 3가지.
리액트 컴포넌트
리액트 앱을 html 구조로 랩핑한후 결과물을 보여주는 서버
서버에서 렌더링한 결과를 리액트가 가져오고 필요한 곳에 이벤트 리스너를 추가하는 방법
import React from 'react'
const App = () => {
return (
<div>
안녕하세요!
</div>
)
}
export default App
일단 간단하게만 만들어 두고 2로 넘어가자.
src/server/index.js
경로로 index 파일을 만들어서 코드를 작성한다. 이때 express를 사용할 것이다. express는 http통신 요청, view의 렌더링 엔진과 결합, 접속을 위한 포트나 응답 렌더링을 위한 템플릿 위치같은 공통 웹 어플리케이션 세팅,핸들링 파이프라인 중 필요한 곳에 추가적인 미들웨어 처리 요청을 추가 하는 기능을 제공한다.import express from "express";
import cors from "cors";
const app = express();
app.use(cors());
app.use(express.static("dist"));
const PORT = process.env.PORT || 3000;
app.listen(PORT, () => {
console.log(`${PORT}번 포트에 연결 됐어! 확인해봐!`);
});
간단하다 여기서 눈여겨 볼 것은 dist
폴더에 저장된 파일을 배포한다는 뜻이다 dist
는 웹팩이 최종적으로 번들한 코드를 저장하는 곳이다.
여기서 멈추지 않고 서버가 get requrest를 받을 때 App 컴포넌트의 내용과 함께 html 구조를 브라우저로 전송하게 만들고자 한다면 renderToString
을 사용하면 된다.
import express from "express";
import cors from "cors";
import ReactDOM from "react-dom/server";
import * as React from "react";
import App from "../shared/App";
const app = express();
app.use(cors());
app.use(express.static("dist"));
app.get("*", (req, res, next) => {
const markup = ReactDOM.renderToString(<App />);
res.send(`
<!DOCTYPE html>
<html>
<head>
<title>SSR with React Router</title>
</head>
<body>
<div id="app">${markup}</div>
</body>
</html>
`);
});
const PORT = process.env.PORT || 3000;
app.listen(PORT, () => {
console.log(`${PORT}번 포트에 연결 됐어! 확인해봐!`);
});
markup 이라는 변수를 선언 해서 App 컴포넌트를 렌더링한 내용을 html 구조안추가해서 전송한다.
그럼 이제 3번째인 서버에서 렌더링한 결과를 리액트가 가져오고 필요한 곳에 이벤트 리스너를 추가하는 방법만 추가하는 방법인데 이것은 ReactDOM.render
사용해서 컴포넌트와 DOM노드를 전달하면 된다. 평상시에는
ReactDOM.render(
<App />,
document.getElementById('app)
)
이렇게 했겠지만 서버사이드 렌더링의 경우는 조금 다르게 작성한다.
ReactDOM.hydrate(
<App />,
document.getElementById('app)
)
hydrate
의 경우에는 리액트에게 '우린 이미 렌더링한 결과물이 존재하고 니가 이걸 다시 만드는 대신에 이걸 보존했으면 좋겠다' 라고 얘기하는 것과 같다.
그럼 이제 index.js를 만들어서 렌더링 결과물을 브라우저에 나타나게 해보자.
src/browser/index.js
import * as React from "react";
import ReactDOM from "react-dom";
import App from "../shared/App";
ReactDOM.hydrate(<App />, document.getElementById("app"));
이제 터미널에서 npm run dev 혹은 yarn dev라고 입력한뒤 결과를 살펴보면.
제대로 App 컴포넌트가 렌더링되어 출력된 모습을 볼수 있다. 내용을 추가해서 어떤 방식으로 작동되는지 확인해보자. 안녕하세요! 에서 그치지 않고, 안녕하세요! {props.name}님! 으로 작성한다.
import React from 'react'
const App = (props) => {
return (
<div>
안녕하세요! {props.name}님!
</div>
)
}
export default App
이제 name의 값을 넣어줘야 하는데 그 값을 추가할수 있는 장소는 2군데, browser/index와 server/index가 있다.
한번 이 두군데 모두 name의 값을 넣어보자.
/browser/index.js
ReactDOM.hydrate(<App name="제임스"/>, document.getElementById("app"));
/server/index.js
const markup = ReactDOM.renderToString(<App name="제임스" />);
이제 다시 실행해보면
이렇게 name 값이 함께 출력된 모습을 볼 수 있다.
그럼 만약에 browser의 index에서 가지고 있는 props 의 값을 '리처드'라고 변경하고 다시 실행하면 어떻게 될까?
/browser/index.js
ReactDOM.hydrate(<App name="리처드"/> , document.getElementById('app'));
수정한뒤 실행하고 사이트를 새로고침 해보면?
처음에는 제임스로 있다가 시간이 지나니 리처드로 변경되는 모습을 확인할수 있다. 그리고 이런 오류 메세지도 확인할 수 있다.(webpack에서 mode를 development로 설정할 경우에만 나타난다.)
따라서 서버와 클라이언트가 일치하는 데이터를 받게끔 해야하는데 옛날 방법으로는 (window)를 사용해서 클라이언트가 앱에서 사용될때 그것을 인용하게끔 하는 것이다.
server/index.js
import serialize from 'serialize-javascript';
const app = express();
app.use(cors());
app.use(express.static("dist"));
app.get("*", (req, res, next) => {
const name ="제임스"
const markup = ReactDOM.renderToString(<App name={name} />);
res.send(`
<!DOCTYPE html>
<html>
<head>
<title>SSR with React Router</title>
<script src="/bundle.js" defer></script>
<link href="/main.css" rel="stylesheet">
<script>
window.__INITIAL_DATA__ = ${serialize(name)}
</script>
</head>
<body>
<div id="app">${markup}</div>
</body>
</html>
`);
});
browser/index.js
ReactDOM.hydrate(<App name={window.__INITIAL_DATA__}/> , document.getElementById('app'));
server의 index 에서 name의 값을 window.INITIAL_DATA 에 집어넣고 browser에서 해당 변수를 name 값으로 지정하면 server에서 지정한 데이터를 browser에서도 공유할수 있게 된다.
그럼 이제 서버 사이드 렌더링에 대해 알게 됐으니 본격적으로 시작해보자.
각 프로그래밍 언어마다 가장 인기있는 리포지토리를 가져오는 Github API를 사용해서 선택 메뉴중 하나를 클릭하면 내용이 나타나게 해야한다.
shared/api.js
import fetch from 'isomorphic-fetch';
export function fetchPopularRepos(language = 'all') {
const encodedURI = encodeURI(`https://api.github.com/search/repositories?q=stars:>1+language:${language}&sort=stars&order=desc&type=Repositories`)
return fetch (encodedURI)
.then((data) => data.json())
.then((repos) => repos.items)
.catch((error) => {
console.warn(error);
return null;
});
}
GitHub API 에서 데이터를 가져오는 함수를 만들고 server에서 getdmf 통해 renderToString
을 먼저 부르는 대신에 fetchPopularRepos
를 호출해서 데이터를 받아온다.
server/index.js
import { fetchPopularRepos } from '../shared/api'
app.get("*", (req, res, next) => {
fetchPopularRepos()
.then((data) => {
const markup = ReactDOM.renderToString(
<App serverData={data} />
)
res.send(`
<!DOCTYPE html>
<html>
<head>
<title>SSR with React Router</title>
<script src="/bundle.js" defer></script>
<link href="/main.css" rel="stylesheet">
<script>
window.__INITIAL_DATA__ = ${serialize(data)}
</script>
</head>
<body>
<div id="app">${markup}</div>
</body>
</html>
`)
})
})
이렇게 하면 get requset 가 server로 전달되면 react UI 뿐만 아니라 GitHub API로부터 전달된 데이터까지 받아오게 된다.
다음에는 App 컴포넌트가 serverData prop을 사용하게끔 수정한다. 이제 안녕하세요! 라는 문구대신에 다른 컴포넌트인 Grid를 제작해서 렌더링할 것이다.
shard/App.js
import React from 'react'
import "./styles.css";
const App = ({serverData}) => {
return (
<div>
<Grid data={serverData}></Grid>
</div>
)
}
export default App
shard/Grid.js
import React from "react"
const Grid = ({data}) => {
return (
<ul className='grid'>
{data.map(({name, owner, stargazers_count, html_url}, i)=> (
<li key={name}>
<h2>#{i+1}</h2>
<h3>
<a href={html_url}>{name}</a>
</h3>
<p>
by
<a href={`https://github.com/${owner.login}`}>
@{owner.login}
</a>
</p>
<p>
{stargazers_count.toLocaleString()} stars
</p>
</li>
))}
</ul>
)
}
export default Grid
이제 app이 request를 받으면 서버는 app이 필요한 데이터와 HTML을 fetch할 것이다.
여기서 끝이 아니라 이제 라우터 작업을 진행해야한다.
리액트 라우터는 선언적이고 컴포넌트 기반 접근 방식으로 라우팅을 하지만, 서버 사이드 렌더링을 하기 때문에 이 방식 대신에 중앙 라우터 구성으로 만든다.
클라이언트와 서버 모두 동일한 경로를 공유하기 때문인데, 사용자가 특정 경로를 요청할 때 가져올 데이터를 알아야 하기 때문에 사용자가 앱과 서버를 탐색할 때 렌더링할 구성 요소를 알아야하기 때문이다
이렇게 하기 위해서 shared
폴더 내부에 routes.js
파일을 생성하여 이 파일에는 경로를 개체 배열로 나타내며, 각 개체는 새 경로를 나타낸다.
그전에 먼저 컴포넌트 몇개를 더 만들어놓고 시작하자
shared/Home.js
import * as React from 'react';
export default function Home() {
return <h2 className='heading-center'>Select a Language</h2>
}
그리고 routes.js
const routes = [
{
path: '/',
component: Home,
},
{
path:'/popular/:id',
component: Grid,
}
];
export default routes;
아까 전에 중앙 라우터 구성으로 한 이유에서 사용자가 특정 경로를 요청할때 가져올 데이터를 알아야한다고 설명했는데, 이제 이를 위해서 fetchInitialData를 추가한다.
import { fetchPopularRepos } from "./api";
const routes = [
{
path: "/",
component: Home,
},
{
path: "/popular/:id",
component: Grid,
fetchInitialData: (path = "") => fetchPopularRepos(path.split("/").pop()),
},
];
/popular/:id
에 fetchInitialData 를 추가해서 사용자가 GET
request를 진행하면 클라이언트에게 response를 전송하기 전에 fetchInitialData를 호출해야할 필요가 있다는 것을 알 수 있게 된다.
이제 서버가 request된 경로가 어느 경로인지 파악할수 있게 해야한다. 예를 들어서 사용자가 '/' 페이지를 request 했다면, 나는 경로와 함께 '/'의 path를 찾아야 한다. 리액트 라우터는 matchPath
메소드를 통해서 이런 기능을 수행한다.
server/index.js
import { matchPath } from "react-router-dom";
import routes from "../shared/routes";
const app = express();
app.use(cors());
app.use(express.static("dist"));
app.get("*", (req, res, next) => {
const activeRoute = routes.find((route) =>
matchPath(route.path, req.url)
) || {}
이제 activeRoute
가 사용자가 request한 (req.url) 페이지의 경로가 될것이다.
다음은 해당 경로가 어느 데이터를 요구하는지를 확인해야한다. activeReoute
가 fetchInitialData 속성을 가지고 있는지 확인하고, 만약 존재한다면, 현재 경로를 호출한다. 만약 그렇지 않다면 그대로 진행하게 된다.
이제 데이터를 전송할지 말지를 확인하는 promise를 가지고 있다. 이제 요청된 컴포넌트에게 serverData를 전송하고 이를 window 객체에 놓아두면 클라이언트가 집어가게된다.
promise.then((data) => {
const markup = ReactDOM.renderToString(<App serverData={data} />);
res.send(`
<!DOCTYPE html>
<html>
<head>
<title>SSR with React Router</title>
<script src="/bundle.js" defer></script>
<link href="/main.css" rel="stylesheet">
<script>
window.__INITIAL_DATA__ = ${serialize(data)}
</script>
</head>
<body>
<div id="app">${markup}</div>
</body>
</html>
`);
}).catch(next);
이제 사용자가 request한 경로를 바탕으로 서버에 올바른 데이터를 fetch 할수 있게 되었다.여기서 클라이언트 사이드 라우팅을 추가해보자.
browser/index.js
import * as React from 'react';
import ReactDOM from 'react-dom';
import App from '../shared/App';
import {BrowserRouter} from 'react-router-dom'
ReactDOM.hydrate(
<BrowserRouter>
<App/>
</BrowserRouter>,
document.getElementById('app')
);
이제 클라이언트에 대한 제어 권한을 리액트 라우터에게 넘겼기 때문에 서버에서도 동일한 작업을 수행하여 클라이언트가 일치하도록 해야한다. 서버에 있기 때문에 BrowserRouter라는 구성요소를 렌더링 하는 것은 의미가 없고, 대신 StaticRouter 구성 요소를 사용한다.
server/index.js
const markup = ReactDOM.renderToString(
<StaticRouter location={req.url}>
<App serverData={data} />
</StaticRouter>
);
클라이언트 사이드 경로를 렌더링 하기 전에 컴포넌트 몇개를 더 제작한다. Navbar
, ColorfulBorder
, NoMatch
사이트 화면 맨 위에 선택할 언어 목록을 보여주는 Navbar
import * as React from "react";
import { NavLink } from "react-router-dom";
const languages = [
{
name: "All",
param: "all",
},
{
name: "JavaScript",
param: "javascript",
},
{
name: "Ruby",
param: "ruby",
},
{
name: "Python",
param: "python",
},
{
name: "Java",
param: "java",
},
];
export default function Navbar() {
return (
<ul className="nav">
{languages.map(({ name, param }) => (
<li key={param}>
<NavLink
activeStyle={{ fontWeight: "bold" }}
to={`/popular/${param}`}
>
{name}
</NavLink>
</li>
))}
</ul>
);
}
Navbar
위에 테두리 선 역할을 하는 ColorfulBorder
import * as React from "react";
export default function ColorfulBorder() {
return <div className="border-container" />;
}
오류가 발생했을때 보여주는 화면인 NoMatch
import * as React from "react";
export default function NoMatch() {
return <h2 className="heading-center">Four Oh Four</h2>;
}
자 이제 클라이언트 사이드 라우트를 렌더링해보자. 이미 routes 배열은 존재하니 map 을 사용해서 다른 Route를 생성해야한다. 또한 해당 컴포넌트가 fetchInitialData
속성을 가지고 있는지 확인해야하며 만약 존재한다면 클라이언트에서 호출한다.
shared/App.js
import React from 'react'
import ColorfulBorder from './ColorfulBorder';
import Navbar from './Navbar';
import routes from './routes';
import {Route, Routes} from 'react-router-dom'
import "./styles.css";
import NoMatch from './NoMatch';
const App = ({serverData}) => {
return (
<div>
<ColorfulBorder/>
<div className='container'>
<Navbar/>
<Routes>
{routes.map((route)=> {
const {path, fetchInitialData, component: C} = route;
return (
<Route
key={path}
path={path}
element ={
<C data={serverData} fetchInitialData={fetchInitialData}/>
}/>
);
})}
<Route path="*" element={<NoMatch/>}/>
</Routes>
</div>
</div>
)
}
export default App
이제 실행해보면 메인화면은 잘 나타나지만 언어선택을 한 다음에 화면이 나타나지 않는다.
github API로부터 받아온 데이터는 서버상에만 있지 클라이언트에는 존재하지 않기 때문에 렌더링이 이루어지지 않은 것이다. 서버의 데이터가 아직 없는 경우에만 클라이언트에서 repo 데이터를 가져와야 한다. 이렇게 하려면 먼저 클라이언트에서 렌더링을 한다면 초기 렌더링이라는 것을 알아야하고 그말은 즉슨 이미 window.INITIAL_DATA 를 가지고 있다는 뜻이며 다시 fetch할 필요가 없다는 뜻이다.
이 글 초반에 작성했지만 webpackconfig 파일에서 browserConfig 에서 클라이언트 윈도우에 __isBrowser__
속성을 추가하기 위해 webpack.DefinePlugin
을 사용했다. 이걸 이용하면 서버에서 렌더링 되는지 클라이언트에서 렌더링 되는지를 파악할 수 있게 되는 것이다.
그럼 이제 Grid 컴포넌트에 repos state를 추가해서 window.INITIAL_DATA 를 기본값으로 지정해서 렌더링의 위치를 파악하게 해보자
shared/Grid.js
const Grid = ({data}) => {
const [repos, setRepos] = React.useState(() => {
return __isBrowser__
? window.__INITIAL_DATA__
: data
})
Grid 컴포넌트의 내용의 일부는 아래와 같았다.
{
path: '/popular/:id',
component: Grid,
fetchInitialData: (path = '') =>
fetchPopularRepos(path.split('/').pop())
}
우리는 URL Parameter를 사용해서 language를 대신한다. 우리는 URL parameter 로 엑세스를 할수 있게 되고 language 또한 마찬가지이다. 이를 위해 react router의 useParams를 사용한다.
shared/Grid.js
const Grid = ({data}) => {
const [repos, setRepos] = React.useState(() => {
return __isBrowser__
? window.__INITIAL_DATA__
: data
})
const {id} = useParams()
이제 우리는 repos state와 URL parameter로부터 받아온 language 정보를 얻었다. 다음으로 해야할 일은 language repo를 가져오는 방법과 우리의 로컬 repos의 state를 업데이트하는 방법을 찾아야 하는데 이를 위해서 loading
state를 추가한다.
loading
은 우리에게 새로운 데이터를 가져오는 중이라고 알려주는 것으로, 우리가 만약 이미 repos 값을 가지고 있다면 false한다. 이미 서버로 만들어졌다는 것을 의미하기 때문이다.
const [loading, setLoading] = React.useState(
repos ? false : true
)
if ( loading === true) {
return <i className='loading'>로딩중...</i>
}
마지막으로 사용자가 언제든지 Navbar에서 다른 언어를 선택 했을때, 새로운 인기 리포지토리 목록을 가져오고 repos state를 업데이트하게 만들고 싶다. 새로운 데이터를 가져오려면 fetchInitialData
속성을 사용한다.
여기서 질문은 언제 fetchInitialData
를 호출해야할까? useEffect
훅이 익숙하다면 두 번째 인수로 종속성 배열을 전달할 수 있다는 걸 알고 있을것이다. 배열의 원소중 하나가 변경될 때마다 리액트는 다시 effect를 적용시킬 것이다. 그말은 id
URL parameter를 보낸다면 리액트는 그것이 변동될 때마다 다시 effect를 적용한다는 말이다.
여기서 useRef
를 사용하면 데이터가 변동되지 않을 경우 다시 렌더링 되지 않게 할 수 있다.
const fetchNewRepos = React.useRef(
repos ? false: true
)
React.useEffect(() => {
if(fetchNewRepos.current === true){
setLoading(true);
fetchInitialData(id).then((repos) => {
setRepos(repos);
setLoading(false);
});
} else {
fetchNewRepos.current = true
}
},[id])
repos
값이 true 일 경우, fetchNewRepos
를 false로 설정했다. 만약 이것이 true라면 repos
가 없다는 뜻이 되기 때문에 새로운 lanugage 데이터를 가져온다. fetchNewRepos
가 false 라면 이것은 초기 랜더링 상태이며 이미 서버에 repos 값을 가져왔다는 뜻이된다.
fetchNewRepos.current 를 true로 설정했기 때문에 새로운 language를 선택할 경우 자연스럽게 해당 데이터를 가져오는 작업을 실행 시키는 것이다(repos가 false인 경우에 true로 설정했지만 초기 렌더링 상태에서는 데이터가 존재하지 않으므로 자연스럽게 true가 되는 것이다)
그리고 Grid 컴포넌트에서 map 함수 안에 data 대신에 repos를 넣어줘야한다. repos 내부에 데이터가 들어있기 때문이다.
{repos.map(({name, owner, stargazers_count, html_url}, i)
자 그럼 다시 실행해보면
완성되었다.
그냥 create-react-app으로 앱을 만들어서 실행하는 것과는 다르게 서버 사이드 렌더링을 구현하기 위해서는 나름 복잡한 과정을 거쳐야 한다는 것을 배웠다. 데이터가 서버에 있는지 클라이언트에 있는지도 체크해야했고, 라우터 경로의 설정도 다른 방식으로 진행했다. 앞으로도 다른 프로젝트에서도 서버 사이드 렌더링을 하게 된다면 어떻게 해야할지 생각하는 계기가 되어서 좋았다.