Vite와 함께하는 React SSR

방구석 코딩쟁이·2024년 2월 6일
0

리액트

목록 보기
1/1

Next.js를 공부하다보니 React를 통해 SSR을 어떻게 구현하는 걸까 라는 의문점이 생겨 찾아보게 되었고 출처에 적어둔 글이 있어 구현 방법에 대해 알게 되었습니다.

첫 번째로 vite를 통해 SSR을 이미 구현해둔 세팅을 가져옵니다.
npm create vite-extra@latest 명령어를 입력해둡시다.
Project namePackage name은 원하시는 대로 해두고,
✔ Select a template: › ssr-react
✔ Select a variant: › TypeScript

이 두 부분만 신경써서 설정해주면 됩니다.

이렇게 실행하면 다음과 같은 폴더구조를 갖는 프로젝트를 세팅해줍니다.

그럼 하나씩 파헤쳐보도록 합시다.

Package.json

{
  "name": "ssrtest",
  "private": true,
  "version": "0.0.0",
  "type": "module",
  "scripts": {
    "dev": "node server",
    "build": "npm run build:client && npm run build:server",
    "build:client": "vite build --ssrManifest --outDir dist/client",
    "build:server": "vite build --ssr src/entry-server.tsx --outDir dist/server",
    "preview": "cross-env NODE_ENV=production node server"
  },
  "dependencies": {
    "compression": "^1.7.4",
    "express": "^4.18.2",
    "react": "^18.2.0",
    "react-dom": "^18.2.0",
    "sirv": "^2.0.4"
  },
  "devDependencies": {
    "@types/express": "^4.17.21",
    "@types/node": "^20.10.5",
    "@types/react": "^18.2.45",
    "@types/react-dom": "^18.2.18",
    "@vitejs/plugin-react": "^4.2.1",
    "cross-env": "^7.0.3",
    "typescript": "^5.3.3",
    "vite": "^5.0.10"
  }
}

먼저 dependencies에서는 특이한 패키지가 눈에 띕니다. compression, express, sirv인데, 3가지 패키지는 서버 구성을 위한 패키지입니다.

  • express: node.js 기반 백엔드 라이브러리입니다.
  • compression: production 단계에서 리소스를 압축하기 위해서
  • sirv: 정적 파일을 효율적으로 전달하기 위해 사용

devDependencies에서는 cross-env라는 패키지가 눈에 띄네요.

  • cross-env: 실행 환경에 따라 동적으로 env를 변경하기 위해 사용

그 다음으로 scripts를 봅시다.
express를 실행해야 하므로 node server로 실행을 하게 되었습니다.
build 단계도 서버와 클라이언트 두 단계로 나눠서 진행을 하게 됩니다.

빌드 단계에서의 옵션을 살펴봅시다.

클라이언트 빌드 옵션

  • --ssrManifest: 모듈 ID와 관련된 chunk 파일, asset 파일 등에 대한 매핑이 포함된 파일
  • --outDir: 빌드 결과물이 생성될 디렉토리

서버 빌드 옵션

  • --ssr: SSR 빌드임을 명시하며 진입점을 지정
  • --outDir: 빌드 결과물이 생성될 디렉토리

index.html

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <link rel="icon" type="image/svg+xml" href="/vite.svg" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Vite + React + TS</title>
    <!--app-head-->
  </head>
  <body>
    <div id="root"><!--app-html--></div>
    <script type="module" src="/src/entry-client.tsx"></script>
  </body>
</html>

CSR을 활용하는 리액트에서 사용하는 HTML과 유사하지만 몇가지 차이점이 존재합니다.
<script>를 통해 "/src/entry-client.tsx" 파일을 가져옵니다.
또한, 주석이 존재합니다. 이것이 무엇을 의미하는바는 천천히 살펴보도록 합시다.

entry-client.tsx

// entry-client.tsx
import './index.css'
import React from 'react'
import ReactDOM from 'react-dom/client'
import App from './App'

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

이 파일은 일반적인 index.tsx와 유사해보입니다. 저희가 cra를 통해 리액트 앱을 만들면 아래와 같은 index.tsx 파일을 만들어주죠

// index.tsx
import React from "react";
import ReactDOM from "react-dom/client";
import "./index.css";
import App from "./App";
import reportWebVitals from "./reportWebVitals";

const root = ReactDOM.createRoot(
  document.getElementById("root") as HTMLElement
);

root.render(
  <React.StrictMode> 
    <App />
  </React.StrictMode>
);

// If you want to start measuring performance in your app, pass a function
// to log results (for example: reportWebVitals(console.log))
// or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals
reportWebVitals();

비슷해보이지만 분명 차이점이 있습니다.
SSR의 경우 hydrateRoot 메서드를 사용하고,
CSR의 경우 createRoot 메서드로 반환된 React Root의 render 메서드를 사용합니다.

