안녕하세요! 프론트엔드 개발이라는 멋진 길을 걷고 계신 여러분을 위한 강사입니다. 오늘은 Zustand 공식 문서 중에서 아주 중요한 주제인 SSR과 하이드레이션(Hydration) 파트를 함께 살펴보겠습니다.
이 개념들은 단순히 Zustand뿐만 아니라 React 기반의 최신 프레임워크(Next.js 등)를 다룰 때 반드시 알아야 하는 핵심 원리에요. 특히 프론트엔드 직무 기술 면접에서도 아주 단골로 나오는 주제이니, 이번 기회에 확실하게 내 것으로 만들어두시면 앞으로 큰 무기가 될 겁니다! 자, 그럼 시작해볼까요?
서버 사이드 렌더링(SSR)은 서버에서 우리의 컴포넌트들을 HTML 문자열로 미리 렌더링한 다음, 이를 브라우저로 바로 보내고, 마지막으로 클라이언트에서 이 정적인 마크업을 완벽하게 상호작용(interactive)할 수 있는 앱으로 "하이드레이션(hydrate)"하는 기법을 말합니다.
💡 [강사의 부연 설명 & 팁]
원래 React는 기본적으로 클라이언트에서 모든 화면을 그리는 CSR(Client-Side Rendering) 방식이에요. 하지만 빈 화면이 먼저 보인다는 단점과 SEO(검색 엔진 최적화)에 불리하다는 점 때문에 SSR이 등장했죠. 브라우저가 자바스크립트를 다운로드하고 실행하기 전에, 서버에서 미리 완성된 뼈대(HTML)를 만들어서 보내주기 때문에 사용자에게 훨씬 빠르게 첫 화면을 보여줄 수 있답니다!
만약 우리가 React를 사용해서 상태가 없는(stateless) 단순한 앱을 렌더링하고 싶다고 가정해볼게요. 이를 위해 우리는 express, react, 그리고 react-dom/server라는 패키지가 필요합니다. 상태가 없는 앱이기 때문에 클라이언트 쪽 렌더링을 담당하는 react-dom/client는 필요하지 않아요.
각각이 어떤 역할을 하는지 자세히 살펴볼까요?
express는 Node 환경에서 실행할 수 있는 웹 서버 앱을 만들 수 있게 도와줍니다.react는 우리 앱에서 사용할 UI 컴포넌트들을 만드는 데 사용하죠.react-dom/server는 우리가 만든 컴포넌트들을 서버 환경에서 렌더링할 수 있도록 돕는 역할을 합니다.// tsconfig.json
{
"compilerOptions": {
"noImplicitAny": false,
"noEmitOnError": true,
"removeComments": false,
"sourceMap": true,
"target": "esnext"
},
"include": ["**/*"]
}
참고: 여러분의
tsconfig.json파일에서 모든 주석을 지우는 것을 잊지 마세요! (설정 파일에 주석이 있으면 빌드 과정에서 예상치 못한 에러가 날 수 있거든요.)
// app.tsx
export const App = () => {
return (
<html>
<head>
<meta charSet="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Static Server-side-rendered App</title>
</head>
<body>
<div>Hello World!</div>
</body>
</html>
)
}
// server.tsx
import express from 'express'
import React from 'react'
import ReactDOMServer from 'react-dom/server'
import { App } from './app.tsx'
const port = Number.parseInt(process.env.PORT || '3000', 10)
const app = express()
app.get('/', (_, res) => {
const { pipe } = ReactDOMServer.renderToPipeableStream(<App />, {
onShellReady() {
res.setHeader('content-type', 'text/html')
pipe(res)
},
})
})
app.listen(port, () => {
console.log(`Server is listening at ${port}`)
})
tsc --build
node server.js
하이드레이션은 서버에서 전달받은 초기 HTML 스냅샷에 생명력을 불어넣어, 브라우저에서 실제로 동작하고 상호작용할 수 있는 완전한 앱으로 탈바꿈시키는 과정이에요. 컴포넌트를 올바르게 "하이드레이트"하려면 리액트의 hydrateRoot 메서드를 사용해야 합니다.
💡 [강사의 부연 설명 & 팁]
'하이드레이션(Hydration)'은 직역하면 '수분 공급'이라는 뜻이에요. 서버에서 내려온 메마른 HTML 뼈대 위에, 자바스크립트라는 물(생명력)을 쫙 뿌려서 이벤트 리스너도 달아주고 상태도 연결해주는 과정이라고 이해하시면 기억하기 쉽습니다! 이 과정을 거쳐야 비로소 버튼을 클릭했을 때 반응하는 진짜 앱이 되는 거죠.
이번에는 상태를 가지는(stateful) 앱을 React로 렌더링한다고 해볼게요. 이를 위해서는 앞서 사용했던 express, react, react-dom/server에 더해서 클라이언트 쪽을 위한 react-dom/client도 필요합니다.
자세히 들여다볼까요?
express는 Node 환경에서 실행할 웹 서버를 구축합니다.react는 UI 컴포넌트를 만듭니다.react-dom/server는 컴포넌트를 서버에서 렌더링(HTML 문자열화)합니다.react-dom/client는 생성된 컴포넌트를 클라이언트 측에서 하이드레이트(생명력 부여)하는 역할을 합니다.참고: 컴포넌트를 서버에서 성공적으로 렌더링했다고 하더라도, 사용자가 앱과 상호작용할 수 있게 하려면 반드시 클라이언트에서 "하이드레이션" 과정을 거쳐야 한다는 점을 절대 잊지 마세요!
// tsconfig.json
{
"compilerOptions": {
"noImplicitAny": false,
"noEmitOnError": true,
"removeComments": false,
"sourceMap": true,
"target": "esnext"
},
"include": ["**/*"]
}
참고: 앞서 말씀드린 것처럼
tsconfig.json파일의 주석은 모두 제거해주세요.
// app.tsx
export const App = () => {
return (
<html>
<head>
<meta charSet="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Static Server-side-rendered App</title>
</head>
<body>
<div>Hello World!</div>
</body>
</html>
)
}
// main.tsx
import ReactDOMClient from 'react-dom/client'
import { App } from './app.tsx'
// 바로 여기가 하이드레이션이 일어나는 마법의 순간입니다!
ReactDOMClient.hydrateRoot(document, <App />)
// server.tsx
import express from 'express'
import React from 'react'
import ReactDOMServer from 'react-dom/server'
import { App } from './app.tsx'
const port = Number.parseInt(process.env.PORT || '3000', 10)
const app = express()
app.use('/', (_, res) => {
const { pipe } = ReactDOMServer.renderToPipeableStream(<App />, {
bootstrapScripts: ['/main.js'], // 클라이언트 스크립트를 주입해줍니다.
onShellReady() {
res.setHeader('content-type', 'text/html')
pipe(res)
},
})
})
app.listen(port, () => {
console.log(`Server is listening at ${port}`)
})
tsc --build
node server.js
경고:
hydrateRoot에 전달하는 React 트리는 서버에서 생성했던 결과물과 완벽하게 똑같아야 합니다. > 하이드레이션 에러를 일으키는 가장 흔한 원인들은 다음과 같아요:
- 루트 노드 안의 React가 생성한 HTML 주변에 불필요한 공백(예: 줄바꿈 등)이 들어간 경우.
- 렌더링 로직 안에서
typeof window !== 'undefined'와 같은 체크(분기문)를 사용한 경우.- 렌더링 로직 안에서
window.matchMedia와 같은 브라우저 전용 API를 직접 호출한 경우.- 서버와 클라이언트에서 서로 다른 데이터를 렌더링하는 경우. (예: 난수 생성이나 현재 시간 사용 등)
React가 일부 하이드레이션 에러는 자체적으로 복구하기도 하지만, 여러분은 이것들을 일반적인 버그처럼 반드시 고쳐야 합니다. 운이 좋으면 그저 성능이 조금 느려지는 정도로 끝나겠지만, 최악의 경우엔 이벤트 핸들러가 엉뚱한 HTML 요소에 붙어버려서 앱 전체가 꼬여버릴 수 있거든요!
💡 [강사의 팁]
현업에서나 토이 프로젝트를 하실 때 "Hydration Mismatch" 에러는 정말 지겹도록 만나게 되실 겁니다. 주로 브라우저에만 존재하는window객체나localStorage에 접근하려 할 때 많이 발생해요. 서버에는 브라우저가 없으니까요!
이럴 때는 컴포넌트가 클라이언트에 마운트된 이후(즉,useEffect안에서) 브라우저 전용 API를 사용하거나 상태를 업데이트하도록 처리하는 패턴을 주로 사용하니 꼭 기억해두세요!
이러한 주의사항과 흔히 빠지기 쉬운 함정들에 대한 더 자세한 내용은 공식 문서에서 확인하실 수 있습니다: hydrateRoot 보러가기
이전 문서
Next.js와 함께 설정하기
다음 문서
props로 상태 초기화하기