공식 문서를 돌같이 보는 버릇을 고치자!
CRA
나 Vite
로 생성한 프로젝트는 CSR(Client Side Rendering)
로 실행된다. CSR
은 웹을 구성하는 모든 자원을 브라우저에서 다운로드해 실행하기 때문에 서버 부하를 최소화하고, 페이지 전환이나 웹 내의 상호작용이 빠르다는 장점이 있다. 또한, 템플릿이 제공되기 때문에 상대적으로 개발 효율성도 높은 편이다.
하지만 모든 자원을 한 번에 받는 만큼 초기 로딩 속도가 느릴 수밖에 없다. JS
가 다운로드되어 실행되기 전까지 사용자는 빈 페이지를 봐야 한다. 여기서 SEO 문제가 유발된다. 렌더링되기 전까지 html은 <div>
단 하나만 가지고 있다. 때문에 검색 엔진 크롤러는 페이지에 대한 아무런 정보도 가지고 가지 못한다. (요즘 구글 크롤러는 JS까지 실행해서 긁어간다는데, 일반적인 내용은 아닌 듯하니 넘어가자.)
preview도 비었고, response의 div도 비었다.
검색 엔진에 노출되어 유입을 유도하는 입장에서 SEO는 중요한 문제이다. 검색 엔진 크롤러가 페이지의 정보를 잘 긁어가게 하기 위해서는 사이트에 방문했을 때 내용이 담긴 html을 보여줘야 한다. JS가 실행되기 전에 페이지 내용을 담은 자원을 호출하는 방식이 SSR(Server Side Rendering)
이다. 서버에서 페이지의 골조를 만들어 보내주기 때문에 초기 로딩 속도가 빠르고 SEO 대응도 원활하다. 대신 CSR
과 반대로 서버가 할 일이 많아 부하가 늘어나고, 페이지 전환 시 새로운 html을 생성하므로 전환 속도가 느릴 수 있다.
preview도, reponse의 div로 뭔가 들어있다.
어쨌든 SSR
은 중요하기 때문에 내가 애용하는 Vite
에서도 SSR을 지원한다. 스스로 구현하기에는 아직 잘 모르겠으니 Vite
에서 제공하는 예제 코드를 살펴보자. React를 기준이다.
예제 코드는 다음 명령어로 설치할 수 있다.
npm create vite-extra@latest
한국어 공식 문서 예제 코드 깃허브 : https://github.com/bluwy/create-vite-extra
영어 공식 문서React
예제 코드 : https://github.com/vitejs/vite-plugin-react/tree/main/playground/ssr-react
{
"dependencies": {
"compression": "^1.7.4",
"express": "^4.18.2",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"sirv": "^2.0.3"
},
"devDependencies": {
"@types/react": "^18.2.28",
"@types/react-dom": "^18.2.13",
"@vitejs/plugin-react": "^4.1.0",
"cross-env": "^7.0.3",
"vite": "^4.4.11"
}
}
서버에서 렌더링을 해야 하기 때문에 서버 구성 패키지가 눈에 띈다. express
는 노드 서버 생성을 위해서, compression
은 production 단계에서 리소스를 압축하기 위해서, sirv
는 정적 파일을 효율적으로 전달하기 위해 사용한다. cross-env
는 실행 환경에 따라 동적으로 env를 변경하기 위해 사용한다.
스크립트도 신기했다.
{
"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.jsx --outDir dist/server",
"preview": "cross-env NODE_ENV=production node server"
}
}
express
를 실행해야 하므로 기존의 CSR 명령어와 다르게 vite
가 아닌 node server
로 대체되었다. build 역시 서버와 클라이언트로 나뉘었다.
build:client
의 --ssrManifest
는 모듈 ID와 관련된 청크 파일이나 에셋 파일에 대한 매핑이 포함된 파일이다. --outDir
은 빌드 결과물이 생성될 디렉토리이다.
build:server
는 --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</title>
<!--app-head-->
</head>
<body>
<div id="root"><!--app-html--></div>
<script type="module" src="/src/entry-client.jsx"></script>
</body>
</html>
일반 템플릿의 html
과 별다를 바 없지만, 주석이 의아했다. 저건 왜 붙어 있는 거지? 의문점은 server.js
를 보고 풀렸다. 하지만 맛있는 건 나중에 먹기로 하고, 진입점 파일부터 보도록 하자.
import "./index.css";
import React from "react";
import ReactDOM from "react-dom/client";
import App from "./App";
ReactDOM.hydrateRoot(
document.getElementById("root"),
<React.StrictMode>
<App />
</React.StrictMode>
);
React
에서는 서버에서 사전에 만들어진 html 내부에 ReactDom을 그리기 위한 hydrateRoot
함수가 있다. React - hydrateRoot
서버에서 html을 만들어 보내면 React는 그 문서 위에 ReactDom을 붙여 정적인 파일을 축축(hydrate)하게 만든다. 아무런 이벤트가 없는 문서에 상호작용 가능한 이벤트를 달아주는 것이다.
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 컴포넌트를 문자열로 변환한다. React - renderToString
hydrateRoot
를 호출하면 서버에서 생성된 HTML을 상호작용하게 만든다.
가장 중요하게 보이는 server.js
를 살펴보자. 여기서 조건부로 개발 환경과 프로덕션 환경을 나누어 서버를 실행한다.
import fs from "node:fs/promises";
// 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/ssr-manifest.json", "utf-8")
: undefined;
문자열 그대로다. 프로덕션인지 아닌지에 따라 개발 환경과 프로덕션 환경을 구분한다. 프로덕션일 때는 서버 요청마다 html
과 ssrManifest
를 읽지 않도록 fs
모듈을 이용해 미리 읽어온다. async
없이 사용되는 await
은 Top-level Await
이라는 새로 추가된 문법이다. TC39 - Top-level Await
import express from "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: [] }));
}
express
로 서버를 생성한다. vite
의 역할이 달라졌다. 여기에서 vite
는 미들웨어 역할을 하며 프로덕션과 개발 환경을 분리하여 사용한다. appType
을 'custom'으로 적는 이유는 vite 자체의 html 제공 로직을 비활성화하기 위함이다. Vite - 서버 측 렌더링:개발 서버 구성하기
프로덕션 환경에서는 동적으로 compression
과 sirv
를 호출해 미들웨어를 등록한다.
// 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.jsx")).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);
}
});
조금씩 구분해서 보도록 하자.
const url = req.originalUrl.replace(base, "");
공식 문서에서는 req.originalUrl
만 썼는데, 템플릿에서는 왜 replace
를 했는지 모르겠다. 일종의 안전장치인가? 뭔가 base
를 지워야 하는 상황이 있기 때문에 replace
를 했을 것이다. 근데 지금은 모르겠다.
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.jsx")).render;
}
fs.readFile
로 index.html
을 가져온다. vite.transformIndexHtml
에서는 HMR(Hot Module Replacement)이 가능하도록 hook을 붙인다. vite.ssrLoadModule
는 SSR 진입점을 서버에 알려주고, entry-server.jsx
에서 export한 render
함수를 가져온다.
else {
template = templateHtml;
render = (await import("./dist/server/entry-server.js")).render;
}
프로덕션에서는 사전에 로드한 templateHtml
을 그대로 사용하고, 빌드된 서버 측 render
를 가져온다.
const rendered = await render(url, ssrManifest);
render
에 url
과 ssrManifest
가 왜 들어가는지도 모르겠다. 내부에서 뭔가 처리를 하는 건가? React 공식 문서의 renderToString
에도 별다른 설명이 없는 걸 보니 모종의 이유가 있는 듯한데 그걸 모르겠다. 으으, 답답. 일단 여기도 패스.
const html = template
.replace(`<!--app-head-->`, rendered.head ?? "")
.replace(`<!--app-html-->`, rendered.html ?? "");
개인적으로 여기가 가장 중요해 보였다. index.html
에서 보았던 주석은 이곳에서 사용하기 위함이었다. 공식 문서 설명을 보면 주석은 React 코드가 주입될 placeholder였다! 예전에 원티드 프리온보딩 7월 강의에서 비슷한 무언가를 본 기억이 났다. Next.js
의 SSR에서 B:0
, S:0
등의 표시로 주입될 자리를 명시하고 요소를 갈아 끼운다는 점 말이다.
이렇게 자리를 정하고 갈아끼우기 때문에 서버와 클라이언트 간의 DOM 위치를 정확하게 맞춘다.
res.status(200).set({ "Content-Type": "text/html" }).end(html);
완성된 문서의 타입을 지정하고 보내면 SSR 작업이 완료된다.
빌드 후 결과물 폴더 구조는 다음과 같다.
"preview": "cross-env NODE_ENV=production node server"
스크립트를 이용해 실행해 보면 CSR과 달리 문서가 채워진 채로 온다.
이렇게만 보면 그다지 어려워 보이지는 않는다. 하지만 응용해서 무언가를 만들면 미지의 에러에 빠지겠지? 새삼 SSR을 편하게 생성하도록 프레임워크를 내주신 여타 분들에게 감사를 전하고 싶어진다.
참고
Vite - 서버 측 렌더링(SSR)
ECMAScript proposal: Top-level await
create-vite-extra
React - hydrateRoot
React - renderToString
포스팅 잘 보고갑니다! 감사합니다!