-
Next.js 하이드레이션 에러 우아하게 처리하기프론트엔드 2026. 6. 22. 16:06
Next.js가 어떻게 돌아가는지 부터 파악해보기
- 프로젝트를 진행하면서 hydration에러가 발생했습니다.React error 418이 Hydration에러가 발생했다라는 뜻이라고 합니다.

- 근데 왜 발생했을까요

- 우선 Hydration이 뭔지 부터 저는 바로잡고자 다시한번 공부를 했습니다.
Hydration?

이정환님의 인프런 강의를 참고한 이미지입니다. - 이번 Next.js에서 어떻게 렌더링을 하는지에 대해서 봅시다.
- 유저가 브라우저에서 접속 요청을 하면 서버에서 사전 렌더링이 시작됩니다. 이때 Next.js는 서버 컴포넌트(SC)와 클라이언트 컴포넌트(CC)를 구분해서 처리합니다.
- 서버 컴포넌트는 서버에서 실행된 뒤 RSC Payload 형태로 직렬화됩니다. 클라이언트 컴포넌트는 서버에서 HTML로 렌더링됩니다. 이 둘을 합쳐 완성된 HTML을 브라우저에 전달하고 유저는 즉시 화면을 볼 수 있습니다.
🚨 여기서 주의할 점 — SC는 RSC Payload로 클라이언트에 전달될 뿐 추가 작업이 없습니다. 반면 CC는 JS Bundle을 통해 Hydration 과정이 필요합니다.
- JS Bundle에는 SC가 제외된 CC만 포함되어 있으며 Hydration은 서버에서 받은 HTML에 이벤트 핸들러를 연결하는 과정입니다. 이 과정이 완료되어야 버튼 클릭 상태 변경 같은 상호작용이 가능해집니다.
다시 한번 정리하면 아래 그림으로 정리를 할 수 있습니다.
- 유저 접속 요청 -> SC, CC구분 해서 따로 실행 -> 실행된 결과 HTML을 전달 -> 유저가 화면 볼 수 있음 -> JS Bundle(CC만) -> 하이드레이션

다시 프로젝트로
- 이제 흐름을 이해했습니다.
- 근데 왜 프로젝트에 에러가 발생할까요?? 코드를 봅시다
const getGenerationStatus = sessionStorage.getItem( WRITE_GENERATION_STATUS_KEY ); const helperText = getGenerationStatus === "generating" ? "글 생성 중입니다. 버튼을 눌러 생성 화면으로 이동하세요." : getGenerationStatus === "done" ? "생성이 완료되었습니다. 버튼을 눌러 결과를 확인하세요." : getGenerationStatus === "error" && Boolean(getGenerationStatus) ? "생성 중 오류가 발생했습니다. 버튼을 눌러 다시 확인하세요." : `저장 가능: 포스트 최대 ${MAX_STORED_POSTS}개까지. 생성 후 자동으로 저장됩니다.`; const buttonLabel = getGenerationStatus === "generating" ? "생성 화면으로 이동" : getGenerationStatus === "done" ? "생성 결과 보기" : getGenerationStatus === "error" && Boolean(getGenerationStatus) ? "생성 화면으로 이동" : "AI 글 생성하기";- 코드를 보면 세션 스토리지를 사용합니다.
- 여기서 문제가 발생하는데 세션 스토리지는 브라우저에만 존재합니다. 이게 왜 문제가 되는지 살펴보면
서버에서 렌더링할 때 → sessionStorage 없음 → getGenerationStatus = null
브라우저에서 Hydration할 때 → sessionStorage 있음 → getGenerationStatus = "generating" (등)
서버 HTML이랑 클라이언트 HTML이 달라서 → 💥 Hydration 에러초기 서버 렌더링 시 sessionStorage가 없어 null로 렌더링되는데 Hydration 과정에서 클라이언트가 sessionStorage 값을 읽어 다른 값을 렌더링하기 때문에 불일치 에러가 발생합니다.
- Next.js 공식문서에도 다음과 같이 나와있습니다. https://nextjs.org/docs/messages/react-hydration-error#common-causes
Text content does not match server-rendered HTML
Next.js by Vercel is the full-stack React framework for the web.
nextjs.org

- Hydration 오류가 발생할 경우중 3번 케이스가 제가 해당하는 케이스입니다.
- 해결방법도 아래에 나와있습니다.


