서버 사이드 렌더링(SSR)을 사용하는 React 앱에서는 서버가 보낸 HTML과 브라우저에서 처음 렌더링한 결과가 다를 때 하이드레이션 불일치 오류가 발생할 수 있다. 이 글은 localStorage에 저장한 사이드바 상태를 다루는 Next.js 앱 사례를 통해 오류의 원인과 해결 방법, 각 방법의 절충점을 설명한다.

Next.js에서 서버 사이드 렌더링을 사용하는 클라이언트 컴포넌트는 서버에서 미리 렌더링되고, 그 결과인 HTML이 브라우저로 전송된다. HTML에는 페이지의 내용과 구조가 담기지만 상호작용을 위한 자바스크립트 로직은 포함되지 않는다. 브라우저가 초기 HTML을 받은 뒤 Next.js는 클라이언트 컴포넌트의 자바스크립트를 실행해 페이지를 상호작용할 수 있게 한다. 이 과정을 하이드레이션이라고 한다.

서버 컴포넌트는 이와 다르다. 특별한 데이터 형식으로 서버에서 완전히 렌더링되며, 해당 컴포넌트의 자바스크립트는 클라이언트로 전송되지 않는다.

클라이언트의 하이드레이션은 주로 두 단계로 이뤄진다. React는 SSR에 사용한 것과 같은 컴포넌트 트리를 클라이언트 메모리에서 다시 렌더링하고, 그 결과가 서버가 보낸 HTML과 일치하는지 확인한다. 이때 DOM 노드를 새로 만들지는 않는다. 이어 서버에서 받은 HTML에 이벤트 리스너를 붙이는 등 상호작용을 가능하게 한다. 서버 렌더링 결과와 클라이언트의 첫 렌더링 결과가 달라 갑자기 다른 내용이 나타나면, 서버 HTML이 앱을 더 빨리 불러온 것처럼 보이게 하는 효과가 깨질 수 있기 때문이다. 자세한 설명은 React 문서를 참고할 수 있다.

하이드레이션 불일치 오류는 첫 단계에서 서버가 만든 HTML과 하이드레이션 중 React가 클라이언트 메모리에서 렌더링한 결과가 일치하지 않을 때 발생한다. localStorage처럼 클라이언트에서만 확인할 수 있는 정보를 렌더링에 쓰면 서버에서는 그 값에 접근할 수 없어 두 결과가 달라질 수 있다.

일반적인 해결 방법은 서버와 클라이언트의 첫 렌더링에서 모두 아무것도 표시하지 않고, 컴포넌트가 마운트된 뒤 실제 내용을 보여주는 것이다.

import { useState, useEffect } from 'react'

export default function SampleComponent() {
 const [isClient, setIsClient] = useState(false)

 useEffect(() => {
 setIsClient(true)
 }, [])

 if (!isClient) return null;

 return <h1>{new Date().toLocaleString()}</h1>
}

useEffect는 클라이언트에서만 실행된다. 서버와 클라이언트의 첫 렌더링에서는 모두 isClient가 false이므로 아무것도 렌더링하지 않는다. 두 결과가 같아져 하이드레이션 불일치를 피할 수 있다. 다만 이 방법을 쓰면 해당 컴포넌트와 그 자식도 서버에서 렌더링되지 않는다. 자식이 많거나 검색 엔진 최적화(SEO)에 중요한 내용이 있는 경우에는 문제가 될 수 있다.

글쓴이는 Next.js와 PostgreSQL로 만든 풀스택 영화 앱에서 사이드바가 열려 있는지 여부를 localStorage에 저장했다. 이 값은 (main)/layout.tsx 같은 컴포넌트에서 필요했다. 처음에는 useState 초기화 함수에서 값을 읽는 방식을 고려했다.

export default function Layout() {
 
 
 
 const [showSidebar, setShowSidebar] = useState(() => {
 if (typeof window === "undefined") return false; 
 const stored = localStorage.getItem("movie-app-sidebar");
 return stored ? JSON.parse(stored) : false;
 });

 
}

이 방법에는 두 가지 문제가 있다. SSR 중에는 showSidebar가 false지만, 하이드레이션 중 첫 렌더링에서는 true일 수 있어 결과가 달라질 수 있다. 또한 React 문서는 useState 초기화 함수가 순수 함수여야 한다고 설명하는데, localStorage를 읽는 것이 순수한 동작인지 확실하지 않다고 글쓴이는 덧붙였다.

