useLocalStorage: Next.js(SSR)에서도 안전한 브라우저 저장소 훅

크리스가 만든 웹사이트에 '다크 모드' 기능이 추가되었다.
사용자가 한 번 다크 모드를 켜면, 다음에 접속해도 설정이 유지되어야 한다. 크리스는 당연히 브라우저의 localStorage를 떠올렸다.
"새로고침 해도 유지되려면 useState 초기값에 localStorage를 넣으면 되겠지?"
// ❌ 크리스의 코드 (Next.js에서 폭파됨)
function ThemeToggle() {
// 💥 Error: window is not defined
const [theme, setTheme] = useState(
localStorage.getItem('theme') || 'light'
);
const toggle = () => {
const newTheme = theme === 'light' ? 'dark' : 'light';
setTheme(newTheme);
localStorage.setItem('theme', newTheme);
};
return <button onClick={toggle}>{theme} 모드</button>;
}로컬 환경(CRA, Vite)에서는 잘 돌던 코드가 Next.js에 올리자마자 에러를 뿜으며 서버가 죽어버렸다.
Next.js는 서버에서 먼저 페이지를 그리기(SSR) 때문에, 서버에는 존재하지 않는 window나 localStorage에 접근하려고 하면 에러가 발생한다.
이 외에도 JSON.parse 에러 처리, 탭 간 동기화 등 신경 쓸 게 한두 가지가 아니다. 이 모든 걸 해결하는 완전무결한 useLocalStorage 훅을 만들어보자.
1. SSR 안전장치: window 체크
가장 먼저 해야 할 일은 코드가 서버에서 실행될 때 localStorage에 접근하지 못하게 막는 것이다.
// utils.ts
export function getStorageValue<T>(key: string, initialValue: T): T {
// 1. 서버 환경(SSR)이라면 초기값 반환
if (typeof window === 'undefined') {
return initialValue;
}
try {
// 2. 클라이언트라면 로컬 스토리지 조회
const item = window.localStorage.getItem(key);
return item ? JSON.parse(item) : initialValue;
} catch (error) {
console.warn(`Error reading localStorage key "${key}":`, error);
return initialValue;
}
}이제 서버에서는 안전하게 initialValue를 반환하고, 브라우저에서는 저장된 값을 반환한다.
2. Hydration Mismatch 해결하기
하지만 위 함수만으로는 부족하다.
서버가 준 HTML과 클라이언트가 그린 결과가 다르면 리액트는 "Hydration Mismatch" 경고를 띄운다. 이를 해결하려면 첫 렌더링(Mount) 후에 값을 업데이트해야 한다.
// useLocalStorage.ts
import { useState, useEffect, useCallback } from 'react';
export function useLocalStorage<T>(key: string, initialValue: T) {
// 1. 초기화: 일단 initialValue로 시작 (Mismatch 방지)
// 혹은, 더 복잡하지만 useSyncExternalStore를 쓸 수도 있다.
// 여기서는 이해하기 쉬운 useEffect 방식을 택한다.
const [storedValue, setStoredValue] = useState<T>(initialValue);
// 2. 마운트 후: 로컬 스토리지에서 진짜 값을 가져옴
useEffect(() => {
const item = window.localStorage.getItem(key);
if (item) {
setStoredValue(JSON.parse(item));
}
}, [key]);
// 3. Setter 함수: State와 LocalStorage를 동시에 업데이트
const setValue = useCallback((value: T | ((val: T) => T)) => {
try {
// 함수형 업데이트 지원 (setState(prev => prev + 1))
const valueToStore = value instanceof Function ? value(storedValue) : value;
setStoredValue(valueToStore);
window.localStorage.setItem(key, JSON.stringify(valueToStore));
// 커스텀 이벤트 발생 (탭 간 동기화를 위해 필요하다면)
window.dispatchEvent(new Event("local-storage"));
} catch (error) {
console.warn(`Error setting localStorage key "${key}":`, error);
}
}, [key, storedValue]);
return [storedValue, setValue] as const;
}이렇게 하면 첫 화면은 'light'로 잠깐 보였다가(FOUC), 자바스크립트가 로드된 직후 'dark'로 바뀐다. 완벽한 UX를 원한다면 layout.tsx 등에서 블로킹 스크립트로 처리해야 하지만, 컴포넌트 레벨에서는 이것이 최선이다.
3. 심화: 여러 탭 간의 동기화
사용자가 탭 A에서 '다크 모드'를 켰다. 탭 B를 보고 있었는데 여전히 '라이트 모드'라면? UX가 끊긴다.
window 객체는 스토리지 값이 바뀌면 storage 이벤트를 발생시킨다. 이를 구독하면 탭 간 동기화가 가능하다.
// useLocalStorage.ts (useEffect 추가)
useEffect(() => {
const handleStorageChange = (e: StorageEvent) => {
// 내 키(key)가 바뀐 경우에만 업데이트
if (e.key === key && e.newValue) {
setStoredValue(JSON.parse(e.newValue));
}
};
// 다른 탭에서 변경했을 때 발생하는 이벤트
window.addEventListener('storage', handleStorageChange);
// 같은 탭에서 변경했을 때를 위한 커스텀 이벤트 리스너 (선택 사항)
window.addEventListener('local-storage', handleStorageChange as any);
return () => {
window.removeEventListener('storage', handleStorageChange);
window.removeEventListener('local-storage', handleStorageChange as any);
};
}, [key]);4. 실전 적용: 크리스의 다크 모드
이제 크리스는 완성된 훅을 가져다 쓰기만 하면 된다.
// ThemeToggle.tsx
function ThemeToggle() {
// 이제 Next.js에서도 에러가 나지 않는다.
// 새로고침 해도, 탭을 여러 개 띄워도 완벽하게 동기화된다.
const [theme, setTheme] = useLocalStorage<'light' | 'dark'>('theme', 'light');
const toggle = () => {
setTheme((prev) => (prev === 'light' ? 'dark' : 'light'));
};
return (
<div style={{ background: theme === 'dark' ? '#333' : '#fff' }}>
<h1>현재 모드: {theme}</h1>
<button onClick={toggle}>모드 전환</button>
</div>
);
}5. 핵심 정리
데이터 저장은 해결했다. 이제 UI 인터랙션으로 넘어가자.
모달이나 드롭다운 메뉴를 열었을 때, "메뉴 바깥쪽을 클릭하면 닫히게" 하고 싶다.
단순해 보이지만 ref와 이벤트 버블링을 모르면 구현하기 까다로운 이 기능을 훅 하나로 해결해 보자.
"useOnClickOutside: 모달과 드롭다운을 닫는 이벤트 위임의 미학" 편에서 계속된다.