React 18 기준 SSR(Server-side Rendering)과 Hydration의 내부 타임라인, 미스매치 원인, 그리고 상태관리 라이브러리(Recoil / Jotai / Zustand)를 정리했습니다.
1. SSR과 Hydration 개념
SSR (Server-side Rendering)
- 서버에서
<App />
을 HTML로 렌더링 후 브라우저로 전송 - 브라우저는 즉시 DOM을 그려 빠른 FCP(First Contentful Paint) 보장
- 단점: 이벤트 핸들러가 없음 → 정적인 화면
Hydration
- 클라이언트에서
hydrateRoot()
실행 - 서버가 만든 DOM과 클라이언트가 만든 VDOM을 비교
- 같으면: DOM은 그대로 두고 이벤트 핸들러만 붙임
- 다르면: mismatch 경고, 해당 노드 교체/재마운트
- 이후엔 평소처럼 CSR 업데이트 사이클로 전환
Why SSR + Hydration?
SSR: 서버가 HTML을 미리 렌더링해서 fast first paint / SEO 확보.
Hydration: 그 HTML에 JS event & state를 붙여서 실제로 interactive하게 만듦.
➡️ 결국 SSR만 하면 “보이는 것”만 되고, Hydration까지 해야 “쓸 수 있는 것”이 됨.
Hydration 비용은 작은가?
작지 않음 오히려 페이지 크기에 따라 꽤 큰 비용이 될 수 있음.
비용 구성:
- JS download: 클라이언트 번들 다운로드
- Parse & execute: JavaScript 파싱 및 실행
- Virtual DOM ↔ Real DOM matching: 서버 DOM과 클라이언트 VDOM 비교
- Event binding & effect run: 이벤트 핸들러 연결 및 useEffect 실행
특히 HTML은 이미 있는데 동일한 트리를 다시 JS로 만들어 매칭해야 해서 double work처럼 느껴지기도 함.
언제 사용하면 좋을까?
사용했을때 가치가 있는 경우: SEO + 초기 interaction이 꼭 필요한 페이지 (검색, 필터, 장바구니 등).
SSR 쓰기에는 과도한 경우: 대부분 static content인데 전부 client component로 hydration 하는 경우.
Cost reduction strategies
- RSC (React Server Components): 기본은 server에서 처리, “use client”는 꼭 필요한 부분만.
- Islands architecture: 페이지 전체가 아니라 특정 widget만 hydration.
- Lazy / selective hydration:
dynamic()
+ssr:false
,Suspense
등을 이용해 필요할 때만. - Streaming SSR: Above-the-fold 먼저 보여주고 아래는 나중에.
- Minimize serialization: 큰 JSON을 HTML에 박아 넣지 않기.
- Optimize effects: 불필요한
useEffect
줄이고 event delegation 사용.
2. React 18에서 바뀐 점
- Streaming SSR
- 서버가 HTML을 청크 단위로 스트리밍 → 클라이언트가 부분적으로 렌더 가능
- Selective Hydration
- 유저가 먼저 상호작용한 컴포넌트부터 우선적으로 하이드레이션
- Server Components
- 서버에서만 실행되는 컴포넌트 개념 (Next.js App Router에서 사용)
3. SSR → Hydration 타임라인
-
서버 렌더링
renderToPipeableStream
(Node.js) 또는renderToReadableStream
(Edge) 호출- HTML 청크가 브라우저로 전송
-
HTML 파싱 & JS 로딩
- 브라우저가 HTML을 파싱하면서 초기 DOM 트리 구성
<script>
다운로드 및 실행 준비
-
Hydration 시작
- 클라이언트가 동일한
<App />
트리를 다시 생성 - 서버 DOM vs 클라 VDOM 비교
- 매칭 성공 → DOM 유지 + 이벤트 연결
- 매칭 실패 → mismatch 경고 + 재마운트
- 클라이언트가 동일한
-
Hydration 완료 → CSR 단계
- 이제 React는 평소처럼 상태 업데이트/이벤트 기반으로 렌더링 진행
하이드레이션 결정 요소
App Router에서 페이지·레이아웃·하위 트리 전체가 Server Component만으로 이루어져 있으면, 클라이언트 하이드레이션은 일어나지 않습니다.
SSG냐 SSR이냐는 “HTML을 언제/어디서 만들었냐”의 차이일 뿐, **하이드레이션 여부는 전적으로 use client
가 있느냐(=클라 컴포넌트가 있느냐)**로 결정됩니다.
정리
-
SSR/SSG + 전부 Server Component → (거의) 0KB 클라이언트 JS · 하이드레이션 없음
- 링크는 그냥
<a>
태그로 정상 네비게이션(풀 리로드) 합니다.
- 링크는 그냥
-
SSR/SSG + 일부 Client Component → 그 클라 컴포넌트 “섬(island)“만 하이드레이션
하이드레이션이 “몰래” 생기는 흔한 트리거
- 어느 파일이든 상단에
"use client"
useState
/useEffect
, 이벤트 핸들러(onClick
) 사용- 브라우저 전용 API 사용하는 서드파티 UI 라이브러리 import
- 레이아웃에서 클라 컴포넌트를 끼워 넣음(경계가 위쪽에 있으면 하위 전부 영향)
진짜 “서버 전용”인지 확인 팁
- DevTools Network에서 해당 라우트 진입 시 큰
.js
청크가 안 딸려오는지 확인 - **페이지 소스(View Source)**에 클라 번들 로더/런타임이 최소인지 확인
- 일부 링크가 소프트 내비(히스토리 교체) 대신 풀 리로드로 동작한다면 보통 클라 런타임 없이 렌더된 상태
4. Hydration Error 원인
Math.random()
,Date.now()
등 비결정적 값localStorage
,matchMedia
같은 브라우저 전용 API- 조건부 렌더링이 서버/클라에서 다르게 동작
- 불필요한 공백/개행 차이
4-1. App Router에서의 Mismatch 경고
App Router에서 불일치(mismatch) 경고는 “클라이언트 컴포넌트가 처음 하이드레이션할 때 서버가 만들어 둔 그 컴포넌트의 HTML과 다를 때” 뜹니다.
서버 컴포넌트는 하이드레이트 대상이 아니지만, 서버가 내려준 HTML 안에 클라 컴포넌트의 초기 DOM도 포함돼 있고, 그 경계에서 비교가 일어납니다.
왜 다를까? (대표 원인)
1. 비결정적 렌더링
Date.now()
, Math.random()
, new Date()
, performance.now()
등을 렌더링 중에 사용
해결: 렌더 단계에서는 쓰지 말고 useEffect
로 마운트 후 상태 갱신.
// ❌ 렌더 중 시각 사용
<span>{new Date().toLocaleTimeString()}</span>;
// ✅ SSR과 동일한 초기값 → 마운트 후 교체
function Clock() {
const [time, setTime] = useState<string>(() => "__");
useEffect(() => setTime(new Date().toLocaleTimeString()), []);
return <span suppressHydrationWarning>{time}</span>;
}
2. 서버와 클라이언트의 데이터/환경 차이
- 서버는 KST/UTC 등 고정 타임존, 클라는 사용자의 로컬 타임존 → 날짜/숫자 포맷이 달라짐
- UA, 언어(locale), Intl 옵션 차이로 포맷 문자열이 달라짐
해결: 서버에서 문자열로 포맷해 props로 넘기기(클라에서도 그대로 사용), 혹은 타임존/locale을 쿠키로 통일.
3. 브라우저 전용 값에 조건부 렌더
window
, matchMedia
, 뷰포트(width) 기반 조건이 SSR 시엔 기본값, 클라에선 실제 값 → 첫 렌더가 달라짐
해결: SSR에선 안전한 기본값으로 렌더하고, 마운트 후 useEffect
로 실제 값 반영.
(또는 useMediaQuery
계열 훅에서 SSR fallback을 명시)
4. 로컬 스토리지/세션 데이터 초기화
서버에선 기본값 []
, 클라에선 localStorage
읽어 [...]
→ 목록 길이, 텍스트 다름
해결: 서버도 쿠키 등으로 같은 초기상태를 만들거나, 클라에서만 읽고 초기 렌더는 서버 값 유지 → 이후 동기화.
5. 키/정렬 불안정
리스트 key가 인덱스거나, 정렬이 클라에서 달라짐
해결: 안정적인 key(id) 사용, 정렬 기준/데이터를 서버와 동일하게.
6. Suspense 경계 시점 차이
서버는 데이터가 준비되어 본문을 렌더, 클라는 첫 렌더에 fallback을 그리면 텍스트가 다름
해결: 동일한 데이터 준비 전략(서버에서 주입한 데이터를 클라에서 그대로 재사용: 예 React Query de/rehydrate).
7. useId 사용 시 트리 차이
React 18의 useId
는 트리 구조가 동일해야 일치. 조건부 렌더로 구조가 바뀌면 id가 달라질 수 있음
해결: SSR/CSR에서 같은 경로로 동일한 요소 수·순서 유지.
8. 직렬화/이스케이프 문제
서버에서 주입한 JSON이 깨지거나 XSS 필터링 차이로 문자열이 달라짐
해결: serialize-javascript
등 안전 직렬화 사용, HTML 엔티티 일관성 유지.
”서버 컴포넌트는 하이드레이션 안 하는데도 왜?”의 핵심
경고는 서버 컴포넌트가 아니라, 그 안에 박힌 **“클라이언트 컴포넌트의 서브트리”**에서 발생합니다.
서버는 클라 컴포넌트의 초기 HTML 스냅샷을 만들어 넣고, 브라우저에서 그 경계에 도달했을 때 React가 DOM vs 최초 클라 렌더 결과를 비교합니다. 이때 다르면 경고가 발생합니다.
5. Hydration Error 해결 방법
- SSR에서는 반드시 결정적 값만 사용
- 브라우저 API는
useEffect
이후에만 실행 - 초기 데이터는 서버에서 직렬화해 클라로 전달
- 큰 객체/리스트 상태는 atom을 쪼개 관리 (불필요 리렌더 방지)
6. 상태관리 비교: Recoil vs Jotai vs Zustand
Recoil
atom
+selector
기반- 여러 atom 의존/새 객체 반환 시 리렌더 범위 커짐
- SSR 초기화 시
atom effects + localStorage
사용 → mismatch 발생 위험
Jotai
- 모든 게 atom
selectAtom(base, pick, equalityFn)
으로 부분 구독 + equality 제어 가능- SSR에서는
useHydrateAtoms
로 서버 직렬화 값 주입
Zustand
useStore(selector, equalityFn)
으로 필요한 조각만 구독shallow
비교 제공- SSR/Hydration 패턴이 문서화가 잘 돼 있음
7. Next.js App Router
Recoil
`