Lighthouse of FE biginner

[React] 커스텀 훅을 활용해 ExcelJS 로직 공통화 하기 본문

[WEB] 프론트엔드

[React] 커스텀 훅을 활용해 ExcelJS 로직 공통화 하기

[FE] Lighthouse 2024. 5. 24. 15:56

History

회사에서 현재 진행중인 프로젝트 프론트엔드의 환경은 CRA 템플릿을 활용한 SPA 프로젝트입니다.

CRA의 유지보수가 중단되고 Vite라는 더 좋은 개발툴이 있기에 해당 툴로 개발 환경과 번들러를 Rollup으로 마이그레이션 할 계획을 잡았습니다.

 

프로젝트가 시작되고 유지보수까지 굉장히 오랜 시간이 흘렀기에 한번에 딸깍 모든 것을 교체할 수 없었습니다. 본격적으로 마이그레이션 하기 전 어떤 문제가 있을지 테스트를 해봤고 도출한 여러 문제 중 react-data-export 라는 라이브러리에서 문제가 발생한다는 것을 알게 됐습니다.

 

그래서 이번 이슈를 통해 해당 라이브러리를 사용하는 엑셀 다운로드를 exceljs를 활용해 엑셀을 다운로드 받는 것으로 리팩토링을 결정했고 리팩토링 한 과정을 소개하겠습니다.

react-data-export 대체
해당 라이브러리가 유지보수가 안되고 라이브러리의 특정 필드 값으로 인해 에러가 발생합니다. 다른 라이브러리로 교체하는 방안을 검토해야 할 것 같습니다. (react-csv가 대체할 수 있는 라이브러리로 확인됩니다.)

 

리팩토링에 들어가기 앞서 라이브러리를 조사하는 과정에서 exceljs의 Unpacked Size가 21.8MB로 생각보다 커서 걱정이 앞서긴 하지만 Excel을 다룰 수 있는 많은 API를 제공하는 이점이 있기 때문에 해당 라이브러리로 진행을 결정했습니다.

 

react-data-exportreact-csv 라이브러리는 데이터를 받아서 csv, xlsx 파일로 다운로드를 해주는 간단한 라이브러리 입니다. 하지만 exceljs 라이브러리는 workbook 객체를 생성하고 API를 활용해 객체를 조작하는 기능만 제공하여 다운로드를 위한 별도의 로직이 필요로 합니다.

https://www.npmjs.com/package/exceljs

요구사항

  • 커스텀 훅으로 엑셀을 다운로드 받을 수 있어야 한다.
  • 통일된 리스트 버전이 아닌 스프레드 시트를 커스텀 할 수 있어야 한다.

현재 스프레드시트를 내려받기 위해서는 Excel 컴포넌트가 별도로 필요하고 useExcel 훅에서 데이터, ref, 다운로드 훅을 반환해 컴포넌트로 내려주고, 버튼과 결합해 다운로드 받는 식으로 설계되어 있습니다.

exceljsfile-saver를 활용해 별도의 컴포넌트와 ref를 결합 하는 것이 아닌 커스텀 훅에서 내려받는 이벤트 함수를 통해서 다운로드를 받을 수 있게 설계하도록 하겠습니다.

고민중인 내용

현재 사용 중인 훅은 grid apicolumnDef를 인자로 넘겨 받아서 커스텀 훅에서 데이터를 직접 파싱해 엑셀로 내려받고 있습니다.

 

새로 설계한 내용은 훅에서 별도의 데이터 파싱 없이 다운로드 이벤트 핸들러 함수가 실행될 때 인자로 내려받은 ExcelSheet 리스트를 그대로 엑셀 시트로만 파싱해 파일을 다운로드 받는 구조로 설계할 생각입니다. (훅의 역할을 단순히 엑셀 시트로 변경한 후 파일을 내려주는 역할까지만)

 

이렇게 변경되면 현재의 useExcel 훅에서 데이터를 엑셀 시트에 맞게 파싱 하는 부분(소팅, 데이터 검색)을 컴포넌트(페이지)에서 직접 해야 한다는 점이 생깁니다.

 

