서버와 브라우저의 '오늘'은 다르다

날짜 계산이 만드는 SSR hydration mismatch

hydrationssrtimezoneuseEffect

난이도: ★★★★☆ 연관 노트: useLayoutEffect가 SSR에서 경고를 내는 이유, 새로고침해도 안 바뀌는 ‘랜덤’이 필요할 때


핵심 요약

  1. 서버의 “오늘”은 서버 타임존(보통 UTC) 기준이고, 브라우저의 “오늘”은 사용자 국가 시간 기준이다. 날짜 경계 근처에서는 두 “오늘”이 서로 다른 날이 된다.
  2. React hydration은 서버가 보낸 HTML클라이언트의 “첫 번째 렌더” 결과물을 비교한다. 렌더 본문에서 new Date()로 계산하면 이 첫 렌더부터 서버와 달라질 수 있어 mismatch가 난다.
  3. useEffect는 그 비교(hydration 커밋)가 끝난 뒤에 실행된다. 그래서 첫 렌더는 서버와 동일한 초기 state를 그대로 내보내 비교를 통과시키고, 실제 값은 effect에서 갱신한다.

왜 헷갈렸나

두 가지가 매번 헷갈렸다:

  1. “클라이언트에서 계산하니까 안전하다”고 생각했다 — 아니다. 렌더 본문의 new Date()도 클라이언트에서 계산되는데 그건 mismatch가 난다. 관건은 “어디서 계산하느냐”가 아니라 **“첫 렌더 전이냐 후냐”**다.
  2. 비교가 곧 커밋인지, “hydration 커밋”이 일반 React 커밋과 다른 것인지 불분명했다.

메커니즘

hydration은 특별한 커밋이 아니다

[render phase]  컴포넌트 함수 실행 → React 엘리먼트 트리 생성  ← "클라 첫 렌더"
[hydration]     그 트리를 "새 DOM 생성" 대신
                서버 HTML의 기존 DOM과 매칭·입양(adopt)
                └─ 여기서 불일치 발견 시 mismatch 경고/에러
[commit phase]  이벤트 핸들러 부착, ref 연결 — hydration의 마무리

hydration은 똑같은 render → commit 사이클이다. 다른 점 하나: commit이 DOM을 새로 만들어 삽입하는 대신 이미 있는 DOM을 입양한다. 그래서 hydration이 빠른 것이다 — DOM 생성 비용을 안 내니까.

useEffect는 검사가 끝난 뒤의 세계

서버 렌더:      count = BASE (고정 초기값)  → HTML 전송
클라 첫 렌더:   count = BASE (같은 초기 state) → 비교 ✓ 통과
─── hydration 완료 (커밋) ───
─── 브라우저 paint ───
useEffect:     setCount(계산(new Date())) → 안전한 재렌더

useEffect는 커밋 후 paint까지 끝난 뒤 비동기로 실행된다(passive effect). mismatch 검사 대상은 “첫 렌더”까지이므로, effect 안에서는 무엇을 계산하든 안전하다.

왜 날짜가 갈리나

서버(UTC)                          브라우저(KST, UTC+9)
2026-07-02 23:30                   2026-07-03 08:30
"오늘 = 7/2"                       "오늘 = 7/3"
     └── elapsedDays 다름 ──→ 렌더 결과 다름 ──→ 💥 mismatch

날짜 기반 파생값(D-day, 경과일 카운터, “오늘의 ~“)은 전부 이 지뢰를 밟을 수 있다.


해결 패턴

const Counter = () => {
  // 초기값은 서버와 클라가 무조건 같은 상수
  const [count, setCount] = useState(BASE_COUNT);

  // hydration 커밋이 끝난 뒤 실행 — 여기서의 new Date()는 안전
  useEffect(() => {
    setCount(getCount(new Date()));
  }, []);

  return <span>{count.toLocaleString()}</span>;
};

트레이드오프: 유저는 이론상 초기값이 한 프레임 보였다가 실제 값으로 바뀌는 걸 본다. 값 차이가 작으면 체감되지 않는다. 깜빡임이 문제라면 useLayoutEffect(paint 전 동기 실행)로 없앨 수 있지만 그만큼 paint를 블로킹한다.


다음에 이 상황을 만나면

SSR 페이지에서 new Date(), Date.now(), Math.random(), window.*을 렌더 본문에서 부르고 싶어지면 → 멈추고 “이 값이 서버와 클라 첫 렌더에서 같은가?”를 묻는다.

  • 초기 렌더는 서버·클라 공통의 상수로
  • 환경 의존 값(시간, 랜덤, 뷰포트)은 useEffect에서 setState
  • 날짜 경계(타임존) 시나리오를 머릿속으로 한 번 돌려보기

커넥팅 닷

← 선행 개념 (이걸 알아야 이해된다)

  • React render phase vs commit phase — “첫 렌더 결과물”과 “커밋 이후”의 경계가 이 노트의 전부
  • 크롤러가 CSR 페이지를 못 읽는 이유 — 서버가 HTML을 미리 만들어 보낸다는 SSR 전제

→ 확장 개념 (여기서 더 나아가면)

  • useLayoutEffect가 SSR에서 경고를 내는 이유 — 커밋 후 paint 전/후 타이밍의 정밀한 구분. 깜빡임 제거가 필요할 때
  • suppressHydrationWarning — 시계처럼 mismatch가 불가피한 단일 텍스트 노드의 탈출구. 남용 금지

↔ 같은 원리가 적용되는 곳


참고