하이드레이션 미스매치, Hydration failed, 서버 클라이언트 불일치 | 콘솔에 빨간 줄이 뜨면 뭐부터 볼까?
Hydration failed는 코드가 틀린 게 아니라 서버가 그려 보낸 HTML과 브라우저 첫 렌더가 달라 리액트가 그 부분을 버리고 다시 그리는 것이다. new Date·Math.random·window·localStorage를 화면 그릴 때 읽거나, p 안의 div 같은 잘못된 HTML 중첩이 흔한 원인이다. 리액트 19 diff와 onRecoverableError로 범인을 찾고, useEffect 두 번 그리기·isClient 상태·dynamic ssr false·suppressHydrationWarning으로 안전하게 고치는 법까지. 개발자, Next.js, 리액트, 프론트엔드, SSR 기준. 2026년 8월 Next.js·React 공식 문서.
하이드레이션 미스매치는 코드가 틀린 게 아니라, 서버가 그려 보낸 HTML과 브라우저가 처음 그린 화면이 서로 달라서 리액트가 그 부분을 버리고 다시 그리는 상황입니다. 그러니 컴포넌트를 붙잡고 노려보기 전에, 서버와 클라이언트가 왜 다르게 그려졌는지부터 찾는 게 먼저예요.
잘 돌아가던 화면인데 콘솔에 Hydration failed because the server rendered HTML didn't match the client라는 빨간 줄이 뜨는 일, 넥스트로 개발하다 보면 한 번쯤 만나셨을 겁니다. 시간 표시나 랜덤 값, 혹은 window 같은 브라우저 전용 값을 화면 그릴 때 읽었을 때 흔히 나오죠.
아래에서는 이 에러가 무슨 뜻인지, 어떤 코드가 서버와 클라이언트를 갈라놓는지, 리액트 19의 진단 화면으로 원인을 좁히는 순서, 그리고 useEffect 두 번 그리기와 suppressHydrationWarning 같은 안전한 수정법까지 짚어 봅니다. 넥스트 앱을 혼자 만들며 같은 빨간 줄 앞에서 뭐부터 눌러야 할지 막막하셨다면 도움이 되실 거예요. 근거는 Next.js 공식 문서와 리액트 공식 문서에 뒀습니다.
하이드레이션 미스매치는 왜 뜨나
하이드레이션은 서버가 먼저 그려서 보낸 HTML 위에, 브라우저의 리액트가 이벤트를 붙이며 화면을 "살아 있게" 만드는 과정입니다. 이때 리액트는 서버가 보낸 DOM을 새로 만들지 않고 그대로 재사용하는데, 자기가 처음 그리는 결과가 그 DOM과 똑같을 거라고 믿고 진행합니다. 두 결과가 어긋나는 순간이 바로 하이드레이션 미스매치예요.
어긋나면 리액트 19는 그 부분(서브트리)을 통째로 버리고 브라우저에서 다시 그립니다. 화면이 한 번 깜빡이고, 콘솔에는 어느 노드가 달랐는지 서버 값과 클라이언트 값을 나란히 보여 주는 진단이 찍히죠. Next.js 공식 문서도 이 에러를 서버에서 미리 그린 리액트 트리와 브라우저 첫 렌더가 달랐을 때 난다고 설명합니다.
흔한 오해 하나만 짚고 가면, 리액트 19가 경고를 에러로 "승격"시킨 게 아닙니다. 리액트 18도 미스매치에서 똑같이 에러를 던졌어요. 달라진 건 보고 방식입니다. 예전에는 중복된 경고 더미에 파묻혀 원인을 못 찾았는데, 19부터는 하나의 에러에 어느 쪽이 무엇을 그렸는지 diff로 보여 주니 훨씬 찾기 쉬워졌습니다.
먼저 기억할 것 | 하이드레이션 미스매치는 성능 버그이자 결정론(determinism) 문제입니다. 서버와 브라우저가 첫 렌더에서 글자 하나까지 똑같이 나와야 한다는 뜻이에요. 억지로 경고만 끄면 사용자는 잘못된 값을 잠깐 보게 됩니다.
서버 HTML과 브라우저 첫 렌더가 다르면 리액트는 그 부분을 버리고 다시 그린다
가장 흔한 원인 여섯 가지
원인은 생각보다 짧은 목록 안에 있습니다. 대부분 "서버와 브라우저가 다른 값을 그릴 수밖에 없는 코드"에서 나오죠. Next.js 문서가 열거하는 대표 원인을 표로 먼저 훑어 보겠습니다.
원인
왜 어긋나나
먼저 볼 코드
시간 값
서버의 지금과 브라우저의 지금은 다른 순간, 다른 시간대
new Date(), Date.now(), toLocaleString()
랜덤 값
부를 때마다 다른 결과
Math.random(), 직접 만든 id 생성기
브라우저 전용 API
서버에는 그 객체가 아예 없음
window, document, localStorage, navigator
잘못된 HTML 중첩
브라우저가 파싱하며 구조를 몰래 고침
p 안의 div, a 안의 a, ul 안의 잘못된 자식
브라우저 확장
하이드레이션 전에 확장이 DOM을 건드림
번역기, 다크모드 확장 등
CDN, 엣지 변조
응답 HTML을 중간에서 다시 씀
클라우드플레어 자동 압축(Auto Minify)
이 중에서 가장 자주 걸리는 건 위 세 가지, 그러니까 화면 그릴 때 시간과 랜덤 값과 브라우저 전용 값을 읽는 경우입니다. localStorage에 저장한 테마나 window.innerWidth로 화면 크기를 재서 첫 렌더에 반영하면, 서버는 그 값을 몰라 한쪽을 그리고 브라우저는 다른 쪽을 그리니 반드시 어긋나죠.
반대로 사람들이 가장 오래 노려보는 원인은 잘못된 HTML 중첩입니다. diff에 찍힌 값은 서버와 클라이언트가 똑같아 보이는데도 에러가 나거든요. 데이터가 아니라 구조가 옮겨진 경우라, 값만 봐서는 안 보입니다. 이건 뒤에서 따로 다루겠습니다.
미스매치를 부르는 전형적인 코드
// 화면 그릴 때 랜덤 값을 읽으면 서버와 브라우저가 다르다
function Widget() {
return <span>{Math.random()}</span>; // 매 렌더 다른 값
}
// 브라우저 전용 값을 렌더에서 바로 읽어도 마찬가지
function Theme() {
const dark = localStorage.getItem('dark'); // 서버엔 localStorage가 없음
return <div className={dark ? 'dark' : 'light'}>...</div>;
}
원인을 찾는 순서
순서를 정해 두면 헤매지 않습니다. 먼저 개발 모드에서 재현하고, 콘솔에 뜬 첫 하이드레이션 에러를 읽습니다. 리액트 19의 진단은 어느 컴포넌트가 문제인지 경로와 함께, 서버가 그린 값과 클라이언트가 그린 값을 나란히 보여 줍니다. 예를 들어 - Server: "2026-05-05T12:00:00Z"와 + Client: "May 5th, 2026 12:00 PM"처럼 찍히면, 날짜 포맷이 브라우저 시간대에 기대고 있다는 뜻이 바로 드러나죠.
diff만으로 안 잡히는 큰 앱이라면 계측을 붙입니다. hydrateRoot에 onRecoverableError를 넘겨 error.message와 errorInfo.componentStack을 찍으면, 어느 컴포넌트가 미스매치를 냈는지 스택으로 확인할 수 있습니다. 그다음 화면 일부를 하나씩 지워 가며 에러가 사라지는 지점을 찾으면 범위가 빠르게 좁혀집니다.
한 가지 더, 브라우저 확장이 의심될 때는 시크릿 창이나 확장을 하나씩 끄면서 확인하세요. 번역기나 다크모드 확장이 하이드레이션 전에 DOM을 건드리면, 내 코드는 멀쩡한데도 에러가 납니다. 그리고 진짜 원인은 개발 서버가 아니라 프로덕션 빌드에서만 보이는 경우도 있어서, next build 뒤 next start로 한 번 재현해 보는 게 안전합니다.
고치는 원칙은 하나입니다. 첫 브라우저 렌더가 서버 HTML과 똑같아지게 만드는 것이죠. 시간이나 랜덤 값처럼 서버에서 정할 수 없는 값은 화면 그릴 때 읽지 말고, 마운트가 끝난 뒤 useEffect 안에서 채웁니다. 이렇게 하면 첫 렌더는 서버와 같은 값(예: null)을 그리고, 하이드레이션 직후 한 번 더 그리며 클라이언트 값을 반영합니다. 이걸 두 번 그리기(two-pass)라고 부릅니다.
브라우저 전용 값이라면 마운트 여부를 알려 주는 상태를 두는 방법도 자주 씁니다. isClient를 useEffect에서 true로 바꾸면, 첫 렌더는 서버와 같은 분기를 그리고 그다음에 클라이언트 분기로 넘어가죠. 아예 서버 렌더가 필요 없는 컴포넌트라면 next/dynamic의 ssr: false로 그 경계만 서버 렌더에서 빼면 됩니다.
마지막 수단이 suppressHydrationWarning입니다. 타임스탬프처럼 서버와 클라이언트가 어쩔 수 없이 다른 한 요소에만 붙이세요. 리액트 문서 기준 이 속성은 한 단계(one level deep)만 적용되고, 텍스트 내용 말고는 리액트가 값을 맞춰 주지도 않습니다. 큰 트리를 통째로 감싸거나 진짜 원인을 덮는 용도로 쓰면 안 됩니다.
상황
권장 방법
주의
시간, 랜덤 값
useEffect에서 값 채우기
첫 렌더는 서버와 같은 값
브라우저 전용 값
isClient 상태로 두 번 그리기
렌더에서 직접 읽지 않기
외부 스토어, 미디어쿼리
useSyncExternalStore
getServerSnapshot로 서버 값 지정
서버 렌더가 불필요
next/dynamic ssr: false
그 경계만 최소로
피할 수 없는 한 요소
suppressHydrationWarning
한 단계만, 남발 금지
useEffect 두 번 그리기 패턴
import { useState, useEffect } from 'react';
function Clock() {
const [now, setNow] = useState(null); // 서버와 같은 첫 값
useEffect(() => {
setNow(new Date().toLocaleTimeString()); // 브라우저에서만
}, []);
return <span>{now ?? '불러오는 중...'}</span>;
}
// 어쩔 수 없이 다른 한 요소만 예외 처리
// <time suppressHydrationWarning>{new Date().toLocaleDateString()}</time>
첫 렌더를 서버와 같게 맞추고, 클라이언트 값은 마운트 뒤에 채운다
HTML 중첩 오류는 값이 아니라 구조 문제
값은 분명 같은데도 미스매치가 난다면 HTML 중첩을 의심해야 합니다. 브라우저는 잘못된 중첩을 파싱하는 동안 조용히 구조를 고쳐 버립니다. 그래서 리액트가 하이드레이션하려는 DOM이, 서버가 직렬화해 보낸 DOM과 달라지죠. 데이터는 완벽하게 결정론적인데도 에러가 나는 이유입니다.
대표 사례는 <p> 안에 <div>를 넣는 경우입니다. <p>는 블록 요소를 자식으로 못 갖기 때문에, 브라우저가 <div>가 나오는 순간 앞의 <p>를 강제로 닫아 버립니다. <a> 안에 또 <a>, <button> 안에 <button>처럼 상호작용 요소를 겹쳐 넣는 것, 테이블 마크업을 규격과 다르게 쓰는 것도 같은 문제를 냅니다. 특히 CMS나 마크다운에서 온 HTML을 그대로 꽂을 때 자주 걸리죠.
리액트 19의 진단은 어느 태그가 규격을 어겼는지 짚어 줍니다. diff의 값이 똑같아 보이면 "구조가 옮겨졌구나" 하고 태그 중첩부터 확인하세요. 이 경우 해법은 useEffect가 아니라, 잘못된 태그를 올바른 구조로 바꾸는 것입니다. 저처럼 한국에서 넥스트 사이트를 여러 개 혼자 돌리는 1인 개발자라면, 마크다운이나 CMS 본문을 <p>에 그대로 꽂는 자리에서 이 문제를 가장 자주 만나게 됩니다. 폼이나 라우팅이 얽혀 헷갈릴 때는 넥스트 에러 처리 글이나 폼 상태 훅 글도 함께 보시면 됩니다.
[ ] diff 값이 같아 보이면 태그 중첩부터 의심했다
[ ] p 안의 div, a 안의 a, button 안의 button을 확인했다
[ ] CMS, 마크다운에서 온 HTML 구조를 검사했다
[ ] 중첩 문제는 useEffect가 아니라 태그를 고쳐서 해결했다
[ ] CSS-in-JS 설정이 공식 예제를 따르는지 확인했다
suppressHydrationWarning은 만능이 아니다 | 이 속성은 한 요소의 경고만 끕니다. 리액트는 텍스트가 아닌 값은 맞춰 주지 않으니, 사용자는 서버 값을 그대로 보다가 다음 렌더에서야 바뀐 값을 봅니다. 구조 문제나 브라우저 전용 값을 이걸로 덮으면 진짜 버그가 그대로 남습니다.
문법 오류라기보다 서버와 브라우저가 첫 렌더에서 다른 결과를 그렸다는 신호입니다. 시간, 랜덤 값, 브라우저 전용 값을 화면 그릴 때 읽었거나, HTML 중첩이 잘못됐을 때 주로 납니다. 값이 아니라 결정론이 깨진 지점을 찾아야 합니다.
리액트 19가 경고를 에러로 바꾼 건가요?
아닙니다. 리액트 18도 미스매치에서 에러를 던졌습니다. 19에서 달라진 건 보고 방식으로, 하나의 에러에 서버 값과 클라이언트 값을 diff로 보여 줘서 원인을 찾기 쉬워졌습니다. 버그가 새로 생긴 게 아니라 잘 보이게 된 것뿐입니다.
suppressHydrationWarning을 붙이면 다 해결되나요?
한 요소의 경고만 끄는 마지막 수단입니다. 한 단계만 적용되고 텍스트가 아닌 값은 맞춰 주지 않아서, 큰 트리를 감싸거나 진짜 원인을 덮으면 버그가 그대로 남습니다. 타임스탬프처럼 어쩔 수 없이 다른 요소에만 아껴 쓰세요.
localStorage 값을 첫 화면에 반영하려면 어떻게 하나요?
렌더에서 직접 읽지 말고, isClient 같은 상태를 useEffect에서 true로 바꿔 두 번 그리기로 처리합니다. 첫 렌더는 서버와 같은 분기를 그리고 마운트 뒤에 클라이언트 값을 반영하면 됩니다. 외부 스토어나 미디어쿼리라면 useSyncExternalStore가 더 알맞습니다.
값은 같은데도 미스매치가 나요.
HTML 중첩을 의심하세요. p 안의 div, a 안의 a처럼 규격에 어긋난 중첩은 브라우저가 파싱하며 구조를 고쳐서, 값이 같아도 DOM 구조가 달라집니다. 이 경우 useEffect가 아니라 태그 구조 자체를 올바르게 바꿔야 합니다.
개발에선 나는데 원인이 안 잡혀요.
브라우저 확장을 하나씩 끄거나 시크릿 창으로 확인해 보세요. 번역기나 다크모드 확장이 하이드레이션 전에 DOM을 건드리면 내 코드가 멀쩡해도 에러가 납니다. 또 next build 뒤 next start로 프로덕션과 같은 조건에서 재현해 보는 편이 정확합니다.
하이드레이션 미스매치는 결국 서버와 브라우저가 첫 렌더에서 같은 화면을 그렸는가의 문제입니다. 리액트 19의 diff로 어느 값이 달랐는지 읽고, 시간이나 랜덤, 브라우저 전용 값이면 useEffect 두 번 그리기로 옮기고, 값이 같은데도 나면 HTML 중첩을 고치면 대부분 풀립니다. 관련 글: 넥스트 에러 처리, 폼 상태 훅, CORS 프리플라이트.