만약 이렇게 훅의 역할을 단순히 엑셀 형식으로 변환 후 다운로드 받는 용도로 정의한다면 테이블 라이브러리를 교체했을 때 excel 훅의 내용을 변경하지 않아도 되어서 작업 양이 줄어드는 장점이 생깁니다. (훅의 응집도를 강하게 설계하고 타 라이브러리와의 결합도를 줄일 수 있습니다.)

 

그럼 엑셀 조작 커스텀이 필요하다면?

엑셀을 조작해 워크북을 리턴 해주는 함수(customParsingFunction)를 인자로 받아서 커스텀 훅에서 실행하고 파싱된 워크북을 다운로드 받는 형식으로 구현 가능합니다.

// onClickDownloadExcelFile 함수 내부의 로직

if (customParsingFunction instanceof Function) {
  const workbook = customParsingFunction(excelSheetList);
  return downloadExcelFile(workbook, fileName);
}

 

타입 정의

ExcelSheet

type ExcelSheet = {
  sheetName: string;
  data: unknown[];
  titleRow?: {
    title: string;
    mergeCell?: string; // 합병할 셀 레인지 (ex: "A1:E1")
    titleCellStyle?: (cell: ExcelJS.Cell) => void;
  };
  headers?: string[];
  width?: number[];
  headerCellStyle?: (cell: ExcelJS.Cell) => void;
  dataCellStyle?: (cell: ExcelJS.Cell) => void;
};

 

ExcelSheet 타입은 엑셀의 시트에 대한 정의입니다. useExcelDownload 훅에서 ExcelSheet 리스트를 ExceljsWorkbook으로 파싱해 엑셀 파일로 내려받게 됩니다.

 

ExcelSheet 타입은 확장에 열려있습니다. 타입을 확장한 후 기능을 추가하고 싶다면 parsingToWorkbook 함수에 로직을 추가하면 됩니다.

필드 타입 비고
sheetName string 시트 이름
headers string[] 옵셔널 필드
헤더 컬럼 리스트
data unknown[] 실제로 들어가게 될 데이터 리스트
titleRow title: string;
mergeCell?: string;
titleCellStyle?: (cell: ExcelJS.Cell) => void;
옵셔널 필드
시트에 제목이 들어갈 경우 사용하는 객체 형식의 필
width number[] 옵셔널 필드
각 셀의 너비를 지정할 수 있는 숫자 타입의 리스트
headerCellStyle function 옵셔널 필드
시트별 헤더 컬럼의 스타일을 커스텀 할 수 있는 함수
dataCellStyle function 옵셔널 필드
시트별 데이터 컬럼의 스타일을 커스텀 할 수 있는 함수

UseExcelDownloadProps

필드 타입 비고
fileName string 파일의 이름
customParsingFunction function Workbook으로 변환하는 다른 로직이 필요하다면 컴포넌트에서 함수를 정의해 훅에 제공할 수 있음.
(excelSheet: ExcelSheet[]) => ExcelJS.Workbook

UseExcelDownloadResult

필드 타입 비고
onClickDownloadExcelFile function 파일을 다운로드 할 수 있는 이벤트 핸들러 함수

 

useExcelDownload hook

사용법

  • 훅에서 반환하는 onClickDownloadExcelFile 이벤트 핸들링 함수를 통해 엑셀을 다운로드 받는다.
    • 해당 함수에 ExcelSheet 리스트를 넘겨줘야 합니다. 해당 리스트는 테이블에 의해 정렬, 검색어로 필터 된 데이터를 넘겨줍니다.
    • 데이터는 makeExcelSheetData 유틸 함수를 사용해 파싱 할 수 있습니다. 해당 함수에 파라미터로 data, COL_DEF, searchTerm, GridAPI를 넘겨줍니다. 추후 테이블 라이브러리를 교체할 때 작업 양을 줄이기 위해 파라미터로 넘기는 방식으로 구현했습니다.
    • 헤더는 getExcelHeaderColumns 유틸 함수를 사용해 파싱 할 수 있습니다. 해당 함수에 파라미터로 ColDef[]를 넣어줍니다.