- useEffect가 가장 쉬워보이는데 함정이 있는데 초기설정 값이 나왔다가 다시 실제값이 넣어지는데 이로 인해 깜빡이는 것처럼 보이는 현상이 발생할 수 있습니다.
- 코드로 예시를 보면
- 아래 코드를 보면 처음 마운트 이후 한번만 값을 읽기 때문에 SessionStorage 값을 변경해도 UI가 자동으로 안바뀝니다.
const [status, setStatus] = useState<string | null>(null); useEffect(() => { setStatus(sessionStorage.getItem(WRITE_GENERATION_STATUS_KEY)); }, []);다른 방법 뭐가 있을까?
useSyncExternalStore 도입
- 문서 자료를 찾던중 useSyncExternalStore라는 리액트 훅을 발견했습니다.- useSyncExternalStore는 React 18에서 추가된 훅으로 외부 스토어를 구독하기 위한 공식 API입니다.- useSyncExternalStore의 도입 이유는 외부스토어를 구독하고 있어 실시간으로 반영이 가능하기 때문입니다.- useSyncExternalStore는 인자를 다음과 같이 3개를 받습니다.const value = useSyncExternalStore( subscribe, // 스토어 변경 감지 구독 함수 getSnapshot, // 클라이언트에서 현재 값 읽기 getServerSnapshot // 서버에서 현재 값 읽기 (SSR용) );subscribe
스토어가 변경됐을 때 React에게 알려주는 함수로 React가 내부적으로 callback을 넘겨주고 변경이 생기면 그걸 호출하면 됩니다.
() => { window.addEventListener('storage', callback); return () => window.removeEventListener('storage', callback); }getSnapshot
클라이언트에서 현재 스토어 값을 읽는 함수로 매 렌더링마다 호출되고 이전 값이랑 다르면 리렌더링 트리거합니다.
() => sessionStorage.getItem(WRITE_GENERATION_STATUS_KEY)getServerSnapshot
서버 렌더링할 때 사용할 값이고 sessionStorage처럼 서버에 없는 API면 null을 반환합니다.
() => null프로젝트에 적용시켜보기
- 현재 같은 탭 안에서 sessionStorage 변경을 UI에 반영을 해야하기 때문에 커스텀 이벤트로 직접 발행했습니다
export const WRITE_GENERATION_STATUS_KEY = "self:write-generation-status"; export const GENERATION_STATUS_CHANGE_EVENT = "write-generation-status-change"; export function setGenerationStatus(status: GenerationStatus): void { sessionStorage.setItem(WRITE_GENERATION_STATUS_KEY, status); // ① 값 저장 notifyGenerationStatusChange(); // ② 알림 } function notifyGenerationStatusChange(): void { window.dispatchEvent(new Event(GENERATION_STATUS_CHANGE_EVENT)); } export function getGenerationStatus(): GenerationStatus | null { const raw = sessionStorage.getItem(WRITE_GENERATION_STATUS_KEY); ... }- dispatchEvent로 상태변경을 알리고 useGenerationStatus가 subscribe를 통해 그 신호를 읽도록 구현했습니다.
function subscribe(onStoreChange: () => void) { window.addEventListener(GENERATION_STATUS_CHANGE_EVENT, onStoreChange); return () => window.removeEventListener(GENERATION_STATUS_CHANGE_EVENT, onStoreChange); } function getSnapshot() { return getGenerationStatus(); // 클라이언트: sessionStorage 읽기 } function getServerSnapshot() { return null; // 서버 + hydration 첫 렌더: 항상 null } export function useGenerationStatus() { return useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot); }- 서버와 클라이언트 첫 렌더는 둘 다 null → "AI 글 생성하기"로 하였고 hydration 이후 getSnapshot()이 실제 값을 읽어 UI가 바뀝니다.
마치며
이번 에러를 통해 Hydration이 단순히 "서버 HTML에 이벤트 연결하는 것" 이상의 의미가 있다는 걸 체감했습니다.
서버와 클라이언트의 렌더링 결과가 반드시 일치해야 한다는 제약이 있고 sessionStorage처럼 브라우저에만 존재하는 API를 아무 생각 없이 쓰면 바로 터진다라는 것을 깨달았습니다.. 🥹
useEffect로 빠르게 해결할 수도 있었지만 이번엔 useSyncExternalStore를 써보면서 외부 스토어를 React답게 구독하는 방법도 익혔다. 에러 하나가 꽤 많은 걸 알려주는 좋은 계기가 된거같습니다.
'프론트엔드' 카테고리의 다른 글
미들웨어(프록시)로 유연하게 처리 해보기 (0) 2026.06.21 프로젝트를 다방면으로 생각해보기 (0) 2026.06.14 클라이언트 중복 방어의 한계와 서버 이중 방어 — isSubmitting부터 idempotency key까지 (0) 2026.06.07 AbortController 도입시도 (0) 2026.06.01 React Activity? (0) 2026.03.12