# React Vite 프로젝트에 SSR 적용하기

### 서버 사이드 렌더링을 해야 하는 이유

React 프로젝트는 기본적으로 클라이언트 사이드 렌더링이다. 웹 사이트에 접속하면 필요한 에셋 파일들을 불러온 다음 브라우저에서 자바스크립트 파일을 실행시켜 화면에 렌더링하는 방식이다.

검색 엔진은 기본적으로 자바스크립트를 실행시키지 못하기 때문에 웹 사이트가 무엇인지 인식하지 못하는 문제가 있다.

브라우저에서 모든 페이지 구성요소를 렌더링하기 때문에 사용자 기기에 부담이 증가하는 문제점도 존재한다.

서버 사이드 렌더링을 적용하면 웹 페이지를 서버에서 렌더링하기 때문에 검색 엔진이 웹 사이트를 인식하기 용이해진다. 웹 페이지 컨텐츠 뿐만 아니라 메타 태그 설정도 가능하다. 다만 그만큼 서버의 부담을 줄 수 있는 단점 또한 존재한다.

브라우저는 미리 렌더링된 HTML, CSS, JS 파일을 받아서 웹 사이트의 화면을 완성하는데 이를 `Hydration`이라고 한다.

### 서버 사이드 렌더링을 적용하려는 프로젝트

SSR을 적용할 프로젝트는 다음과 같다.

* React, TypeScript, Vite 기반
    
* 각 라우터 간에 Suspense, lazy가 적용되어 있음
    
* 외부 서버 API 데이터를 필요로 하는 페이지가 있음
    
* 서버 데이터 관리하기 위해 React Query가 설치되어 있음
    

### SSR 작업

리액트 라우터는 다음과 같이 작성되어있다.

Suspense를 적용한 이유는 특정 페이지에 접속했을 때 해당 페이지의 데이터만 불러오게 하기 위함이다. 예를 들어 Suspense가 없을 때에는 `/` 페이지에 접속했을 때 `/people` 페이지의 데이터 또한 요청하는 문제가 있었다.

```typescript
import { Suspense, lazy } from "react";
import { RouteObject } from "react-router-dom";
import AppLayout from "~/layouts";

const Page = lazy(() => import("./pages/Page"));
const Main = lazy(() => import("./pages/Main"));
const People = lazy(() => import("./pages/People"));

const routes: RouteObject[] = [
  {
    path: "/",
    element: <AppLayout />,
    children: [
      {
        path: "/",
        element: (
          <Suspense>
            <Main />
          </Suspense>
        ),
      },
      {
        path: "/people",
        element: (
          <Suspense>
            <People />
          </Suspense>
        ),
      },
      {
        path: "/:id",
        element: (
          <Suspense>
            <Page />
          </Suspense>
        ),
      },
    ],
  },
];
```

서버 라이브러리는 `fastify`를 선택했다. `fastify` 공식 사이트에는 `fastify`가 `express`보다 성능이 더 좋다고 적혀있었는데, 서버 사이드 렌더링을 채택하게 되면 서버의 부담이 증가하기 때문에 서버 부하를 줄이기 위함이었다.

패키지 설치

```sh
npm install fastify serve-static @fastify/compress @fastify/middie cross-env
npm install -D @types/serve-static
```

프로젝트 폴더에 두 가지의 파일을 추가한다. `create-vite`로 프로젝트를 세팅했을 경우 `./src` 폴더에 생성한다.

`entry.server.tsx`

* SSR API를 활용하여 웹 애플리케이션을 렌더링한다.
    

`entry.client.tsx`

* DOM에 웹 애플리케이션을 연결하는 역할을 한다.
    

`index.html` 파일에서 `entry.client.tsx` 파일을 참조할 수 있게 해준다. HTML에 `<!--app:html-->` 주석을 추가했는데 서버에서 렌더링이 끝나면 여기에 렌더링된 HTML로 교체할 것이다.

```html
<div id="root"><!--app:html--></div>
<script type="module" src="/src/entry.client.tsx"></script>
```

`entry.client.tsx`는 Suspense & lazy 처리된 라우팅까지 렌더링된 HTML을 Hydration을 할 수 있는 함수, `hydrate`을 작성한다.

