ERP-node/docs/엑셀_다운로드_개선_계획.md

657 lines
18 KiB
Markdown

# 엑셀 다운로드 기능 개선 계획서
## 📋 문서 정보
- **작성일**: 2025-01-10
- **작성자**: AI Developer
- **상태**: 계획 단계
- **우선순위**: 🔴 높음 (보안 취약점 포함)
---
## 🚨 현재 문제점
### 1. 보안 취약점 (Critical)
-**멀티테넌시 규칙 위반**: 모든 회사의 데이터를 가져옴
-**회사 필터링 없음**: `dynamicFormApi.getTableData` 호출 시 `autoFilter` 미적용
-**데이터 유출 위험**: 회사 A 사용자가 회사 B, C, D의 데이터를 다운로드 가능
-**규정 위반**: GDPR, 개인정보보호법 등 법적 문제
**관련 코드**:
```typescript
// frontend/lib/utils/buttonActions.ts (2043-2048 라인)
const response = await dynamicFormApi.getTableData(context.tableName, {
page: 1,
pageSize: 10000, // 최대 10,000개 행
sortBy: context.sortBy || "id",
sortOrder: context.sortOrder || "asc",
// ❌ autoFilter 없음 - company_code 필터링 안됨
// ❌ search 없음 - 사용자 필터 조건 무시
});
```
### 2. 기능 문제
-**모든 컬럼 포함**: 화면에 표시되지 않는 컬럼도 다운로드됨
-**필터 조건 무시**: 사용자가 설정한 검색/필터가 적용되지 않음
-**DB 컬럼명 사용**: 사용자 친화적이지 않음 (예: `user_id` 대신 `사용자 ID`)
-**컬럼 순서 불일치**: 화면 표시 순서와 다름
### 3. 우선순위 문제
현재 다운로드 데이터 우선순위:
1. ✅ 선택된 행 데이터 (`context.selectedRowsData`)
2. ✅ 화면 표시 데이터 (`context.tableDisplayData`)
3. ✅ 전역 저장소 데이터 (`tableDisplayStore`)
4.**테이블 전체 데이터** (API 호출) ← **보안 위험!**
---
## 🎯 개선 목표
### 1. 보안 강화
-**멀티테넌시 준수**: 현재 사용자의 회사 데이터만 다운로드
-**필터 조건 적용**: 사용자가 설정한 검색/필터 조건 반영
-**권한 검증**: 데이터 접근 권한 확인
-**감사 로그**: 다운로드 이력 기록
### 2. 사용자 경험 개선
-**화면 표시 컬럼만**: 사용자가 선택한 컬럼만 다운로드
-**컬럼 순서 유지**: 화면 표시 순서와 동일
-**라벨명 사용**: 한글 컬럼명 (예: `사용자 ID`, `부서명`)
-**정렬 유지**: 화면 정렬 상태 반영
### 3. 데이터 정확성
-**필터링된 데이터**: 화면에 보이는 조건과 동일한 데이터
-**선택 우선**: 사용자가 행을 선택했으면 선택된 행만
-**데이터 일관성**: 화면 ↔ 엑셀 데이터 일치
---
## 📐 개선 계획
### Phase 1: 데이터 소스 우선순위 재정의
#### 새로운 우선순위
```
1. 선택된 행 데이터 (가장 높은 우선순위)
- 출처: context.selectedRowsData
- 설명: 사용자가 체크박스로 선택한 행
- 특징: 필터/정렬 이미 적용됨, 가장 명확한 의도
- 처리: 그대로 사용
2. 화면 표시 데이터 (두 번째 우선순위)
- 출처: tableDisplayStore.getTableData(tableName)
- 설명: 현재 화면에 표시 중인 데이터
- 특징: 필터/정렬/페이징 적용됨, 가장 안전
- 처리:
- 현재 페이지 데이터만 (기본)
- 또는 전체 페이지 데이터 (옵션)
3. API 호출 - 필터 조건 포함 (최후 수단)
- 출처: entityJoinApi.getTableDataWithJoins()
- 설명: 위의 데이터가 없을 때만
- 특징:
- ✅ company_code 자동 필터링 (autoFilter: true)
- ✅ 검색/필터 조건 전달
- ✅ 정렬 조건 전달
- 제한: 최대 10,000개 행
4. ❌ 테이블 전체 데이터 (제거)
- 보안상 위험하므로 완전 제거
- 대신 경고 메시지 표시
```
### Phase 2: ButtonActionContext 확장
#### 현재 구조
```typescript
interface ButtonActionContext {
tableName?: string;
formData?: Record<string, any>;
selectedRowsData?: any[];
tableDisplayData?: any[];
columnOrder?: string[];
sortBy?: string;
sortOrder?: "asc" | "desc";
}
```
#### 추가 필드
```typescript
interface ButtonActionContext {
// ... 기존 필드
// 🆕 필터 및 검색 조건
filterConditions?: Record<string, any>; // 필터 조건 (예: { status: "active", dept: "dev" })
searchTerm?: string; // 검색어
searchColumn?: string; // 검색 대상 컬럼
// 🆕 컬럼 정보
visibleColumns?: string[]; // 화면에 표시 중인 컬럼 목록 (순서 포함)
columnLabels?: Record<string, string>; // 컬럼명 → 라벨명 매핑
// 🆕 페이징 정보
currentPage?: number; // 현재 페이지
pageSize?: number; // 페이지 크기
totalItems?: number; // 전체 항목 수
// 🆕 엑셀 옵션
excelScope?: "selected" | "current-page" | "all-filtered"; // 다운로드 범위
}
```
### Phase 3: TableListComponent 수정
#### 위치
`frontend/lib/registry/components/table-list/TableListComponent.tsx`
#### 변경 사항
```typescript
// 버튼 클릭 시 context 생성
const buttonContext: ButtonActionContext = {
tableName: tableConfig.selectedTable,
// 기존
selectedRowsData: selectedRows,
tableDisplayData: data, // 현재 페이지 데이터
columnOrder: visibleColumns.map((col) => col.columnName),
sortBy: sortColumn,
sortOrder: sortDirection,
// 🆕 추가
filterConditions: searchValues, // 필터 조건
searchTerm: searchTerm, // 검색어
visibleColumns: visibleColumns.map((col) => col.columnName), // 표시 컬럼
columnLabels: columnLabels, // 컬럼 라벨 (한글)
currentPage: currentPage, // 현재 페이지
pageSize: localPageSize, // 페이지 크기
totalItems: totalItems, // 전체 항목 수
excelScope: selectedRows.length > 0 ? "selected" : "current-page", // 기본: 현재 페이지
};
```
### Phase 4: handleExcelDownload 수정
#### 4-1. 데이터 소스 선택 로직
```typescript
private static async handleExcelDownload(
config: ButtonActionConfig,
context: ButtonActionContext
): Promise<boolean> {
try {
let dataToExport: any[] = [];
let dataSource: string = "unknown";
// 1순위: 선택된 행 데이터
if (context.selectedRowsData && context.selectedRowsData.length > 0) {
dataToExport = context.selectedRowsData;
dataSource = "selected";
console.log("✅ 선택된 행 사용:", dataToExport.length);
}
// 2순위: 화면 표시 데이터
else if (context.tableDisplayData && context.tableDisplayData.length > 0) {
dataToExport = context.tableDisplayData;
dataSource = "current-page";
console.log("✅ 현재 페이지 데이터 사용:", dataToExport.length);
}
// 3순위: 전역 저장소 데이터
else if (context.tableName) {
const { tableDisplayStore } = await import("@/stores/tableDisplayStore");
const storedData = tableDisplayStore.getTableData(context.tableName);
if (storedData && storedData.data.length > 0) {
dataToExport = storedData.data;
dataSource = "store";
console.log("✅ 저장소 데이터 사용:", dataToExport.length);
}
}
// 4순위: API 호출 (필터 조건 포함) - 최후 수단
if (dataToExport.length === 0 && context.tableName) {
console.log("⚠️ 화면 데이터 없음 - API 호출 필요");
// 사용자 확인 (선택사항)
const confirmed = await this.confirmLargeDownload(context.totalItems || 0);
if (!confirmed) {
return false;
}
dataToExport = await this.fetchFilteredData(context);
dataSource = "api";
}
// 데이터 없음
if (dataToExport.length === 0) {
toast.error("다운로드할 데이터가 없습니다.");
return false;
}
// ... 계속
}
}
```
#### 4-2. API 호출 메서드 (필터 조건 포함)
```typescript
private static async fetchFilteredData(
context: ButtonActionContext
): Promise<any[]> {
try {
console.log("🔄 필터된 데이터 조회 중...", {
tableName: context.tableName,
filterConditions: context.filterConditions,
searchTerm: context.searchTerm,
sortBy: context.sortBy,
sortOrder: context.sortOrder,
});
const { entityJoinApi } = await import("@/lib/api/entityJoin");
// 🔒 멀티테넌시 준수: autoFilter로 company_code 자동 적용
const response = await entityJoinApi.getTableDataWithJoins(
context.tableName!,
{
page: 1,
size: 10000, // 최대 10,000개
sortBy: context.sortBy || "id",
sortOrder: context.sortOrder || "asc",
search: context.filterConditions, // ✅ 필터 조건
enableEntityJoin: true, // ✅ Entity 조인
autoFilter: true, // ✅ company_code 자동 필터링
}
);
if (response.success && response.data) {
console.log("✅ API 데이터 조회 완료:", {
count: response.data.length,
total: response.total,
});
return response.data;
} else {
console.error("❌ API 응답 실패:", response);
return [];
}
} catch (error) {
console.error("❌ API 호출 오류:", error);
toast.error("데이터를 가져오는데 실패했습니다.");
return [];
}
}
```
#### 4-3. 컬럼 필터링 및 라벨 적용
```typescript
private static applyColumnFiltering(
data: any[],
context: ButtonActionContext
): any[] {
// 표시 컬럼이 지정되지 않았으면 모든 컬럼 사용
const visibleColumns = context.visibleColumns || Object.keys(data[0] || {});
const columnLabels = context.columnLabels || {};
console.log("🔧 컬럼 필터링 및 라벨 적용:", {
totalColumns: Object.keys(data[0] || {}).length,
visibleColumns: visibleColumns.length,
hasLabels: Object.keys(columnLabels).length > 0,
});
return data.map(row => {
const filteredRow: Record<string, any> = {};
visibleColumns.forEach(columnName => {
// 라벨 우선 사용, 없으면 컬럼명 사용
const label = columnLabels[columnName] || columnName;
filteredRow[label] = row[columnName];
});
return filteredRow;
});
}
```
#### 4-4. 대용량 다운로드 확인
```typescript
private static async confirmLargeDownload(totalItems: number): Promise<boolean> {
if (totalItems === 0) {
return true; // 데이터 없으면 확인 불필요
}
if (totalItems > 1000) {
const confirmed = window.confirm(
`총 ${totalItems.toLocaleString()}개의 데이터를 다운로드합니다.\n` +
`(최대 10,000개까지만 다운로드됩니다)\n\n` +
`계속하시겠습니까?`
);
return confirmed;
}
return true; // 1000개 이하는 자동 진행
}
```
#### 4-5. 전체 흐름
```typescript
private static async handleExcelDownload(
config: ButtonActionConfig,
context: ButtonActionContext
): Promise<boolean> {
try {
// 1. 데이터 소스 선택
let dataToExport = await this.selectDataSource(context);
if (dataToExport.length === 0) {
toast.error("다운로드할 데이터가 없습니다.");
return false;
}
// 2. 최대 행 수 제한
const MAX_ROWS = 10000;
if (dataToExport.length > MAX_ROWS) {
toast.warning(`최대 ${MAX_ROWS.toLocaleString()}개 행까지만 다운로드됩니다.`);
dataToExport = dataToExport.slice(0, MAX_ROWS);
}
// 3. 컬럼 필터링 및 라벨 적용
dataToExport = this.applyColumnFiltering(dataToExport, context);
// 4. 정렬 적용 (필요 시)
if (context.sortBy) {
dataToExport = this.applySorting(dataToExport, context.sortBy, context.sortOrder);
}
// 5. 엑셀 파일 생성
const { exportToExcel } = await import("@/lib/utils/excelExport");
const fileName = config.excelFileName ||
`${context.tableName}_${new Date().toISOString().split("T")[0]}.xlsx`;
const sheetName = config.excelSheetName || "Sheet1";
const includeHeaders = config.excelIncludeHeaders !== false;
await exportToExcel(dataToExport, fileName, sheetName, includeHeaders);
toast.success(`${dataToExport.length}개 행이 다운로드되었습니다.`);
// 6. 감사 로그 (선택사항)
this.logExcelDownload(context, dataToExport.length);
return true;
} catch (error) {
console.error("❌ 엑셀 다운로드 실패:", error);
toast.error("엑셀 다운로드에 실패했습니다.");
return false;
}
}
```
---
## 🔧 구현 단계
### Step 1: 타입 정의 업데이트
**파일**: `frontend/lib/utils/buttonActions.ts`
- [ ] `ButtonActionContext` 인터페이스에 새 필드 추가
- [ ] `ExcelDownloadScope` 타입 정의 추가
- [ ] JSDoc 주석 추가
**예상 작업 시간**: 10분
---
### Step 2: TableListComponent 수정
**파일**: `frontend/lib/registry/components/table-list/TableListComponent.tsx`
- [ ] 버튼 context 생성 시 필터/컬럼/라벨 정보 추가
- [ ] `columnLabels` 생성 로직 추가
- [ ] `visibleColumns` 목록 생성
**예상 작업 시간**: 20분
---
### Step 3: handleExcelDownload 리팩토링
**파일**: `frontend/lib/utils/buttonActions.ts`
- [ ] 데이터 소스 선택 로직 분리 (`selectDataSource`)
- [ ] API 호출 메서드 추가 (`fetchFilteredData`)
- [ ] 컬럼 필터링 메서드 추가 (`applyColumnFiltering`)
- [ ] 대용량 확인 메서드 추가 (`confirmLargeDownload`)
- [ ] 정렬 메서드 개선 (`applySorting`)
- [ ] 기존 코드 정리 (불필요한 로그 제거)
**예상 작업 시간**: 40분
---
### Step 4: 테스트
**테스트 시나리오**:
1. **선택된 행 다운로드**
- 체크박스로 여러 행 선택
- 엑셀 다운로드 버튼 클릭
- 예상: 선택된 행만 다운로드
- 확인: 라벨명, 컬럼 순서, 데이터 정확성
2. **현재 페이지 다운로드**
- 행 선택 없이 엑셀 다운로드
- 예상: 현재 페이지 데이터만
- 확인: 페이지 크기만큼 다운로드
3. **필터 적용 다운로드**
- 검색어 입력 또는 필터 설정
- 엑셀 다운로드
- 예상: 필터된 결과만
- 확인: 화면 데이터와 일치
4. **멀티테넌시 테스트**
- 회사 A로 로그인
- 엑셀 다운로드
- 확인: 회사 A 데이터만
- 회사 B로 로그인
- 엑셀 다운로드
- 확인: 회사 B 데이터만
5. **대용량 데이터 테스트**
- 10,000개 이상 데이터 조회
- 엑셀 다운로드
- 예상: 10,000개까지만 + 경고 메시지
6. **컬럼 라벨 테스트**
- 엑셀 파일 열기
- 확인: DB 컬럼명이 아닌 한글 라벨명
**예상 작업 시간**: 30분
---
### Step 5: 문서화 및 커밋
- [ ] 코드 주석 추가
- [ ] README 업데이트 (있다면)
- [ ] 커밋 메시지 작성
**예상 작업 시간**: 10분
---
## ⏱️ 총 예상 시간
**약 2시간** (코딩 + 테스트)
---
## ⚠️ 주의사항
### 1. 하위 호환성
- 기존 `context.tableDisplayData`를 사용하는 코드가 있을 수 있음
- 새 필드는 모두 선택사항(`?`)으로 정의
- 기존 동작은 유지하면서 점진적으로 개선
### 2. 성능
- API 호출 시 최대 10,000개 제한
- 대용량 데이터는 페이징 권장
- 브라우저 메모리 제한 고려
### 3. 보안
- **절대 `autoFilter: false` 사용 금지**
- 모든 API 호출에 `autoFilter: true` 필수
- 감사 로그 기록 권장
### 4. 사용자 경험
- 다운로드 중 로딩 표시
- 완료/실패 토스트 메시지
- 대용량 다운로드 시 확인 창
---
## 📊 예상 결과
### Before (현재)
```
엑셀 다운로드:
❌ 모든 회사의 데이터 (보안 위험!)
❌ 모든 컬럼 포함 (불필요한 정보)
❌ 필터 조건 무시
❌ DB 컬럼명 (user_id, dept_code)
❌ 정렬 상태 무시
```
### After (개선)
```
엑셀 다운로드:
✅ 현재 회사 데이터만 (멀티테넌시 준수)
✅ 화면 표시 컬럼만 (사용자 선택)
✅ 필터 조건 적용 (검색/필터 반영)
✅ 한글 라벨명 (사용자 ID, 부서명)
✅ 정렬 상태 유지 (화면과 동일)
✅ 컬럼 순서 유지 (화면과 동일)
```
---
## 🔗 관련 파일
### 수정 대상
1. `frontend/lib/utils/buttonActions.ts`
- `ButtonActionContext` 인터페이스
- `handleExcelDownload` 메서드
2. `frontend/lib/registry/components/table-list/TableListComponent.tsx`
- 버튼 context 생성 로직
### 참고 파일
1. `frontend/lib/api/entityJoin.ts`
- `getTableDataWithJoins` API
2. `frontend/lib/utils/excelExport.ts`
- `exportToExcel` 함수
3. `.cursor/rules/multi-tenancy-guide.mdc`
- 멀티테넌시 규칙
---
## 📝 후속 작업 (선택사항)
### 1. 엑셀 다운로드 옵션 UI
사용자가 다운로드 범위를 선택할 수 있는 모달:
```
[ ] 선택된 행만 (N개)
[x] 현재 페이지 (20개)
[ ] 필터된 전체 데이터 (최대 10,000개)
```
### 2. 엑셀 스타일링
- 헤더 배경색
- 자동 너비 조정
- 필터 버튼 추가
### 3. CSV 내보내기
- 대용량 데이터에 적합
- 가벼운 파일 크기
### 4. 감사 로그
- 누가, 언제, 어떤 데이터를 다운로드했는지 기록
- 보안 감사 추적
---
## ✅ 체크리스트
### 계획 단계
- [x] 계획서 작성 완료
- [x] 사용자 검토 및 승인
- [x] 수정 사항 반영
### 구현 단계
- [x] Step 1: 타입 정의 업데이트
- [x] Step 2: TableListComponent 수정
- [x] Step 3: handleExcelDownload 리팩토링
- [ ] Step 4: 테스트 완료 (사용자 테스트 필요)
- [ ] Step 5: 문서화 및 커밋 (대기 중)
### 배포 단계
- [ ] 코드 리뷰
- [ ] QA 테스트
- [ ] 프로덕션 배포
- [ ] 모니터링
---
## 🤝 승인
- [ ] 개발팀 리뷰
- [ ] 보안팀 검토
- [ ] 사용자 승인
- [ ] 최종 승인
---
**작성 완료**: 2025-01-10
**다음 업데이트**: 구현 완료 후