Next.js 하이드레이션 에러, 이제 그만 쫄자: Text content does not match 완벽 해결법
Next.js로 개발하다 보면 무조건 한 번은 마주치는 그 녀석.
콘솔창을 빨갛게 물들이는 바로 그 에러입니다.
Error: Text content does not match server-rendered HTML.
Warning: PropclassNamedid not match. Server: "bg-blue-500" Client: "bg-red-500"
"아니, 그냥 화면 잘 나오는데 무시하면 안 되나?" 싶으실 수도 있습니다.
하지만 이 녀석, 생각보다 악질입니다. 사용자는 아주 잠깐 이상하게 깨진 UI를 보게 되고(CLS),
심한 경우 버튼을 눌러도 반응이 없는 '먹통' 상태가 되기도 하거든요.
단순히 suppressHydrationWarning 덕지덕지 붙여서 빨간 줄만 없애는 건 하수가 하는 일이죠.
오늘은 이 '하이드레이션(Hydration)'이라는 놈이 대체 왜 이렇게 예민한 건지,
그리고 실무에서 맞닥뜨리는 대표적인 상황 4가지와 해결책을 제대로 파보겠습니다.
하이드레이션(Hydration), 왜 이렇게 까탈스러울까?
적을 알아야 백전백승. 원리부터 아주 쉽게 정리해 드릴게요.
옛날 리액트(CRA)는 브라우저한테 텅 빈 <body> 하나 던져주고 "자, 이제 자바스크립트로 알아서 그려!" 하는 식이었죠. (CSR)
그래서 초기 로딩도 느리고 SEO도 꽝이었습니다.
반면 Next.js (SSR) 는 친절합니다.
- 서버: 백엔드에서 미리 리액트를 돌려서 완성된 HTML(
<h1>안녕</h1>)을 만듭니다. - 전송: 이걸 브라우저에 바로 쏴줍니다. 사용자는 0.1초 만에 글씨를 볼 수 있죠.
- 하이드레이션: 그 뒤에 리액트(JS)가 살금살금 와서, "이미 그려져 있는 HTML"에 이벤트 리스너 같은 생명력을 불어넣습니다. 이게 바로 수분 보충, 하이드레이션입니다.
여기서 대원칙 하나!
"서버가 내려준 HTML이랑, 브라우저에서 방금 내가 계산한 결과랑 토씨 하나라도 다르면 안 돼!"
만약 서버는 "안녕하세요"라고 보냈는데, 브라우저에서 막상 코드를 돌려보니 "반갑습니다"가 나왔다?
리액트는 멘붕에 빠집니다.
"야, 이 DOM 못 믿겠다. 다 엎어!"
이러면서 기껏 서버가 그려준 화면을 무시하고 새로 그리거나, 붉은 에러를 뿜어내는 거죠.
에러가 터지는 4가지 국룰 패턴
저도 맨날 당했던 그 상황들입니다.
1. 시간 여행자의 실수 (타임스탬프)
가장 흔한 범인입니다.
export default function Footer() { // 💣 펑! 에러 발생 return <footer>현재 시각: {new Date().toLocaleTimeString()}</footer>; }
- 서버: 서울 리전 서버에서 렌더링 할 땐 "오전 10:00:00"
- 클라이언트: 사용자 브라우저에서 실행될 땐 "오전 10:00:01" (혹은 미국 시간이라 전혀 다름)
서버랑 클라이언트가 서로 다른 시간을 보고 있는데, 당연히 결과가 다르겠죠?
Math.random()으로 랜덤 숫자 뽑는 것도 똑같습니다. 실행할 때마다 결과가 달라지면 무조건 터집니다.
2. HTML 족보 꼬임 (잘못된 태그 중첩)
브라우저는 HTML을 대충 써도 찰떡같이 알아듣지만, 리액트는 얄짤없습니다.
제발 이러지 마세요: <p> 태그 안에 <div> 넣기.
// ❌ Next.js가 제일 싫어하는 코드 <p> 안녕하세요 <div>반갑습니다</div> </p>
HTML 표준에서 <p>(문단) 안에는 <div>(구역)가 못 들어갑니다.
브라우저는 이걸 보자마자 "어? 문단 끝났네" 하고 <p>를 강제로 닫아버립니다.
<!-- 브라우저가 해석한 결과 --> <p>안녕하세요</p><div>반갑습니다</div><p></p>
근데 리액트(Virtual DOM)는 "아닌데? 내 기억엔 <div>가 <p> 안에 있었는데?" 하고 우깁니다.
현실(DOM)과 이상(Virtual DOM)의 괴리. 여기서 에러가 빵 터집니다.
해결책: 그냥 <div> 쓰거나, 인라인 요소인 <span>을 쓰세요.
3. 범인은 제3자 (브라우저 확장 프로그램)
"아니 코드 진짜 완벽한데 왜 에러 남??" 싶을 때 있죠.
범인은 사용자가 깔아둔 크롬 확장 프로그램일 확률이 높습니다. (Grammarly, 번역기, 다크 모드 플러그인 등)
이 녀석들은 리액트가 하이드레이션 하기도 전에 DOM에 몰래 침입해서 <span> 같은 걸 끼워 넣습니다.
리액트 입장에선 "누구세요? 저 이런 거 렌더링 한 적 없는데요?" 하고 당황하는 거죠.
시크릿 모드(Incognito) 켰을 때 에러가 안 난다? 100%입니다. 이건 우리 잘못 아닙니다. (근데 해결은 해야 함...)
4. window 찾지 마오
export default function Navbar() { // 서버(Node.js)엔 window가 없어요... const isMobile = window.innerWidth < 768; return <nav>{isMobile ? '메뉴' : 'PC 메뉴'}</nav>; }
보통 typeof window !== 'undefined' 체크해서 막잖아요?
export default function Navbar() { // 서버: window 없음(false) -> "PC 메뉴" 렌더링 // 클라이언트(모바일): window 있음(true) -> "메뉴" 렌더링 const isMobile = typeof window !== 'undefined' && window.innerWidth < 768; }
이게 더 문제입니다.
서버는 PC 메뉴를 (window가 없으니까),
모바일 사용자는 모바일 메뉴를 (window가 있으니까) 그리게 됩니다.
결국 또 불일치! 💥
해결책: 깔끔하게 처리하는 3가지 방법
전략 1: useEffect로 '마운트 여부' 체크 (정석)
서버랑 클라이언트가 다를 수밖에 없는 데이터(시간, 로컬스토리지)라면, 리액트한테 솔직히 말해야 합니다.
"일단 서버랑 똑같이(빈 값 or 로딩) 그리고, 나중에 진짜 데이터로 갈아끼울게."
// hooks/useIsMounted.ts import { useState, useEffect } from 'react'; export function useIsMounted() { const [mounted, setMounted] = useState(false); useEffect(() => { setMounted(true); }, []); return mounted; } // 사용 예시 function Clock() { const isMounted = useIsMounted(); // 1. 마운트 전엔 아무것도 보여주지 마 (서버랑 일치 시킴) if (!isMounted) return null; // 2. 마운트 된 후에야 비로소 진짜 시간 렌더링 return <span>{new Date().toLocaleTimeString()}</span>; }
조금 귀찮고 렌더링이 두 번 일어나긴 하지만, 이게 가장 확실하고 안전한 방법입니다.
전략 2: suppressHydrationWarning (눈감아주기)
"아, 시간 1초 차이 나는 거? 알아요. 그냥 넘어갑시다 좀."
리액트한테 경고 좀 끄라고 시킬 수 있습니다.
<span suppressHydrationWarning> {new Date().toLocaleTimeString()} </span>
이러면 리액트가 "오키, 여긴 텍스트 달라도 넘어감. 클라이언트 값으로 덮어씀." 하고 조용히 지나갑니다.
주의: 이거 만능치트키 아닙니다. 딱 텍스트 텍스트(Text Content) 차이에만 먹힙니다. 컴포넌트 구조가 다르면 소용없어요.
전략 3: dynamic import로 SSR 끄기 (Next.js 필살기)
지도를 쓴다거나, 에디터를 쓰는데 이 라이브러리가 자꾸 window를 찾아서 터진다?
그냥 그 컴포넌트만 SSR에서 빼버리세요.
import dynamic from 'next/dynamic'; // "이 컴포넌트는 서버에서 돌리지 마. 클라이언트에서만 로드해." const MapComponent = dynamic(() => import('./Map'), { ssr: false, // 핵심! loading: () => <p>지도 그리는 중...</p>, }); export default function Page() { return <MapComponent />; }
Next.js는 서버에서 loading 컴포넌트만 그려서 내려보냅니다. 충돌 날 일이 아예 없죠.
마치며
이 에러, 처음엔 무섭지만 원리만 알면 별거 아닙니다.
요약 들어갑니다.
- 에러 메시지를 읽자:
Expected div, found p면 태그 중첩 실수. window쓰지 마라: 렌더링 중에 쓰지 말고useEffect안으로 격리해라.- 확장 프로그램 꺼봐라: 시크릿 모드 ㄱㄱ.
- 정 안되면 끄자:
suppressHydrationWarning이나dynamic(ssr: false)로 탈출.
하이드레이션은 빠른 UX를 위해 우리가 치러야 할 비용입니다.
이제 빨간 에러에 쫄지 말고 우아하게 해결해 봅시다. 즐코! 🚀