```typescript
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import React from "react";
import * as ReactDOM from "react-dom/client";
import {
  RouterProvider,
  createBrowserRouter,
  matchRoutes,
} from "react-router-dom";
import "./index.css";
import routes from "./routes.tsx";

const queryClient = new QueryClient({});

async function hydrate() {
  const lazyMatches = matchRoutes(routes, window.location)?.filter(
    (m) => m.route.lazy
  );
  if (lazyMatches && lazyMatches.length > 0) {
    await Promise.all(
      lazyMatches.map(async (match) => {
        const routeModule = await match.route.lazy!();
        Object.assign(match.route, { ...routeModule, lazy: undefined });
      })
    );
  }

  const router = createBrowserRouter(routes);
  const App = () => {
    return (
      <React.StrictMode>
        <QueryClientProvider client={queryClient}>
          <RouterProvider router={router} />
        </QueryClientProvider>
      </React.StrictMode>
    );
  };

  ReactDOM.hydrateRoot(document.getElementById("root")!, <App />);
}

hydrate();
```

`entry.server.ts`은 서버에서 컴포넌트를 렌더링할 수 있는 함수, render를 작성한다.

미들웨어 라이브러리 `@fastify/middie`가 제공하는 요청, 응답 타입과 맞추기 위해 `ServerRequest` 타입을 정의했다.

`createFetchRequest` 함수는 Fastify 요청을 `React Router Dom`의 Static 핸들러가 사용할 수 있도록 변환하는 역할을 한다.

프론트엔드에서 Suspense 컴포넌트가 작성되어 있어 `renderToString` 대신 `renderToPipeableStream`을 활용한다.

`renderToPipeableStream` 메소드에 전달되는 컴포넌트는 `hydrateRoot`에 전달되는 컴포넌트와 구조가 같아야 한다. 서버에서 렌더링한 HTML과 클라이언트에서 렌더링한 HTML과 맞지 않으면 에러가 발생한다.

```typescript
import { IncomingMessageExtended } from "@fastify/middie";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { IncomingMessage, ServerResponse } from "http";
import React from "react";
import ReactDOMServer, {
  RenderToPipeableStreamOptions,
} from "react-dom/server";
import {
  StaticRouterProvider,
  createStaticHandler,
  createStaticRouter,
} from "react-router-dom/server";
import routes from "~/routes";

type ServerRequest = IncomingMessage &
  IncomingMessageExtended & { protocol?: string; originalUrl?: string };

export const render = async (
  req: ServerRequest,
  rep: ServerResponse,
  options: RenderToPipeableStreamOptions
) => {
  const { query, dataRoutes } = createStaticHandler(routes);
  const fetchRequest = createFetchRequest(req, rep);
  const context = await query(fetchRequest);

  if (context instanceof Response) {
    throw context;
  }
  const router = createStaticRouter(dataRoutes, context);
  const queryClient = new QueryClient({});
  const stream = ReactDOMServer.renderToPipeableStream(
    <React.StrictMode>
      <QueryClientProvider client={queryClient}>
        <StaticRouterProvider
          router={router}
          context={context}
          nonce="the-nonce"
        />
      </QueryClientProvider>
    </React.StrictMode>,
    {
      ...options,
    }
  );
  return stream;
};

export const createFetchRequest = (req: ServerRequest, rep: ServerResponse) => {
  const protocol = req.protocol ?? "http";
  const origin = `${protocol}://${req.headers.host}`;
  const url = new URL(req.originalUrl ?? req.url!, origin);

  const controller = new AbortController();
  rep.on("close", () => controller.abort());

  const headers = new Headers();

  for (const [key, values] of Object.entries(req.headers)) {
    if (values) {
      if (Array.isArray(values)) {
        for (const value of values) {
          headers.append(key, value);
        }
      } else {
        headers.set(key, values);
      }
    }
  }

  const init: RequestInit = {
    method: req.method,
    headers,
    signal: controller.signal,
  };

  (init as { duplex: "half" }).duplex = "half";
  if (req.method !== "GET" && req.method !== "HEAD") {
    init.body = req.body as BodyInit;
  }
  return new Request(url.href, init);
};
```

서버를 실행할 수 있는 `server.ts`를 작성한다.

`fastify`는 미들웨어를 지원하지 않아 `@fastify/middie` 패키지를 활용했다.

`viteApp`을 설정할 때는 `appType`을 custom으로 설정해야 한다.

`entry.server.{tsx, js}` 파일을 동적 import로 불러오고 있는데, 배포 환경에서는 빌드된 모듈을 사용해야 하기 때문이다.

`index.html` 파일을 불러온 다음 주석 `<!--app:html-->`을 렌더링된 HTML로 바꿔주면 된다.

만약 메타 태그 설정까지 하고 싶으면 `index.html` head 태그 내부에 `<!--app:head-->` 주석을 추가한 다음 메타 태그로 교체해주면 된다.

```typescript
import { fastifyCompress } from "@fastify/compress";
import fastifyMiddie, { IncomingMessageExtended } from "@fastify/middie";
import fastify from "fastify";
import * as fs from "node:fs/promises";
import { IncomingMessage, ServerResponse } from "node:http";
import * as path from "node:path";
import { fileURLToPath } from "node:url";
import ReactDOMServer, {
  RenderToPipeableStreamOptions,
} from "react-dom/server";
import serveStatic from "serve-static";
import { createServer as createViteServer } from "vite";

