fix(WEB): 마스터데이터 캐시 테넌트 격리 및 상세 템플릿 개선
- masterDataStore: 테넌트별 캐시 격리 로직 강화 - AuthContext: 인증 컨텍스트 안정성 개선 - IntegratedDetailTemplate: 상세 템플릿 동작 수정 - VendorDetail: 거래처 상세 수정 - AttendanceManagement: 타입 정의 추가 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -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` | 통합 리스크 |
|
||||
|
||||
@@ -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` - 참고 (기존 정상 동작)
|
||||
@@ -362,7 +362,7 @@ export function VendorDetail({ mode, vendorId, openModal }: VendorDetailProps) {
|
||||
error={!!validationErrors.businessNumber}
|
||||
/>
|
||||
</div>
|
||||
{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)}
|
||||
|
||||
@@ -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시간)
|
||||
|
||||
@@ -232,8 +232,12 @@ function IntegratedDetailTemplateInner<T extends Record<string, unknown>>(
|
||||
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 () => {
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
|
||||
@@ -79,6 +79,7 @@ const createEmptyPageConfigs = (): Record<PageType, PageConfig | null> => ({
|
||||
});
|
||||
|
||||
const initialState = {
|
||||
currentTenantId: null as number | null,
|
||||
pageConfigs: createEmptyPageConfigs(),
|
||||
loading: {} as Record<PageType, boolean>,
|
||||
errors: {} as Record<PageType, string | null>,
|
||||
@@ -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<MasterDataStore>()(
|
||||
// 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<MasterDataStore>()(
|
||||
}
|
||||
|
||||
// 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<MasterDataStore>()(
|
||||
'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<MasterDataStore>()(
|
||||
// ===== 캐시 무효화 =====
|
||||
|
||||
invalidateConfig: (pageType: PageType) => {
|
||||
const { pageConfigs } = get();
|
||||
const { pageConfigs, currentTenantId } = get();
|
||||
const newConfigs = { ...pageConfigs };
|
||||
delete newConfigs[pageType];
|
||||
|
||||
@@ -280,16 +300,17 @@ export const useMasterDataStore = create<MasterDataStore>()(
|
||||
'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<MasterDataStore>()(
|
||||
// 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');
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user