# 엑셀 다운로드 기능 개선 계획서

## 📋 문서 정보

- **작성일**: 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  
**다음 업데이트**: 구현 완료 후
