Back to blog
Aug 17, 2025
7 min read

React 18 SSR & Hydration

React 18의 SSR과 Hydration 내부 동작 원리, 타임라인, 에러 해결 방법, 그리고 Recoil/Jotai/Zustand 상태관리 라이브러리 비교

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 타임라인

  1. 서버 렌더링

    • renderToPipeableStream (Node.js) 또는 renderToReadableStream (Edge) 호출
    • HTML 청크가 브라우저로 전송
  2. HTML 파싱 & JS 로딩

    • 브라우저가 HTML을 파싱하면서 초기 DOM 트리 구성
    • <script> 다운로드 및 실행 준비
  3. Hydration 시작

    • 클라이언트가 동일한 <App /> 트리를 다시 생성
    • 서버 DOM vs 클라 VDOM 비교
    • 매칭 성공 → DOM 유지 + 이벤트 연결
    • 매칭 실패 → mismatch 경고 + 재마운트
  4. 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

`