Files
sam-react-prod/claudedocs/architecture/[IMPL-2026-02-23] phase1-item5-zustand-selectors.md
유병철 07374c826c refactor(WEB): claudedocs 재정리 및 AuthContext/Zustand/유틸 코드 개선
- claudedocs 폴더 구조 재정리: archive/sessions, guides/migration·mobile·universal-list, refactoring 분류
- 오래된 세션 컨텍스트/체크리스트 문서 정리 (아카이브 이동 또는 삭제)
- AuthContext → authStore(Zustand) 전환 시작, RootProvider 간소화
- GenericCRUDDialog 공통 다이얼로그 컴포넌트 추가
- PermissionDialog 삭제 → GenericCRUDDialog로 대체
- RankDialog/TitleDialog GenericCRUDDialog 기반으로 리팩토링
- toast-utils.ts 삭제 (미사용)
- fileDownload.ts 개선, excel-download.ts 정리
- menuStore/themeStore Zustand 셀렉터 최적화
- useColumnSettings/useTableColumnStore 기능 보강
- 세금계산서/견적/작업자화면/결재 등 소규모 개선

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 17:17:13 +09:00

7.3 KiB

Phase 1-5: Zustand 셀렉터 훅 추가 (3개 스토어)

난이도: 저 | 영향도: 🟡 리렌더 최적화 | 예상 변경: 3 스토어 + 4 컨슈머


현황 요약

셀렉터 없이 전체 스토어를 구독하면, 무관한 상태 변경에도 컴포넌트가 리렌더됩니다.

스토어 셀렉터 훅 사용처 문제
masterDataStore usePageConfig() 다수 양호
authStore useCurrentUser() 4곳 양호 (방금 추가)
useTableColumnStore 없음 1곳 전체 스토어 구독
useMenuStore 없음 15곳 일부 전체 구독
useThemeStore 없음 2곳 전체 구독

작업 내역

Step 1: src/stores/useTableColumnStore.ts — 셀렉터 훅 추가

파일 끝에 추가:

// ===== 셀렉터 훅 =====

/** 특정 페이지의 컬럼 설정만 구독 */
export const usePageColumnSettings = (pageId: string) =>
  useTableColumnStore((state) => state.pageSettings[pageId] ?? DEFAULT_PAGE_SETTINGS);

/** 특정 페이지의 숨김 컬럼만 구독 */
export const useHiddenColumns = (pageId: string) =>
  useTableColumnStore((state) => state.pageSettings[pageId]?.hiddenColumns ?? []);

/** 특정 페이지의 컬럼 너비만 구독 */
export const useColumnWidths = (pageId: string) =>
  useTableColumnStore((state) => state.pageSettings[pageId]?.columnWidths ?? {});

주의: DEFAULT_PAGE_SETTINGS 객체는 파일 내에 이미 정의되어 있음 (line 30-33).

컨슈머 변경src/hooks/useColumnSettings.ts:

// Before (line 17)
const store = useTableColumnStore();        // 전체 스토어 구독
const settings = store.getPageSettings(pageId);

// After
const settings = usePageColumnSettings(pageId);  // 해당 페이지 설정만 구독
const { setColumnWidth: storeSetWidth, toggleColumnVisibility: storeToggle, resetPageSettings } = useTableColumnStore.getState();
// 또는 액션만 별도 구독 (액션은 참조 안정적이라 리렌더 유발 안 함):
const setColumnWidth = useTableColumnStore((s) => s.setColumnWidth);
const toggleColumnVisibility = useTableColumnStore((s) => s.toggleColumnVisibility);
const resetPageSettings = useTableColumnStore((s) => s.resetPageSettings);

Step 2: src/stores/menuStore.ts — 셀렉터 훅 추가

파일 끝에 추가:

// ===== 셀렉터 훅 =====

/** 사이드바 접힘 상태만 구독 */
export const useSidebarCollapsed = () =>
  useMenuStore((state) => state.sidebarCollapsed);

/** 활성 메뉴 ID만 구독 */
export const useActiveMenu = () =>
  useMenuStore((state) => state.activeMenu);

/** 메뉴 아이템 목록만 구독 */
export const useMenuItems = () =>
  useMenuStore((state) => state.menuItems);

/** 하이드레이션 완료 여부만 구독 */
export const useMenuHydrated = () =>
  useMenuStore((state) => state._hasHydrated);

컨슈머 변경 대상:

2-A. src/layouts/AuthenticatedLayout.tsx (line 99) — 🔴 핵심

현재: 전체 스토어 디스트럭처링

const { menuItems, activeMenu, setActiveMenu, setMenuItems, sidebarCollapsed, toggleSidebar, _hasHydrated } = useMenuStore();

