기록
리액트 엑셀 업로드 기능 간단하게 구현해보기
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.onload는 FileReader 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: 빈 행을 제외하고 변환