fix(WEB): 마스터데이터 캐시 테넌트 격리 및 상세 템플릿 개선

- masterDataStore: 테넌트별 캐시 격리 로직 강화
- AuthContext: 인증 컨텍스트 안정성 개선
- IntegratedDetailTemplate: 상세 템플릿 동작 수정
- VendorDetail: 거래처 상세 수정
- AttendanceManagement: 타입 정의 추가

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
유병철
2026-01-29 16:57:49 +09:00
parent 4014b3fb84
commit 106ce09482
7 changed files with 211 additions and 29 deletions

View File

@@ -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` | 통합 리스크 |

View File

@@ -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` - 참고 (기존 정상 동작)

View File

@@ -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)}

View File

@@ -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시간)

View File

@@ -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 () => {

View File

@@ -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}`);
}

View File

@@ -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');
}