훅 내부 함수

  1. downloadExcelFile
    • 파일 다운로드를 해주는 함수입니다.
  2. parsingToWorkbook
    • ExcelSheet 리스트를 Workbook 으로 파싱해주는 함수입니다.
  3. onClickDownloadExcelFile
    • 다운로드 프로세스를 실행할 수 있는 함수입니다. 해당 함수의 파라미터로 ExcelSheet 리스트를 넘겨줍니다.

기본 스타일

엑셀 시트의 기본 스타일은 훅 상단에 위치한 setter 함수 내부에 정의합니다. 해당 함수들은 Cell 객체를 인자로 받아 직접 프로퍼티를 수정해 스타일링을 할 수 있습니다.

  • setDefaultHeaderCellStyle 헤더 컬럼의 기본 스타일을 정의합니다.
  • setDefaultDataCellStyle 데이터 컬럼의 기본 스타일을 정의합니다.

코드 전문

import ExcelJS from "exceljs";

// 기본 헤더 컬럼 스타일 정의
const setDefaultHeaderCellStyle = (cell: ExcelJS.Cell) => {
  cell.font = {
    bold: true,
  };
  cell.fill = {
    type: "pattern",
    pattern: "solid",
    fgColor: {
      argb: "E0E0E0",
    },
  };
  cell.alignment = {
    horizontal: "center",
  };
};

// 기본 데이터 컬럼 스타일 정의
const setDefaultDataCellStyle = (cell: ExcelJS.Cell) => {
  cell.alignment = {
    horizontal: "center",
  };
  cell.font = {
    size: 10,
  };
  cell.border = {
    top: {
      style: "thin",
    },
    bottom: {
      style: "thin",
    },
    right: {
      style: "thin",
    },
    left: {
      style: "thin",
    },
  };
};

export type ExcelSheet = {
  sheetName: string;
  data: unknown[];
  titleRow?: {
    title: string;
    mergeCell?: string;
    titleCellStyle?: (cell: ExcelJS.Cell) => void;
  };
  headers?: string[];
  width?: number[];
  headerCellStyle?: (cell: ExcelJS.Cell) => void;
  dataCellStyle?: (cell: ExcelJS.Cell) => void;
};

export type CustomParsingFunction = (
  excelSheet: ExcelSheet[]
) => ExcelJS.Workbook;

type UseExcelDownloadProps = {
  fileName: string;
  noDataLabel?: string;
  customParsingFunction?: CustomParsingFunction;
};

type UseExcelDownloadResult = {
  onClickDownloadExcelFile: (excelSheet: ExcelSheet[]) => void;
};

