기록

리액트 엑셀 업로드 기능 간단하게 구현해보기

als982001 2025. 2. 16. 17:44

1. 개요

 얼마 전에 작성하였던 엑셀 다운로드 기능에 이어서, 엑셀 업로드 기능을 간단히 구현해보았다.

 

2. 작성한 코드 및 실행 흐름

 엑셀 업로드 기능을 구현하기 위해 react-dropzone 라이브러리와 xlsx 라이브러리를 이용하였다.

 

2-1. 엑셀 업로드

import { cloneDeep, isUndefined } from "lodash";
import { Dispatch, SetStateAction, useState } from "react";
import Dropzone, { Accept } from "react-dropzone";
import styled from "styled-components";
import * as XLSX from "xlsx";

import { IData } from "../App";

const gameCharacterKeys: Array<keyof IGameCharacter> = [
// 생략
];

const productKeys: Array<keyof IProduct> = [
// 생략
];

const customerOrderKeys: Array<keyof ICustomerOrder> = [
// 생략
];

const accept: Accept = {
  "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet": [
    ".xls",
    ".xlsx",
  ],
};

interface IProps {
  selectedKey: TKey;
  selectedFieldLabels: Record<string, string>;
  setShowModal: Dispatch<SetStateAction<boolean>>;
  setIsLoading: React.Dispatch<React.SetStateAction<boolean>>;
  handleUpdateData: <T extends keyof IData>({
    dataKey,
    updatedData,
  }: {
    dataKey: T;
    updatedData: IData[T];
  }) => void;
}

export default function ExcelUploadModal({
  selectedKey,
  selectedFieldLabels,
  setShowModal,
  setIsLoading,
  handleUpdateData,
}: IProps) {
  const [errors, setErrors] = useState<string[]>([]);

  const validExcelData = (rows: (string | number)[][]) => {
    const validationMessages: string[] = [];

    // 1. 데이터가 존재하는지 검사
    if (rows.length === 0) {
      return ["데이터가 없습니다."];
    }

    const labels: string[] = Object.values(selectedFieldLabels);

    const excelColumnNames = rows.shift();

    // 2. 데이터가 존재하는지 검사
    if (isUndefined(excelColumnNames)) {
      return ["데이터가 없습니다."];
    }

    // 3. 컬럼 개수가 일치하는지 검사
    if (excelColumnNames.length !== labels.length) {
      return ["컬럼 개수가 일치하지 않습니다."];
    }

    // 4. 컬럼명들이 일치하는지 검사
    const isAllValidColumnNames = excelColumnNames.every((columnName) =>
      labels.includes(columnName as string)
    );

    const isAllIncluded = labels.every((label) =>
      excelColumnNames.includes(label)
    );

    if (isAllValidColumnNames === false || isAllIncluded === false) {
      return ["컬럼 이름을 확인해주시기 바랍니다."];
    }

    // 5. 입력된 데이터 개수가 일치하지 않는 것이 있는지 검사
    rows.forEach((row, rowIndex) => {
      if (row.length !== excelColumnNames.length) {
        validationMessages.push(
          `${rowIndex + 2}행의 데이터 개수가 일치하지 않습니다.`
        );
      }
    });

    return validationMessages;
  };

  const uploadExcel = (files: File[]) => {
    setIsLoading(true);
    setErrors([]);

    files.forEach((file) => {
      const reader = new FileReader();

      reader.readAsArrayBuffer(file);

      reader.onloadstart = () => {
        console.log("reader.onloadstart");
      };

      reader.onload = async () => {
        try {
          const bstr = reader.result;
          const workbook = XLSX.read(bstr, { type: "array" });

          const workSheetName = workbook.SheetNames[0]; // 시트가 하나만 있다고 가정
          const workSheet = workbook.Sheets[workSheetName];

          const rows: string[][] = XLSX.utils.sheet_to_json(workSheet, {
            header: 1,
            blankrows: false,
          });

          const tempErrors = validExcelData(cloneDeep(rows));

          if (tempErrors.length > 0) {
            setErrors([...tempErrors]);
            return;
          }

          rows.shift();

          const keys = (() => {
            switch (selectedKey) {
              case "productList":
                return productKeys;
              case "gamecharacters":
                return gameCharacterKeys;
              case "customerOrderList":
                return customerOrderKeys;
              default:
                return customerOrderKeys;
            }
          })();

          const updatedData = rows.map((row, rowIndex) => {
            // eslint-disable-next-line @typescript-eslint/no-explicit-any
            const updatedDatum = row.reduce<Record<string, any>>(
              (acc, datum, index) => {
                const key = keys[index];
                acc[key] = datum;

                return acc;
              },
              {}
            );

            updatedDatum.id = String(rowIndex);

            return updatedDatum;
          }) as IData[TKey];

          handleUpdateData({
            dataKey: selectedKey,
            updatedData,
          });

          console.log(updatedData);
        } catch (error) {
          console.error(error);
        } finally {
          setIsLoading(false);
          setShowModal(false);
        }
      };
    });
  };

  return (
    <>
      <Overlay onClick={() => setShowModal(false)} />
      <Modal>
        <Title>엑셀 업로드</Title>
        <Dropzone onDrop={uploadExcel} accept={accept}>
          {({
            getRootProps, // Dropzone의 **루트 요소(div)**에 필요한 속성을 추가하는 함수
            getInputProps, // 파일 업로드를 위한 input[type="file"] 요소에 속성을 추가하는 함수
            isDragAccept, // 드래그한 파일이 허용된 파일
            isDragActive, // 사용자가 파일을 드래그해서 Dropzone 위로 가져왔는지 여부
            isDragReject, // 드래그한 파일이 허용되지 않은 경우
            isFileDialogActive, // 파일 선택 창이 열려 있는지 여부
            isFocused, // Dropzone이 포커스를 받았는지 여부
            open, // input[type="file"] 클릭 없이 파일 선택 창을 열도록 강제 실행하는 함수
            acceptedFiles, // 사용자가 업로드한 파일 리스트
            fileRejections, // 업로드가 거부된 파일 리스트
          }) => {
            console.log({
              getRootProps,
              getInputProps,
              isDragAccept,
              isDragActive, // 사용자가 파일을 드래그해서 Dropzone 위로 가져왔는지 여부
              isDragReject,
              isFileDialogActive,
              isFocused,
              open,
              acceptedFiles,
              fileRejections,
            });

            return (
              <Container {...getRootProps()}>
                <input {...getInputProps()} />
                <UploadZone>여기에 업로드해주세요.</UploadZone>
                <ErrorContainers>
                  {errors.map((error) => (
                    <p key={error}>{error}</p>
                  ))}
                </ErrorContainers>
              </Container>
            );
          }}
        </Dropzone>
      </Modal>
    </>
  );
}

