서버와 브라우저의 '오늘'은 다르다
날짜 계산이 만드는 SSR hydration mismatch
난이도: ★★★★☆ 연관 노트: useLayoutEffect가 SSR에서 경고를 내는 이유, 새로고침해도 안 바뀌는 ‘랜덤’이 필요할 때
핵심 요약
- 서버의 “오늘”은 서버 타임존(보통 UTC) 기준이고, 브라우저의 “오늘”은 사용자 국가 시간 기준이다. 날짜 경계 근처에서는 두 “오늘”이 서로 다른 날이 된다.
- React hydration은 서버가 보낸 HTML과 클라이언트의 “첫 번째 렌더” 결과물을 비교한다. 렌더 본문에서
new Date()로 계산하면 이 첫 렌더부터 서버와 달라질 수 있어 mismatch가 난다. useEffect는 그 비교(hydration 커밋)가 끝난 뒤에 실행된다. 그래서 첫 렌더는 서버와 동일한 초기 state를 그대로 내보내 비교를 통과시키고, 실제 값은 effect에서 갱신한다.
왜 헷갈렸나
두 가지가 매번 헷갈렸다:
- “클라이언트에서 계산하니까 안전하다”고 생각했다 — 아니다. 렌더 본문의
new Date()도 클라이언트에서 계산되는데 그건 mismatch가 난다. 관건은 “어디서 계산하느냐”가 아니라 **“첫 렌더 전이냐 후냐”**다. - 비교가 곧 커밋인지, “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가 불가피한 단일 텍스트 노드의 탈출구. 남용 금지
↔ 같은 원리가 적용되는 곳
- 새로고침해도 안 바뀌는 ‘랜덤’이 필요할 때 — 같은 카운터 기능의 다른 반쪽. 계산이 결정적이어도 입력(오늘 날짜)이 환경마다 다르면 결과가 갈린다
- sessionStorage 대신 URL에 상태를 담는 이유 — 정적 페이지 hydration flash. “서버가 모르는 값은 첫 렌더에 쓸 수 없다”는 동일 원리
- 모듈 상단 상수에 쿠키를 읽으면 왜 안 되는가 — 평가 시점과 환경이 어긋나면 값이 어긋난다는 공통 구조
참고
- React docs — Hydration mismatch — mismatch 원인 목록에 “Date.now() 같은 환경 의존 값” 명시