Files
sam-react-prod/claudedocs/[REF-2025-11-19] multi-tenancy-implementation.md
byeongcheolryu df3db155dd [feat]: Item Master 데이터 관리 기능 구현 및 타입 에러 수정
- ItemMasterDataManagement 컴포넌트 구조화 (tabs, dialogs, components 분리)
- HierarchyTab 타입 에러 수정 (BOMItem section_id, updated_at 추가)
- API 클라이언트 구현 (item-master.ts, 13개 엔드포인트)
- ItemMasterContext 구현 (상태 관리 및 데이터 흐름)
- 백엔드 요구사항 문서 작성 (CORS 설정, API 스펙 등)
- SSR 호환성 수정 (navigator API typeof window 체크)
- 미사용 변수 ESLint 에러 해결
- Context 리팩토링 (AuthContext, RootProvider 추가)
- API 유틸리티 추가 (error-handler, logger, transformers)

🤖 Generated with Claude Code

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-23 16:10:27 +09:00

1026 lines
26 KiB
Markdown
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.

# 멀티테넌시 구현 검토 및 개선 방안
**작성일**: 2025-11-19
**목적**: 현재 프로젝트의 로그인/데이터 저장 구조를 멀티테넌시 관점에서 검토하고 개선 방안 제시
---
## 📋 목차
1. [현재 상태 분석](#현재-상태-분석)
2. [핵심 문제점](#핵심-문제점)
3. [데이터 오염 시나리오](#데이터-오염-시나리오)
4. [개선 방안](#개선-방안)
5. [구현 로드맵](#구현-로드맵)
---
## 현재 상태 분석
### 1. 실제 로그인 응답 구조
#### 🔍 서버 응답 (실제)
```typescript
// 로그인 성공 시 받는 실제 데이터
{
userId: "TestUser3",
name: "드미트리",
position: "시스템 관리자",
roles: [
{
id: 19,
name: "system_manager",
description: "시스템 관리자"
}
],
tenant: {
id: 282, // ✅ 테넌트 고유 ID
company_name: "(주)테크컴퍼니", // ✅ 테넌트 이름
business_num: "123-45-67890",
tenant_st_code: "trial",
other_tenants: [] // 다중 테넌트 지원 가능성
},
menu: [
{
id: "13664",
label: "시스템 대시보드",
iconName: "layout-dashboard",
path: "/dashboard"
},
// ...
]
}
```
#### ✅ 중요 발견
1. **tenant.id**: 테넌트 고유 ID (숫자 타입) → **캐시 키로 사용해야 함**
2. **tenant.company_name**: 회사명 (UI 표시용)
3. **other_tenants**: 다중 테넌트 전환 가능성 (향후 확장)
---
### 2. 인증 시스템 (AuthContext)
#### 📁 파일 위치
```
src/contexts/AuthContext.tsx
```
#### 🔍 현재 구조 (문제점)
**User 타입 정의** (9-25 라인)
```typescript
export interface User {
id: string;
username: string;
email: string;
password: string;
name: string;
role: UserRole;
companyName: string; // ⚠️ 실제 응답과 구조 불일치
position?: string;
// ...
// ❌ tenant 객체가 없음!
// ❌ tenant.id를 참조할 방법 없음!
}
```
**localStorage 사용** (119-145 라인)
```typescript
// 초기 로드
const savedUsers = localStorage.getItem('mes-users'); // ❌ tenant.id 없음
const savedCurrentUser = localStorage.getItem('mes-currentUser'); // ❌ tenant.id 없음
// 저장
localStorage.setItem('mes-users', JSON.stringify(users));
localStorage.setItem('mes-currentUser', JSON.stringify(currentUser));
```
#### ⚠️ 문제점
1. **타입 불일치**: User 타입이 실제 서버 응답과 다름
2. **tenant 객체 부재**: tenant.id를 참조할 수 없음
3. **localStorage 키 고정**: 모든 테넌트가 같은 키 사용 → 데이터 충돌
---
### 3. 품목 마스터 데이터 관리 (ItemMasterContext)
#### 📁 파일 위치
```
src/contexts/ItemMasterContext.tsx
```
#### 🔍 localStorage 사용 패턴
**사용 중인 localStorage 키** (778-861 라인)
```typescript
// 13개의 마스터 데이터
'mes-itemMasters' // ❌ tenant.id 없음
'mes-specificationMasters' // ❌ tenant.id 없음
'mes-specificationMasters-version'
'mes-materialItemNames'
'mes-materialItemNames-version'
'mes-itemCategories'
'mes-itemUnits'
'mes-itemMaterials'
'mes-surfaceTreatments'
'mes-partTypeOptions'
'mes-partUsageOptions'
'mes-guideRailOptions'
'mes-sectionTemplates'
'mes-itemMasterFields'
'mes-itemPages'
```
#### ⚠️ 문제점
1. **tenant.id 미포함**: 모든 키에 tenant.id가 없음
2. **데이터 격리 불가**: 여러 테넌트가 같은 키 사용 → 데이터 충돌
---
## 핵심 문제점
### 🚨 1. User 타입과 실제 응답 구조 불일치
**영향도**: 🔴 CRITICAL
```typescript
// ❌ 현재 AuthContext
interface User {
companyName: string; // 실제 응답에는 없음
}
// ✅ 실제 서버 응답
interface ActualUser {
tenant: {
id: 282, // 테넌트 고유 ID
company_name: "(주)테크컴퍼니",
business_num: "123-45-67890",
tenant_st_code: "trial",
other_tenants: []
}
}
```
**문제**:
- 실제 tenant.id를 참조할 수 없음
- 타입 불일치로 인한 런타임 에러 가능성
- 멀티테넌시 구현 불가능
---
### 🚨 2. localStorage 키에 tenant.id 미포함
**영향도**: 🔴 CRITICAL
```typescript
// ❌ 현재 - 모든 테넌트가 같은 키 사용
localStorage.getItem('mes-itemMasters')
// ✅ 필요 - tenant.id 기반 격리
const tenantId = currentUser.tenant.id; // 282
localStorage.getItem(`mes-${tenantId}-itemMasters`) // 'mes-282-itemMasters'
```
**문제**:
- 같은 브라우저에서 여러 테넌트 사용 시 데이터 충돌
- 테넌트 A(id: 282)의 데이터가 테넌트 B(id: 350)에 노출될 위험
---
### 🚨 3. 테넌트 전환 감지 로직 부재
**영향도**: 🔴 CRITICAL
```typescript
// ❌ 현재 - 테넌트 전환 감지 없음
// ✅ 필요 - tenant.id 변경 감지
useEffect(() => {
const prevTenantId = previousTenantRef.current;
const currentTenantId = currentUser?.tenant?.id;
if (prevTenantId && prevTenantId !== currentTenantId) {
clearTenantCache(prevTenantId);
}
previousTenantRef.current = currentTenantId;
}, [currentUser?.tenant?.id]);
```
---
## 데이터 오염 시나리오
### 시나리오 1: 순차적 로그인
```yaml
# 타임라인
1. [09:00] 사용자 A (tenant.id: 282) 로그인
→ localStorage.setItem('mes-itemMasters', [...TENANT-282 데이터...])
2. [09:30] 사용자 A 로그아웃
3. [10:00] 사용자 B (tenant.id: 350) 로그인
→ 품목관리 페이지 진입
→ localStorage.getItem('mes-itemMasters')
4. [10:00:01] ❌ 문제 발생
→ TENANT-282의 데이터가 TENANT-350 사용자에게 잠깐 보임
→ API 응답 도착 후 TENANT-350 데이터로 교체 (늦음)
# 결과
- 잠깐이지만 잘못된 데이터 노출
- 보안 위반 (GDPR, 개인정보보호법 위반 가능성)
- 사용자 혼란 (화면 깜빡임)
```
---
### 시나리오 2: 다중 탭 동시 사용
```yaml
# 타임라인
1. [브라우저 탭1] 사용자 A (tenant.id: 282) 로그인
→ localStorage.setItem('mes-itemMasters', [...TENANT-282...])
2. [브라우저 탭2] 사용자 B (tenant.id: 350) 로그인
→ localStorage.setItem('mes-itemMasters', [...TENANT-350...])
→ ❌ TENANT-282 데이터 덮어씀!
3. [탭1로 돌아옴]
→ localStorage.getItem('mes-itemMasters')
→ ❌ TENANT-350 데이터가 나옴!
# 결과
- localStorage는 오리진(도메인) 단위 공유
- 탭 간 데이터 충돌
- 예측 불가능한 동작
```
---
### 시나리오 3: other_tenants 기능 사용 시
```yaml
# 사용자가 여러 테넌트에 소속된 경우
User: {
tenant: { id: 282, company_name: "A기업" },
other_tenants: [
{ id: 350, company_name: "B기업" },
{ id: 415, company_name: "C기업" }
]
}
# 테넌트 전환 시나리오
1. A기업(282) 데이터 로드 → localStorage 저장
2. B기업(350)으로 전환
3. localStorage에 여전히 A기업 데이터 존재
4. ❌ 데이터 오염 발생
# 결과
- 다중 테넌트 전환 시 캐시 관리 필수
```
---
## 개선 방안
### Phase 1: User 타입을 실제 구조에 맞게 수정 (필수 🔴)
#### 1.1 AuthContext.tsx 수정
**타입 정의 추가**
```typescript
// src/contexts/AuthContext.tsx
// ✅ 추가: Tenant 타입 정의
export interface Tenant {
id: number; // 테넌트 고유 ID
company_name: string; // 회사명
business_num: string; // 사업자번호
tenant_st_code: string; // 테넌트 상태 코드 (trial, active 등)
other_tenants?: Tenant[]; // 다른 소속 테넌트 목록 (다중 테넌트)
}
// ✅ 추가: Role 타입 정의
export interface Role {
id: number;
name: string;
description: string;
}
// ✅ 추가: MenuItem 타입 정의
export interface MenuItem {
id: string;
label: string;
iconName: string;
path: string;
}
// ✅ 수정: User 타입을 실제 서버 응답에 맞게
export interface User {
userId: string; // 사용자 ID
name: string; // 사용자 이름
position: string; // 직책
roles: Role[]; // 권한 목록
tenant: Tenant; // ✅ 테넌트 정보 (필수!)
menu: MenuItem[]; // 메뉴 목록
}
```
**초기 데이터 업데이트**
```typescript
const initialUsers: User[] = [
{
userId: "TestUser1",
name: "김대표",
position: "대표이사",
roles: [
{
id: 1,
name: "ceo",
description: "최고경영자"
}
],
tenant: {
id: 282, // ✅ 테넌트 ID
company_name: "(주)테크컴퍼니", // ✅ 회사명
business_num: "123-45-67890",
tenant_st_code: "trial",
other_tenants: []
},
menu: [
{
id: "13664",
label: "시스템 대시보드",
iconName: "layout-dashboard",
path: "/dashboard"
}
]
},
// ... 나머지 사용자
];
```
---
#### 1.2 테넌트 전환 감지 로직 추가
```typescript
// src/contexts/AuthContext.tsx
export function AuthProvider({ children }: { children: ReactNode }) {
const [users, setUsers] = useState<User[]>(initialUsers);
const [currentUser, setCurrentUser] = useState<User | null>(null);
// ✅ 추가: 이전 tenant.id 추적
const previousTenantIdRef = useRef<number | null>(null);
// ✅ 추가: 테넌트 변경 감지
useEffect(() => {
const prevTenantId = previousTenantIdRef.current;
const currentTenantId = currentUser?.tenant?.id;
if (prevTenantId && currentTenantId && prevTenantId !== currentTenantId) {
console.log(`[Auth] Tenant changed: ${prevTenantId}${currentTenantId}`);
clearTenantCache(prevTenantId);
}
previousTenantIdRef.current = currentTenantId || null;
}, [currentUser?.tenant?.id]);
// ✅ 추가: 테넌트별 캐시 삭제 함수
const clearTenantCache = (tenantId: number) => {
const prefix = `mes-${tenantId}-`;
// localStorage 캐시 삭제
Object.keys(localStorage).forEach(key => {
if (key.startsWith(prefix)) {
localStorage.removeItem(key);
console.log(`[Cache] Cleared localStorage: ${key}`);
}
});
// sessionStorage 캐시 삭제
Object.keys(sessionStorage).forEach(key => {
if (key.startsWith(prefix)) {
sessionStorage.removeItem(key);
console.log(`[Cache] Cleared sessionStorage: ${key}`);
}
});
};
// ✅ 추가: 로그아웃 시 현재 테넌트 캐시 삭제
const logout = () => {
if (currentUser?.tenant?.id) {
clearTenantCache(currentUser.tenant.id);
}
setCurrentUser(null);
localStorage.removeItem('mes-currentUser');
};
const value: AuthContextType = {
users,
currentUser,
setCurrentUser,
logout, // ✅ 추가
clearTenantCache, // ✅ 추가
// ... 기존 함수들
};
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
}
```
---
### Phase 2: TenantAwareCache 유틸리티 구현 (필수 🔴)
#### 2.1 캐시 유틸리티 생성
```typescript
// src/lib/cache/TenantAwareCache.ts
interface CachedData<T> {
tenantId: number; // ✅ tenant.id 타입 (number)
data: T;
timestamp: number;
version?: string;
}
export class TenantAwareCache {
private tenantId: number; // ✅ tenant.id 타입 (number)
private storage: Storage;
private ttl: number; // Time to Live (ms)
constructor(
tenantId: number, // ✅ tenant.id를 받음
storage: Storage = sessionStorage, // sessionStorage 기본값 (탭 격리)
ttl: number = 3600000 // 1시간 기본값
) {
this.tenantId = tenantId;
this.storage = storage;
this.ttl = ttl;
}
/**
* 테넌트별 고유 키 생성
* 예: tenant.id = 282 → 'mes-282-itemMasters'
*/
private getKey(key: string): string {
return `mes-${this.tenantId}-${key}`;
}
/**
* 캐시에 데이터 저장
*/
set<T>(key: string, data: T, version?: string): void {
const cacheData: CachedData<T> = {
tenantId: this.tenantId,
data,
timestamp: Date.now(),
version
};
this.storage.setItem(this.getKey(key), JSON.stringify(cacheData));
}
/**
* 캐시에서 데이터 조회 (tenantId 및 TTL 검증)
*/
get<T>(key: string): T | null {
const cached = this.storage.getItem(this.getKey(key));
if (!cached) return null;
try {
const parsed: CachedData<T> = JSON.parse(cached);
// 🛡️ tenantId 검증
if (parsed.tenantId !== this.tenantId) {
console.warn(
`[Cache] tenantId mismatch for key "${key}": ` +
`${parsed.tenantId} !== ${this.tenantId}`
);
this.remove(key);
return null;
}
// 🛡️ TTL 검증 (만료 시간)
if (Date.now() - parsed.timestamp > this.ttl) {
console.warn(`[Cache] Expired cache for key: ${key}`);
this.remove(key);
return null;
}
return parsed.data;
} catch (error) {
console.error(`[Cache] Parse error for key: ${key}`, error);
this.remove(key);
return null;
}
}
/**
* 캐시에서 특정 키 삭제
*/
remove(key: string): void {
this.storage.removeItem(this.getKey(key));
}
/**
* 현재 테넌트의 모든 캐시 삭제
*/
clear(): void {
const prefix = `mes-${this.tenantId}-`;
Object.keys(this.storage).forEach(key => {
if (key.startsWith(prefix)) {
this.storage.removeItem(key);
}
});
}
/**
* 버전 일치 여부 확인
*/
isVersionMatch(key: string, expectedVersion: string): boolean {
const cached = this.storage.getItem(this.getKey(key));
if (!cached) return false;
try {
const parsed: CachedData<any> = JSON.parse(cached);
return parsed.version === expectedVersion;
} catch {
return false;
}
}
/**
* 캐시 메타데이터 조회
*/
getMetadata(key: string): { tenantId: number; timestamp: number; version?: string } | null {
const cached = this.storage.getItem(this.getKey(key));
if (!cached) return null;
try {
const parsed: CachedData<any> = JSON.parse(cached);
return {
tenantId: parsed.tenantId,
timestamp: parsed.timestamp,
version: parsed.version
};
} catch {
return null;
}
}
}
```
---
#### 2.2 ItemMasterContext에 적용
```typescript
// src/contexts/ItemMasterContext.tsx
import { useAuth } from './AuthContext';
import { TenantAwareCache } from '@/lib/cache/TenantAwareCache';
export function ItemMasterProvider({ children }: { children: ReactNode }) {
const { currentUser } = useAuth();
// ✅ tenant.id 추출
const tenantId = currentUser?.tenant?.id;
// ✅ TenantAwareCache 인스턴스 생성
const cache = useMemo(
() => {
if (!tenantId) return null;
return new TenantAwareCache(
tenantId, // tenant.id = 282
sessionStorage, // 탭 격리
3600000 // 1시간 TTL
);
},
[tenantId]
);
// 상태
const [itemMasters, setItemMasters] = useState<ItemMaster[]>([]);
const [specificationMasters, setSpecificationMasters] = useState<SpecificationMaster[]>([]);
// ...
// ✅ 초기 로드 (캐시 + API)
useEffect(() => {
if (!tenantId || !cache) return;
const loadData = async () => {
// 1⃣ 캐시 확인 (즉시 렌더)
const cachedSpec = cache.get<SpecificationMaster[]>('specificationMasters');
if (cachedSpec) {
setSpecificationMasters(cachedSpec);
console.log(`[Cache] Loaded from cache (tenant: ${tenantId})`);
}
// 2⃣ 백그라운드 API 호출
try {
const response = await fetch(
`/api/tenants/${tenantId}/item-master-config/masters/specifications`
);
if (!response.ok) throw new Error('Failed to fetch specifications');
const { data } = await response.json();
setSpecificationMasters(data);
// 3⃣ 캐시 갱신
cache.set('specificationMasters', data, '1.0');
console.log(`[API] Data loaded and cached (tenant: ${tenantId})`);
} catch (error) {
console.error('[API] Failed to load specifications:', error);
// 4⃣ 에러 시 캐시 폴백 (이미 사용 중)
if (!cachedSpec) {
console.error('[Cache] No cache available, showing error');
}
}
};
loadData();
}, [tenantId, cache]);
// ✅ 저장 (API + 캐시 갱신)
const addSpecificationMaster = async (spec: SpecificationMaster) => {
if (!tenantId || !cache) {
throw new Error('Tenant ID not available');
}
try {
const response = await fetch(
`/api/tenants/${tenantId}/item-master-config/masters/specifications`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(spec)
}
);
if (!response.ok) throw new Error('Failed to add specification');
// 상태 업데이트
const newData = [...specificationMasters, spec];
setSpecificationMasters(newData);
// 캐시 갱신
cache.set('specificationMasters', newData, '1.0');
console.log(`[Cache] Updated after add (tenant: ${tenantId})`);
} catch (error) {
console.error('[API] Failed to add specification:', error);
throw error;
}
};
return (
<ItemMasterContext.Provider value={{ /* ... */ }}>
{children}
</ItemMasterContext.Provider>
);
}
```
---
### Phase 3: API 서버 측 tenant.id 검증 (필수 🔴)
#### 3.1 인증 미들웨어
```typescript
// backend/middleware/auth.ts
import { NextRequest, NextResponse } from 'next/server';
import { verifyJWT } from '@/lib/jwt';
export async function validateTenantAccess(
request: NextRequest,
requestedTenantId: string | number
): Promise<boolean> {
// 1⃣ JWT 토큰에서 사용자 정보 추출
const token = request.headers.get('Authorization')?.replace('Bearer ', '');
if (!token) {
throw new Error('No authentication token');
}
const payload = await verifyJWT(token);
// ✅ tenant.id 타입 통일 (문자열 → 숫자)
const requestedId = typeof requestedTenantId === 'string'
? parseInt(requestedTenantId, 10)
: requestedTenantId;
// 2⃣ 토큰의 tenant.id와 요청의 tenant.id 비교
if (payload.tenant.id !== requestedId) {
throw new Error(
`Tenant access denied: ${payload.tenant.id} !== ${requestedId}`
);
}
return true;
}
```
#### 3.2 API 라우트 핸들러
```typescript
// app/api/tenants/[tenantId]/item-master-config/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { validateTenantAccess } from '@/backend/middleware/auth';
export async function GET(
request: NextRequest,
{ params }: { params: { tenantId: string } }
) {
try {
// 🛡️ tenant.id 검증
await validateTenantAccess(request, params.tenantId);
// ✅ 검증 통과 → 해당 테넌트 데이터만 반환
const config = await db.itemMasterConfig.findUnique({
where: {
tenantId: parseInt(params.tenantId, 10),
isActive: true
}
});
return NextResponse.json({
success: true,
data: config
});
} catch (error) {
return NextResponse.json(
{
success: false,
error: {
code: 'FORBIDDEN',
message: '테넌트 접근 권한이 없습니다.',
details: error.message
}
},
{ status: 403 }
);
}
}
```
---
## 구현 로드맵
### ✅ Phase 1: User 타입 수정 (1일)
```yaml
우선순위: 🔴 CRITICAL
예상 시간: 1일
작업 항목:
1. AuthContext.tsx 수정:
- Tenant, Role, MenuItem 타입 정의 추가
- User 타입을 실제 서버 응답 구조에 맞게 수정
- 초기 데이터 업데이트 (tenant.id 포함)
- 테넌트 전환 감지 로직 추가
- clearTenantCache 함수 구현
- logout 함수에 캐시 삭제 추가
2. 검증:
- 로그인 시 tenant.id 정상 로드 확인
- console.log로 tenant.id 값 확인
```
---
### ✅ Phase 2: TenantAwareCache 구현 (1일)
```yaml
우선순위: 🔴 CRITICAL
예상 시간: 1일
작업 항목:
1. TenantAwareCache 유틸리티:
- src/lib/cache/TenantAwareCache.ts 생성
- tenantId를 number 타입으로 처리
- 단위 테스트 작성 (선택)
2. 검증:
- cache.set() 호출 시 키 확인: 'mes-282-itemMasters'
- cache.get() 호출 시 tenantId 검증 확인
- TTL 만료 테스트
```
---
### ✅ Phase 3: ItemMasterContext 마이그레이션 (2일)
```yaml
우선순위: 🔴 CRITICAL
예상 시간: 2일
작업 항목:
1. ItemMasterContext 리팩토링:
- TenantAwareCache 적용
- 모든 localStorage 호출 → cache.set/get 교체
- localStorage → sessionStorage 전환
- tenant.id 추출 로직 추가
- 13개 마스터 데이터 모두 적용
2. 검증:
- 각 마스터 데이터 캐시 키 확인
- 다중 탭 테스트 (같은 테넌트)
- 다중 탭 테스트 (다른 테넌트)
- 로그아웃 후 재로그인 테스트
```
---
### ✅ Phase 4: API 서버 검증 (1-2일)
```yaml
우선순위: 🔴 CRITICAL
예상 시간: 1-2일
작업 항목:
1. 인증 미들웨어:
- validateTenantAccess 구현
- JWT에서 tenant.id 추출
- tenant.id 타입 통일 (string ↔ number)
2. API 라우트:
- 모든 /api/tenants/[tenantId]/* 엔드포인트에 검증 추가
- 403 에러 응답 처리
3. 검증:
- 정상 tenant.id 접근 테스트
- 잘못된 tenant.id 접근 차단 확인
- 에러 응답 확인
```
---
### ✅ Phase 5: 다중 테넌트 전환 지원 (선택, 2일)
```yaml
우선순위: 🟢 RECOMMENDED
예상 시간: 2일
작업 항목:
1. other_tenants 기능:
- 테넌트 전환 UI 추가
- 전환 시 캐시 삭제 확인
- 전환 시 API 재호출 확인
2. 검증:
- A기업 → B기업 전환 테스트
- 각 테넌트별 데이터 격리 확인
```
---
## 체크리스트
### 🔴 필수 항목 (Phase 1-4)
```yaml
□ AuthContext User 타입 수정 (tenant 객체 포함)
□ Tenant, Role, MenuItem 타입 정의 추가
□ 초기 사용자 데이터에 tenant.id 할당
□ 테넌트 전환 감지 로직 추가 (useEffect + useRef)
□ clearTenantCache 함수 구현
□ logout 함수에 캐시 삭제 추가
□ TenantAwareCache 유틸리티 구현 (tenantId: number)
□ ItemMasterContext에 TenantAwareCache 적용
□ 13개 마스터 데이터 모두 캐시 마이그레이션
□ localStorage → sessionStorage 전환
□ API 미들웨어 validateTenantAccess 추가
□ 모든 API 라우트에 tenant.id 검증 추가
□ 다중 탭 테스트 완료 (같은 테넌트)
□ 다중 탭 테스트 완료 (다른 테넌트)
□ 테넌트 전환 테스트 완료
□ 로그아웃 후 재로그인 테스트 완료
```
### 🟢 권장 항목 (Phase 5)
```yaml
□ other_tenants 다중 테넌트 전환 기능
□ 테넌트 전환 UI 구현
□ Stale-While-Revalidate 패턴 적용
□ HTTP 캐싱 헤더 설정
□ 캐시 메트릭 수집
□ 성능 테스트
```
---
## 실제 구현 예시
### 예시 1: 캐시 키 생성
```typescript
// tenant.id = 282인 사용자
const cache = new TenantAwareCache(282, sessionStorage);
// 키 생성
cache.set('itemMasters', data);
// → sessionStorage에 'mes-282-itemMasters' 저장
cache.set('specificationMasters', data);
// → sessionStorage에 'mes-282-specificationMasters' 저장
```
---
### 예시 2: 테넌트 전환 시
```typescript
// 사용자 A (tenant.id: 282) 로그인
currentUser = {
tenant: { id: 282, company_name: "A기업" }
}
// sessionStorage: 'mes-282-itemMasters', 'mes-282-specificationMasters', ...
// 사용자 B (tenant.id: 350)로 전환
currentUser = {
tenant: { id: 350, company_name: "B기업" }
}
// useEffect 트리거 → clearTenantCache(282) 호출
// sessionStorage에서 'mes-282-*' 모두 삭제
// 새로운 캐시: 'mes-350-itemMasters', 'mes-350-specificationMasters', ...
```
---
### 예시 3: API 호출
```typescript
// 클라이언트
const tenantId = currentUser.tenant.id; // 282
const response = await fetch(`/api/tenants/${tenantId}/item-master-config`);
// 서버
// validateTenantAccess(request, "282")
// JWT 토큰: { tenant: { id: 282 } }
// 비교: 282 === 282 → ✅ 통과
// 만약 잘못된 요청
const response = await fetch(`/api/tenants/350/item-master-config`);
// JWT 토큰: { tenant: { id: 282 } }
// 비교: 282 !== 350 → ❌ 403 Forbidden
```
---
## 보안 고려사항
### 🛡️ 클라이언트 측 보안
1. **sessionStorage 사용**: localStorage보다 탭 격리로 더 안전
2. **tenant.id 검증**: 캐시 조회 시 항상 검증
3. **TTL 설정**: 만료된 캐시 자동 삭제 (1시간)
4. **에러 처리**: 손상된 캐시 안전 제거
### 🛡️ 서버 측 보안
1. **JWT 검증**: 모든 요청에 토큰 검증
2. **tenant.id 검증**: JWT의 tenant.id와 URL 파라미터 비교
3. **403 Forbidden**: 권한 없는 접근 차단
4. **데이터베이스 격리**: WHERE tenant_id = ? 항상 포함
### 🛡️ 타입 안정성
1. **tenant.id 타입**: number (서버 응답 기준)
2. **URL 파라미터**: string → number 변환 필요
3. **TypeScript**: 컴파일 타임 타입 체크
---
## 참고 자료
### 관련 문서
- [API_DESIGN_ITEM_MASTER_CONFIG.md](./_API_DESIGN_ITEM_MASTER_CONFIG)
- [CLEANUP_SUMMARY.md](./CLEANUP_SUMMARY.md)
### 외부 참고
- [Multi-Tenancy Best Practices](https://www.postgresql.org/docs/current/ddl-rowsecurity.html)
- [Browser Storage Security](https://developer.mozilla.org/en-US/docs/Web/Security/Types_of_attacks#cross-site_scripting_xss)
---
**문서 버전**: 1.1 (tenant.id 반영)
**마지막 업데이트**: 2025-11-19
**다음 리뷰**: Phase 1 완료 후