IT일상

React SSR (서버 사이드 렌더링) 얕게 시작해보기 (React.hydrateRoot)

  • 프론트엔드
Profile picture

Written by solo5star

2023. 5. 5. 22:46

CSR(Client-Side Rendering)

위는 전통적으로 사용하던 방식인 CSR(Client-Side Rendering) 방식이 동작하는 그림입니다.

  1. 껍데기 뿐인 html 파일을 다운로드
  2. bundle.js 다운로드 및 실행 (모든 기능이 여기에 있습니다)
  3. 추가 데이터(users.json)를 다운로드하여 렌더링

JavaScript가 동적으로 컨텐츠를 그리는 방식이기 때문에, 화면 전체를 새로고침하지 않고 일부만 변경하여 빠르고 끊기지 않는 웹 사이트를 만들 수 있어 사용자에게 좋은 경험을 제공합니다.

하지만 이러한 CSR 방식엔 단점이 있습니다.

1. 로딩 시간이 깁니다.

bundle.js 를 다운받아야 비로소 실행이 되기 때문에 (특히 무거운 웹 사이트에서) 로딩 시간이 깁니다.

2. SEO 대응이 되지 않습니다.

검색 엔진은 사이트의 정보를 얻기 위해 크롤러를 사용하여 사이트를 읽는데, 크롤러는 JavaScript를 실행하지 않기 때문에 SEO 대응이 되지 않습니다.

구글 검색 엔진의 경우 CSR도 문제없이 크롤링한다고 하지만 다른 검색 엔진의 경우 CSR을 지원하지 않을 수도 있습니다.

SSR(Server-Side Rendering)

  1. 서버에서 index.html을 렌더링
  2. 클라이언트는 index.html을 다운로드하여 화면에 띄움
  3. bundle.js를 다운로드하여 앱을 살아있는 상태로 만듬

이미 index.html이 렌더링되어 있기 때문에 bundle.js를 다운로드하지 않아도 컨텐츠를 볼 수 있습니다.

3번의 앱을 살아있는 상태로 만듬이라는 말을 이해하기 어려울 수 있는데요, index.html만 다운된 상태에선 JavaScript가 없기 때문에 사이트의 기능을 이용하기 어렵습니다. 따라서 bundle.js를 다운 · 실행하여 JavaScript로 만든 기능을 사용할 수 있게끔 하는 것이라고 할 수 있습니다.

서버에서 렌더링을 수행하기 때문에 서버 측에 다소 부담이 될 순 있지만 사용자는 페이지를 빠르게 볼 수 있어 사용자 경험에 좋다고 할 수 있습니다. 검색 엔진 크롤러 측에서도 JavaScript 없이 내용을 읽을 수 있기 때문에 SEO에 좋다고 할 수도 있습니다.

SSR이라고 서버에서만 렌더링하는 것이 아니라 처음에만 렌더링 된 index.html을 보내는 것이며, 이후에는 CSR 방식과 똑같이 Client-Side에서 렌더링을 수행합니다. 즉, SSR -> CSR 순서로 동작한다고 볼 수 있습니다. 이러한 점 때문에 유니버설 렌더링이라고도 부릅니다.

React SSR 시작해보기

서버 사이드 렌더링이기 때문에 말 그대로 서버가 필요합니다. 서버와 클라이언트 두 가지로 나뉘기 때문에 프로젝트 구성이 다소 복잡합니다.

사용하는 구성 요소는 이와 같습니다.

  • TypeScript
  • react, react-dom (React 18)
  • webpack
npm install react react-dom express
npm install -D typescript
npm install -D @types/express @types/node @types/react @types/react-dom
npm install -D webpack webpack-cli ts-loader html-webpack-plugin

react-dom 패키지에 client, server 구현이 모두 있습니다.

3

최종적으로 위와 같은 폴더 및 파일 구조가 될 것입니다.

완성된 소스코드는 https://github.com/solo5star/react-ssr-counter-example 여기에서 확인할 수 있습니다. 코드를 작성하는 과정을 건너뛰고 싶으신 분들은 GitHub 링크를 참고해주세요.

webpack 설정하기

서버, 클라이언트 각각 config가 필요합니다. 웹팩은 크게 중요한 부분이 아니기 때문에 복붙하고 넘어가도 좋습니다.

4

중복되는 코드는 common으로 관리하셔도 좋습니다. 이 글에서는 따로 다루도록 하겠습니다.

webpack.server.js
const path = require("path");

/** @type {import('webpack').Configuration} */
module.exports = {
  target: "node",
  mode: "development",
  entry: path.resolve(__dirname, "server/index.tsx"),
  resolve: {
    extensions: [".ts", ".js", ".tsx", ".jsx"],
  },
  output: {
    filename: "[name].js",
    path: path.resolve(__dirname, "dist/server"),
  },
  module: {
    rules: [
      {
        test: /\.(ts|tsx|js|jsx)$/,
        use: "ts-loader",
        exclude: /node_modules/,
      },
    ],
  },
};
webpack.client.js
const path = require("path");
const HtmlWebpackPlugin = require("html-webpack-plugin");

/** @type {import('webpack').Configuration} */
module.exports = {
  mode: "development",
  entry: path.resolve(__dirname, "client/index.tsx"),
  resolve: {
    extensions: [".ts", ".js", ".tsx", ".jsx"],
  },
  output: {
    path: path.resolve(__dirname, "dist/client"),
  },
  module: {
    rules: [
      {
        test: /\.(ts|tsx|js|jsx)$/,
        use: "ts-loader",
        exclude: /node_modules/,
      },
    ],
  },
  plugins: [
    new HtmlWebpackPlugin({
      template: "client/index.html",
    }),
  ],
};

