/** * 품목기준관리 Zustand Store * * 하이브리드 로딩 전략: * 1단계: Zustand (메모리 캐시) * 2단계: sessionStorage (브라우저 세션) * 3단계: API/Redis (백엔드 캐시, 10분 TTL) * 4단계: Database (영구 저장소) */ import { create } from 'zustand'; import { devtools } from 'zustand/middleware'; import { shallow } from 'zustand/shallow'; import type { PageConfig, PageType, DynamicFormData } from '@/types/master-data'; import { fetchPageConfigByType, invalidatePageConfigCache } from '@/lib/api/master-data'; // ===== Store 타입 정의 ===== interface MasterDataStore { // === State === // 페이지 구성 캐시 (페이지 타입별) pageConfigs: Record; // 로딩 상태 loading: Record; // 에러 상태 errors: Record; // 선택된 페이지 타입 selectedPageType: PageType | null; // 폼 데이터 (임시 저장) formData: Record; // === Actions === // 페이지 구성 가져오기 (하이브리드 로딩) fetchPageConfig: (pageType: PageType) => 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 initialState = { pageConfigs: {} as Record, loading: {} as Record, errors: {} as Record, selectedPageType: null, formData: {}, }; // ===== sessionStorage 유틸리티 ===== const STORAGE_PREFIX = 'page_config_'; const STORAGE_TIMESTAMP_SUFFIX = '_timestamp'; const CACHE_TTL = 10 * 60 * 1000; // 10분 /** * sessionStorage에서 페이지 구성 가져오기 */ function getConfigFromSessionStorage(pageType: PageType): PageConfig | null { if (typeof window === 'undefined') return null; try { const cachedData = window.sessionStorage.getItem(`${STORAGE_PREFIX}${pageType}`); const timestamp = window.sessionStorage.getItem(`${STORAGE_PREFIX}${pageType}${STORAGE_TIMESTAMP_SUFFIX}`); if (!cachedData || !timestamp) return null; // TTL 확인 const cacheAge = Date.now() - parseInt(timestamp, 10); if (cacheAge > CACHE_TTL) { // 만료된 캐시 삭제 window.sessionStorage.removeItem(`${STORAGE_PREFIX}${pageType}`); window.sessionStorage.removeItem(`${STORAGE_PREFIX}${pageType}${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(pageType: PageType, config: PageConfig): void { if (typeof window === 'undefined') return; try { window.sessionStorage.setItem(`${STORAGE_PREFIX}${pageType}`, JSON.stringify(config)); window.sessionStorage.setItem(`${STORAGE_PREFIX}${pageType}${STORAGE_TIMESTAMP_SUFFIX}`, Date.now().toString()); } catch (error) { console.error(`[sessionStorage] Failed to set config for ${pageType}:`, error); } } /** * sessionStorage에서 페이지 구성 삭제 */ function removeConfigFromSessionStorage(pageType: PageType): void { if (typeof window === 'undefined') return; try { window.sessionStorage.removeItem(`${STORAGE_PREFIX}${pageType}`); window.sessionStorage.removeItem(`${STORAGE_PREFIX}${pageType}${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, // ===== 하이브리드 로딩 전략 ===== fetchPageConfig: async (pageType: PageType) => { const { pageConfigs, loading, errors } = get(); // 🔒 이미 로딩 중이면 중복 요청 방지 if (loading[pageType]) { console.log(`⏳ [Already Loading] ${pageType}`); return pageConfigs[pageType] || null; } // 🔒 최근에 에러가 발생했으면 재시도 방지 (캐시 무효화 전까지) if (errors[pageType] && pageConfigs[pageType] === null) { console.log(`🚫 [Skip - Previous Error] ${pageType}: ${errors[pageType]}`); return null; } // 1️⃣ 메모리 캐시 확인 (Zustand) if (pageConfigs[pageType]) { console.log(`✅ [Cache Hit - Memory] ${pageType}`); return pageConfigs[pageType]; } // 2️⃣ sessionStorage 확인 const cachedConfig = getConfigFromSessionStorage(pageType); if (cachedConfig) { console.log(`✅ [Cache Hit - Session] ${pageType}`); // 메모리에 저장 set( (state) => ({ pageConfigs: { ...state.pageConfigs, [pageType]: cachedConfig, }, }), false, 'fetchPageConfig/cacheHit' ); return cachedConfig; } // 3️⃣ API 요청 (Redis/Database) console.log(`🌐 [API Request] ${pageType}`); 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(pageType, config); console.log(`✅ [Config Loaded] ${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 } = get(); const newConfigs = { ...pageConfigs }; delete newConfigs[pageType]; set( { pageConfigs: newConfigs }, false, 'invalidateConfig' ); removeConfigFromSessionStorage(pageType); console.log(`🗑️ [Cache Invalidated] ${pageType}`); }, invalidateAllConfigs: () => { const pageTypes: PageType[] = ['item-master', 'quotation', 'sales-order', 'formula', 'pricing']; pageTypes.forEach((pageType) => { removeConfigFromSessionStorage(pageType); }); set( { pageConfigs: {} }, false, 'invalidateAllConfigs' ); // 서버 캐시도 무효화 invalidatePageConfigCache().catch((error) => { console.error('[Cache Invalidation Error]:', error); }); console.log('🗑️ [All Caches Invalidated]'); }, // ===== 페이지 타입 선택 ===== 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: () => set( initialState, false, 'reset' ), }), { 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( (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, }), shallow ); // ===== 타입 추출 ===== export type { MasterDataStore };