
Vue로 되어 있던 어드민을 Next.js로 마이그레이션한 적이 있다.
기존 어드민은 LNB의 접힘 여부를 localStorage에 저장하고 있었다. Vue는 전부 클라이언트에서 렌더링하니까 아무 문제 없었지만, Next.js에 SSR이 끼는 순간, 페이지를 새로고침할 때마다 접혀 있어야 할 LNB가 한 번 펼쳐졌다가 다시 접히는 깜빡임 현상이 생겼다.
그리고 콘솔에는 우리 프론트엔드 개발자들이 싫어하는 "그 경고"가 떴다.
Hydration failed because the server rendered HTML didn't match the client.
원인은 단순하다. Next.js의 서버(Node.js)에는 window도 localStorage도 없다.
| 서버 | 클라이언트 | |
|---|---|---|
| localStorage | 접근 불가 | 접근 가능 |
| 렌더링 기준값 | 하드코딩된 기본값 | 저장된 값 |
| 결과 | 불일치 → hydration mismatch + 깜빡임 | 정상적으로 렌더링 |
내 상황인 LNB를 예로 들면, 서버는 기본값(열림)으로 HTML을 생성하고, 클라이언트는 localStorage에서 설정값(닫힘)을 읽는다.
React는 hydration 시점에 이 차이를 발견하면 mismatch 경고를 뱉고 다시 렌더링한다. 이때 UI가 기본값에서 설정값으로 바뀌면서 깜빡임이 발생한다.
이런 문제를 만나면 자연스럽게 떠올리는 해법들이 있다.
useEffect로 값 넣기const [lnbMode, setLnbMode] = useState('full'); // 서버와 동일한 기본값
useEffect(() => {
setLnbMode(localStorage.getItem('lnb-mode') ?? 'full');
}, []);
서버와 클라이언트 모두 초기값이 'full'로 동일하므로 hydration mismatch는 피할 수 있다. 하지만 서버에서 하드코딩한 기본값과 사용자의 설정값이 다르다면 깜빡임이 발생한다. useEffect는 브라우저가 화면을 paint한 이후에 실행되기 때문에, 사용자는 기본값 UI를 무조건 보게 된다.
useLayoutEffect는?"useLayoutEffect는 paint 이전에 실행되니까 괜찮지 않을까?"
맞다. 하지만 두 가지 문제가 있다.
useLayoutEffect를 사용하면 React가 경고를 낸다. 서버에는 layout이라는 개념 자체가 없기 때문이다.suppressHydrationWarning<html suppressHydrationWarning>
말 그대로 hydration mismatch라는 경고만 숨긴다. React는 여전히 서버/클라이언트 UI의 불일치를 감지하고, 리렌더링한다. 깜빡임과 불필요한 리렌더링이라는 본질은 해결되지 않는다.
위의 시도들이 전부 실패하는 이유는 하나다: React가 너무 늦게 개입한다는 것.
먼저 브라우저가 서버에서 받은 HTML을 어떤 순서로 처리하는지를 잠깐 보자.
SSR 응답을 받은 브라우저는 대략 이런 순서로 동작한다:
① HTML 파싱 + DOM 생성
├─ <head> 내 리소스 처리 (CSS, 인라인 script 등)
└─ <body> 파싱
② CSSOM 생성
③ Render Tree 생성 (DOM + CSSOM 결합)
④ Layout (요소 위치 및 크기 계산)
⑤ Paint (픽셀 렌더링)
⑥ JS 번들 로드 + 실행
⑦ React Hydration (HTML에 이벤트 핸들러 부착)
⑤ Paint 단계에서 사용자가 처음으로 화면을 보게 된다. React는 ⑦ Hydration 단계에서 본격적으로 개입하기 시작한다. useEffect, useLayoutEffect는 모두 ⑦ Hydration 이후에 실행된다. 이미 ⑤ Paint에서 기본값이 적용된 UI가 렌더링되었기 때문에, Hydration 이후에 깜빡임이 발생한다.
브라우저는 **HTML을 파싱(①)**하는 도중 <script> 태그를 만나면, 파싱을 멈추고 그 스크립트를 즉시 실행한다(render-blocking). 이 특성을 이용해서 <script>에서 localStorage 값을 먼저 읽으면, ⑤ Paint 이전에 값을 들고 있을 수 있다.
이 값을 어디에 들고 있을지는 상황에 따라 다를 것 같다. URL 쿼리 파라미터, dataset 속성 등?
나는 LNB의 스타일 조정이 필요했기 때문에 <html> 태그의 data-lnb-mode 속성에 보관하기로 했다.
① HTML 파싱 시작
├─ <head> 파싱
├─ ★ 인라인 <script> 만남 → 파싱 중단
│ └─ localStorage 읽기 → <html data-lnb-mode="folded"> 속성 설정
├─ 파싱 재개
└─ <body> 파싱
② CSSOM 구축 (data-lnb-mode="folded" 기준으로 스타일 적용)
③~⑤ → 처음부터 접힌 LNB로 Paint
⑦ Hydration → 서버 HTML과 일치 → mismatch 없음 ✅
LNB의 CSS도 [data-lnb-mode="folded"] 선택자로 스타일링해뒀다. 속성만 올바르게 설정되면 First Paint부터 정확한 UI가 그려진다.
이후 Hydration이 끝나고 React가 클라이언트 상태를 주입해도, 초기값과 동일하기 때문에 mismatch는 사라진다.
이때 <script>에 defer나 async를 쓰면 안 된다. 이 속성들은 스크립트를 비동기적으로 불러오기 때문에 Paint 이전에 실행된다는 보장이 없다. 일반 <script>는 렌더링을 막기 때문에 성능상 피하는 게 보통이지만, localStorage.getItem + setAttribute 수준의 blocking은 무시할 수 있다.
처음에 이 문제를 어떻게 해결할지 고민하던 와중에 Mantine UI의 다크모드 기능을 떠올렸다. Mantine의 다크모드 값도 localStorage에 저장되는 걸 봤는데, SSR 환경에서도 잘 유지되고 있었다. 이 로직을 참고해봤다.
Mantine은 ColorSchemeScript 컴포넌트를 제공하는데, 내부를 보면 동일한 blocking script 패턴이다. 핵심은 dangerouslySetInnerHTML로 인라인 스크립트를 삽입해서 <html data-mantine-color-scheme="dark">를 hydration 전에 설정하는 부분이다.
// @mantine/core > ColorSchemeScript.tsx (핵심 부분 발췌)
export function ColorSchemeScript({
defaultColorScheme = 'light',
localStorageKey = 'mantine-color-scheme-value',
forceColorScheme,
...others
}: ColorSchemeScriptProps) {
return (
<script
{...others}
data-mantine-script
dangerouslySetInnerHTML={{
__html: getScript({ defaultColorScheme, localStorageKey, forceColorScheme }),
}}
/>
);
}
물론 오픈소스답게 forceColorScheme으로 관리자가 강제 지정하거나, media 옵션으로 prefers-color-scheme 미디어 쿼리에 따라 분기하는 등 edge case도 처리하고 있다. 내 구현은 이 중 핵심인 "localStorage 읽기 → html 속성 설정" 부분만 차용한 셈이다.
const LNB_MODE_STORAGE_KEY = 'lnb-mode';
const LNB_MODE_ATTRIBUTE_KEY = 'data-lnb-mode';
const createLnbScript = (defaultMode: string) => `
try {
var stored = window.localStorage.getItem("${LNB_MODE_STORAGE_KEY}");
var mode = stored === "full" || stored === "folded" ? stored : "${defaultMode}";
document.documentElement.setAttribute("${LNB_MODE_ATTRIBUTE_KEY}", mode);
} catch (e) {}
`;
localStorage에서 값을 읽고 <html> 속성에 설정하는 로직이다.
try-catch는 시크릿 모드 등 localStorage 접근이 막힌 환경 대응이고, var는 blocking script가 모듈이 아니기 때문에 구형 브라우저까지 고려한 선택이다.
dangerouslySetInnerHTML로 삽입되는 스크립트이므로, defaultMode에 외부 입력이 들어오는 경우 허용된 값인지 검증이 필요하다는 점도 주의해야 한다.
<head>에 스크립트 삽입Pages Router — _document.tsx
<Html>
<Head />
<script dangerouslySetInnerHTML={{ __html: createLnbScript('full') }} />
<body>
<Main />
<NextScript />
</body>
</Html>
App Router — app/layout.tsx
나는 Pages Router에서 구현했지만, App Router에서는 이곳에 넣으면 동일하게 동작할 것 같다.
export default function RootLayout({ children }) {
return (
<html>
<head>
<script dangerouslySetInnerHTML={{ __html: createLnbScript('full') }} />
</head>
<body>{children}</body>
</html>
);
}
[data-lnb-mode='full'] .lnb {
...;
}
[data-lnb-mode='folded'] .lnb {
...;
}
/* 전환 애니메이션은 hydration 이후에만 활성화 */
[data-hydrated='true'] .lnb {
transition: width 0.2s ease;
}
transition을 처음부터 켜두면 초기 로드 시 너비가 변하는 것 자체가 애니메이션으로 보일 수 있다. hydration이 끝난 뒤에만 transition을 활성화하면 이 문제도 방지된다.
function useLnbMode() {
const [mode, setMode] = useState(() => {
// SSR 시점에는 window가 없으므로 기본값 반환
if (typeof window === 'undefined') return 'full';
// 클라이언트에서는 script가 미리 설정한 html 속성으로 초기값 설정
return document.documentElement.getAttribute('data-lnb-mode') ?? 'full';
});
const toggle = useCallback(() => {
const next = mode === 'full' ? 'folded' : 'full';
setMode(next);
localStorage.setItem('lnb-mode', next);
document.documentElement.setAttribute('data-lnb-mode', next);
}, [mode]);
return { mode, toggle };
}
useState의 lazy 초기화는 클라이언트에서의 첫 렌더링 시 한 번만 실행된다. SSR 시점에는 typeof window === 'undefined' 가드에 의해 기본값 'full'이 반환되고, 클라이언트에서는 blocking script가 이미 설정해둔 data-lnb-mode 속성을 읽는다. 양쪽 값이 일치하므로 mismatch가 발생하지 않는다.
다만, 이 방식은 상태 변경 시 localStorage, html 속성, React 상태를 모두 동기화해야 한다는 번거로움이 있다. 하나라도 빠지면 다음 새로고침에서 값이 어긋난다.
SSR 환경에서 localStorage 기반 UI가 깜빡이는 건 서버가 사용자 설정을 알 수 없기 때문이다.
해결의 핵심은 단순하다: Hydration이 시작되기 전에, 서버와 클라이언트의 초기 상태를 일치시키는 것.
나는 blocking script로 해결했지만, 이게 유일한 방법은 아니다. cookie를 서버에서 읽어 처음부터 정확한 HTML을 내려주는 방식도 있고, App Router 환경이라면 오히려 그쪽이 더 자연스러울 수 있다.
이 문제는 LNB에만 국한되지 않는다. 다크모드, 언어 설정, 폰트 크기 등 브라우저의 저장소에서 값을 읽어 초기 UI를 결정하는 곳이라면 어디서든 동일하게 발생할 수 있다. CSR에서 SSR로 넘어가는 과정에서 "왜 깜빡이지?"를 만나게 된다면, 이 글이 도움이 되었으면 한다.