React|Custom Hooks

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

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

크리스가 만든 웹사이트에 '다크 모드' 기능이 추가되었다.

사용자가 한 번 다크 모드를 켜면, 다음에 접속해도 설정이 유지되어야 한다. 크리스는 당연히 브라우저의 localStorage를 떠올렸다.

"새로고침 해도 유지되려면 useState 초기값에 localStorage를 넣으면 되겠지?"

typescript
// ❌ 크리스의 코드 (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에 접근하지 못하게 막는 것이다.

typescript
// 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 해결하기

하지만 위 함수만으로는 부족하다.

  • 서버: window가 없으니 'light' 모드로 렌더링 -> HTML 전송.
  • 클라이언트: localStorage에 'dark'가 저장되어 있음 -> 'dark' 모드로 렌더링.
  • 서버가 준 HTML과 클라이언트가 그린 결과가 다르면 리액트는 "Hydration Mismatch" 경고를 띄운다. 이를 해결하려면 첫 렌더링(Mount) 후에 값을 업데이트해야 한다.

    typescript
    // 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 이벤트를 발생시킨다. 이를 구독하면 탭 간 동기화가 가능하다.

    typescript
    // 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. 실전 적용: 크리스의 다크 모드

    이제 크리스는 완성된 훅을 가져다 쓰기만 하면 된다.

    typescript
    // 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. 핵심 정리

  • SSR 안전성: typeof window !== 'undefined' 체크를 통해 서버 렌더링 중 window 접근 에러를 방지해야 한다.
  • JSON 파싱: localStorage는 문자열만 저장하므로 JSON.stringifyJSON.parse 처리가 필수다. 이 과정에서 발생하는 에러도 try-catch로 잡아야 한다.
  • 동기화: storage 이벤트를 리스닝 하면 여러 탭 간의 데이터 상태를 실시간으로 동기화할 수 있다.
  • : 최근에는 React 18의 useSyncExternalStore를 활용하여 더 정교하게 스토리지 훅을 구현하는 추세다. (Part 6 참고)
  • 데이터 저장은 해결했다. 이제 UI 인터랙션으로 넘어가자.

    모달이나 드롭다운 메뉴를 열었을 때, "메뉴 바깥쪽을 클릭하면 닫히게" 하고 싶다.

    단순해 보이지만 ref와 이벤트 버블링을 모르면 구현하기 까다로운 이 기능을 훅 하나로 해결해 보자.

    "useOnClickOutside: 모달과 드롭다운을 닫는 이벤트 위임의 미학" 편에서 계속된다.


    🔗 참고 링크

  • usehooks-ts: useLocalStorage
  • MDN - Window: storage event
  • 댓글 (0)

    0/1000
    댓글을 불러오는 중...