이 둘에 차이점에 대해서는 나중에 따로 작성할 수 있으면 해보겠습니다.

일단 hydrateRoot를 통해서는 react-dom/server를 통해 미리 만들어진 HTML로 그려진 브라우저 DOM 노드에 React 컴포넌트를 렌더링할 수 있는 API이며 대표적인 사용예시로는 서버에서 렌더링 HTML을 hydrate하는데 있습니다.

서버에서 정적인 HTML을 만들어서 보내면 React는 그 HTML 위에 React 코드를 더해 동적으로 만들어줍니다.

entry-server.tsx

import React from 'react'
import ReactDOMServer from 'react-dom/server'
import App from './App'

export function render() {
  const html = ReactDOMServer.renderToString(
    <React.StrictMode>
      <App />
    </React.StrictMode>
  )
  return { html }
}

공식문서에 따르면 renderToString 메서드는 React 트리를 단순히 HTML 문자열로 렌더링해줍니다.

이 때, 클라이언트에서 hydrateRoot를 호출하면 서버에서 생성된 HTML을 동적으로 만들어주는 hydration이 발생하게 되는 것이빈다.

즉, 이를 도식화하면 아래와 같습니다.

저희가 작성할 리액트 코드가 이렇게 두 파일(entry-client, entry-server)에서 사용되는 것이죠.

server.js

server.js의 전체 코드는 아래와 같습니다.
production인지 아닌지에 따라 파일을 import하는 곳이 다르다는 것을 알아야 합니다.

import fs from 'node:fs/promises'
import express from 'express'

// Constants
const isProduction = process.env.NODE_ENV === 'production'
const port = process.env.PORT || 5173
const base = process.env.BASE || '/'

// Cached production assets
const templateHtml = isProduction
  ? await fs.readFile('./dist/client/index.html', 'utf-8')
  : ''
const ssrManifest = isProduction
  ? await fs.readFile('./dist/client/.vite/ssr-manifest.json', 'utf-8')
  : undefined

// Create http server
const app = express()

// Add Vite or respective production middlewares
let vite
if (!isProduction) {
  const { createServer } = await import('vite')
  vite = await createServer({
    server: { middlewareMode: true },
    appType: 'custom',
    base
  })
  app.use(vite.middlewares)
} else {
  const compression = (await import('compression')).default
  const sirv = (await import('sirv')).default
  app.use(compression())
  app.use(base, sirv('./dist/client', { extensions: [] }))
}

// Serve HTML
app.use('*', async (req, res) => {
  try {
    const url = req.originalUrl.replace(base, '')

    let template
    let render
    if (!isProduction) {
      // Always read fresh template in development
      template = await fs.readFile('./index.html', 'utf-8')
      template = await vite.transformIndexHtml(url, template)
      render = (await vite.ssrLoadModule('/src/entry-server.tsx')).render
    } else {
      template = templateHtml
      render = (await import('./dist/server/entry-server.js')).render
    }

    const rendered = await render(url, ssrManifest)

    const html = template
      .replace(`<!--app-head-->`, rendered.head ?? '')
      .replace(`<!--app-html-->`, rendered.html ?? '')

    res.status(200).set({ 'Content-Type': 'text/html' }).end(html)
  } catch (e) {
    vite?.ssrFixStacktrace(e)
    console.log(e.stack)
    res.status(500).end(e.stack)
  }
})

// Start http server
app.listen(port, () => {
  console.log(`Server started at http://localhost:${port}`)
})

좀 길기 때문에, 파일을 쪼개면서 분석을 해보도록 하겠습니다.

먼저 환경에 따른 변수들, 필요한 리소스를 가져오는 부분을 살펴보도록 합시다.

import fs from 'node:fs/promises'
import express from 'express'

// Constants
const isProduction = process.env.NODE_ENV === 'production'
const port = process.env.PORT || 5173
const base = process.env.BASE || '/'

// Cached production assets
const templateHtml = isProduction
  ? await fs.readFile('./dist/client/index.html', 'utf-8')
  : ''
const ssrManifest = isProduction
  ? await fs.readFile('./dist/client/.vite/ssr-manifest.json', 'utf-8')
  : undefined
  • production일 때, 리소스를 캐시처리하기 위한 코드입니다.
  • 프로덕션 환경일 때는 요청마다 index.htmlssr-manifest.json 파일을 읽어오지 않도록 처리해주는 코드입니다.

그 다음으로는 express를 활용하여 서버를 만든 코드입니다.

// Create http server
const app = express();

// Add Vite or respective production middlewares
let vite;
if (!isProduction) {
  const { createServer } = await import("vite");
  vite = await createServer({
    server: { middlewareMode: true },
    appType: "custom",
    base,
  });
  app.use(vite.middlewares);
} else {
  const compression = (await import("compression")).default;
  const sirv = (await import("sirv")).default;
  app.use(compression());
  app.use(base, sirv("./dist/client", { extensions: [] }));
}
  • 개발환경일 때, vite는 미들웨어 역할을 합니다.
    appTypecustom으로 적는 이유는 vite 자체의 html 제공 로직을 비활성화하기 위함이라고 합니다.
  • 프로덕션 환경에서는 compressionsirv를 통해 미들웨어를 등록합니다.

