들어가며
최근 SSR(Server-Side Rendering)을 도입하는 회사가 빠르게 늘어나고 있다. 당근, 토스증권, 인프런 등 주요 서비스들이 SSR을 적극 활용하고 있으며, 대부분 Next.js를, 일부는 SvelteKit 등을 선택한다. 근데 정작 SSR이 안에서 어떻게 도는지는 잘 모르고 쓰는 경우가 많다.
이번에 우아한테크코스에서 SSR을 직접 구현해볼 기회를 얻었다. 프레임워크 없이 SSR을 어떻게 구현했는지, 그리고 실제 팀 프로젝트에 적용하며 마주친 문제들을 여기에 정리했다.
설계
기존 CSR 방식
사용자가 우리 서버에 데이터를 요청한다. 기존에 React.js같은 CSR 방식이었다면 정적 자원을 브라우저에 전달했을 것이다. 이때 빈 index.html이 필요한 JavaScript 번들 파일을 요청하는 script태그를 담아서 말이다. 아래와 같이 말이다.
이 html은 브라우저에서 렌더링이 되고, JavaScript파일을 요청하는 script태그를 만나면 해당 JavaScript파일들을 html을 전달받은 동일한 엔드포인트로 요청한다. 그럼 이 JavaScript파일들이 실행되면서 브라우저에 컴포넌트들을 렌더링하고, 필요한 폰트, 이미지 등의 리소스를 다운로드하는 것이다.
HTML, JavaScript, 이미지, 폰트 등 모든 자원이 정적 파일이기에 CSR방식에서는 서버가 필요 없다. AWS S3와 같은 정적 저장소에 정적 파일을 저장해두고, CloudFront같은 CDN을 통해 사용자들에게 제공하면 되기 때문이다.
새로운 SSR 방식
SSR방식에서는 서버에서 동적으로 HTML을 생성해야하기에 서버가 필요하다. 이 SSR방식에서는 어떻게 사용자들에게 HTML파일과 정적 파일들을 제공하는지 살펴보자.
CSR방식과 동일하게 사용자가 우리 서버에 데이터를 요청한다. 이 때 Express 등의 서버가 먼저 사용자의 요청을 처리한다. 루트(/)로 왔다면 루트(/)에 대한 HTML파일을 생성하고, 다른 페이지(/other)로 왔다면 다른 페이지에 대한 HTML파일을 생성한다. 그리고 이 HTML파일을 사용자에게 전달하는 것이다.
여기서 중요한 점은 이렇게 생성된 HTML은 이미 데이터가 채워진 상태라는 것이다. CSR에서는 빈 HTML에 JavaScript가 로드된 후에야 데이터를 fetch해서 화면을 그렸다면, SSR에서는 서버가 미리 데이터를 가져와서 완성된 HTML을 만들어 보내준다. 그래서 사용자는 JavaScript가 로드되기 전에도 콘텐츠를 볼 수 있는 것이다.
전체적인 그림은 이게 전부다. 물론 서버 단에서 데이터를 프리패칭하고, 클라이언트에서 하이드레이션하는 작업도 있지만 이러한 내용은 아래 개발 단계에서 구체적으로 설명할 것이다.
개발
클라이언트 빌드
첫 페이지는 서버 단에서 페이지를 생성해서 제공(SSR)하지만, 이후에 클라이언트에서 로그인, 데이터 생성 등 사용자가 브라우저와 상호작용하도록 하기 위해서는 리액트같은 CSR방식이 반드시 필요하다. 이러한 CSR방식을 위한 빌드를 먼저 해보자.
- webpack.client.config.js
전체 코드
module.exports = {
mode: 'development',
entry: path.resolve(__dirname, './src/client/main.tsx'),
output: {
path: path.resolve(__dirname, 'dist/static'),
filename: 'bundle.js',
clean: true,
publicPath: '/static/',
},
module: {
rules: [
{
test: /\.(jsx?|tsx?)$/,
exclude: /node_modules/,
use: {
loader: 'babel-loader',
options: {
presets: [
'@babel/preset-env',
['@babel/preset-react', { runtime: 'automatic' }],
[
'@babel/preset-typescript',
{ isTSX: true, allExtensions: true },
],
],
},
},
},
{
test: /\.css$/,
use: ['style-loader', 'css-loader'],
},
{
test: /\.(png|jpe?g|gif|svg)$/i,
type: 'asset/resource',
generator: {
filename: 'images/[name][ext]',
},
},
],
},
plugins: [
new CopyPlugin({
patterns: [
{ from: 'public/images', to: 'images' },
{ from: 'public/styles', to: 'styles' },
],
}),
new Dotenv({
systemvars: true,
}),
],
resolve: {
extensions: ['.js', '.jsx', '.ts', '.tsx'],
},
};
webpack 설정 파일은 어느 리액트의 webpack 파일과 크게 다르지 않다. 주요 코드만 살펴보면 다음과 같다.
// webpack.client.config.js
module.exports = {
entry: path.resolve(__dirname, './src/client/main.tsx'),
// ..
}
진입점은 src/client폴더의 main.tsx파일이 될 것이다.
// webpack.client.config.js
module.exports = {
output: {
path: path.resolve(__dirname, 'dist/static'),
filename: 'bundle.js',
clean: true,
publicPath: '/static/',
},
// ..
}
결과물을 생성할 위치는 dist폴더의 static이다. dist폴더로 설정하지 않은 이유는, dist폴더에는 서버 코드를 빌드한 결과물도 함께 저장해야하기 때문이다. 그래서 클라이언트 빌드 결과물은 dist/static에, 서버 빌드 결과물은 dist/server에 분리해서 저장하는 구조로 만들었다.
여기서 publicPath: '/static/' 설정이 중요한데, 이는 브라우저가 번들 파일을 요청할 때 사용할 경로를 지정하는 것이다. 나중에 서버 설정에서 이 경로로 정적 파일들을 제공하게 된다.
- 클라이언트 코드
그럼 위 config파일에서 확인한 진입점 파일부터 살펴보자.
// src/client/main.tsx
import { hydrateRoot } from 'react-dom/client';
import App from './App';
import { routes } from './routes';
const initialData = window.__INITIAL_DATA__;
const initialPath = window.__INITIAL_PATH__;
const Component = routes.find((route) => route.path === initialPath).component;
hydrateRoot(
document.getElementById('root'),
<App Component={Component} initialData={initialData} />,
);
window 전역 객체로부터 __INITIAL_DATA__와 __INITIAL_PATH__ 값을 꺼낸다. 이 값들은 서버에서 프리 패칭한 데이터이며, 서버가 HTML을 생성할 때 script 태그 안에 넣어준 것이다. 클라이언트에서는 이 데이터를 활용해서 초기 렌더링 시 불필요한 API 요청을 방지할 수 있다.
그리고 hydrateRoot를 이용해 document의 root라는 id를 가진 DOM과 App 컴포넌트를 연결한다. hydrateRoot는 React 18에 추가됐다. 서버가 그려준 HTML에 이벤트만 붙여 실제로 동작하는 앱으로 만들어준다.
여기서 핵심은, 서버에서 이미 만들어진 HTML 구조를 재사용한다는 것이다. 만약 일반 createRoot를 사용하면 React가 기존 HTML을 버리고 처음부터 다시 렌더링하게 되는데, hydrateRoot는 기존 HTML을 그대로 두고 거기에 이벤트 핸들러만 붙여준다. 이를 통해 사용자는 JavaScript가 로드되기 전에도 서버에서 렌더링된 콘텐츠를 볼 수 있고, JavaScript가 로드된 후에는 완전한 React 앱으로 동작하게 된다.
- 클라이언트 빌드
// package.json
{
"scripts": {
"build:client": "webpack --config webpack.client.config.js",
// ..
}
// ..
}
빌드 또한 기존 CSR방식의 React와 같이 수행한다.
정리하면, 기존 CSR과 달랐던 점은 window 전역 객체에서 초기 데이터와 경로를 꺼낸다는 점, 그리고 hydrateRoot를 이용해 클라이언트 사이드에서 이벤트 리스너와 상태 관리 로직을 추가한다는 점이 있었다.
서버 빌드
- webpack.server.config.js
전체 코드
const path = require('path');
const nodeExternals = require('webpack-node-externals');
const Dotenv = require('dotenv-webpack');
module.exports = {
mode: 'development',
target: 'node',
entry: path.resolve(__dirname, 'src/server/main.ts'),
output: {
filename: 'server.js',
path: path.resolve(__dirname, 'dist/server'),
},
externals: [nodeExternals()],
resolve: {
extensions: ['.js', '.jsx', '.ts', '.tsx'],
},
module: {
rules: [
{
test: /\.(jsx?|tsx?)$/,
exclude: /node_modules/,
use: {
loader: 'babel-loader',
options: {
presets: [
'@babel/preset-env',
['@babel/preset-react', { runtime: 'automatic' }],
[
'@babel/preset-typescript',
{ isTSX: true, allExtensions: true },
],
],
},
},
},
],
},
plugins: [new Dotenv()],
};
서버의 webpack 설정 파일은 클라이언트의 설정 파일과 크게 다르지 않다.
진입점은 src/server폴더의 main.ts파일이고
// webpack.server.config.js
module.exports = {
entry: path.resolve(__dirname, './src/server/main.ts'),
// ..
}
결과물을 생성할 위치는 dist/server폴더의 server.js파일이다.
// webpack.server.config.js
module.exports = {
output: {
filename: 'server.js',
path: path.resolve(__dirname, 'dist/server'),
},
// ..
}
여기서 클라이언트 설정과 다른 부분이 몇 가지 있다. 첫째로 target: 'node'를 설정했는데, 이는 Node.js 환경에서 실행되는 코드를 빌드한다는 것을 webpack에게 알려주는 것이다. 둘째로 externals: [nodeExternals()]를 설정했는데, 이는 node_modules의 패키지들을 번들에 포함시키지 않고 외부 의존성으로 처리하겠다는 의미다. 서버 환경에서는 어차피 node_modules를 직접 참조할 수 있기 때문에 번들 크기를 줄이기 위해 이렇게 설정하는 것이다.
- 서버 코드
위의 진입점에서 확인한 src/server/main.ts파일을 먼저 살펴보자.
// src/server/main.ts
const app = express();
app.use('/static', express.static(path.join(__dirname, '../dist/static')));
app.use('/', movieRouter);
app.use('/detail', detailRouter);
app.listen(3000);
express 서버 인스턴스를 생성한다. 그리고 use를 이용해 미들웨어를 등록한다.
/static 경로로 들어오는 요청에 대해서는 dist/static 폴더의 정적 파일들을 제공하도록 설정했다. 이는 클라이언트 빌드에서 생성된 bundle.js, 이미지, CSS 파일 등을 브라우저에서 요청할 수 있도록 하기 위함이다. 예를 들어 브라우저가 /static/bundle.js를 요청하면, 서버는 /dist/static/bundle.js 파일을 찾아서 제공하게 된다.
그리고 각 라우트(/, /detail)에 대한 라우터를 등록하고 3000번 포트로 서버를 실행한다.
- 각 라우터 함수
사용자가 만약 루트(/)페이지로 접속했다고 해보자. 그럼 movieRouter라는 라우터 함수가 실행이 될 것이다. 이 라우터 함수에 대해 살펴보자.
전체 코드
// src/server/routes/index.tsx
const router = Router();
router.get('/', async (req: Request, res: Response) => {
const movies = await moviesApi.getPopular();
const renderedApp = renderToString(
<App
Component={MovieHomePage}
initialData={{ movies: movies.data.results }}
/>,
);
const html = generateHTMLTemplate({
title: '영화 리뷰',
metadata: {
type: 'website',
title: '영화 리뷰 - 인기 영화 목록',
description: '최신 인기 영화를 확인하고 리뷰를 작성해보세요.',
url: `${req.protocol}://${req.get('host')}${req.originalUrl}`,
siteName: '영화 리뷰',
keywords: '영화, 영화리뷰, 인기영화, 최신영화',
image:
'https://i.namu.wiki/i/iSfHeleDYTu3WatYclmsDG80uvF1346xhhouw5EnYaNK6VNn-Xt8iFW5zE6pQjCMjKT3WfsH1iK3iUgSq15vcA.webp',
},
bodyContent: renderedApp,
initialData: {
movies: movies.data.results,
},
});
res.send(html);
});
export default router;
이 라우터가 하는 일을 단계별로 살펴보면 다음과 같다.
1. 데이터를 패칭한다.
// src/server/routes/index.tsx
router.get('/', async (req: Request, res: Response) => {
const movies = await moviesApi.getPopular();
// ..
}
서버에서 미리 데이터를 가져온다. 이게 SSR의 핵심이다. 클라이언트에서 페이지를 로드한 후 데이터를 요청하는 것이 아니라, 서버가 HTML을 생성하기 전에 먼저 데이터를 가져오는 것이다. 이렇게 하면 사용자가 페이지에 접속했을 때 이미 데이터가 채워진 완성된 HTML을 받게 된다.
2. App 컴포넌트를 문자열로 변환한다.
// src/server/routes/index.tsx
import { renderToString } from 'react-dom/server';
router.get('/', async (req: Request, res: Response) => {
const renderedApp = renderToString(
<App
Component={MovieHomePage}
initialData={{ movies: movies.data.results }}
/>,
);
// ..
}
renderToString은 React 컴포넌트를 HTML 문자열로 바꿔준다. 서버에서 React 컴포넌트를 미리 렌더링해 HTML 문자열을 생성하고, 이를 클라이언트에 전달한다.
패칭한 데이터를 initialData props로 전달해 서버에서 렌더링된 컴포넌트가 이미 데이터를 가진 상태로 HTML을 생성하게 된다. 그러면 renderToString이 실행될 때 컴포넌트가 실제로 렌더링되면서 영화 목록이 이미 포함된 HTML 문자열이 만들어지는 것이다.
3. HTML 템플릿 파일을 생성하고 반환한다.
// src/server/routes/index.tsx
import { renderToString } from 'react-dom/server';
router.get('/', async (req: Request, res: Response) => {
// ..
const html = /*html*/ `
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="stylesheet" href="/static/styles/index.css" />
<title>영화 리뷰</title>
</head>
<body>
<div id="root">${renderedApp}</div>
<script>
window.__INITIAL_DATA__ = ${JSON.stringify({ movies }).replace(/</g, '\x3c')}
window.__INITIAL_PATH__ = "/";
</script>
<script src="/static/bundle.js"></script>
</body>
</html>
`
res.send(html);
}
body에는 위에서 renderToString을 이용해 App컴포넌트를 렌더링한 문자열을 넣는다. 그리고, 패칭한 데이터를 JSON.stringify()로 직렬화해서 window 객체에 넣어준다.
여기서 그냥 JSON.stringify한 값을 그대로 넣으면 안 된다. 데이터 안에 </script> 같은 문자열이 섞여 있으면 브라우저가 여기서 script 태그가 끝난 걸로 착각해버린다. 사용자 입력이 데이터에 들어있다면 이건 곧 XSS 구멍이 된다. 그래서 나는 직렬화한 문자열에서 <를 이스케이프(\x3c, <와 같은 문자)로 치환해서 넣어줬다. 이렇게 하면 JSON으로 파싱될 때는 원래 값 그대로 복원되면서도, HTML 파서가 태그로 오해하는 일은 막을 수 있다. 실무에서는 이 처리를 대신 해주는 serialize-javascript 같은 라이브러리를 쓰기도 한다.
클라이언트에서 하이드레이션을 할 때 이 데이터를 사용하기 때문이다. 만약 이 데이터를 전달하지 않으면 클라이언트에서 다시 API를 호출해야 하고, 그 사이에 화면이 깜빡이거나 데이터가 다시 로드되는 문제가 생긴다. 그래서 서버에서 가져온 데이터를 클라이언트로 "전달"하는 이 과정이 SSR 구현의 핵심이다.
그리고 res.send로 만든 html 결과물을 반환한다. 그럼 끝이다!
- 서버 코드 빌드
Node.js 환경에서 TypeScript 파일을 실행하고, 파일 크기를 줄이기 위해서는 빌드를 해야한다. 1번에서 설정한 webpack 설정 파일을 기반으로 빌드를 해보자.
// package.json
{
"scripts": {
"build:server": "webpack --config webpack.server.config.js",
// ..
}
// ..
}
nodemon을 이용한 자동 빌드
클라이언트와 서버 코드가 변경될 때마다 다시 빌드를 하는건 상당히 비효율적이다.
nodemon은 Node.js 애플리케이션의 파일 변경을 감지하고 자동으로 서버를 재시작해주는 개발 도구이다. 서버 코드를 수정할 때마다 수동으로 서버를 재시작하지 않아도 되어 개발 생산성을 크게 향상시킬 수 있다.
- nodemon파일 생성
// nodemon.json
{
"watch": ["src/server", "src/client"],
"ext": "ts,tsx",
"ignore": ["dist", "node_modules"],
"exec": "npm run build:client && npm run build:server && node dist/server/server.js"
}
src폴더의 server와 client폴더를 감지한다. 확장자가 ts, tsx인 파일의 변경사항을 감지하며, dist와 node_modules 폴더는 감시 대상에서 제외한다.
파일이 변경되면 클라이언트와 서버를 다시 빌드하고, 빌드된 서버 파일을 실행한다. 이렇게 하면 코드를 수정하고 저장만 하면 자동으로 빌드가 되고 서버가 재시작되기 때문에 개발할 때 정말 편하다. 다만 빌드 시간이 좀 걸린다. 실무라면 webpack의 watch 모드나 HMR을 같이 쓴다.
- 스크립트 추가
// package.json
{
"scripts": {
"dev": "nodemon",
// ..
}
// ..
}
이제 npm run dev 명령어 하나로 개발 서버를 실행하고, 파일을 수정하면 자동으로 빌드와 재시작이 이루어진다.
실제 동작 흐름 정리
지금까지 설정한 내용들이 실제로 어떻게 동작하는지 전체 흐름을 정리해보자.
사용자가 브라우저에서 http://localhost:3000에 접속하면 다음과 같은 일이 일어난다.
Express 서버가 요청을 받아서 movieRouter를 실행한다. 라우터는 영화 API를 호출해서 데이터를 가져온 뒤, React 컴포넌트를 renderToString으로 HTML 문자열로 변환한다. 이 HTML 문자열을 템플릿에 넣고, 패칭한 데이터를 script 태그 안에 JSON으로 삽입한다. 완성된 HTML을 브라우저로 전송한다.
브라우저는 이 HTML을 받아서 화면에 표시한다. 이 시점에는 아직 JavaScript가 실행되지 않았지만, 사용자는 이미 영화 목록을 볼 수 있다. 그 다음 브라우저가 HTML 안의 script 태그를 만나서 /static/bundle.js를 요청한다. 서버는 dist/static/bundle.js 파일을 전송한다.
JavaScript 파일이 로드되고 실행되면, hydrateRoot가 호출되어 서버에서 렌더링된 HTML에 이벤트 리스너를 붙인다. 이때 window 객체에서 __INITIAL_DATA__를 꺼내서 사용하기 때문에 추가 API 요청 없이 바로 인터랙티브한 앱이 된다. 이후부터는 일반적인 React 앱처럼 동작한다.
주의할 점
SSR을 구현하면서 몇 가지 주의할 점이 있었다.
서버와 클라이언트의 렌더링 결과가 달라지면 안 된다. hydrateRoot는 서버에서 만든 HTML과 클라이언트에서 렌더링한 결과가 동일하다고 가정하고 작동한다. 만약 결과가 다르면 hydration mismatch 에러가 발생한다. 특히 Math.random()이나 Date.now() 같은 랜덤한 값을 사용하면 서버와 클라이언트에서 다른 결과가 나올 수 있으니 조심해야 한다.
또한 서버에서는 브라우저 API를 사용할 수 없다. window, document, localStorage 같은 브라우저 전용 API를 서버 코드에서 사용하면 에러가 난다. 만약 꼭 사용해야 한다면 useEffect 안에서 사용하거나, typeof window !== 'undefined' 같은 조건문으로 감싸야 한다.
마지막으로, 초기 데이터를 전달할 때 보안에 주의해야 한다. 서버에서 JSON.stringify()로 데이터를 HTML의 script 태그 안에 넣을 때, <나 </script> 같은 문자를 이스케이프하지 않으면 XSS로 이어질 수 있다. 위 개발 단계에서 본 것처럼 <를 \x3c로 치환하거나 전용 라이브러리를 쓰는 게 안전하다. 특히 사용자 입력이 포함된 데이터를 전달할 때는 더욱 조심해야 한다.
트러블 슈팅
서버 실행 시 Unexpected token ‘export’
서버를 실행했을 때 발생한 문제이다. 위 에러 메시지에서 shared/src/theme.ts 파일은 모노레포 내의 shared 패키지이다. 현재 shared 패키지는 사용하는 측에서 번들링을 하는 Just In Time Package 방식을 사용하고 있으며, 즉 이 shared 패키지를 사용하는 측에서 컴파일을 해야 함을 의미한다.
문제의 원인은 Webpack의 기본 동작 방식에 있다. Node.js 환경에서 webpack을 사용할 때, 일반적으로 node_modules의 패키지들은 이미 빌드된 상태라고 가정하고 번들링에서 제외한다. 하지만 우리의 shared 패키지는 빌드되지 않은 TypeScript/ES6 소스 코드 상태이기 때문에, webpack이 이를 처리하지 않으면 export 같은 ES6 구문을 Node.js가 이해할 수 없어 에러가 발생한다.
해결 방법으로 webpack-node-externals를 설치하고, allowlist 옵션을 통해 모노레포 내 패키지는 번들링에 포함하도록 설정했다.
externals: [
nodeExternals({
allowlist: [/@bombom\/shared/], // workspace 패키지는 번들에 포함
}),
],
이렇게 하면 외부 node_modules는 번들링에서 제외하되, @bombom/shared 패키지는 webpack이 처리하여 TypeScript를 컴파일하고 ES6 구문을 변환한다.
❓ 왜 React.js를 webpack으로 번들링할 때는 이런 문제가 생기지 않은걸까?
브라우저 환경을 위해 클라이언트 빌드를 할 때는 모든 코드를 하나의 번들 파일로 만들어야하며, 이 때 node_modules, @bombom/shared 등 모든 파일을 webpack이 컴파일을 한다.
하지만 Node.js 환경을 위해 서버 빌드를 할 때는 런타임에서 node_modules를 직접 읽기가 가능하다. 그렇기에 node_modules는 externals로 제외되어 컴파일을 하지 않고, 이 과정에서 모노레포 내의 shared 패키지도 제외가 되어 컴파일되지 않은 것이었다. 이를
externals.nodeExternals.allowlist로 webpack이 별도로 컴파일하도록 설정해준 것이다.❓ 타겟하는 브라우저/Node.js 환경을 webpack은 어떻게 아는걸까?
webpack설정의 target 필드를 통해 webpack은 이를 알 수 있다.
<img src="/_next/image?url=%2Fnotion-assets%2F5eccf3446797613f.png&w=640&q=75" srcset="/_next/image?url=%2Fnotion-assets%2F5eccf3446797613f.png&w=640&q=75 1x, /_next/image?url=%2Fnotion-assets%2F5eccf3446797613f.png&w=1200&q=75 2x" data-original-src="/notion-assets/5eccf3446797613f.png" alt="Next.js 없이 React.js + SSR 서버 직접 만들기" width="1106" height="200" loading="lazy" decoding="async" style="width:553px;height:auto;aspect-ratio:1106/200" /> 기본값은 web이다.💡 브라우저와 Node.js 환경을 위한 빌드 방식은 다르다.
브라우저 환경을 위해 빌드할 때는 모든 의존성을 번들 파일에 포함시킨다. (코드 스플리팅으로 여러 청크로 나뉘더라도, 모든 코드가 번들링 결과물에 포함된다)
하지만 Node.js 환경을 위해 빌드할 때는 node_modules의 패키지들을 번들에서 제외하고, require 혹은 import 구문을 그대로 남겨둔다. 왜냐하면 Node.js는 파일 시스템 위에서 동작하며 런타임에 node_modules에 직접 접근할 수 있기 때문이다.
이것이 브라우저 환경과 Node.js 환경 빌드의 가장 큰 차이점이다.
서버 실행 시 ReferenceError: window is not defined
ReferenceError: window is not defined는 SSR 구현 시 자주 마주치는 에러이다. 이는 브라우저 환경이 아닌 Node.js 서버 환경에서 window 전역 객체에 접근할 때 발생한다. 이를 해결하려면 현재 실행 환경이 서버인지 확인하여, 서버 환경에서는 window 객체에 접근하지 않도록 분기 처리해야 한다.
- 예시
export const isServer = typeof window === 'undefined';
// ..
const useMediaQuery = (condition: MediaCondition): boolean => {
const getInitialMatches = () => {
const query = convertToMediaQueryString(condition);
return isServer ? false : window.matchMedia(query).matches;
};
// ..
}
❓ isServer를 위와 같이 변수로 설정하면, 번들링 과정에서 평가가 되어 항상 번들링 과정인 Node.js 환경에서 결정되고, true로 평가되는게 아닐까?
Webpack, Vite 등의 번들러는 위의 표현식의 결과를 번들링 과정에서 미리 평가하지 않는다. 런타임 과정에서 JavaScript 코드를 실행한느 과정에서 값을 평가한다.
이를 실제 코드와 함께 살펴보자.
기존 코드 (번들링 전)
// src/utils/environment.ts export const isServer = typeof window === 'undefined';빌드된 코드 (번들링 후)
// dist/server/server.js // .. l="undefined"==typeof window; // ..// dist/static/js/main~6b882012.c09e6da9.js // .. l="undefined"==typeof window; // ..위와 같이 번들링 과정에서 값을 미리 평가하지 않고, 런타임 때 평가한다는 것을 알 수 있다. 그렇기에 우리는 환경을 체크하는 로직을 함수가 아니라 변수로 작성해도 되는 것이다.
❓
window가 아니라typeof window로 체크하는 이유
window객체가 존재하지 않는 환경(Node.js 서버)에서 window를 직접 참조하면 ReferenceError가 발생한다.<img src="/_next/image?url=%2Fnotion-assets%2F39f87849081a9eb6.png&w=480&q=75" srcset="/_next/image?url=%2Fnotion-assets%2F39f87849081a9eb6.png&w=480&q=75 1x, /_next/image?url=%2Fnotion-assets%2F39f87849081a9eb6.png&w=750&q=75 2x" data-original-src="/notion-assets/39f87849081a9eb6.png" alt="Next.js 없이 React.js + SSR 서버 직접 만들기" width="662" height="72" loading="lazy" decoding="async" style="width:480px;height:auto;aspect-ratio:662/72" />하지만 typeof 연산자는 선언되지 않은 변수에 대해서도 에러를 발생시키지 않고, 문자열 "undefined"를 반환한다.
<img src="/_next/image?url=%2Fnotion-assets%2F41807ba9da670337.png&w=384&q=75" data-original-src="/notion-assets/41807ba9da670337.png" alt="Next.js 없이 React.js + SSR 서버 직접 만들기" width="300" height="86" loading="lazy" decoding="async" style="width:300px;height:auto;aspect-ratio:300/86" />따라서 typeof를 사용하면 안전하게 전역 객체의 존재 여부를 확인할 수 있다.
서버 실행 시 ReferenceError: Cannot access 'st' before initialization
Tanstack Query 설정 과정에서 발생한 순환 참조(Circular Dependency) 문제였다. 우리 프로젝트는 Tanstack Router와 Tanstack Query를 함께 사용하고 있다. 공식 문서에 따르면, 두 라이브러리를 통합하려면 라우터 파일에서 createRouter의 context 옵션에 queryClient를 전달해야 한다.
루트 파일에서 QueryClientProvider가 없는 것을 발견하고, 무심코 라우터 파일로부터 queryClient를 import해 Provider를 추가했다.
라우터 파일 → queryClient 생성 ↓ 루트 파일 → queryClient import → QueryClientProvider 생성 ↓ 라우터 파일에서 루트 파일 참조
이로 인해 순환 참조가 발생하면서 초기화 오류가 발생한 것이다. 별생각 없이 Provider부터 추가한 게 화근이었다. 해결은 공식 문서가 알려준 대로였다. Provider를 루트 파일에서 직접 만들지 말고, 라우터를 만들 때 context로 queryClient를 흘려보내는 것이다. 이러면 루트 파일이 라우터 파일을 다시 import할 일이 없어서 순환 고리가 끊어진다. 공식 문서의 통합 패턴을 옮기면 대략 이런 모양이다.
// router.tsx
const queryClient = new QueryClient();
export const router = createRouter({
routeTree,
context: { queryClient }, // context로 전달해 순환 참조를 끊는다
});
이렇게 두면 각 라우트에서는 넘겨받은 context의 queryClient를 꺼내 쓰면 되고, QueryClientProvider도 라우터가 들고 있는 queryClient를 그대로 쓰면 된다. 루트 파일이 queryClient를 만드는 파일을 거꾸로 참조하던 구조가 사라지니 초기화 오류도 없어졌다.
앞으로 내가 사용하는 기술의 설정과 구조를 명확하게 파악하고 개발을 해야겠다는 생각을 했다.
SSR을 언제 쓸까
직접 만들어보고 나니 SSR이 공짜가 아니라는 게 확실해졌다. 무엇을 얻고 무엇을 내주는지 정리해두려 한다.
SSR은 서버가 요청마다 HTML을 그려서 내려준다. 그래서 완성된 화면을 초기 로딩부터 보여줄 수 있고, 크롤러도 데이터가 채워진 HTML을 그대로 읽으니 SEO에 유리하다. 콘텐츠가 검색에 노출되어야 하거나, 첫 화면이 빨리 보여야 하는 서비스라면 이 이점이 크다.
근데 그만큼 내주는 것도 있다. 우선 서버가 반드시 필요하다. CSR은 S3에 정적 파일만 올려두면 끝이지만, SSR은 요청마다 렌더링을 도는 서버를 계속 띄워둬야 하니 서버 비용과 관리 부담이 생긴다. 그리고 서버가 데이터를 미리 패칭하고 HTML을 만들어 보내는 만큼, 이 과정이 무거우면 오히려 첫 바이트까지 걸리는 시간(TTFB)이 늘어난다. 서버가 붙잡고 있는 시간이 길어지니까.
그래서 나는 이렇게 정리했다. 검색 노출이 중요하거나 초기 로딩 속도가 사용자 경험을 좌우하는 서비스라면 SSR을 쓴다. 반대로 로그인 이후에만 쓰는 내부 대시보드처럼 SEO가 필요 없고 인터랙션 위주인 화면이라면 굳이 서버를 떠안을 이유가 없으니 CSR로 간다. 둘 중 뭐가 낫냐가 아니라, 이 서비스가 서버 비용을 내고서라도 SEO와 초기 로딩을 살 만한가를 따지면 되는 것이다.
맺으며
처음에는 SSR이 복잡해 보였는데, 직접 구현해보니 생각보다 단순한 원리였다. 서버에서 React 컴포넌트를 HTML로 만들고, 그 HTML과 데이터를 클라이언트로 전달한 뒤, 하이드레이션으로 인터랙티브하게 만든다. 이게 전부다.
물론 실무에서는 코드 스플리팅, 스트리밍 SSR, 캐싱 전략 등 고려할 게 더 많지만, 이번에 만든 기본 구조를 이해하면 Next.js 같은 프레임워크가 어떻게 동작하는지도 훨씬 잘 이해할 수 있을 것 같다. 그리고 무엇보다 SSR을 언제 쓰고 언제 쓰지 말아야 하는지 판단할 수 있는 기준이 생긴 것 같다.
다음에는 React 18의 Streaming SSR이나, Suspense를 활용한 데이터 패칭 최적화도 한번 시도해봐야겠다.