495 lines
12 KiB
Markdown
495 lines
12 KiB
Markdown
|
|
# 멀티 테넌시 검증 및 테스트 가이드
|
||
|
|
|
||
|
|
**작성일**: 2025-11-19
|
||
|
|
**목적**: Phase 1-4 구현 후 테넌트 격리 기능 검증
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## 📋 목차
|
||
|
|
|
||
|
|
1. [테스트 환경 준비](#테스트-환경-준비)
|
||
|
|
2. [테스트 시나리오](#테스트-시나리오)
|
||
|
|
3. [체크리스트](#체크리스트)
|
||
|
|
4. [문제 해결](#문제-해결)
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## 테스트 환경 준비
|
||
|
|
|
||
|
|
### 1. 개발 서버 실행
|
||
|
|
|
||
|
|
```bash
|
||
|
|
npm run dev
|
||
|
|
```
|
||
|
|
|
||
|
|
### 2. 브라우저 개발자 도구 열기
|
||
|
|
|
||
|
|
- Chrome: `F12` 또는 `Cmd+Option+I` (Mac)
|
||
|
|
- Console 탭과 Application 탭을 주로 사용
|
||
|
|
|
||
|
|
### 3. 테스트 사용자 확인
|
||
|
|
|
||
|
|
현재 등록된 테스트 사용자 (모두 tenant.id: 282):
|
||
|
|
|
||
|
|
| userId | name | tenant.id | 역할 |
|
||
|
|
|--------|------|-----------|------|
|
||
|
|
| TestUser1 | 이재욱 | 282 | 일반 사용자 |
|
||
|
|
| TestUser2 | 박관리 | 282 | 생산관리자 |
|
||
|
|
| TestUser3 | 드미트리 | 282 | 시스템 관리자 |
|
||
|
|
|
||
|
|
**⚠️ 테넌트 전환 테스트를 위해 다른 tenant.id를 가진 사용자가 필요합니다.**
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## 테스트 시나리오
|
||
|
|
|
||
|
|
### 시나리오 1: 기본 캐시 동작 확인 ✅
|
||
|
|
|
||
|
|
**목적**: TenantAwareCache가 제대로 동작하는지 확인
|
||
|
|
|
||
|
|
**단계**:
|
||
|
|
1. 로그인: TestUser3 (tenant.id: 282)
|
||
|
|
2. `/master-data/item-master-data-management` 페이지 이동
|
||
|
|
3. 데이터 입력:
|
||
|
|
- 규격 마스터 1개 추가
|
||
|
|
- 품목 분류 1개 추가
|
||
|
|
4. **개발자 도구 → Application → Session Storage** 확인
|
||
|
|
|
||
|
|
**기대 결과**:
|
||
|
|
```
|
||
|
|
✅ sessionStorage에 다음 키가 생성되어야 함:
|
||
|
|
- mes-282-itemMasters
|
||
|
|
- mes-282-specificationMasters
|
||
|
|
- mes-282-itemCategories
|
||
|
|
- (기타 입력한 데이터)
|
||
|
|
|
||
|
|
✅ 각 키의 값에 tenantId: 282 포함
|
||
|
|
✅ timestamp 포함
|
||
|
|
```
|
||
|
|
|
||
|
|
**확인 방법**:
|
||
|
|
```javascript
|
||
|
|
// Console에서 실행
|
||
|
|
Object.keys(sessionStorage).filter(k => k.startsWith('mes-'))
|
||
|
|
// 결과: ["mes-282-itemMasters", "mes-282-specificationMasters", ...]
|
||
|
|
```
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
### 시나리오 2: 페이지 새로고침 시 캐시 로드 ✅
|
||
|
|
|
||
|
|
**목적**: 캐시에서 데이터를 제대로 불러오는지 확인
|
||
|
|
|
||
|
|
**단계**:
|
||
|
|
1. 시나리오 1 완료 후
|
||
|
|
2. `F5` 또는 `Cmd+R`로 새로고침
|
||
|
|
3. Console에서 로그 확인
|
||
|
|
|
||
|
|
**기대 결과**:
|
||
|
|
```
|
||
|
|
✅ Console 로그:
|
||
|
|
[Cache] Loaded from cache: itemMasters
|
||
|
|
[Cache] Loaded from cache: specificationMasters
|
||
|
|
...
|
||
|
|
|
||
|
|
✅ 입력했던 데이터가 그대로 표시됨
|
||
|
|
✅ 서버 API 호출 없이 캐시에서 로드
|
||
|
|
```
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
### 시나리오 3: TTL (1시간) 만료 확인 ⏱️
|
||
|
|
|
||
|
|
**목적**: 캐시가 1시간 후 자동 삭제되는지 확인
|
||
|
|
|
||
|
|
**⚠️ 주의**: 실제 1시간을 기다릴 수 없으므로 **수동 테스트**
|
||
|
|
|
||
|
|
**단계**:
|
||
|
|
1. sessionStorage에서 캐시 데이터 조회:
|
||
|
|
```javascript
|
||
|
|
const cached = sessionStorage.getItem('mes-282-itemMasters');
|
||
|
|
const parsed = JSON.parse(cached);
|
||
|
|
console.log('Timestamp:', new Date(parsed.timestamp));
|
||
|
|
console.log('Age (minutes):', (Date.now() - parsed.timestamp) / 60000);
|
||
|
|
```
|
||
|
|
|
||
|
|
2. **수동으로 timestamp 수정** (과거 시간으로):
|
||
|
|
```javascript
|
||
|
|
const cached = sessionStorage.getItem('mes-282-itemMasters');
|
||
|
|
const parsed = JSON.parse(cached);
|
||
|
|
|
||
|
|
// 2시간 전으로 설정 (TTL 1시간 초과)
|
||
|
|
parsed.timestamp = Date.now() - (7200 * 1000);
|
||
|
|
|
||
|
|
sessionStorage.setItem('mes-282-itemMasters', JSON.stringify(parsed));
|
||
|
|
```
|
||
|
|
|
||
|
|
3. 페이지 새로고침
|
||
|
|
|
||
|
|
**기대 결과**:
|
||
|
|
```
|
||
|
|
✅ Console 로그:
|
||
|
|
[Cache] Expired cache for key: itemMasters
|
||
|
|
|
||
|
|
✅ 만료된 캐시 자동 삭제
|
||
|
|
✅ 초기 데이터로 리셋
|
||
|
|
```
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
### 시나리오 4: 다중 탭 격리 확인 🔗
|
||
|
|
|
||
|
|
**목적**: 탭마다 독립적인 sessionStorage 사용 확인
|
||
|
|
|
||
|
|
**단계**:
|
||
|
|
1. **탭 1**: TestUser3 로그인 → 데이터 입력 (규격 마스터 A)
|
||
|
|
2. **탭 2**: 동일 URL을 새 탭으로 열기 (`Cmd+T` → URL 복사)
|
||
|
|
3. 탭 2에서 sessionStorage 확인
|
||
|
|
|
||
|
|
**기대 결과**:
|
||
|
|
```
|
||
|
|
✅ 탭 2의 sessionStorage는 비어있음
|
||
|
|
✅ 탭 1의 데이터가 탭 2에 공유되지 않음
|
||
|
|
✅ 각 탭이 독립적으로 동작
|
||
|
|
|
||
|
|
sessionStorage는 탭마다 격리됨!
|
||
|
|
```
|
||
|
|
|
||
|
|
**확인 방법**:
|
||
|
|
```javascript
|
||
|
|
// 탭 1
|
||
|
|
sessionStorage.setItem('test', 'tab1');
|
||
|
|
|
||
|
|
// 탭 2 (새로 열린 탭)
|
||
|
|
sessionStorage.getItem('test'); // null (공유 안 됨)
|
||
|
|
```
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
### 시나리오 5: 탭 닫기 시 자동 삭제 🗑️
|
||
|
|
|
||
|
|
**목적**: 탭을 닫으면 sessionStorage가 자동으로 삭제되는지 확인
|
||
|
|
|
||
|
|
**단계**:
|
||
|
|
1. 탭에서 데이터 입력
|
||
|
|
2. Application → Session Storage에서 데이터 확인
|
||
|
|
3. **탭 닫기**
|
||
|
|
4. **동일 URL을 새 탭으로 다시 열기**
|
||
|
|
5. Session Storage 확인
|
||
|
|
|
||
|
|
**기대 결과**:
|
||
|
|
```
|
||
|
|
✅ sessionStorage가 완전히 비어있음
|
||
|
|
✅ 이전 탭의 데이터가 남아있지 않음
|
||
|
|
✅ 새로운 세션으로 시작
|
||
|
|
```
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
### 시나리오 6: 로그아웃 시 캐시 삭제 🚪
|
||
|
|
|
||
|
|
**목적**: 로그아웃하면 테넌트 캐시가 완전히 삭제되는지 확인
|
||
|
|
|
||
|
|
**단계**:
|
||
|
|
1. TestUser3 로그인 → 데이터 입력
|
||
|
|
2. sessionStorage 확인 (캐시 있음)
|
||
|
|
3. **로그아웃 버튼 클릭**
|
||
|
|
4. Console 로그 확인
|
||
|
|
5. sessionStorage 다시 확인
|
||
|
|
|
||
|
|
**기대 결과**:
|
||
|
|
```
|
||
|
|
✅ Console 로그:
|
||
|
|
[Cache] Cleared sessionStorage: mes-282-itemMasters
|
||
|
|
[Cache] Cleared sessionStorage: mes-282-specificationMasters
|
||
|
|
...
|
||
|
|
[Auth] Logged out and cleared tenant cache
|
||
|
|
|
||
|
|
✅ sessionStorage에서 mes-282-* 키가 모두 삭제됨
|
||
|
|
✅ localStorage에서 mes-currentUser도 삭제됨
|
||
|
|
```
|
||
|
|
|
||
|
|
**확인 방법**:
|
||
|
|
```javascript
|
||
|
|
// 로그아웃 후
|
||
|
|
Object.keys(sessionStorage).filter(k => k.startsWith('mes-282-'))
|
||
|
|
// 결과: [] (빈 배열)
|
||
|
|
```
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
### 시나리오 7: 테넌트 전환 시 캐시 삭제 🔄
|
||
|
|
|
||
|
|
**⚠️ 현재 제약**: 모든 테스트 사용자가 tenant.id: 282를 사용 중
|
||
|
|
|
||
|
|
**필요 작업**: 다른 tenant.id를 가진 사용자 추가
|
||
|
|
|
||
|
|
#### 7-1. 테스트 사용자 추가 (tenant.id: 283)
|
||
|
|
|
||
|
|
`src/contexts/AuthContext.tsx` 수정:
|
||
|
|
|
||
|
|
```typescript
|
||
|
|
const initialUsers: User[] = [
|
||
|
|
// ... 기존 사용자 ...
|
||
|
|
{
|
||
|
|
userId: "TestUser4",
|
||
|
|
name: "김테넌트",
|
||
|
|
position: "다른 회사 관리자",
|
||
|
|
roles: [
|
||
|
|
{
|
||
|
|
id: 1,
|
||
|
|
name: "admin",
|
||
|
|
description: "관리자"
|
||
|
|
}
|
||
|
|
],
|
||
|
|
tenant: {
|
||
|
|
id: 283, // ✅ 다른 테넌트!
|
||
|
|
company_name: "(주)다른회사",
|
||
|
|
business_num: "987-65-43210",
|
||
|
|
tenant_st_code: "active",
|
||
|
|
other_tenants: []
|
||
|
|
},
|
||
|
|
menu: [
|
||
|
|
{
|
||
|
|
id: "13664",
|
||
|
|
label: "시스템 대시보드",
|
||
|
|
iconName: "layout-dashboard",
|
||
|
|
path: "/dashboard"
|
||
|
|
}
|
||
|
|
]
|
||
|
|
}
|
||
|
|
];
|
||
|
|
```
|
||
|
|
|
||
|
|
#### 7-2. 테넌트 전환 테스트
|
||
|
|
|
||
|
|
**단계**:
|
||
|
|
1. **TestUser3 로그인** (tenant.id: 282)
|
||
|
|
- 데이터 입력 (규격 마스터 A, B)
|
||
|
|
- sessionStorage 확인: `mes-282-specificationMasters`
|
||
|
|
|
||
|
|
2. **로그아웃**
|
||
|
|
|
||
|
|
3. **TestUser4 로그인** (tenant.id: 283)
|
||
|
|
- Console 로그 확인
|
||
|
|
|
||
|
|
**기대 결과**:
|
||
|
|
```
|
||
|
|
✅ Console 로그:
|
||
|
|
[Auth] Tenant changed: 282 → 283
|
||
|
|
[Cache] Cleared sessionStorage: mes-282-itemMasters
|
||
|
|
[Cache] Cleared sessionStorage: mes-282-specificationMasters
|
||
|
|
...
|
||
|
|
|
||
|
|
✅ 이전 테넌트(282)의 캐시가 모두 삭제됨
|
||
|
|
✅ TestUser4의 데이터는 mes-283-* 키로 저장됨
|
||
|
|
✅ 테넌트 간 데이터 격리 확인
|
||
|
|
```
|
||
|
|
|
||
|
|
**확인 방법**:
|
||
|
|
```javascript
|
||
|
|
// 테넌트 전환 후
|
||
|
|
Object.keys(sessionStorage).forEach(key => {
|
||
|
|
console.log(key);
|
||
|
|
});
|
||
|
|
|
||
|
|
// 결과:
|
||
|
|
// mes-283-itemMasters (새 테넌트)
|
||
|
|
// mes-283-specificationMasters
|
||
|
|
// (mes-282-* 키는 없어야 함!)
|
||
|
|
```
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
### 시나리오 8: PHP 백엔드 tenant.id 검증 🛡️
|
||
|
|
|
||
|
|
**⚠️ 주의**: PHP 백엔드가 실행 중이어야 함
|
||
|
|
|
||
|
|
**목적**: 다른 테넌트의 데이터 접근 시 403 반환 확인
|
||
|
|
|
||
|
|
**단계**:
|
||
|
|
1. **TestUser3 로그인** (tenant.id: 282)
|
||
|
|
2. 브라우저 Console에서 다른 테넌트 API 직접 호출:
|
||
|
|
|
||
|
|
```javascript
|
||
|
|
// 자신의 테넌트 (282) - 성공해야 함
|
||
|
|
fetch('/api/tenants/282/item-master-config')
|
||
|
|
.then(r => r.json())
|
||
|
|
.then(d => console.log('Own tenant:', d));
|
||
|
|
|
||
|
|
// 다른 테넌트 (283) - 403 에러여야 함
|
||
|
|
fetch('/api/tenants/283/item-master-config')
|
||
|
|
.then(r => r.json())
|
||
|
|
.then(d => console.log('Other tenant:', d));
|
||
|
|
```
|
||
|
|
|
||
|
|
**기대 결과**:
|
||
|
|
```
|
||
|
|
✅ 자신의 테넌트 (282):
|
||
|
|
{
|
||
|
|
success: true,
|
||
|
|
data: { ... }
|
||
|
|
}
|
||
|
|
|
||
|
|
✅ 다른 테넌트 (283):
|
||
|
|
{
|
||
|
|
success: false,
|
||
|
|
error: {
|
||
|
|
code: "FORBIDDEN",
|
||
|
|
message: "접근 권한이 없습니다."
|
||
|
|
}
|
||
|
|
}
|
||
|
|
Status: 403 Forbidden
|
||
|
|
|
||
|
|
✅ Next.js는 단순히 PHP 응답을 전달만 함
|
||
|
|
✅ PHP가 tenant.id 불일치를 감지하고 403 반환
|
||
|
|
```
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## 체크리스트
|
||
|
|
|
||
|
|
### 캐시 동작 ✅
|
||
|
|
- [ ] sessionStorage에 `mes-{tenantId}-{key}` 형식으로 저장
|
||
|
|
- [ ] 캐시 데이터에 `tenantId`, `timestamp`, `version` 포함
|
||
|
|
- [ ] 페이지 새로고침 시 캐시에서 로드
|
||
|
|
- [ ] TTL (1시간) 만료 시 자동 삭제
|
||
|
|
|
||
|
|
### 탭 격리 🔗
|
||
|
|
- [ ] 탭마다 독립적인 sessionStorage
|
||
|
|
- [ ] 다른 탭과 데이터 공유 안 됨
|
||
|
|
- [ ] 탭 닫으면 sessionStorage 자동 삭제
|
||
|
|
|
||
|
|
### 로그아웃 🚪
|
||
|
|
- [ ] 로그아웃 시 `mes-{tenantId}-*` 캐시 모두 삭제
|
||
|
|
- [ ] Console에 삭제 로그 출력
|
||
|
|
- [ ] localStorage의 `mes-currentUser` 삭제
|
||
|
|
|
||
|
|
### 테넌트 전환 🔄
|
||
|
|
- [ ] 테넌트 변경 감지 (useEffect)
|
||
|
|
- [ ] 이전 테넌트 캐시 자동 삭제
|
||
|
|
- [ ] 새 테넌트 데이터는 새 키로 저장
|
||
|
|
- [ ] Console에 전환 로그 출력
|
||
|
|
|
||
|
|
### API 보안 🛡️
|
||
|
|
- [ ] 자신의 테넌트 API 호출 성공
|
||
|
|
- [ ] 다른 테넌트 API 호출 시 403 Forbidden
|
||
|
|
- [ ] PHP 백엔드가 tenant.id 검증 수행
|
||
|
|
- [ ] Next.js는 PHP 응답 그대로 전달
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## 문제 해결
|
||
|
|
|
||
|
|
### 문제 1: 캐시가 저장되지 않음
|
||
|
|
|
||
|
|
**증상**: sessionStorage가 비어있음
|
||
|
|
|
||
|
|
**원인**:
|
||
|
|
- ItemMasterContext가 제대로 마운트되지 않음
|
||
|
|
- tenantId가 null
|
||
|
|
|
||
|
|
**해결**:
|
||
|
|
1. Console에서 확인:
|
||
|
|
```javascript
|
||
|
|
// AuthContext의 currentUser 확인
|
||
|
|
console.log(JSON.parse(localStorage.getItem('mes-currentUser')));
|
||
|
|
|
||
|
|
// tenant.id 확인
|
||
|
|
console.log(user?.tenant?.id);
|
||
|
|
```
|
||
|
|
|
||
|
|
2. ItemMasterContext가 AuthContext 하위에 있는지 확인
|
||
|
|
|
||
|
|
### 문제 2: 테넌트 전환 시 캐시가 삭제되지 않음
|
||
|
|
|
||
|
|
**증상**: 이전 테넌트 캐시가 남아있음
|
||
|
|
|
||
|
|
**원인**:
|
||
|
|
- `useEffect` 의존성 배열 문제
|
||
|
|
- `previousTenantIdRef` 초기화 안 됨
|
||
|
|
|
||
|
|
**해결**:
|
||
|
|
```typescript
|
||
|
|
// AuthContext.tsx 확인
|
||
|
|
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]);
|
||
|
|
```
|
||
|
|
|
||
|
|
### 문제 3: TTL 만료 후에도 캐시가 남아있음
|
||
|
|
|
||
|
|
**증상**: 1시간 이상 지난 캐시가 그대로 사용됨
|
||
|
|
|
||
|
|
**원인**:
|
||
|
|
- `TenantAwareCache.get()` 메서드에서 TTL 체크 안 함
|
||
|
|
|
||
|
|
**해결**:
|
||
|
|
```typescript
|
||
|
|
// TenantAwareCache.ts 확인
|
||
|
|
get<T>(key: string): T | 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;
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
### 문제 4: PHP 403 에러가 반환되지 않음
|
||
|
|
|
||
|
|
**증상**: 다른 테넌트 API 호출이 성공함
|
||
|
|
|
||
|
|
**원인**:
|
||
|
|
- PHP 백엔드에 tenant.id 검증 로직이 없음
|
||
|
|
- JWT에 tenant.id가 포함되지 않음
|
||
|
|
|
||
|
|
**해결**:
|
||
|
|
1. PHP 백엔드 확인 (프론트엔드 작업 범위 밖)
|
||
|
|
2. JWT payload에 `tenant_id` 포함 여부 확인
|
||
|
|
3. PHP middleware에서 tenant.id 검증 로직 확인
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## 테스트 완료 기준
|
||
|
|
|
||
|
|
### ✅ 모든 시나리오 통과
|
||
|
|
- 시나리오 1-8 모두 기대 결과와 일치
|
||
|
|
|
||
|
|
### ✅ 모든 체크리스트 완료
|
||
|
|
- 캐시, 탭, 로그아웃, 테넌트 전환, API 보안
|
||
|
|
|
||
|
|
### ✅ Console 에러 없음
|
||
|
|
- 개발자 도구 Console에 빨간색 에러 없음
|
||
|
|
|
||
|
|
### ✅ 성능 확인
|
||
|
|
- 페이지 로드 시간 < 1초
|
||
|
|
- 캐시 히트 시 API 호출 없음
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## 다음 단계
|
||
|
|
|
||
|
|
Phase 5 완료 후:
|
||
|
|
- **Phase 6**: 품목기준관리 페이지 작업 진행
|
||
|
|
- API 연동 및 실제 CRUD 구현
|
||
|
|
- UI/UX 개선
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
**작성자**: Claude
|
||
|
|
**버전**: 1.0
|
||
|
|
**최종 업데이트**: 2025-11-19
|