React SSR 서버 직접 만들어보기

React의 SSR 과정을 Next.js와 같은 React의 서버 사이드 렌더링 프레임워크를 사용하지 않고 직접 만들어 보며 이해해 봅시다.
2023.12.04

모던 리액트 Deep Dive라는 책에서 SSR을 React에서 제공하는 API를 사용하는 간단한 서버를 만드는 내용이 소개되어 있는데, 매우 인상깊어서 직접 코드를 만져가면서 해보고 싶었다.

React SSR이 어떻게 동작하는거지? 싶은 분들이 가볍게 훑기 좋은 내용을 적어 보겠다. React SSR 관련 함수들의 내부 구현 깊은 곳은 이 블로그에서는 다루지 않는다.

이 블로그에서 다루는 내용말고도 stream 처리라던지 이외의 내용들은 책을 참고하면 좋다.

모던 리액트 Deep Dive | 위키북스

React에서의 SSR

SSR, 서버 사이드 렌더링, 서버에서 html을 만들어 사용자의 요청이 있으면 해당 html을 내려주는 방식이다. 간단하지만 그래서 React를 사용하는 경우 어떻게 해야할까?

이전까지 Next.js가 알아서 해주겠거니 생각했던 것도 있고, 어떻게 동작하는지 궁금해서 찾아보려하면 Next.js의 코드 베이스가 너무 커서 좌절했던 경험이 있다.

그런데 생각해보면 이러한 SSR은 Next.js도 React의 API를 사용하기에 React 프레임워크라 불리울 것이다.

이러한 React의 프레임워크의 성질을 나타내는 것은 Next.js 외에도 Remix 라던지 React 기반 SSR 프레임워크들이 존재한다.

그럼 이러한 React에서 사용하는 SSR API들을 사용해서 직접 SSR 서버를 만들어보자.

SSR 서버를 만들어보자

간단한 React 컴포넌트를 만든 뒤 서버에서 컴포넌트를 string화 시킨뒤에 index.html 을 내려줘보자.

// index.html
<html>
  <head>
    <title>react-ssr</title>
  </head>
  <body>
    <!-- root div 안에 string화 되어진 컴포넌트를 넣기 위해 위치를 표시 -->
    <div id="root">__placeholder__</div>
    <script src="https://unpkg.com/react@18.2.0/umd/react.development.js"></script>
    <script src="https://unpkg.com/react-dom@18.2.0/umd/react-dom.development.js"></script>
    <script type="module" src="/main.js"></script>
  </body>
</html>

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

export default function App() {
  const [count, setCount] = useState(1);

  const addCount = () => {
    setCount((prv) => prv + 1);
  };

  return (
    <>
      <div>Hello react SSR!</div>
      <button onClick={addCount}>Like it {count} times!</button>
    </>
  );
}

// server.ts
import http from "node:http";

import { renderToString } from "react-dom/server";
import { createElement } from "react";

import App from "./App";

function handler(req: http.IncomingMessage, res: http.ServerResponse) {

  if (url === "/") {
    // 여기서 만들어둔 컴포넌트를 createElement를 통해 element로 만듦
    const app = createElement(App);

    // 만들어진 element를 html으로 사용하기 위해 renderToString를 통해 string으로 만듦
    const renderedString = renderToString(app);
    const indexHtml = fs.readFileSync("index.html", "utf8");

    // index.html에 만들어진 React 컴포넌트 string을 추가
    const result = indexHtml.replace("__placeholder__", renderedString);

    res.setHeader("Content-Type", "text/html");
    res.end(result);
    return;
  }


  // 번들 파일을 내려주는 라우팅
  if (url === "/main.js") {
    const main = fs.readFileSync("dist/main.js", "utf8");

    res.setHeader("Content-Type", "application/javascript");
    res.end(main);
    return;
  }

  res.end();
  return;
}

(function server() {
  http.createServer(handler).listen(3000);

  console.log("server started!");
})();

SSR이 되려면 React의 컴포넌트들이 string화 되어서 html으로 사용되어질 수 있어야 할 것이다. 이러한 과정을 위해 React에서는 renderToString 를 제공한다.

이후에 index.html에 만들어진 string을 추가해주면 사용자는 React의 컴포넌트가 들어간 html을 마주하게 될 것이다. React로 만들어진 컴포넌트가 SSR 된 것이다.

<div>Hello react SSR!</div><button>Like it <!-- -->1<!-- --> times!</button>

콘솔로 string으로 만들어진 컴포넌트를 확인하면 위와 같이 우리가 만든 컴포넌트가 string화가 잘된 것을 볼 수 있다. useState에 초깃값으로 들어있는 1도 들어오는 것을 확인할 수 있다.

하지만 내려온 html에는 버튼에 설정한 onclick 속성이 보이지 않고, 실제로 버튼을 클릭하더라도 버튼 안에 있는 숫자는 증가하지 않는다. 아직 자바스크립트가 적용이 안된 정적인 페이지가 되버렸다.

hydration로 이러한 동작이 가능하게 해보자.

hydration으로 컴포넌트에 자바스크립트 연결하기

SSR 해서 내려온 html 은 아직 자바스크립트가 적용되지 않았다.

react-dom/client 패키지에는 hydrateRoot 라는 함수가 있다. hydrateRoot가 하는 역할은 SSR 로 내려온 html과 React 컴포넌트를 비교해 동작에 필요한 자바스크립트를 넣어주는 역할, 즉 hydration을 하게 된다.

또한, 컴포넌트를 확인하고 렌더링하는 역할을 하기 때문에 기존에 root를 만들기 위해 사용하던 createRoot(...).render()를 사용하지 않고 hydrateRoot만 사용하면 되게 된다.

그러면 실제로 추가해보자.

// main.ts
import { createElement } from "react";
import { hydrateRoot } from "react-dom/client";

import App from "./App";

(function main() {
  const root = document.getElementById("root") as HTMLElement;
  const app = createElement(App);
  hydrateRoot(root, app);
})();

진입점이 되는 컴포넌트를 넣어주고 렌더링할 위치를 지정해주면 hydrateRoot 함수가 위의 언급한 동작을 하게 된다.

네트워크 탭에서 네트워크 속도를 줄이고 해보면 처음에는 눌러도 반응하지 않다가 hydration이 끝나면 버튼의 카운트가 오르는 걸 확인할 수 있다.

이러한 hydrateRoot는 자바스크립트를 넣어주는 역할을 하고 SSR 되어진 html과의 비교도 하게된다. SSR 개발을 하다보면 SSR 되어진 html과 클라이언트에서 만들어진 html과 다르다는 에러를 종종 볼 수 있을 텐데, 이것이 hydration 과정이 거쳐지기 때문이다.

hydrateRoot – React 공식문서

마무리

SSR 이 어떻게 동작한다는 건 알았는데 실제로 React 에서 제공하는 함수들을 사용해 직접 서버로 만들어 보니 감회가 새로웠다.

그렇지만, 위의 방식은 실제 프로젝트에서는 사용하기 힘들다. 추가적으로 번들링이나 서버에서 데이터를 받아온 후에 html을 만든다든지, css 라던지 여러 예외 사항이 존재하게 된다.

이러한 과정을 프레임워크에게 맞기는 이유를 조금 더 체감되게 된 것 같다.

직접만들면 재밌긴 하겠지만, 유지보수는 매우 괴로울 것 같다 🤣

모든 코드는 이 레포 | github.com/tolluset/react-ssr-server 에서 확인할 수 있다.