/** * 품목기준관리 Zustand Store * * 하이브리드 로딩 전략: * 1단계: Zustand (메모리 캐시) * 2단계: sessionStorage (브라우저 세션) * 3단계: API/Redis (백엔드 캐시, 10분 TTL) * 4단계: Database (영구 저장소) */ import { create } from 'zustand'; import { devtools } from 'zustand/middleware'; import { useShallow } from 'zustand/react/shallow'; import type { PageConfig, PageType, DynamicFormData } from '@/types/master-data'; import { fetchPageConfigByType, invalidatePageConfigCache } from '@/lib/api/master-data'; // ===== Store 타입 정의 ===== interface MasterDataStore { // === State === // 현재 테넌트 ID (캐시 격리용) currentTenantId: number | null; // 페이지 구성 캐시 (페이지 타입별) pageConfigs: Record; // 로딩 상태 loading: Record; // 에러 상태 errors: Record; // 선택된 페이지 타입 selectedPageType: PageType | null; // 폼 데이터 (임시 저장) formData: Record; // === Actions === // 테넌트 ID 설정 (로그인 시 호출) setCurrentTenantId: (tenantId: number | null) => void; // 페이지 구성 가져오기 (하이브리드 로딩) // tenantId 파라미터는 선택적 (없으면 currentTenantId 사용) fetchPageConfig: (pageType: PageType, tenantId?: number) => Promise; // 페이지 구성 캐시 무효화 invalidateConfig: (pageType: PageType) => void; // 모든 캐시 무효화 invalidateAllConfigs: () => void; // 선택된 페이지 타입 설정 setSelectedPageType: (pageType: PageType | null) => void; // 폼 데이터 관리 setFormData: (id: string, data: DynamicFormData) => void; getFormData: (id: string) => DynamicFormData | undefined; clearFormData: (id: string) => void; // 로딩/에러 상태 setLoading: (pageType: PageType, loading: boolean) => void; setError: (pageType: PageType, error: string | null) => void; // 초기화 reset: () => void; } // ===== 초기 상태 ===== const createEmptyPageConfigs = (): Record => ({ 'item-master': null, 'quotation': null, 'sales-order': null, 'formula': null, 'pricing': null, }); const initialState = { currentTenantId: null as number | null, pageConfigs: createEmptyPageConfigs(), loading: {} as Record, errors: {} as Record, selectedPageType: null as PageType | null, formData: {} as Record, }; // ===== sessionStorage 유틸리티 ===== const STORAGE_PREFIX = 'page_config_'; const STORAGE_TIMESTAMP_SUFFIX = '_timestamp'; const CACHE_TTL = 10 * 60 * 1000; // 10분 /** * 테넌트별 캐시 키 생성 * tenantId가 있으면: page_config_{tenantId}_{pageType} * tenantId가 없으면: page_config_{pageType} (하위 호환) */ function getStorageKey(tenantId: number | null, pageType: PageType): string { return tenantId != null ? `${STORAGE_PREFIX}${tenantId}_${pageType}` : `${STORAGE_PREFIX}${pageType}`; } /** * sessionStorage에서 페이지 구성 가져오기 */ function getConfigFromSessionStorage(tenantId: number | null, pageType: PageType): PageConfig | null { if (typeof window === 'undefined') return null; try { const key = getStorageKey(tenantId, pageType); const cachedData = window.sessionStorage.getItem(key); const timestamp = window.sessionStorage.getItem(`${key}${STORAGE_TIMESTAMP_SUFFIX}`); if (!cachedData || !timestamp) return null; // TTL 확인 const cacheAge = Date.now() - parseInt(timestamp, 10); if (cacheAge > CACHE_TTL) { // 만료된 캐시 삭제 window.sessionStorage.removeItem(key); window.sessionStorage.removeItem(`${key}${STORAGE_TIMESTAMP_SUFFIX}`); return null; } return JSON.parse(cachedData); } catch (error) { console.error(`[sessionStorage] Failed to get config for ${pageType}:`, error); return null; } } /** * sessionStorage에 페이지 구성 저장 */ function setConfigToSessionStorage(tenantId: number | null, pageType: PageType, config: PageConfig): void { if (typeof window === 'undefined') return; try { const key = getStorageKey(tenantId, pageType); window.sessionStorage.setItem(key, JSON.stringify(config)); window.sessionStorage.setItem(`${key}${STORAGE_TIMESTAMP_SUFFIX}`, Date.now().toString()); } catch (error) { console.error(`[sessionStorage] Failed to set config for ${pageType}:`, error); } } /** * sessionStorage에서 페이지 구성 삭제 */ function removeConfigFromSessionStorage(tenantId: number | null, pageType: PageType): void { if (typeof window === 'undefined') return; try { const key = getStorageKey(tenantId, pageType); window.sessionStorage.removeItem(key); window.sessionStorage.removeItem(`${key}${STORAGE_TIMESTAMP_SUFFIX}`); } catch (error) { console.error(`[sessionStorage] Failed to remove config for ${pageType}:`, error); } } // ===== Store 생성 ===== export const useMasterDataStore = create()( devtools( (set, get) => ({ // Initial state ...initialState, // ===== 테넌트 관리 ===== setCurrentTenantId: (tenantId: number | null) => set({ currentTenantId: tenantId }, false, 'setCurrentTenantId'), // ===== 하이브리드 로딩 전략 ===== fetchPageConfig: async (pageType: PageType) => { const { pageConfigs, loading, errors, currentTenantId } = get(); // 🔒 이미 로딩 중이면 중복 요청 방지 if (loading[pageType]) { return pageConfigs[pageType] || null; } // 🔒 최근에 에러가 발생했으면 재시도 방지 (캐시 무효화 전까지) if (errors[pageType] && pageConfigs[pageType] === null) { return null; } // 1️⃣ 메모리 캐시 확인 (Zustand) if (pageConfigs[pageType]) { return pageConfigs[pageType]; } // 2️⃣ sessionStorage 확인 const cachedConfig = getConfigFromSessionStorage(currentTenantId, pageType); if (cachedConfig) { // 메모리에 저장 set( (state) => ({ pageConfigs: { ...state.pageConfigs, [pageType]: cachedConfig, }, }), false, 'fetchPageConfig/cacheHit' ); return cachedConfig; } // 3️⃣ API 요청 (Redis/Database) try { get().setLoading(pageType, true); get().setError(pageType, null); const config = await fetchPageConfigByType(pageType); if (!config) { console.warn(`⚠️ [Config Not Found] ${pageType}`); get().setError(pageType, '페이지 구성을 찾을 수 없습니다.'); // 에러 상태지만 null을 캐시에 저장하여 재시도 방지 set( (state) => ({ pageConfigs: { ...state.pageConfigs, [pageType]: null, }, }), false, 'fetchPageConfig/notFound' ); return null; } // 캐시 저장 (메모리 + sessionStorage) set( (state) => ({ pageConfigs: { ...state.pageConfigs, [pageType]: config, }, }), false, 'fetchPageConfig/success' ); setConfigToSessionStorage(currentTenantId, pageType, config); return config; } catch (error) { const errorMessage = error instanceof Error ? error.message : '페이지 구성 조회 실패'; console.error(`❌ [API Error] ${pageType}:`, error); get().setError(pageType, errorMessage); // 에러 상태를 캐시에 저장하여 재시도 방지 set( (state) => ({ pageConfigs: { ...state.pageConfigs, [pageType]: null, }, }), false, 'fetchPageConfig/error' ); return null; } finally { get().setLoading(pageType, false); } }, // ===== 캐시 무효화 ===== invalidateConfig: (pageType: PageType) => { const { pageConfigs, currentTenantId } = get(); const newConfigs = { ...pageConfigs }; delete newConfigs[pageType]; set( { pageConfigs: newConfigs }, false, 'invalidateConfig' ); removeConfigFromSessionStorage(currentTenantId, pageType); }, invalidateAllConfigs: () => { const { currentTenantId } = get(); const pageTypes: PageType[] = ['item-master', 'quotation', 'sales-order', 'formula', 'pricing']; pageTypes.forEach((pageType) => { removeConfigFromSessionStorage(currentTenantId, pageType); }); set( { pageConfigs: createEmptyPageConfigs() }, false, 'invalidateAllConfigs' ); // 서버 캐시도 무효화 invalidatePageConfigCache().catch((error) => { console.error('[Cache Invalidation Error]:', error); }); }, // ===== 페이지 타입 선택 ===== setSelectedPageType: (pageType) => set( { selectedPageType: pageType }, false, 'setSelectedPageType' ), // ===== 폼 데이터 관리 ===== setFormData: (id, data) => set( (state) => ({ formData: { ...state.formData, [id]: data, }, }), false, 'setFormData' ), getFormData: (id) => { return get().formData[id]; }, clearFormData: (id) => set( (state) => { const newFormData = { ...state.formData }; delete newFormData[id]; return { formData: newFormData }; }, false, 'clearFormData' ), // ===== 로딩/에러 상태 ===== setLoading: (pageType, loading) => set( (state) => ({ loading: { ...state.loading, [pageType]: loading, }, }), false, 'setLoading' ), setError: (pageType, error) => set( (state) => ({ errors: { ...state.errors, [pageType]: error, }, }), false, 'setError' ), // ===== 초기화 ===== reset: () => { // 1. 메모리 캐시 초기화 set(initialState, false, 'reset'); // 2. sessionStorage 캐시도 정리 (프리픽스 기반으로 모든 테넌트 캐시 제거) if (typeof window !== 'undefined') { Object.keys(window.sessionStorage).forEach(key => { if (key.startsWith(STORAGE_PREFIX)) { window.sessionStorage.removeItem(key); } }); } }, }), { name: 'MasterDataStore', } ) ); // ===== Selector Hooks (성능 최적화) ===== /** * 특정 페이지 타입의 구성만 구독 */ export const usePageConfig = (pageType: PageType) => useMasterDataStore((state) => state.pageConfigs[pageType]); /** * 로딩 상태만 구독 */ export const usePageConfigLoading = (pageType: PageType) => useMasterDataStore((state) => state.loading[pageType]); /** * 에러 상태만 구독 */ export const usePageConfigError = (pageType: PageType) => useMasterDataStore((state) => state.errors[pageType]); /** * 선택된 페이지 타입만 구독 */ export const useSelectedPageType = () => useMasterDataStore((state) => state.selectedPageType); /** * 액션만 가져오기 (리렌더링 방지) */ export const useMasterDataActions = () => useMasterDataStore( useShallow((state) => ({ fetchPageConfig: state.fetchPageConfig, invalidateConfig: state.invalidateConfig, invalidateAllConfigs: state.invalidateAllConfigs, setSelectedPageType: state.setSelectedPageType, setFormData: state.setFormData, getFormData: state.getFormData, clearFormData: state.clearFormData, reset: state.reset, })) ); // ===== 타입 추출 ===== export type { MasterDataStore };