SSR Hydration Mismatch — 터지는 4가지 패턴과 방어 레시피
Next.js static export 환경에서 마주친 hydration mismatch 4유형 (localStorage/Date/Math.random/theme) 과 각각의 방어 패턴. suppressHydrationWarning 의 올바른 사용도 함께.
시작 — "Error: Hydration failed because..."
Static export 설정을 마치고 처음 배포한 밤, 콘솔에 빨간 경고가 수십 줄 찍혔습니다.
Error: Hydration failed because the initial UI does not match what was
rendered on the server.
서버에서 렌더된 HTML 과 클라이언트에서 React 가 재구성한 DOM 이 다르다는 뜻입니다. 페이지가 깜빡이며 다시 렌더링 되는 문제도 함께 왔습니다.
파고들어 보니, Next.js 개발자라면 누구나 만나는 몇 가지 고전적 패턴이 보였습니다. 이 글은 그 4가지 유형과 각각의 방어법을 정리합니다.
유형 1 — localStorage 를 초기값으로
증상 코드:
const [theme, setTheme] = useState(localStorage.getItem('theme') ?? 'light');
왜 터지는가:
- 서버:
localStorage없음 →ReferenceError또는'light' - 클라: 저장값이
'dark'라면 →'dark'로 렌더 - 두 결과가 달라서 hydration mismatch
방어 패턴:
const [theme, setTheme] = useState<'light' | 'dark'>('light');
useEffect(() => {
const saved = localStorage.getItem('theme');
if (saved === 'light' || saved === 'dark') setTheme(saved);
}, []);
초기값은 서버·클라에서 동일한 'light' 로 맞추고, 마운트 후에만 실제 값 주입. 첫 프레임은 기본값으로 렌더, 다음 tick 에서 정확한 값으로 갱신됩니다.
⚠️
useMemo함정:const x = useMemo(() => localStorage.get(...), [])로 쓰면 SSR 에서 null 로 계산된 값이 캐시돼 hydration 후에도 재실행되지 않습니다. 영구 null 이 됩니다. 반드시useEffect로.
유형 2 — Date.now() / new Date()
증상 코드:
const timeLabel = new Date().toLocaleTimeString();
return <span>{timeLabel}</span>;
왜 터지는가:
- 서버: 빌드 시점의 시각
- 클라: 접속 시점의 시각
- 초 단위로만 달라도 mismatch
방어 패턴:
const [time, setTime] = useState<string>('');
useEffect(() => {
setTime(new Date().toLocaleTimeString());
}, []);
return <span>{time}</span>;
초기값은 빈 문자열. 클라이언트 마운트 후에 실제 시각 표시. "시간이 한 박자 늦게 나온다" 는 것은 허용할 수 있는 트레이드오프.
시간 표시가 반드시 첫 페인트에 필요하다면, 서버에서 미리 계산한 값을 prop 으로 내려받고 클라에서도 그 값을 기준 시점으로 사용하는 패턴도 있습니다.
유형 3 — Math.random()
증상 코드:
const id = Math.random().toString(36);
return <div id={id}>...</div>;
왜 터지는가:
- 매 호출마다 다른 값 → 서버와 클라 불일치 100%
방어 패턴:
// React 18+
const id = useId();
return <div id={id}>...</div>;
React 가 서버·클라에서 동일한 ID 를 보장해주는 useId 훅을 사용합니다. aria-* 연결 등에 특히 적합.
만약 "매 렌더마다 다른 난수" 가 진짜 필요하다면 그건 useEffect 안에서 생성하고 state 로 관리해야 합니다.
유형 4 — 날짜 기반 결정론적 계산
증상 코드:
const today = new Date();
const seed = today.getFullYear() * 10000 + today.getMonth() * 100 + today.getDate();
const todayPick = pickByseed(ITEMS, seed);
return <Card item={todayPick} />;
"오늘의 추천" 같이 날짜로 시드를 만드는 패턴. 서버 빌드 시점의 날짜와 사용자 방문 날짜가 다르면 mismatch.
방어 패턴:
const [todayPick, setTodayPick] = useState<Item | null>(null);
useEffect(() => {
const today = new Date();
const seed = today.getFullYear() * 10000 + today.getMonth() * 100 + today.getDate();
setTodayPick(pickBySeed(ITEMS, seed));
}, []);
if (!todayPick) return <CardSkeleton />; // 서버·클라 공통 렌더
return <Card item={todayPick} />;
skeleton 같은 placeholder 를 서버와 클라 둘 다 동일하게 렌더. 값이 정해지는 건 마운트 이후.
suppressHydrationWarning 은 언제 쓰나
React 가 제공하는 escape hatch 입니다. "이 element 의 자식은 mismatch 나도 괜찮아" 라고 선언:
<time suppressHydrationWarning>{new Date().toLocaleTimeString()}</time>
언제 쓰는가:
<time>같이 시각 표시가 의도적으로 다를 수밖에 없을 때- 단일 text node 수준의 mismatch 만
언제 쓰면 안 되는가:
- 하위 컴포넌트 트리까지 덮으려 할 때 (단일 레벨만 적용)
- 근본 원인을 무시하는 회피 수단으로
보통은 useEffect 패턴이 먼저고, suppressHydrationWarning 은 최후 수단입니다.
디버깅 팁
- 프로덕션 빌드 후 테스트: 개발 모드는 관대해서 경고만 출력, 프로덕션 빌드에서 실제 hydration 실패가 드러남
- React DevTools "Highlight updates when components render": 어느 컴포넌트가 재렌더되는지 시각화
- console.error 스택: "Hydration failed" 에러는 어느 element 에서 차이 났는지 알려줌. DOM inspector 로 서버 HTML 확인
요약 — 한 줄 레시피
| 유형 | 해결 |
|---|---|
| localStorage 초기값 | useState + useEffect 로 분리 |
| Date/시각 표시 | 초기 빈 문자열 + 마운트 후 주입 |
| Math.random ID | useId() |
| 날짜 기반 결정 | placeholder + 마운트 후 계산 |
회고
hydration mismatch 는 "React 가 왜 이런 귀찮은 제약을 뒀나" 싶을 정도로 초반엔 괴롭습니다. 하지만 한 번 "서버·클라에서 같은 결정을 내릴 수 있는가" 라는 관점으로 보면 대부분 자연스럽게 해결됩니다.
규칙은 하나입니다: 첫 렌더에는 서버·클라 모두 계산 가능한 값만 넣고, 환경에 의존하는 값은 마운트 이후에 주입한다. 이 습관이 들면 mismatch 자체가 거의 발생하지 않습니다.
방명록
이 글에 대한 한 줄을 남겨주세요
불러오는 중...