Skip to main content

Command Palette

Search for a command to run...

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

Vite로 구성된 리액트 프로젝트에 Fastify로 서버 사이드 렌더링을 구성했습니다.

Updated
6 min read
React Vite 프로젝트에 SSR 적용하기

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

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

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

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

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

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

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

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

  • React, TypeScript, Vite 기반

  • 각 라우터 간에 Suspense, lazy가 적용되어 있음

  • 외부 서버 API 데이터를 필요로 하는 페이지가 있음

  • 서버 데이터 관리하기 위해 React Query가 설치되어 있음

SSR 작업

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

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

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 공식 사이트에는 fastifyexpress보다 성능이 더 좋다고 적혀있었는데, 서버 사이드 렌더링을 채택하게 되면 서버의 부담이 증가하기 때문에 서버 부하를 줄이기 위함이었다.

패키지 설치

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로 교체할 것이다.

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

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

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과 맞지 않으면 에러가 발생한다.

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--> 주석을 추가한 다음 메타 태그로 교체해주면 된다.

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

{
  "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 설정을 할 수 있다.

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

More from this blog

AI 에이전트에서 작업 맥락을 관리하기 위해 사용한 방법

AI 에이전트의 불편함 Codex CLI와 같은 AI 코딩 에이전트를 통해서 프로젝트 개발 속도를 높일 수 있다. 그리고 혼자 작업했을 때는 놓칠 수 있는 부분도 발생할 수 있는데 AI 에이전트를 통해 보완할 수 있었다. 컴포넌트에 대해서 접근성 태그 작성 API 호출하는 비동기 코드 작성할 때 에러 처리 그리고 AI 에이전트를 잘 활용하면 불가능하

Mar 8, 202610 min read

지시사항을 분할하여 Codex로 효율적인 작업하기

이슈 Codex와 같은 AI 기반 코드 에이전트는 출력 토큰의 수가 제한되어 있다. 그래서 다양한 작업을 하나의 프롬프트에 전부 몰아서 작성하면 원하는 결과가 나오지 않을 수 있다. Codex와 같은 LLM 기반 서비스는 출력 토큰 수를 초과하게 되면 일부 단계가 누락되거나 특정 요소를 과하게 요약할 수 있고 이는 전체적인 답변 퀄리티를 낮출 수 있기 때문이다. 그래서 다양한 AI 공식문서는 특정 작업을 더 작은 단위로 분할하는 방식을 강조한다...

Nov 3, 20255 min read

Key를 활용하여 컴포넌트 상태를 초기화하기

개요 컴포넌트의 상태가 업데이트되어도 컴포넌트 내부에 배치된 컴포넌트의 상태는 그대로 유지되는 경우가 있었다. 예전에는 Props 전달 및 내부에서 useEffect를 통해서 상태를 초기 상태로 업데이트하도록 했는데 useEffect를 남용하게 되면 상태 변화 추적이 어려워지는 문제가 있었다. 그런데 useEffect 외에도 Key를 통해서 컴포넌트 상태를 초기화할 수 있다는 사실을 알게 되었다. 리스트 렌더링 React 프론트엔드에서 목록 형...

Sep 21, 20253 min read

내가 AGENTS.md를 작성하는 방법

Codex CLI 요즘 프로젝트 개발에 Codex CLI를 활용하는 이유는 다음과 같다. 특정 프로젝트를 작업할 때 Claude Code는 종종 사용량을 초과한 것과 다르게 Codex CLI는 ChatGPT Plus 요금제만으로도 토큰 사용량 초과 걱정 없이 충분하게 활용할 수 있었다. 타 CLI 기반 AI 개발 도구에 비해서 요구사항을 더 정확하게 구현하고 꼭 필요한 작업만 진행하기 때문에 코드를 검토하는 시간을 줄일 수 있다. 반복적인 코드...

Sep 21, 20254 min read
N

Nowon Lee

22 posts