상단 띠 배너의 닫기 버튼을 누르면 그 상태를 기억해서, 다음 페이지나 새로고침 때 다시 띄우지 않는 기능이다.
sessionStorage에 값 하나 넣는 단순한 일인데, Next.js App Router처럼 서버에서 HTML을 먼저 만드는 환경에서는 생각보다 조심할 부분이 많았다.
흔히 쓰는 세 가지 방법
브라우저 스토리지를 다룰 때 보통 이 세 가지 중 하나를 쓴다.
const KEY = 'top_banner_dismissed';
// 1. 서버 렌더에서 그대로 터진다
// 초기화 함수는 서버에서도 실행되므로 sessionStorage is not defined
const [isDismissed, setIsDismissed] = useState(() => sessionStorage.getItem(KEY) === 'true');
// 2. 하이드레이션 불일치 경고
// 서버 HTML은 false로 그렸는데 클라이언트 첫 렌더는 true가 된다
const [isDismissed, setIsDismissed] = useState(
() => typeof window !== 'undefined' && sessionStorage.getItem(KEY) === 'true'
);
// 3. 경고는 없지만 깜빡인다
const [isDismissed, setIsDismissed] = useState(false);
useEffect(() => {
if (sessionStorage.getItem(KEY) === 'true') {
setIsDismissed(true);
}
}, []);
대체로 3으로 사용했었다. 하지만 3에서는 아래와같은 문제가 발생한다.
- 서버는
sessionStorage를 볼 수 없으니 무조건 기본값false, 즉 배너가 보이는 HTML을 내려준다. - 화면에 배너가 그려진다.
- 하이드레이션이 끝나고
useEffect가 실행되면서 값을 읽고true로 바꾼다. - 배너가 사라지고, 그 아래 헤더와 본문이 배너 높이만큼 위로 밀린다.
이미 배너를 닫은 사용자는 새로고침할 때마다 이 과정을 다시 겪는다. 배너가 잠깐 나타났다 사라지고 본문이 위로 튄다.
useSyncExternalStore
React 18은 리액트 바깥에 있는 저장소(브라우저 스토리지, window 객체, 외부 상태 라이브러리 등)를 컴포넌트와 안전하게 동기화하라고 useSyncExternalStore를 내놨다.
const state = useSyncExternalStore(
subscribe,
getSnapshot,
getServerSnapshot // SSR에서는 필수
);
subscribe— 외부 저장소가 바뀌면 리액트에게 알려줄 구독 함수다. 인자로 받은 콜백을 이벤트에 연결하고, 해제 함수를 반환한다.getSnapshot— 클라이언트에서 지금 저장소의 값을 꺼내오는 함수다.getServerSnapshot— 서버 렌더와 하이드레이션 시점에 쓸 값이다. 서버는 저장소를 볼 수 없으니 여기서 정한 값이 초기 HTML이 된다.
이 훅이 실제로 해결해 주는 것
useSyncExternalStore로 바꾼다고 깜빡임이 저절로 사라지지는 않는다.
리액트는 하이드레이션 동안 getServerSnapshot 값을 쓰고, 하이드레이션이 끝난 뒤 getSnapshot을 다시 확인해서 값이 다르면 리렌더한다. 이 보정 시점은 useEffect가 도는 시점과 크게 다르지 않다. 즉 서버가 그린 값과 클라이언트의 실제 값이 다르면, 어떤 훅을 쓰든 화면은 한 번 움직인다.
이 훅이 대신 해결해 주는 게 따로 있다.
- 서버 값과 클라이언트 값이 다르다는 사실을 리액트가 알고서 처리한다. 경고 없이, 정해진 순서로 보정된다.
- 동시성 렌더링에서 같은 저장소를 읽는 컴포넌트끼리 서로 다른 값을 보는 tearing이 생기지 않는다.
- 구독과 해제, 값 읽기가 한 곳에 모인다. 여러 컴포넌트가 같은 키를 봐도 흩어진
useEffect를 관리할 필요가 없다.
깜빡임은 훅이 아니라 getServerSnapshot을 무엇으로 두느냐로 갈린다. 아래 데모에서 재생을 눌러보면 차이가 한눈에 보인다.
서버 HTML → 하이드레이션 구간에서 무슨 일이 일어나는가
재생을 누르면 서버가 내려준 HTML이 먼저 그려지고, 약 1초 뒤 하이드레이션이 끝나면서 클라이언트 값으로 보정된다. 이때 배너가 나타나거나 사라지면 그만큼 본문이 밀린다.
useState + useEffect
서버는 항상 기본값(안 닫힘)으로 그린다
useSyncExternalStore · server → false
하이드레이션은 안전하지만 초기 HTML은 위와 같다
useSyncExternalStore · server → true
재방문자는 깜빡임이 없지만 첫 방문자가 밀린다
쿠키 + 서버 렌더
서버가 이미 답을 알고 있어 보정 자체가 없다
구현
// hooks/useSessionDismissed.ts
'use client';
import { useCallback, useSyncExternalStore } from 'react';
const CHANGE_EVENT = 'session-storage-change';
// 스토리지 접근이 막힌 환경을 위한 인메모리 폴백
const memoryStore = new Map<string, string>();
const safeStorage = {
getItem(key: string): string | null {
try {
return window.sessionStorage.getItem(key);
} catch {
return memoryStore.get(key) ?? null;
}
},
setItem(key: string, value: string): void {
try {
window.sessionStorage.setItem(key, value);
} catch {
memoryStore.set(key, value);
}
}
};
// 모듈 스코프에 둬서 참조가 매 렌더마다 바뀌지 않게 한다
function subscribe(callback: () => void) {
window.addEventListener(CHANGE_EVENT, callback);
return () => {
window.removeEventListener(CHANGE_EVENT, callback);
};
}
// 하이드레이션 시점의 값. 항상 같은 값을 반환해야 한다
function getServerSnapshot(): boolean {
return true;
}
export function useSessionDismissed(key: string) {
const getSnapshot = useCallback((): boolean => {
return safeStorage.getItem(key) === 'true';
}, [key]);
const isDismissed = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
const dismiss = useCallback(() => {
safeStorage.setItem(key, 'true');
// 같은 탭의 다른 컴포넌트에게 알린다
window.dispatchEvent(new Event(CHANGE_EVENT));
}, [key]);
return { isDismissed, dismiss };
}
걸렸던 부분들
getSnapshot은 반드시 원시값이나 캐시된 참조를 반환해야 한다. 안에서 { isDismissed: true } 같은 객체를 매번 새로 만들면 리액트는 참조가 달라진 걸 보고 계속 리렌더한다. 무한 루프로 이어진다. getServerSnapshot도 마찬가지라 위에서는 true 리터럴만 반환한다.
subscribe의 참조가 안정적이어야 한다. 컴포넌트 안에서 매 렌더마다 새 함수를 만들면 그때마다 구독을 해제하고 다시 등록한다. 그래서 모듈 스코프에 뒀다. key에 의존해야 한다면 useCallback으로 감싸야 한다.
storage 이벤트는 여기서 거의 쓸모가 없다. storage 이벤트는 다른 탭에서 값이 바뀔 때 발생하는데, sessionStorage는 애초에 탭마다 따로 존재한다. 그래서 커스텀 이벤트로 같은 탭 안의 동기화만 처리했다. localStorage를 쓴다면 storage 이벤트도 같이 연결하는 게 맞다.
스토리지는 읽는 것만으로도 예외를 던질 수 있다. 샌드박스 iframe이나 사이트 데이터 저장이 차단된 환경에서는 window.sessionStorage 접근 자체가 SecurityError를 던진다. try...catch와 인메모리 폴백을 둔 이유다.
아래 데모는 스토리지가 막힌 환경을 실제로 재현한다. 미리보기가 sandbox="allow-scripts" iframe이라 오리진이 불투명하고, 그래서 sessionStorage에 접근하는 순간 진짜로 SecurityError가 난다.
첫 번째 탭의 빨간
SecurityError는 의도한 결과다. 데모가 고장 난 게 아니라, 가드 없는 코드가 실제로 어떻게 죽는지를 보여주는 것이다. 두 번째 탭으로 넘어가면try...catch폴백이 같은 상황을 어떻게 받아내는지 볼 수 있다.
getServerSnapshot을 무엇으로 둘 것인가
true(닫힘)를 반환하면 서버 HTML에 배너가 없다. 이미 닫은 사용자는 깜빡임이 사라진다. 대신 처음 온 사용자는 배너가 없는 화면을 보다가 하이드레이션 후에 배너가 나타나면서 본문이 아래로 밀린다. 깜빡임이 사라진 게 아니라 다른 집단으로 옮겨간 것이다.
어느 쪽을 택할지는 트래픽 구성에 달렸다. 재방문 비중이 높으면 true가 유리하고, 신규 유입이 대부분이면 false가 낫다.
이동 자체를 없애려면 방법은 두 가지다.
하나, 배너를 콘텐츠를 밀지 않는 위치에 둔다. 하단 고정 배너처럼 position: fixed로 띄우면 나타나든 사라지든 본문 레이아웃은 그대로다. 실제로 이후에 만든 국가 안내 배너는 이 방식으로 갔다.
둘, 판단 근거를 서버가 볼 수 있는 곳으로 옮긴다. 쿠키는 요청과 함께 서버로 가므로 서버가 처음부터 정답을 알고 그릴 수 있다.
// app/layout.tsx
import { cookies } from 'next/headers';
export default async function RootLayout({ children }: { children: React.ReactNode }) {
const cookieStore = await cookies();
const isDismissed = cookieStore.get('top_banner_dismissed')?.value === 'true';
return (
<html lang="ko">
<body>
{!isDismissed && <TopBanner />}
{children}
</body>
</html>
);
}
대신 cookies()를 읽는 순간 해당 라우트는 동적 렌더링이 된다. 정적 생성이나 CDN 캐시를 포기하거나, 캐시 키에 쿠키를 넣어 파편화를 감수해야 한다. 배너 하나 때문에 치르기엔 비싼 값일 수 있어서, 우리 쪽은 배너를 고정 위치로 옮기는 쪽을 택했다.
정리
| 방식 | 하이드레이션 경고 | 첫 방문자 | 재방문자 | 비고 |
|---|---|---|---|---|
useState + useEffect | 없음 | 이동 없음 | 배너 나왔다 사라짐 | 구독·tearing은 직접 관리 |
useSyncExternalStore, server → false | 없음 | 이동 없음 | 배너 나왔다 사라짐 | 동기화는 안전해짐 |
useSyncExternalStore, server → true | 없음 | 배너가 나중에 나타남 | 이동 없음 | 깜빡임이 신규 방문자로 이동 |
| 쿠키 + 서버 렌더 | 없음 | 이동 없음 | 이동 없음 | 라우트가 동적 렌더링이 됨 |
- 브라우저 스토리지를 리액트와 맞출 때
useEffect로 값을 끌어오는 패턴은 이제 기본값으로 두지 않아도 된다.useSyncExternalStore가 구독·동시성·하이드레이션을 한 번에 정리해 준다. - 다만 이 훅이 레이아웃 이동까지 없애주지는 않는다. 서버가 무엇을 그렸는지가 이동 여부를 결정한다.
- 이동을 정말 0으로 만들려면 배너가 레이아웃을 밀지 않게 배치하거나, 판단 근거를 쿠키로 옮겨 서버가 알게 해야 한다.
마운트 여부 판별
"하이드레이션이 끝났는가"만 알고 싶을 때도 같은 훅이 쓸모 있다. 서버에서는 false, 클라이언트에서는 true가 되므로 경고 없이 클라이언트 전용 UI를 그릴 수 있다.
여기서도 위에서 말한 규칙이 그대로 적용된다. 세 함수를 인라인으로 넘기면 subscribe 참조가 매 렌더마다 바뀌어 구독을 계속 다시 건다. 반드시 모듈 스코프로 빼야 한다.
// ❌ 매 렌더마다 subscribe 가 새 함수라 구독이 반복된다
const isMounted = useSyncExternalStore(() => () => {}, () => true, () => false);
// ✅ 모듈 스코프에 고정
const subscribeToNothing = () => () => {};
const getMountedSnapshot = () => true;
const getServerSnapshot = () => false;
function Header() {
const isMounted = useSyncExternalStore(subscribeToNothing, getMountedSnapshot, getServerSnapshot);
const { theme, systemTheme } = useTheme();
// 서버는 테마를 모르므로 하이드레이션 전에는 undefined 로 둔다
const currentTheme = isMounted ? (theme === 'system' ? systemTheme : theme) : undefined;
return <img src={currentTheme === 'dark' ? '/menu-light.svg' : '/menu.svg'} alt="menu" />;
}
이 블로그 헤더의 테마 아이콘이 실제로 이 방식으로 동작한다. 원래는 useState + useEffect로 마운트 여부를 잡고 있었는데, 그러면 마운트 직후 리렌더가 한 번 더 돌고 react-hooks/set-state-in-effect 경고도 붙는다.