Files
vexplor/docs/엑셀_다운로드_개선_계획.md
kjs dad7e9edab feat: 엑셀 다운로드 기능 개선
- 화면 편집기 컬럼 설정 기반 다운로드 (visible 컬럼만)
- 필터 조건 적용 (필터링된 데이터만 다운로드)
- 한글 라벨명 표시 (column_labels 테이블 조회)
- Entity 조인 값 표시 (writer → writer_name 등)
- 카테고리 타입 라벨 변환 (코드 → 라벨)
- 멀티테넌시 보안 강화 (autoFilter: true)
- 디버깅 로그 정리

변경된 파일:
- frontend/lib/utils/buttonActions.ts
- frontend/lib/registry/components/table-list/TableListComponent.tsx

관련 이슈: #엑셀다운로드개선
2025-11-10 18:12:09 +09:00

18 KiB

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

📋 문서 정보

  • 작성일: 2025-01-10
  • 작성자: AI Developer
  • 상태: 계획 단계
  • 우선순위: 🔴 높음 (보안 취약점 포함)

🚨 현재 문제점

1. 보안 취약점 (Critical)

  • 멀티테넌시 규칙 위반: 모든 회사의 데이터를 가져옴
  • 회사 필터링 없음: dynamicFormApi.getTableData 호출 시 autoFilter 미적용
  • 데이터 유출 위험: 회사 A 사용자가 회사 B, C, D의 데이터를 다운로드 가능
  • 규정 위반: GDPR, 개인정보보호법 등 법적 문제

관련 코드:

// 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 확장

현재 구조

interface ButtonActionContext {
  tableName?: string;
  formData?: Record<string, any>;
  selectedRowsData?: any[];
  tableDisplayData?: any[];
  columnOrder?: string[];
  sortBy?: string;
  sortOrder?: "asc" | "desc";
}

추가 필드

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

변경 사항

// 버튼 클릭 시 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. 데이터 소스 선택 로직

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 호출 메서드 (필터 조건 포함)

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. 컬럼 필터링 및 라벨 적용

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. 대용량 다운로드 확인

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. 전체 흐름

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. 감사 로그

  • 누가, 언제, 어떤 데이터를 다운로드했는지 기록
  • 보안 감사 추적

체크리스트

계획 단계

  • 계획서 작성 완료
  • 사용자 검토 및 승인
  • 수정 사항 반영

구현 단계

  • Step 1: 타입 정의 업데이트
  • Step 2: TableListComponent 수정
  • Step 3: handleExcelDownload 리팩토링
  • Step 4: 테스트 완료 (사용자 테스트 필요)
  • Step 5: 문서화 및 커밋 (대기 중)

배포 단계

  • 코드 리뷰
  • QA 테스트
  • 프로덕션 배포
  • 모니터링

🤝 승인

  • 개발팀 리뷰
  • 보안팀 검토
  • 사용자 승인
  • 최종 승인

작성 완료: 2025-01-10
다음 업데이트: 구현 완료 후