From 106ce09482878962465fd5d4cc335a5ac5cbfb71 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9C=A0=EB=B3=91=EC=B2=A0?= Date: Thu, 29 Jan 2026 16:57:49 +0900 Subject: [PATCH] =?UTF-8?q?fix(WEB):=20=EB=A7=88=EC=8A=A4=ED=84=B0?= =?UTF-8?q?=EB=8D=B0=EC=9D=B4=ED=84=B0=20=EC=BA=90=EC=8B=9C=20=ED=85=8C?= =?UTF-8?q?=EB=84=8C=ED=8A=B8=20=EA=B2=A9=EB=A6=AC=20=EB=B0=8F=20=EC=83=81?= =?UTF-8?q?=EC=84=B8=20=ED=85=9C=ED=94=8C=EB=A6=BF=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - masterDataStore: 테넌트별 캐시 격리 로직 강화 - AuthContext: 인증 컨텍스트 안정성 개선 - IntegratedDetailTemplate: 상세 템플릿 동작 수정 - VendorDetail: 거래처 상세 수정 - AttendanceManagement: 타입 정의 추가 Co-Authored-By: Claude Opus 4.5 --- claudedocs/_index.md | 5 +- ...1-29] masterdata-cache-tenant-isolation.md | 145 ++++++++++++++++++ .../VendorManagement/VendorDetail.tsx | 2 +- .../hr/AttendanceManagement/types.ts | 2 + .../IntegratedDetailTemplate/index.tsx | 6 +- src/contexts/AuthContext.tsx | 16 +- src/stores/masterDataStore.ts | 64 +++++--- 7 files changed, 211 insertions(+), 29 deletions(-) create mode 100644 claudedocs/architecture/[FIX-2026-01-29] masterdata-cache-tenant-isolation.md diff --git a/claudedocs/_index.md b/claudedocs/_index.md index 592039fe..e66f8d7a 100644 --- a/claudedocs/_index.md +++ b/claudedocs/_index.md @@ -1,6 +1,6 @@ # claudedocs 문서 맵 -> 프로젝트 기술 문서 인덱스 (Last Updated: 2026-01-07) +> 프로젝트 기술 문서 인덱스 (Last Updated: 2026-01-29) ## ⭐ 빠른 참조 @@ -179,7 +179,8 @@ claudedocs/ | 파일 | 설명 | |------|------| -| `[PLAN-2025-12-29] dynamic-menu-refresh.md` | 🔴 **NEW** - 동적 메뉴 갱신 시스템 (1단계: 폴링, 2단계: SSE) | +| `[FIX-2026-01-29] masterdata-cache-tenant-isolation.md` | 🔴 **NEW** - masterDataStore 캐시 테넌트 격리 수정 (page_config 키에 tenantId 추가, dead code 해소) | +| `[PLAN-2025-12-29] dynamic-menu-refresh.md` | 동적 메뉴 갱신 시스템 (1단계: 폴링, 2단계: SSE) | | `multi-tenancy-implementation.md` | 멀티테넌시 구현 | | `multi-tenancy-test-guide.md` | 멀티테넌시 테스트 | | `architecture-integration-risks.md` | 통합 리스크 | diff --git a/claudedocs/architecture/[FIX-2026-01-29] masterdata-cache-tenant-isolation.md b/claudedocs/architecture/[FIX-2026-01-29] masterdata-cache-tenant-isolation.md new file mode 100644 index 00000000..2438f58a --- /dev/null +++ b/claudedocs/architecture/[FIX-2026-01-29] masterdata-cache-tenant-isolation.md @@ -0,0 +1,145 @@ +# masterDataStore 캐시 테넌트 격리 수정 + +**작성일**: 2026-01-29 +**타입**: 버그 수정 (캐시 격리 누락) +**관련 문서**: `[REF-2025-11-19] multi-tenancy-implementation.md` + +--- + +## 배경 + +멀티테넌시 검토 결과, `TenantAwareCache`(`mes-{tenantId}-{key}`)는 테넌트별로 캐시가 격리되어 있지만, `masterDataStore`의 sessionStorage 캐시는 테넌트 구분 없이 `page_config_{pageType}` 키를 사용하고 있었음. + +추가로 `setCurrentTenantId` 액션이 인터페이스에만 선언되어 있고 **구현도, 호출도 없는** dead code 상태였음. + +--- + +## 문제 + +### 1. 캐시 키에 tenantId 미포함 + +``` +TenantAwareCache: mes-282-itemMasters ← 테넌트 격리됨 +masterDataStore: page_config_item-master ← 테넌트 격리 안됨 +``` + +### 2. 발생 가능한 시나리오 + +``` +1. 테넌트 282 사용자가 품목관리 접속 + → sessionStorage: page_config_item-master = {테넌트282 설정} + +2. 세션 내 테넌트 500으로 전환 (로그아웃 없이) + → clearTenantCache()는 mes-282-* 만 삭제 + → page_config_item-master 는 삭제되지 않음 + +3. 테넌트 500 사용자에게 테넌트 282의 페이지 설정이 노출 +``` + +### 3. setCurrentTenantId 미구현 + +```typescript +// 인터페이스에 선언만 있고 구현 없음 +interface MasterDataStore { + currentTenantId: number | null; // ← initialState에도 누락 + setCurrentTenantId: (tenantId: number | null) => void; // ← 구현 없음 +} +``` + +--- + +## 수정 내역 + +### masterDataStore.ts + +| 영역 | Before | After | +|------|--------|-------| +| initialState | `currentTenantId` 누락 | `currentTenantId: null` 추가 | +| 캐시 키 포맷 | `page_config_{pageType}` | `page_config_{tenantId}_{pageType}` | +| setCurrentTenantId | 인터페이스만 선언 | 구현 추가 | +| fetchPageConfig | tenantId 미사용 | `currentTenantId`를 캐시 함수에 전달 | +| invalidateConfig | tenantId 미사용 | `currentTenantId` 기반 삭제 | +| invalidateAllConfigs | tenantId 미사용 | `currentTenantId` 기반 삭제 | +| reset() | pageType 목록 순회 삭제 | `page_config_` 프리픽스 기반 전체 삭제 | + +#### 핵심 변경: 캐시 키 생성 함수 추가 + +```typescript +function getStorageKey(tenantId: number | null, pageType: PageType): string { + return tenantId != null + ? `${STORAGE_PREFIX}${tenantId}_${pageType}` // page_config_282_item-master + : `${STORAGE_PREFIX}${pageType}`; // page_config_item-master (하위 호환) +} +``` + +#### 핵심 변경: reset()을 프리픽스 기반으로 변경 + +```typescript +// Before: 고정된 pageType 목록으로 삭제 (tenantId 포함 키를 찾지 못함) +pageTypes.forEach((pt) => removeConfigFromSessionStorage(pt)); + +// After: page_config_ 프리픽스로 모든 테넌트 캐시 일괄 삭제 +Object.keys(window.sessionStorage).forEach(key => { + if (key.startsWith(STORAGE_PREFIX)) { + window.sessionStorage.removeItem(key); + } +}); +``` + +### AuthContext.tsx + +| 영역 | Before | After | +|------|--------|-------| +| import | - | `useMasterDataStore` 추가 | +| tenantId 동기화 | 없음 | `currentUser.tenant.id` 변경 시 `setCurrentTenantId()` 호출 | +| clearTenantCache | `mes-{tenantId}-*` 만 삭제 | `mes-{tenantId}-*` + `page_config_{tenantId}_*` 삭제 | + +#### 핵심 변경: tenantId 동기화 useEffect + +```typescript +useEffect(() => { + const tenantId = currentUser?.tenant?.id ?? null; + useMasterDataStore.getState().setCurrentTenantId(tenantId); +}, [currentUser?.tenant?.id]); +``` + +#### 핵심 변경: clearTenantCache 범위 확장 + +```typescript +const tenantAwarePrefix = `mes-${tenantId}-`; +const pageConfigPrefix = `page_config_${tenantId}_`; + +Object.keys(sessionStorage).forEach(key => { + if (key.startsWith(tenantAwarePrefix) || key.startsWith(pageConfigPrefix)) { + sessionStorage.removeItem(key); + } +}); +``` + +--- + +## 하위 호환 + +| 항목 | 영향 | +|------|------| +| 기존 캐시 키 | `page_config_item-master` → 키 불일치로 miss → API 재요청 → 새 포맷으로 자동 전환 | +| logout.ts | `page_config_` 프리픽스 매칭이 새 키 포맷(`page_config_282_item-master`)도 커버 | +| sessionStorage TTL | 10분 만료이므로 기존 키는 자연 소멸 | +| tenantId가 null인 경우 | 기존 포맷(`page_config_{pageType}`) 유지하여 동작 보장 | + +--- + +## 효과 + +1. **세션 내 테넌트 전환 시 캐시 누수 차단**: `clearTenantCache`가 `page_config_{tenantId}_*`까지 삭제 +2. **캐시 패턴 일관성**: TenantAwareCache(`mes-{tenantId}-`)와 masterDataStore(`page_config_{tenantId}_`) 모두 테넌트 격리 +3. **dead code 해소**: `currentTenantId` 필드와 `setCurrentTenantId` 액션이 실제로 동작 + +--- + +## 관련 파일 + +- `src/stores/masterDataStore.ts` - 캐시 키 변경, setCurrentTenantId 구현 +- `src/contexts/AuthContext.tsx` - tenantId 동기화, clearTenantCache 범위 확장 +- `src/lib/auth/logout.ts` - 기존 `page_config_` 프리픽스 매칭 (변경 없음, 호환 확인) +- `src/lib/cache/TenantAwareCache.ts` - 참고 (기존 정상 동작) diff --git a/src/components/accounting/VendorManagement/VendorDetail.tsx b/src/components/accounting/VendorManagement/VendorDetail.tsx index 1f9942cb..c064e9a0 100644 --- a/src/components/accounting/VendorManagement/VendorDetail.tsx +++ b/src/components/accounting/VendorManagement/VendorDetail.tsx @@ -362,7 +362,7 @@ export function VendorDetail({ mode, vendorId, openModal }: VendorDetailProps) { error={!!validationErrors.businessNumber} /> - {renderField('거래처코드', 'vendorCode', formData.vendorCode, { placeholder: '자동생성' })} + {renderField('거래처코드', 'vendorCode', formData.vendorCode, { placeholder: '자동생성', disabled: true })} {renderField('거래처명', 'vendorName', formData.vendorName, { required: true })} {renderField('대표자명', 'representativeName', formData.representativeName)} {renderSelectField('거래처 유형', 'category', formData.category, VENDOR_CATEGORY_SELECTOR_OPTIONS, true)} diff --git a/src/components/hr/AttendanceManagement/types.ts b/src/components/hr/AttendanceManagement/types.ts index 318af193..dd10fc41 100644 --- a/src/components/hr/AttendanceManagement/types.ts +++ b/src/components/hr/AttendanceManagement/types.ts @@ -259,7 +259,9 @@ export const HOUR_OPTIONS = Array.from({ length: 24 }, (_, i) => ({ export const MINUTE_OPTIONS = [ { value: '0', label: '0분' }, + { value: '15', label: '15분' }, { value: '30', label: '30분' }, + { value: '45', label: '45분' }, ]; // 연장 시간 옵션 (0-12시간) diff --git a/src/components/templates/IntegratedDetailTemplate/index.tsx b/src/components/templates/IntegratedDetailTemplate/index.tsx index 4ad150c1..881128a3 100644 --- a/src/components/templates/IntegratedDetailTemplate/index.tsx +++ b/src/components/templates/IntegratedDetailTemplate/index.tsx @@ -232,8 +232,12 @@ function IntegratedDetailTemplateInner>( setFormData(transformed); } setErrors({}); + // URL도 view 모드로 변경 (handleEdit와 대칭) + if (itemId) { + router.push(`/${locale}${config.basePath}/${itemId}?mode=view`); + } } - }, [onCancel, isCreateMode, navigateToList, initialData, config.transformInitialData]); + }, [onCancel, isCreateMode, navigateToList, initialData, config.transformInitialData, itemId, router, locale, config.basePath]); // ===== 제출 핸들러 ===== const handleSubmit = useCallback(async () => { diff --git a/src/contexts/AuthContext.tsx b/src/contexts/AuthContext.tsx index 774c0fc1..204cb2bb 100644 --- a/src/contexts/AuthContext.tsx +++ b/src/contexts/AuthContext.tsx @@ -2,6 +2,7 @@ import { createContext, useContext, useState, useEffect, useRef, ReactNode } from 'react'; import { performFullLogout } from '@/lib/auth/logout'; +import { useMasterDataStore } from '@/stores/masterDataStore'; // ===== 타입 정의 ===== @@ -201,24 +202,31 @@ export function AuthProvider({ children }: { children: ReactNode }) { previousTenantIdRef.current = currentTenantId || null; }, [currentUser?.tenant?.id]); + // ✅ 추가: masterDataStore에 현재 테넌트 ID 동기화 + useEffect(() => { + const tenantId = currentUser?.tenant?.id ?? null; + useMasterDataStore.getState().setCurrentTenantId(tenantId); + }, [currentUser?.tenant?.id]); + // ✅ 추가: 테넌트별 캐시 삭제 함수 (SSR-safe) const clearTenantCache = (tenantId: number) => { // 서버 환경에서는 실행 안함 if (typeof window === 'undefined') return; - const prefix = `mes-${tenantId}-`; + const tenantAwarePrefix = `mes-${tenantId}-`; + const pageConfigPrefix = `page_config_${tenantId}_`; // localStorage 캐시 삭제 Object.keys(localStorage).forEach(key => { - if (key.startsWith(prefix)) { + if (key.startsWith(tenantAwarePrefix)) { localStorage.removeItem(key); console.log(`[Cache] Cleared localStorage: ${key}`); } }); - // sessionStorage 캐시 삭제 + // sessionStorage 캐시 삭제 (TenantAwareCache + masterDataStore) Object.keys(sessionStorage).forEach(key => { - if (key.startsWith(prefix)) { + if (key.startsWith(tenantAwarePrefix) || key.startsWith(pageConfigPrefix)) { sessionStorage.removeItem(key); console.log(`[Cache] Cleared sessionStorage: ${key}`); } diff --git a/src/stores/masterDataStore.ts b/src/stores/masterDataStore.ts index 2573243d..46ab0544 100644 --- a/src/stores/masterDataStore.ts +++ b/src/stores/masterDataStore.ts @@ -79,6 +79,7 @@ const createEmptyPageConfigs = (): Record => ({ }); const initialState = { + currentTenantId: null as number | null, pageConfigs: createEmptyPageConfigs(), loading: {} as Record, errors: {} as Record, @@ -92,15 +93,27 @@ 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(pageType: PageType): PageConfig | null { +function getConfigFromSessionStorage(tenantId: number | null, 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}`); + 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; @@ -108,8 +121,8 @@ function getConfigFromSessionStorage(pageType: PageType): PageConfig | null { 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}`); + window.sessionStorage.removeItem(key); + window.sessionStorage.removeItem(`${key}${STORAGE_TIMESTAMP_SUFFIX}`); return null; } @@ -123,12 +136,13 @@ function getConfigFromSessionStorage(pageType: PageType): PageConfig | null { /** * sessionStorage에 페이지 구성 저장 */ -function setConfigToSessionStorage(pageType: PageType, config: PageConfig): void { +function setConfigToSessionStorage(tenantId: number | null, 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()); + 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); } @@ -137,12 +151,13 @@ function setConfigToSessionStorage(pageType: PageType, config: PageConfig): void /** * sessionStorage에서 페이지 구성 삭제 */ -function removeConfigFromSessionStorage(pageType: PageType): void { +function removeConfigFromSessionStorage(tenantId: number | null, pageType: PageType): void { if (typeof window === 'undefined') return; try { - window.sessionStorage.removeItem(`${STORAGE_PREFIX}${pageType}`); - window.sessionStorage.removeItem(`${STORAGE_PREFIX}${pageType}${STORAGE_TIMESTAMP_SUFFIX}`); + 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); } @@ -156,10 +171,15 @@ export const useMasterDataStore = create()( // Initial state ...initialState, + // ===== 테넌트 관리 ===== + + setCurrentTenantId: (tenantId: number | null) => + set({ currentTenantId: tenantId }, false, 'setCurrentTenantId'), + // ===== 하이브리드 로딩 전략 ===== fetchPageConfig: async (pageType: PageType) => { - const { pageConfigs, loading, errors } = get(); + const { pageConfigs, loading, errors, currentTenantId } = get(); // 🔒 이미 로딩 중이면 중복 요청 방지 if (loading[pageType]) { @@ -180,7 +200,7 @@ export const useMasterDataStore = create()( } // 2️⃣ sessionStorage 확인 - const cachedConfig = getConfigFromSessionStorage(pageType); + const cachedConfig = getConfigFromSessionStorage(currentTenantId, pageType); if (cachedConfig) { console.log(`✅ [Cache Hit - Session] ${pageType}`); @@ -239,7 +259,7 @@ export const useMasterDataStore = create()( 'fetchPageConfig/success' ); - setConfigToSessionStorage(pageType, config); + setConfigToSessionStorage(currentTenantId, pageType, config); console.log(`✅ [Config Loaded] ${pageType}`, config); return config; @@ -270,7 +290,7 @@ export const useMasterDataStore = create()( // ===== 캐시 무효화 ===== invalidateConfig: (pageType: PageType) => { - const { pageConfigs } = get(); + const { pageConfigs, currentTenantId } = get(); const newConfigs = { ...pageConfigs }; delete newConfigs[pageType]; @@ -280,16 +300,17 @@ export const useMasterDataStore = create()( 'invalidateConfig' ); - removeConfigFromSessionStorage(pageType); + removeConfigFromSessionStorage(currentTenantId, pageType); console.log(`🗑️ [Cache Invalidated] ${pageType}`); }, invalidateAllConfigs: () => { + const { currentTenantId } = get(); const pageTypes: PageType[] = ['item-master', 'quotation', 'sales-order', 'formula', 'pricing']; pageTypes.forEach((pageType) => { - removeConfigFromSessionStorage(pageType); + removeConfigFromSessionStorage(currentTenantId, pageType); }); set( @@ -376,11 +397,12 @@ export const useMasterDataStore = create()( // 1. 메모리 캐시 초기화 set(initialState, false, 'reset'); - // 2. sessionStorage 캐시도 정리 + // 2. sessionStorage 캐시도 정리 (프리픽스 기반으로 모든 테넌트 캐시 제거) if (typeof window !== 'undefined') { - const pageTypes: PageType[] = ['item-master', 'quotation', 'sales-order', 'formula', 'pricing']; - pageTypes.forEach((pageType) => { - removeConfigFromSessionStorage(pageType); + Object.keys(window.sessionStorage).forEach(key => { + if (key.startsWith(STORAGE_PREFIX)) { + window.sessionStorage.removeItem(key); + } }); console.log('[masterDataStore] Reset: cleared memory and sessionStorage cache'); }