사이드바 리팩토링: 기술적 통일성과 UX 사이의 균형 찾기
작성일: 2026년 2월 26일 오후 12:47(마지막 수정: 2026년 2월 27일 오전 12:23)
조회수: 32
이 글은 사이드바 구현 중 팀원과 의견 차이를 조율해나가는 과정입니다
1) 문제 상황 (Problem)
- Next.js App Router 환경에서 LocalStorage 데이터 사용 시 서버/클라이언트 간 데이터 불일치로 인한 Hydration Mismatch 발생.
- 초기 구현에서는 모든 탭의 사이드바 너비를 실시간으로 동기화했으나, 팀 회의 중 실제 사용 시나리오를 검토한 결과 사용자가 각 창의 크기에 맞게 독립적으로 사이드바를 조절하고 싶어 할 가능성을 인지함.

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개의 댓글
💬
아직 댓글이 없습니다
첫 번째 댓글을 작성해보세요!