앞서 소개한 useEffect 방식으로 마운트 전까지 렌더링을 비우면 레이아웃의 자식도 SSR에서 제외된다. 글쓴이의 앱에서는 사실상 페이지 전체가 영향을 받는 셈이다. 대신 useEffect 안에서 저장된 값을 읽도록 바꿨다.

export default function Layout() {
 const [showSidebar, setShowSidebar] = useState(false);

 
 useEffect(() => {
 const stored = localStorage.getItem("movie-app-sidebar");
 if (stored) {
 setShowSidebar(JSON.parse(stored));
 }
 }, []);

 
}

useEffect는 하이드레이션 이후에 실행되므로 SSR과 클라이언트의 첫 렌더링에서는 초기값 false가 같다. 따라서 하이드레이션 오류는 피할 수 있지만, 효과가 실행된 뒤 사이드바 상태가 바뀌면서 잠깐 깜박이는 현상이 생겼다.

글쓴이는 이를 피하려고 하이드레이션을 기다리지 않고 페이지가 로드될 때 인라인 스크립트로 저장된 상태를 DOM에 적용했다. 스크립트가 사이드바의 클래스를 일찍 바꾸고 값을 window에 저장하면, React도 클라이언트의 첫 렌더링에서 같은 값을 사용해 갱신된 DOM과 일치시킬 수 있다는 접근이다. 이 방법은 관련 글을 바탕으로 했다.

인라인 스크립트는 localStorage를 읽고 필요한 클래스를 바꾼 뒤 값을 window에 저장한다.

function initializeSidebar() {
 const showSidebar = localStorage.getItem("movie-app-sidebar") === "true";

 const layout = document.querySelector("[data-layout]");
 const sidebar = document.querySelector("[data-sidebar]");

 if (layout && sidebar) {
 
 layout.classList.toggle("sm:ml-64", showSidebar);
 sidebar.classList.toggle("left-0", showSidebar);
 sidebar.classList.toggle("opacity-100", showSidebar);
 }

 
 window._INITIAL_SIDEBAR_STATE_ = showSidebar;
}

function InlineScript() {
 return (
 <script
 dangerouslySetInnerHTML={{
 __html: `(${initializeSidebar.toString()})()`,
 }}
 />
 );
}

InlineScript 컴포넌트는 Layout에서 렌더링한다. React에서는 다음과 같이 초기 사이드바 상태를 읽는다.

const [showSidebar, setShowSideBar] = React.useState(() => {
 
 if (typeof window === "undefined") {
 return false;
 }

 
 
 
 
 return window._INITIAL_SIDEBAR_STATE_;
});

저장된 값이 true라면 인라인 스크립트가 하이드레이션 전에 사이드바를 여는 클래스를 추가한다. React도 showSidebar를 true로 초기화해 같은 클래스를 렌더링하므로 첫 결과가 DOM과 일치한다. 전체 구현은 (main)/layout.tsx에서 확인할 수 있다.

테마 전환 버튼에도 비슷한 문제가 있었다. 체크 상태가 서버에서는 알 수 없는 테마에 따라 달라지기 때문이다. 글쓴이는 useEffect를 이용해 마운트 전까지 버튼을 렌더링하지 않는 useIsClient() 훅을 사용했다. 이는 next-themes 문서에서도 권장하는 방식이다. 페이지 전체를 감싸는 레이아웃과 달리 작은 버튼만 초기 HTML에서 빠지므로 이 경우에는 감수할 만한 절충이라고 설명한다.

모바일 사이드바의 어두운 배경 요소도 저장된 상태에 따라 조건부 렌더링하면 같은 불일치가 생길 수 있다. 글쓴이는 배경 요소를 HTML에 항상 넣고 CSS로 보이게 하는 방식을 택했다. 사이드바에 group을 추가하고 배경 요소에 hidden group-[.left-0]:block을 적용해 left-0 클래스가 있을 때 배경이 나타나게 했다. 배경은 필요하지 않은 중간 크기 이상의 화면에서는 숨긴다.

글쓴이는 또 다른 대안으로 React 19.3에서 react-dom에 추가된 browser를 소개한다. React 19.3에서는 클라이언트 컴포넌트에서 use(browser())를 호출하면 서버 렌더링을 건너뛰고, 초기 HTML에 가장 가까운 <Suspense> 대체 UI를 남긴다. 이는 useEffect로 isClient를 true로 바꾼 뒤 내용을 보여주는 방법의 대안이다. 글쓴이는 이를 레이아웃에서 사용하면 기존 useEffect 방식처럼 레이아웃과 자식도 서버에서 렌더링되지 않을 것으로 이해하고 있다고 밝혔다.