클라이언트는 일반 React 셋업하듯이 셋업합니다.

tsconfig.json 설정하기

tsconfig.json
{
  "compilerOptions": {
    "target": "ESNext",
    "jsx": "react-jsx",
    "module": "commonjs",
    "esModuleInterop": true,
    "forceConsistentCasingInFileNames": true,
    "strict": true,
    "skipLibCheck": true
  }
}

간단하게 TypeScript config를 하나 추가해줍니다.

클라이언트

client/App.tsx
import { useState } from "react";

const App = () => {
  const [count, setCount] = useState(0);

  return (
    <div>
      Count: {count}

      <button onClick={() => setCount((count) => count + 1)}>Increase count</button>
    </div>
  );
}

export default App;

간단한 counter 입니다. 클릭하면 count가 증가하는 흔한 예제입니다.

client/index.html
<!DOCTYPE html>
<html lang="ko">
<head>
  <meta charset="UTF-8">
  <meta http-equiv="X-UA-Compatible" content="IE=edge">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>React SSR</title>
</head>
<body>
  <div id="root"></div>
</body>
</html>

뼈가 될 html 파일을 하나 만들어줍니다. 서버에서 클라이언트로 전달할 땐 <div id="root"></div> 부분은 채워진 상태로 클라이언트에 전송될 것입니다.

client/index.tsx
import ReactDOM from 'react-dom/client';
import App from './App';

ReactDOM.hydrateRoot(document.getElementById('root') as HTMLElement, <App />);

ReactDOM.createRoot 대신 ReactDOM.hydrateRoot가 들어갔습니다. 그리고 두 번째 인자로 컴포넌트가 들어갑니다.

서버에서 <App /> 을 렌더링해서 클라이언트에 건네주긴 하지만, 첫 렌더링 이후에 Page Refresh 없이 렌더링(CSR)하는 것은 클라이언트 측에서 이루어져야 합니다.

서버

server/index.tsx
import express from 'express';
import fs from 'fs';
import path from 'path';
import ReactDOMServer from 'react-dom/server';
import App from '../client/App';

const app = express();
// 클라이언트 사이드에서 빌드된 html을 읽어와 사용
const html = fs.readFileSync(path.resolve(__dirname, "../client/index.html"), "utf-8");

app.get("/", (req, res) => {
  // <App /> 을 렌더링
  const renderString = ReactDOMServer.renderToString(<App />);
  // <div id="root"></div> 내부에 삽입
  res.send(html.replace('<div id="root"></div>', `<div id="root">${renderString}</div>`));
});
// 위의 / 이외의 경로로 요청할 경우(js, css 등)
// dist/client 폴더에 있는 파일들 제공
app.use("/", express.static("dist/client"));

app.listen(3000, () => {
  console.log('Server is listening on port 3000');
});

서버 측 코드는 express로 작성합니다.

중요한 부분은 ReactDOMServer.renderToString() 입니다. <App /> 을 렌더링하여 문자열로 만들어줍니다.

5

렌더링된 renderStringconsole.log() 로 표시해보면 위와 같습니다.

이제 이를 <div id="root"></div> 안에 넣어 클라이언트에 보내주게 됩니다. 그럼 클라이언트는 ReactDOM.hydrateRoot로 hydration을 하게 됩니다.

빌드 및 실행

package.json
  "scripts": {
    "build:client": "webpack --config webpack.client.js",
    "build:server": "webpack --config webpack.server.js"
  },

package.json 파일에 script를 추가해줍니다. 각각 클라이언트와 서버를 빌드하는 script 입니다.

npm run build:client
npm run build:server

그리고 서버와 클라이언트를 빌드해줍시다.

6

dist 폴더가 생성되고 위와 같이 파일과 디렉토리가 생성되었을 겁니다.

node dist/server/main.js

server의 main.js를 실행하면 express 서버가 실행될겁니다.

7

localhost:3000 으로 접속하면 됩니다.

8

예제로 만들었던 페이지가 잘 표시됩니다!

9

Chrome의 devtools를 확인해보면, CSR과는 사뭇 다른 점을 확인할 수 있습니다.

바로 <div id="root"></div> 안의 내용이 채워져 있는 상태로 왔다는 점인데요! SSR 구현에 성공했다고 볼 수 있습니다.

카운트도 잘 올라가는 것을 볼 수 있습니다. 이 부분은 흔히들 아는 CSR로 동작한다고 보시면 됩니다.

다시 한번 생각해보자. CSR vs SSR

CSRSSR

SSR을 직접 해보니, 간단한 것 같으면서도 어려웠던 것 같습니다. 지금은 Next.js나 Remix같은 잘 만든 SSR 프레임워크가 있으니 이를 사용하면 어렵지 않게 SSR을 할 수 있을겁니다.

SSR을 적용하면 완성된 index.html 파일을 먼저 받아볼 수 있으며 클라이언트가 처음에 컨텐츠를 빠르게 표시할 수 있다는 장점이 있습니다. 뿐만 아니라, 렌더링 결과물을 캐싱한다면 클라이언트와 서버 사이드 모두 컴퓨팅 비용을 크게 절약할 수 있다는 장점도 있을 것 같습니다.

다만 CSR은 정적 파일 서버만 필요로 하기 때문에 관리가 굉장히 쉽지만 SSR에서는 관리해야 할 서버가 추가되기 때문에 이러한 사항도 고려하여 선택할 수 있지 않을까 싶습니다.

SSR에 관심이 있다면, 이걸 꼭 읽어보세요!

https://web.dev/rendering-on-the-web/


Profile picture

Written by solo5star

안녕하세요 👋 개발과 IT에 관심이 많은 solo5star입니다

  • GitHub
  • Baekjoon
  • solved.ac
  • about
© 2023, Built with Gatsby