const useExcelDownload = ({
  fileName,
  noDataLabel,
  customParsingFunction,
}: UseExcelDownloadProps): UseExcelDownloadResult => {
  const DEFAULT_COL_WIDTH_SIZE = 10;
  const MAX_COL_WIDTH_SIZE = 50;
  const downloadExcelFile = async (
    workbook: ExcelJS.Workbook,
    fileName: string
  ) => {
    try {
      const fileData = await workbook.xlsx.writeBuffer();
      const blob = new Blob([fileData], {
        type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
      });
      const url = window.URL.createObjectURL(blob);
      const anchor = document.createElement("a");
      anchor.href = url;
      anchor.download = fileName + ".xlsx";
      anchor.click();
      window.URL.revokeObjectURL(url);
    } catch (err) {
      console.error(err);
    }
  };

  const parsingToWorkbook = (
    excelSheetList: ExcelSheet[],
    workbook: ExcelJS.Workbook
  ) => {
    excelSheetList.forEach((excelSheet) => {
      const sheet = workbook.addWorksheet(excelSheet.sheetName);

      // Title Row
      if (excelSheet.titleRow) {
        const { title, titleCellStyle, mergeCell } = excelSheet.titleRow;
        const titleRow = sheet.addRow([title]);

        sheet.mergeCells(mergeCell ?? "A1:E1");
        titleRow.eachCell((cell) => {
          cell.font = {
            size: 15,
            bold: true,
          };
        });

        if (titleCellStyle && titleCellStyle instanceof Function) {
          titleRow.eachCell((cell) => {
            titleCellStyle(cell);
          });
        }
      }

      // Column Header Row
      if (excelSheet.headers) {
        const headerRow = sheet.addRow(excelSheet.headers);
        headerRow.eachCell((cell, colNumber) => {
          if (!excelSheet?.width) {
            sheet.getColumn(colNumber).width = cell.value
              ? cell.value.toString().length * 2
              : DEFAULT_COL_WIDTH_SIZE;
          }

          setDefaultHeaderCellStyle(cell);
          // apply custom header cell style
          if (excelSheet?.headerCellStyle instanceof Function) {
            excelSheet.headerCellStyle(cell);
          }
        });
      }

      // Data Row
      excelSheet.data.forEach((value) => {
        const row: unknown[] = [];
        if (Array.isArray(value)) {
          row.push(value.toString());
        } else if (value instanceof Object) {
          const rawData = value as Record<string, unknown>;
          Object.keys(rawData).forEach((key) => row.push(rawData[key] ?? ""));
        } else {
          row.push(value);
        }

        const appendRow = sheet.addRow(row);
        appendRow.eachCell((cell, colNumber) => {
          setDefaultDataCellStyle(cell);

          // apply custom data cell style
          if (excelSheet?.dataCellStyle instanceof Function) {
            excelSheet.dataCellStyle(cell);
          }

          // apply cell width
          if (excelSheet?.width) {
            sheet.getColumn(colNumber).width = excelSheet.width[colNumber - 1];
          } else if (cell.value) {
            const valueLength = cell.value.toString().length;
            const colWidth = sheet.getColumn(colNumber).width ?? 10;

            // Set Default Column Width
            const adjustedWidth =
              colWidth && colWidth > DEFAULT_COL_WIDTH_SIZE
                ? colWidth
                : DEFAULT_COL_WIDTH_SIZE;

            // Set Column width
            sheet.getColumn(colNumber).width =
              colWidth > MAX_COL_WIDTH_SIZE
                ? MAX_COL_WIDTH_SIZE
                : adjustedWidth < valueLength
                ? valueLength * 1.2
                : adjustedWidth;
          }
        });
      });
    });
  };

  const onClickDownloadExcelFile = async (excelSheetList: ExcelSheet[]) => {
    const isEmptyData =
      !excelSheetList.length ||
      excelSheetList.every(({ data }) => !data.length);

    if (isEmptyData) return noDataLabel ?? "데이터가 존재하지 않습니다.";

    const workbook = new ExcelJS.Workbook();

    if (customParsingFunction instanceof Function) {
      const workbook = customParsingFunction(excelSheetList);
      return downloadExcelFile(workbook, fileName);
    }

    parsingToWorkbook(excelSheetList, workbook);
    await downloadExcelFile(workbook, fileName);
  };

  return { onClickDownloadExcelFile };
};

export default useExcelDownload;

 

컴포넌트 (페이지) 사용 예시

import { getExcelHeaderColumns } from '@/utils/excel-utils';

const Page = () => {
   // ...
   
   const { onClickDownloadExcelFile } = useExcelDownload({
      fileName: `파일 이름_${dayjs().format('YYYYMMDDHHmmss')}`,
   });

   const onClickExcelDownload = async () => {
      const headers = getExcelHeaderColumns(COL_DEF); // header 파싱
      const excelSheet: ExcelSheet[] = [
         {
            sheetName: '시트 이름',
            headers,
            data,
         },
      ];
      onClickDownloadExcelFile(excelSheet);
   };
   
   // ...
   return (
      <div>Page</div>
   );
}

 

샘플 코드

아래 깃허브 레포지토리에서 샘플 코드를 확인 및 프로젝트 실행을 통해 데모를 해보실 수 있습니다. 아래는 프로젝트 src 디렉토리의 구조입니다. hooks 디렉토리의 useExcelDownload 파일에서 훅을 정의하고 App.tsx 파일에서 해당 훅을 사용하고 있습니다.

REACT-EXCELJS\SRC
│  App.tsx
│  main.tsx
│  vite-env.d.ts
│
└─ share
    └─ hooks
           useExcelDownload.ts

 

https://github.com/kangactor123/react-exceljs