그다음엔 Handler(정확히 말하자면 미들웨어)를 살펴보도록 합시다.

// Serve HTML
app.use('*', async (req, res) => {
  try {
    const url = req.originalUrl.replace(base, '')

    let template
    let render
    if (!isProduction) {
      // Always read fresh template in development
      template = await fs.readFile('./index.html', 'utf-8')
      template = await vite.transformIndexHtml(url, template)
      render = (await vite.ssrLoadModule('/src/entry-server.tsx')).render
    } else {
      template = templateHtml
      render = (await import('./dist/server/entry-server.js')).render
    }

    const rendered = await render(url, ssrManifest)

    const html = template
      .replace(`<!--app-head-->`, rendered.head ?? '')
      .replace(`<!--app-html-->`, rendered.html ?? '')

    res.status(200).set({ 'Content-Type': 'text/html' }).end(html)
  } catch (e) {
    vite?.ssrFixStacktrace(e)
    console.log(e.stack)
    res.status(500).end(e.stack)
  }
})

// Start http server
app.listen(port, () => {
  console.log(`Server started at http://localhost:${port}`)
})
  • 개발 환경에서는 별도로 빌드된 파일을 사용하지 않고, 작성된 파일 그 자체를 사용할 것이므로, 소스코드를 불러오는 코드를 작성합니다.
  • 프로덕션 환경에서는 빌드된 파일을 통해 가져오도록 합니다.

그런데 비슷한 역할을 하지만 약간 다른 점이 존재하는데 더 자세히 보도록 해보겠습니다.

// 개발 환경에서
template = await fs.readFile("./index.html", "utf-8");
template = await vite.transformIndexHtml(url, template);
render = (await vite.ssrLoadModule("/src/entry-ser ver.jsx")).render;

// 프로덕션 환경에서
template = templateHtml;
render = (await import("./dist/server/entry-server.js")).render;

개발 환경에서는 index.html을 가져와서 HMR가 가능하도록 vite.transformIndexHtml함수로 감쌉니다. 그리고, vite.ssrLoadModule을 통해 SSR 진입점을 서버에 알려주고, export한 render 함수를 가져오게 됩니다.

프로덕션 환경에서는 사전에 로드한 templateHtml을 그대로 사용하고 빌드된 파일에서 render 함수를 가져옵니다.

그 후에, 가져온 render 함수를 호출하여 rendered 변수에 담습니다.

const rendered = await render(url, ssrManifest)

urlssrManifest를 인자로 넘겨주게 되는데 제 생각에는 url은 url에 따라 어떤 컴포넌트가 필요한지에 대해 작성할 수 있게 하기 위해, ssrManifest는 어떤 assets에 대해 넘겨줘야하는지에 대한 명세이므로 2가지를 넘겨주는 것 같습니다.

entry-server.tsx에서 arguments를 console로 출력해봤는데 아래와 같은 결과를 얻을 수 있었습니다.

  • 개발 환경
  • 프로덕션 환경

    프로덕션 환경일 때만 SSR Manifest가 있으므로 당연한 것이죠.

그럼 rendered는 어떤 값일까요??
다음과 같이 우리가 작성해둔 컴포넌트를 html 문자열로 변경해둔 값일 뿐입니다.

그 다음엔 기존의 html 파일에 우리의 리액트 코드를 서버에서 미리 렌더링한 결과를 합칠 코드를 대체해주는 코드입니다.

const html = template
				.replace(`<!--app-head-->`, rendered.head ?? "")
                .replace(`<!--app-html-->`, rendered.html ?? "");

즉, index.html의 주석처리된 부분이 미리 렌더링된 rendered의 값들이 들어갈 공간인 것이죠.

그리고나서, 응답으로 html을 반환해줍니다.

res.status(200).set({ 'Content-Type': 'text/html' }).end(html)

출처
https://velog.io/@real-bird/React-Vite-%EA%B3%B5%EC%8B%9D-%EB%AC%B8%EC%84%9C%EB%A7%8C-%EB%B3%B4%EA%B3%A0-Vite-React-SSR-%EC%98%88%EC%A0%9C-%EC%82%B4%ED%8E%B4%EB%B3%B4%EA%B8%B0

한국어 공식 문서 예제 코드 깃허브 : https://github.com/bluwy/create-vite-extra
영어 공식 문서 React 예제 코드 : https://github.com/vitejs/vite-plugin-react/tree/main/playground/ssr-react

vite 공식문서:
https://ko.vitejs.dev/guide/ssr.html

profile
풀스택으로 나아가기

0개의 댓글