Next.js를 공부하다보니 React를 통해 SSR을 어떻게 구현하는 걸까 라는 의문점이 생겨 찾아보게 되었고 출처에 적어둔 글이 있어 구현 방법에 대해 알게 되었습니다.
첫 번째로 vite를 통해 SSR을 이미 구현해둔 세팅을 가져옵니다.
npm create vite-extra@latest
명령어를 입력해둡시다.
Project name
과 Package name
은 원하시는 대로 해두고,
✔ Select a template: › ssr-react
✔ Select a variant: › TypeScript
이 두 부분만 신경써서 설정해주면 됩니다.
이렇게 실행하면 다음과 같은 폴더구조를 갖는 프로젝트를 세팅해줍니다.
그럼 하나씩 파헤쳐보도록 합시다.
{
"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
: 빌드 결과물이 생성될 디렉토리
<!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
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 코드를 더해 동적으로 만들어줍니다.
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
의 전체 코드는 아래와 같습니다.
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.html
과 ssr-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
는 미들웨어 역할을 합니다.appType
을 custom
으로 적는 이유는 vite 자체의 html 제공 로직을 비활성화하기 위함이라고 합니다.compression
과 sirv
를 통해 미들웨어를 등록합니다.그다음엔 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)
url
과 ssrManifest
를 인자로 넘겨주게 되는데 제 생각에는 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://github.com/bluwy/create-vite-extra
영어 공식 문서 React 예제 코드 : https://github.com/vitejs/vite-plugin-react/tree/main/playground/ssr-reactvite 공식문서:
https://ko.vitejs.dev/guide/ssr.html