const Overlay = styled.div`
// 생략
`;

const Modal = styled.div`
// 생략
`;

const Title = styled.div`
// 생략
`;

const Container = styled.div`
// 생략
`;

const UploadZone = styled.div`
// 생략
`;

const ErrorContainers = styled.div`
// 생략
`;

 

  우선 엑셀 파일을 업로드하기 위해 react-dropzone의 Dropzone을 이용하였다. Dropzone의 자식 컴포넌트에 전달되는 props는 위 코드처럼 여러 개가 존재한다. 우선 파일 업로드 관련 속성은 다음과 같다.

 

2-1-1. 파일 업로드 관련 속성

속성 타입 설명
acceptedFiles File[] 사용자가 업로드한 파일 리스트 (허용된 파일만 포함)
fileRejections { file: File; errors: any[] }[] 업로드가 거부된 파일 리스트 (허용되지 않은 파일 포함)

 

2-1-2. 파일 선택 창 및 드래그 상태

속성 타입 설명
isDragActive boolean 사용자가 파일을 드래그해서 Dropzone 위로 가져왔는지 여부 (true면 파일이 드롭존 위에 있음)
isDragAccept boolean 드래그한 파일이 허용된 파일(accept 설정에 맞는 경우 true)
isDragReject boolean 드래그한 파일이 허용되지 않은 경우(accept 설정에 맞지 않으면 true)
isFileDialogActive boolean 파일 선택 창이 열려 있는지 여부 (사용자가 클릭해서 파일을 선택 중이면 true)
isFocused boolean Dropzone이 포커스를 받았는지 여부 (탭키를 이용해 이동 가능)

 

2-1-3. DOM 조작을 위한 Ref 객체

  • rootRef: Dropzone의 최상위 div 요소를 가리키는 ref
  • inputRef: 파일 업로드를 위한 input[type="file"] 요소 

 

2-1-4. 파일 업로드 관련 메서드

메서드 타입 설명
getRootProps() function 드래그 앤 드롭 영역을 설정하는, Dropzone의 루트 요소(div)에 필요한 속성을 추가하는 함수
getInputProps() function 파일 업로드를 위한 input[type="file"] 요소에 속성을 추가하여 Dropzone과 연결하는 함수
open() function input[type="file"] 클릭 없이 파일 선택 창을 열도록 강제 실행하는 함수

 

 

이처럼 Dropzone 위를 드래그할 때 Dropzone의 자식 컴포넌트에 전달되는 props를 확인할 수 있다.

 

