# 엑셀 다운로드 기능 개선 계획서 ## 📋 문서 정보 - **작성일**: 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; selectedRowsData?: any[]; tableDisplayData?: any[]; columnOrder?: string[]; sortBy?: string; sortOrder?: "asc" | "desc"; } ``` #### 추가 필드 ```typescript interface ButtonActionContext { // ... 기존 필드 // 🆕 필터 및 검색 조건 filterConditions?: Record; // 필터 조건 (예: { status: "active", dept: "dev" }) searchTerm?: string; // 검색어 searchColumn?: string; // 검색 대상 컬럼 // 🆕 컬럼 정보 visibleColumns?: string[]; // 화면에 표시 중인 컬럼 목록 (순서 포함) columnLabels?: Record; // 컬럼명 → 라벨명 매핑 // 🆕 페이징 정보 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 { 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 { 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 = {}; visibleColumns.forEach(columnName => { // 라벨 우선 사용, 없으면 컬럼명 사용 const label = columnLabels[columnName] || columnName; filteredRow[label] = row[columnName]; }); return filteredRow; }); } ``` #### 4-4. 대용량 다운로드 확인 ```typescript private static async confirmLargeDownload(totalItems: number): Promise { 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 { 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 **다음 업데이트**: 구현 완료 후