type ServerRequest = IncomingMessage &
  IncomingMessageExtended & { protocol?: string; originalUrl?: string };

const cwd = process.cwd();
const isProd = process.env.NODE_ENV === "production";
const __dirname = path.dirname(fileURLToPath(import.meta.url));

const resolve = (file: string) => {
  return path.resolve(__dirname, file);
};

async function createServer() {
  const app = fastify({ logger: false });
  const viteApp = await createViteServer({
    root: cwd,
    server: { middlewareMode: true },
    appType: "custom",
    build: { ssr: true },
  });
  await app.register(fastifyMiddie, { hook: "onRequest" });
  if (!isProd) {
    await app.use(viteApp.middlewares);
  } else {
    await app.use(serveStatic("dist/client", { index: false }));
    await app.register(fastifyCompress);
  }

  app.use("/(.*)", async (req: ServerRequest, rep: ServerResponse) => {
    const url = req.originalUrl ?? req.url;
    try {
      let template: string;
      let render: (
        req: ServerRequest,
        rep: ServerResponse,
        options: RenderToPipeableStreamOptions
      ) => Promise<ReactDOMServer.PipeableStream>;
      if (!isProd) {
        template = await fs.readFile(resolve("index.html"), "utf-8");
        template = await viteApp.transformIndexHtml(url!, template);
        const module = await viteApp.ssrLoadModule("src/entry.server.tsx");
        render = module.render;
      } else {
        template = await fs.readFile(
          resolve("dist/client/index.html"),
          "utf-8"
        );
        const module = await import(resolve("dist/server/entry.server.js"));
        render = module.render;
      }
      try {
        const templates = template.split("<!--app:html-->");
        rep.statusCode = 200;

        const stream = await render(req, rep, {
          onShellReady() {
            rep.write(templates[0]);
          },
          onAllReady() {
            stream.pipe(rep);
            rep.write(templates[1]);
            rep.end();
          },
          onShellError(error) {
            if (error instanceof Error) {
              viteApp.ssrFixStacktrace(error);
            } else {
              console.error(error);
            }
          },
          onError(error, errorInfo) {
            if (error instanceof Error) {
              viteApp.ssrFixStacktrace(error);
            } else {
              console.error(error);
              console.warn(errorInfo);
            }
          },
        });
        return;
      } catch (e) {
        if (e instanceof Response && e.status >= 300 && e.status <= 399) {
          const location = e.headers.get("location");
          if (!location) {
            return;
          }
          rep.writeHead(e.status, { location });
          return rep.end();
        }
        throw e;
      }
    } catch (error) {
      if (!isProd) {
        viteApp.ssrFixStacktrace(error);
      }
      console.error(error.stack);
      rep.statusCode = 500;
      rep.end(error.stack);
    }
  });

  return app;
}

createServer().then((app) => {
  app.listen({ port: 8082 }, (error) => {
    if (error instanceof Error) {
      console.error(error);
      process.exit(1);
    }
  });
});
```

`server.ts`은 타입스크립트 파일이라 NodeJS에서 호출할 수 있도록 서버 실행 전 해당 파일만 자바스크립트로 미리 바꿔주는 과정이 필요하다.

`package.json`

```json
{
  "scripts": {
    "predev": "tsc ./server.ts --module nodenext",
    "prestart": "tsc ./server.ts --module nodenext",
    "dev": "cross-env NODE_ENV=development node server.js",
    "build": "npm run build:client && npm run build:server",
    "build:client": "vite build --outDir dist/client --ssrManifest",
    "build:server": "vite build --ssr src/entry.server.tsx --outDir dist/server",
    "start": "cross-env NODE_ENV=production node server.js"
  }
}
```

`routes.tsx` 파일에는 페이지가 미리 렌더링되기 전에 데이터를 제공할 수 있도록 `loader` 설정을 할 수 있다.

```typescript
const loader: RouteObject = {
  path: "/:id",
  loader: async (...args: unknown[]) => {
    return {
      data: [] as string[],
      args,
    };
  },
  element: (
    <Suspense>
      <IdiomPage />
    </Suspense>
  ),
};
```