2-2. 데이터 가공

 

uploadExcel 함수에서 업로드한 엑셀 파일의 데이터를 가공한다. 함수 내의 주요한 부분들에 대해 작성하자면, 다음과 같다.

 

2-2-1. files.forEach((file) => { ... })

files.forEach((file) => {
  • files는 사용자가 업로드한 파일 목록을 의미한다.
  • 일반적으로는 하나의 파일만 업로드하지만, 혹시나 여러 개의 파일을 처리할 수도 있기 때문에 forEach를 이용하였다.

 

2-2-2. const reader = new FileReader();

const reader = new FileReader();
  • FileReader는 브라우저에서 파일을 읽을 수 있도록 도와주는 내장 API이다.
  • 이 객체를 이용하면 파일을 텍스트, 바이너리, DataURL 등 다양한 형태로 읽을 수 있다.

 

2-2-3. reader.readAsArrayBuffer(file);

reader.readAsArrayBuffer(file);
  • reader.readAsArrayBuffer(file)를 호출할 경우, 파일의 내용을 바이너리(이진 데이터) 형태인 ArrayBuffer로 읽어들인다. (엑셀 파일을 처리하기 위해서 바이너리 데이터가 필요하기 때문)

 

2-2-4. reader.onloadstart = () => { console.log("reader.onloadstart"); };

reader.onloadstart = () => {
  console.log("reader.onloadstart");
};
  • reader.onloadstart는 파일 읽기가 시작될 때 실행되는 이벤트 핸들러이다.
  • 위 코드에서는 확인을 위해 "reader.onloadstart"라는 메시지를 콘솔에 출력하였다.

2-2-5. reader.onload = async () => { ... }

 reader.onload = async () => {
        try {
          const bstr = reader.result;
          const workbook = XLSX.read(bstr, { type: "array" });

          const workSheetName = workbook.SheetNames[0]; // 시트가 하나만 있다고 가정
          const workSheet = workbook.Sheets[workSheetName];

          const rows: string[][] = XLSX.utils.sheet_to_json(workSheet, {
            header: 1,
            blankrows: false,
          });
          // 생략
  • reader.onloadFileReader API의 이벤트 핸들러로, 파일 읽기가 완료되었을 때 실행되는 함수이다.
    • 파일을 읽는 작업(readAsArrayBuffer, readAsText 등)이 성공적으로 완료되었을 때 실행
    • reader.result에 읽은 파일의 데이터가 ArrayBuffer 형태로  저장됨.
    • reader.onload = (event) => { ... }; 의 event.target.result에도 동일하게 파일의 내용을 담고 있다.
  • const workbook = XLSX.read(bstr, { type: "array" });
    • xlsx 라이브러리의 XLSX.read 함수를 이용하여 엑셀 파일을 읽어 워크북(Workbook) 객체로 변환한다.
    • XLSX.read의 두 번째 인자로 { type: "array" }를 전달하면, ArrayBuffer 형태의 데이터를 읽을 수 있도록 지정할 수 있다.
    • 워크북(Workbook)은 엑셀 파일 전체를 의미한다. 즉, 여러 개의 시트(Sheet)를 포함하고 있다.
  • const workSheetName = workbook.SheetNames[0];
    • workbook.SheetNames는 워크북 내의 모든 시트의 이름을 배열로 제공한다.
    • 현재는 시트가 하나만 있다고 가정했기에 첫 번째 SheetNames를 이용하였다.
  • const workSheet = workbook.Sheets[workSheetName];
    • workbook.Sheets는 워크북 내의 모든 시트 데이터를 포함하는 객체이다.
    • workbook.Sheets[workSheetName]을 이용하여, 위에서 가져온 workSheetName에 해당하는 실제 시트 데이터를 가져와 변수에 저장한다.
  • const rows: string[][] = XLSX.utils.sheet_to_json(workSheet, {header: 1, blankrows: false, });
    • XLSX.utils.sheet_to_json을 이용해 엑셀 시트 데이터를 JSON 형식의 배열로 변환한다.
    • header 옵션의 값별 동작 방식
      • 1: 첫 번째 행을 데이터의 배열로 반환. 컬럼명을 자동으로 생성하지 않는다.
      • 2: 첫 번째 행을 키 값으로 사용하여 객체 형태로 반환한다.
      • [] (배열): 특정 컬럼명을 직접 지정해서 변환 가능하다.
    • blankrows 옵션: 엑셀에서 빈 행을 어떻게 처리할지 결정하는 옵션
      • true: 빈 행을 포함해서 변환
      • false: 빈 행을 제외하고 변환
       

3. 결과