사이드바 리팩토링: 기술적 통일성과 UX 사이의 균형 찾기

작성일: 2026년 2월 26일 오후 12:47(마지막 수정: 2026년 2월 27일 오전 12:23)
조회수: 32

이 글은 사이드바 구현 중 팀원과 의견 차이를 조율해나가는 과정입니다

1) 문제 상황 (Problem)

  • Next.js App Router 환경에서 LocalStorage 데이터 사용 시 서버/클라이언트 간 데이터 불일치로 인한 Hydration Mismatch 발생.
  • 초기 구현에서는 모든 탭의 사이드바 너비를 실시간으로 동기화했으나, 팀 회의 중 실제 사용 시나리오를 검토한 결과 사용자가 각 창의 크기에 맞게 독립적으로 사이드바를 조절하고 싶어 할 가능성을 인지함.

무제22.gif


2) 해결 과정 (Approach)

Step 1: 커스텀 훅 설계

  • useLocalStorageState를 직접 구현하여 StorageEvent 리스너를 통한 다른 탭/윈도우 간의 실시간 sidebar 너비 동기화, Hydration 에러 방지 로직 적용.
    tsx
       // src/shared/lib/use-local-storage-state.ts
       "use client";
       
       import { useCallback, useEffect, useState } from "react";
       
       interface IUseLocalStorageStateOptions<T> {
         defaultValue: T | (() => T);
         serialize?: (value: T) => string;
         deserialize?: (value: string) => T;
       }
       
       export function useLocalStorageState<T>(key: string, options: IUseLocalStorageStateOptions<T>) {
         const { defaultValue, serialize = JSON.stringify, deserialize = JSON.parse } = options;
       
         const getDefaultValue = () =>
           typeof defaultValue === "function" ? (defaultValue as () => T)() : defaultValue;
       
         // 서버 및 초기 렌더 시에는 항상 defaultValue를 사용해 hydration mismatch를 방지한다.
         const [value, setValue] = useState<T>(() => getDefaultValue());
       
         // 1) 마운트 시 localStorage 값 로드
         useEffect(() => {
           if (typeof window === "undefined") return;
       
           try {
             const stored = window.localStorage.getItem(key);
       
             if (stored != null) {
               const parsed = deserialize(stored);
               setValue(parsed);
             } else {
               window.localStorage.setItem(key, serialize(getDefaultValue()));
             }
           } catch {
             // JSON 파싱 오류나 용량 초과 등은 조용히 무시
           }
         }, [key, deserialize, serialize]);
       
         // 2) 다른 탭/윈도우에서의 변경을 동기화
         useEffect(() => {
           if (typeof window === "undefined") return;
       
           const handleStorageChange = (event: StorageEvent) => {
             if (event.key !== key || event.newValue == null) return;
       
             try {
               const parsed = deserialize(event.newValue);
               setValue(parsed);
             } catch {
               // 파싱 실패는 무시
             }
           };
       
           window.addEventListener("storage", handleStorageChange);
           return () => window.removeEventListener("storage", handleStorageChange);
         }, [key, deserialize]);
       
         // 3) setState와 localStorage 저장을 함께 처리하는 setter
         const setStoredValue = useCallback(
           (next: T | ((prev: T) => T)) => {
             setValue((prev) => {
               const resolved = typeof next === "function" ? (next as (prev: T) => T)(prev) : next;
       
               if (typeof window !== "undefined") {
                 try {
                   window.localStorage.setItem(key, serialize(resolved));
                 } catch {
                   // 저장 실패는 앱 동작에 치명적이지 않으므로 무시
                 }
               }
       
               return resolved;
             });
           },
           [key, serialize],
         );
       
         return [value, setStoredValue] as const;
       }
       ```

Step 2: 팀 리뷰를 통한 의사결정

  • UX 결정: StorageEvent 리스너를 제거하여 탭마다 독립적인 너비를 유지하도록 변경. (새 탭을 열 때만 마지막 저장 값을 가져오고, 열려 있는 탭끼리는 서로 간섭하지 않음)
  • 아키텍처 결정: 프로젝트 내 다른 도메인들이 persist 기반 Zustand를 사용 중이므로, 관리 포인트 일원화를 위해 Zustand 스토어로 리팩토링 결정.

3) 최종 코드 (Final Solution): zustand 기반

  • sidebar-store.ts: skipHydration: true로 초기 렌더링 시 자동 hydration을 끔
tsx
// sidebar-store.ts
import { create } from "zustand";
import { persist } from "zustand/middleware";

const MIN_SIDEBAR_WIDTH = 200;
const MAX_SIDEBAR_WIDTH = 400;
const DEFAULT_SIDEBAR_WIDTH = 272;

interface ISidebarState {
  sidebarWidth: number;
  setSidebarWidth: (width: number) => void;
}

export const useSidebarStore = create<ISidebarState>()(
  persist(
    (set) => ({
      sidebarWidth: DEFAULT_SIDEBAR_WIDTH,
      setSidebarWidth: (width) =>
        set({
          sidebarWidth: Math.min(MAX_SIDEBAR_WIDTH, Math.max(MIN_SIDEBAR_WIDTH, width)),
        }),
    }),
    {
      name: "sidebar-width",
      **skipHydration: true, // Next.js App Router 환경에서 SSR/Hydration 이슈 방지**
    }
  )
);

export { DEFAULT_SIDEBAR_WIDTH, MAX_SIDEBAR_WIDTH, MIN_SIDEBAR_WIDTH };
  • app-sidebar.tsx: 클라이언트에서 마운트 후 rehydrate() 호출

    tsx
    // app-sidebar.tsx
    export function AppSidebar() {
      const sidebarWidth = useSidebarStore((state) => state.sidebarWidth);
      const setSidebarWidth = useSidebarStore((state) => state.setSidebarWidth);
      const [isResizing, setIsResizing] = useState(false);
    
      // Next.js App Router 환경에서 SSR/Hydration 이슈를 피하기 위해 마운트 후에만 rehydrate
      useEffect(() => {
        **useSidebarStore.persist.rehydrate();**
      }, []);

4) 결과(Result)

  • 불필요한 이벤트 리스너 제거로 코드 간결화, 메모리 자원 낭비 방지 및 사용자 의도에 맞는 독립적 레이아웃 제공.
  • 프로젝트 상태 관리 컨벤션 통일로 협업 효율성 증대.
0개의 댓글
💬

아직 댓글이 없습니다

첫 번째 댓글을 작성해보세요!