Files
sam-react-prod/src/stores/masterDataStore.ts
유병철 0db6302652 refactor(WEB): 코드 품질 개선 및 불필요 코드 제거
- 미사용 import/변수/console.log 대량 정리 (100+개 파일)
- ItemMasterContext 간소화 (미사용 로직 제거)
- IntegratedListTemplateV2 / UniversalListPage 개선
- 결재 컴포넌트(ApprovalBox, DraftBox, ReferenceBox) 정리
- HR 컴포넌트(급여/휴가/부서) 코드 간소화
- globals.css 스타일 정리 및 개선
- AuthenticatedLayout 개선
- middleware CSP 정리
- proxy route 불필요 로깅 제거

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-10 20:55:11 +09:00

453 lines
12 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 품목기준관리 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<PageType, PageConfig | null>;
// 로딩 상태
loading: Record<PageType, boolean>;
// 에러 상태
errors: Record<PageType, string | null>;
// 선택된 페이지 타입
selectedPageType: PageType | null;
// 폼 데이터 (임시 저장)
formData: Record<string, DynamicFormData>;
// === Actions ===
// 테넌트 ID 설정 (로그인 시 호출)
setCurrentTenantId: (tenantId: number | null) => void;
// 페이지 구성 가져오기 (하이브리드 로딩)
// tenantId 파라미터는 선택적 (없으면 currentTenantId 사용)
fetchPageConfig: (pageType: PageType, tenantId?: number) => Promise<PageConfig | null>;
// 페이지 구성 캐시 무효화
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<PageType, PageConfig | null> => ({
'item-master': null,
'quotation': null,
'sales-order': null,
'formula': null,
'pricing': null,
});
const initialState = {
currentTenantId: null as number | null,
pageConfigs: createEmptyPageConfigs(),
loading: {} as Record<PageType, boolean>,
errors: {} as Record<PageType, string | null>,
selectedPageType: null as PageType | null,
formData: {} as Record<string, DynamicFormData>,
};
// ===== 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<MasterDataStore>()(
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 };