변경:

const menuItems = useMenuItems();
const activeMenu = useActiveMenu();
const sidebarCollapsed = useSidebarCollapsed();
const _hasHydrated = useMenuHydrated();
// 액션은 참조 안정적이므로 별도 셀렉터:
const setActiveMenu = useMenuStore((s) => s.setActiveMenu);
const setMenuItems = useMenuStore((s) => s.setMenuItems);
const toggleSidebar = useMenuStore((s) => s.toggleSidebar);

2-B. src/components/production/WorkerScreen/index.tsx (line 327)

현재:

const { sidebarCollapsed } = useMenuStore();  // 전체 구독

변경:

const sidebarCollapsed = useSidebarCollapsed();

2-C. src/components/layout/CommandMenuSearch.tsx (line 68)

현재:

const { menuItems } = useMenuStore();  // 전체 구독

변경:

const menuItems = useMenuItems();

2-D. 나머지 sidebarCollapsed 사용 파일 (이미 셀렉터 패턴)

아래 파일들은 이미 useMenuStore((state) => state.sidebarCollapsed) 패턴을 사용 중이므로 변경 불필요:

  • ItemDetail.tsx, ChecklistDetail.tsx, PriceDistributionDetail.tsx
  • StepDetail.tsx, PermissionDetailClient.tsx, BoardDetail/index.tsx
  • ProcessDetail.tsx, PricingTableForm.tsx, DynamicItemForm/index.tsx
  • ItemDetailClient.tsx, ClientDetail.tsx, DetailActions.tsx

단, 셀렉터 훅이 추가되면 이 파일들도 향후 useSidebarCollapsed()로 전환 가능 (선택).


Step 3: src/stores/themeStore.ts — 셀렉터 훅 추가

파일 끝에 추가:

// ===== 셀렉터 훅 =====

/** 현재 테마만 구독 */
export const useTheme = () =>
  useThemeStore((state) => state.theme);

/** setTheme 액션만 구독 */
export const useSetTheme = () =>
  useThemeStore((state) => state.setTheme);

컨슈머 변경 대상:

3-A. src/layouts/AuthenticatedLayout.tsx (line 100)

현재:

const { theme, setTheme } = useThemeStore();

변경:

const theme = useTheme();
const setTheme = useSetTheme();

3-B. src/components/ThemeSelect.tsx (line 24)

현재:

const { theme, setTheme } = useThemeStore();

변경:

const theme = useTheme();
const setTheme = useSetTheme();

검증

npx tsc --noEmit

셀렉터 훅은 기존 API에 추가만 하는 것이므로 기존 코드에 영향 없음. 컨슈머 변경은 import 경로와 호출 패턴만 바뀌므로 타입 에러 가능성 낮음.


변경 파일 총 정리

# 파일 작업 내용
1 src/stores/useTableColumnStore.ts 추가 셀렉터 훅 3개 (usePageColumnSettings, useHiddenColumns, useColumnWidths)
2 src/stores/menuStore.ts 추가 셀렉터 훅 4개 (useSidebarCollapsed, useActiveMenu, useMenuItems, useMenuHydrated)
3 src/stores/themeStore.ts 추가 셀렉터 훅 2개 (useTheme, useSetTheme)
4 src/hooks/useColumnSettings.ts 수정 useTableColumnStore() → 셀렉터 패턴
5 src/layouts/AuthenticatedLayout.tsx 수정 menuStore/themeStore 전체 구독 → 셀렉터
6 src/components/production/WorkerScreen/index.tsx 수정 useMenuStore()useSidebarCollapsed()
7 src/components/layout/CommandMenuSearch.tsx 수정 useMenuStore()useMenuItems()
8 src/components/ThemeSelect.tsx 수정 useThemeStore()useTheme() + useSetTheme()

참고: Zustand 셀렉터가 중요한 이유

// ❌ 전체 구독 — menuItems 변경 시 sidebarCollapsed만 쓰는 컴포넌트도 리렌더
const { sidebarCollapsed } = useMenuStore();

// ✅ 셀렉터 — sidebarCollapsed 변경 시에만 리렌더
const sidebarCollapsed = useMenuStore((state) => state.sidebarCollapsed);
// 또는
const sidebarCollapsed = useSidebarCollapsed();

Zustand는 Object.is로 반환값을 비교. 셀렉터가 원시값(string, boolean, number)을 반환하면 참조 비교로 정확히 변경 감지. 객체를 반환하는 셀렉터(예: usePageColumnSettings)는 같은 참조를 반환하므로 해당 pageId의 설정이 변경될 때만 리렌더.