[React] 파일 업로드 & 다운로드 기능 구현하기
개요
현재 사내에서 진행 중인 프로젝트 CSVtoHex를 개발중이다. 해당 프로젝트는 회사 자제 솔루션 제품에 들어가는 CSV 파일을 Hex로 읽을 수 있는 프로그램을 만드는 것이 목적이다. 프로젝트 기능 명세는 아래와 같으며 오늘 구현했던 것은 CSV 파일 업로드 기능 그리고 변환 없이 다운로드 되는 과정을 구현해보려고 한다.
기능 세부 사항으로 쪼개기
우선 이 기능의 목적을 기억하자. 파일을 업로드하는 기능이라는 것. 여러 방법이 있겠지만, 나는 크게 두 가지로 분류했다.
- 파일 업로드 기능 구현하기
- "업로드" 버튼 혹은 이 기능을 하는 요소를 통해 파일 업로드하기
- drag&drop을 통해 파일 끌어와서 업로드하기
- 업로드 한 파일 값 보여주기
그리고 기본적인 UI를 만들어주었다.
horver하면 점선이 조금 진해지도록 조건부 스타일링을 해주었다.
기능 구현하기 - 파일 업로드
파일 확장자 제한하기
<FileInput type="file" onChange={handleUpload} accept=".csv" />
const FileInput = styled.input`
display: none;
&::file-selector-button {
font-size: 14px;
background: ${({ theme }) => theme.colors.white};
border-radius: 12px;
padding: 4px 32px;
cursor: pointer;
}
`;
CSV 파일만 받기 위해 input 속성 type을 file로 지정하고, 확장자 속성을 accept를 이용해 .csv로 한정했다.
업로드 한 파일 내용 가져오기
그리고 업로드 되는 파일은 객체의 리스트 형태로 넘어오게 되지만, 이대로 사용하면 파일 내에 있는 데이터를 읽어들일 수 없게 된다.
그래서 target.files[0]번째, 즉 첫 부분의 내용만 가져와야한다.
const handleUpload = ({ target }) => {
try {
const file = target.files[0];
console.log(file);
} catch (error) {
alert(ERROR.DATA);
}
};
파일의 정보를 모두 가져오면 setFileInfo에 파일 내용 저장 내용을 업데이트 해준다. 이 setFileInfo를 활용해 파일 정보를 보여줄 수 있다.
그리고 여기서 file과 관련된 유효성 검사를 진행한다. file에 데이터가 존재하는지, 파일의 확장자는 정말 CSV가 맞는지, 파일의 용량은 준수한지 등등 하나씩 확인해준다.
onst handleUpload = ({ target }) => {
try {
const file = target.files[0];
setFileInfo(file);
console.log(file);
if (file.size === 0) {
alert(ERROR.DATA);
return setActive(false);
}
// 파일 확장자 체크
if (!fileExtensionValid(file)) {
target.value = '';
alert(ERROR.EXTENSION);
return;
}
// 파일 용량 체크
if (file.size > MAX_FILE_SIZE) {
target.value = '';
alert(ERROR.MAX);
return;
}
setIsFile(false);
} catch (error) {
alert(ERROR.DATA);
}
};
특히 파일 확장자를 체크하는 함수는 따로 구현해주었는데, 아래와 같다.
import { ALLOW_FILE_EXTENSION } from '../constants';
import removeFileName from './removeFileName';
export default function fileExtensionValid({ name }) {
// 파일 이름 부분을 제외하고 extension 변수에 담아준다.
const extension = removeFileName(name);
// ALLOW_FILE_EXTENSION = 'csv'
// 만약, 허용된 확장자의 인덱스가 확장자 변수(extension)이 -1 즉, 아니거나 확장자 부분이 빈 문자열이면
if (!(ALLOW_FILE_EXTENSION.indexOf(extension) > -1) || extension === '') {
// false를 반환
return false;
}
// 통과되면 true를 반환
return true;
}
파일 이름도 지우고 뒷 부분만 활용하기 위해 파일 이름을 지워주는 함수를 따로 구현하는 방식을 채택했다.
export default function removeFileName(originalFileName) {
const lastIndex = originalFileName.lastIndexOf('.');
if (lastIndex < 0) {
return '';
}
return originalFileName.substring(lastIndex + 1).toLowerCase();
}
그리고 그 결과로 아래와 같이 나타나는 것을 확인 할 수 있다.
기능 구현하기 - 파일 다운로드
파일을 변환하고 나서 다시 해당 정보를 확인하기 위해 파일 다운로드 기능이 필요했다. 파일 데이터를 안전하게 다운로드를 받아야 했기에, 라이브러리 사용이 조심스러웠다. 그래서 HTML5의 File System Access API를 이용하기로 했다.
버튼 추가하기
<FileBtn
text="파일 다운로드"
type="button"
onClick={handleFileDownload}
disabled={isChanged}
/>
파일 버튼을 추가해준다. 파일 관련 버튼이 많아질 것으로 예상하여 공통 컴포넌트로 만들어 두었고 이를 사용하기로 했다.
공통 컴포넌트는 아래와 같다.
import React from 'react';
import { styled } from 'styled-components';
import { ButtonText } from 'styles/font';
const FileChangeBtn = ({ text, type, onClick, disabled }) => {
return (
<Box type={type} onClick={onClick} disabled={disabled}>
<ButtonText>{text}</ButtonText>
</Box>
);
};
const Box = styled.button`
cursor: pointer;
display: flex;
width: 200px;
height: 49px;
padding: 17px 34px;
justify-content: center;
align-items: center;
border-radius: 8px;
border: none;
border-color: ${({ theme }) => theme.colors.p700};
background-color: ${({ theme }) => theme.colors.p700};
color: ${({ theme }) => theme.colors.white};
&:disabled {
cursor: default;
border-color: ${({ theme }) => theme.colors.gs300};
background-color: ${({ theme }) => theme.colors.gray200};
color: ${({ theme }) => theme.colors.gs500};
}
`;
export default FileChangeBtn;
클릭했을 때, 파일 다운로드 처리해주기
파일 다운로드 버튼을 클릭했을 때 파일이 원하는 위치에 다운로드 되도록 해주려고 한다. 이때, 선택한 파일은 Blob으로 변환되어 파일 스트림에 저장되는 원리를 이용할 것이다.
우선 다운로드 함수 안에 try-catch문을 작성했다. 에러 처리를 조금 더 정교하게 하기 위해서 alert으로 에러 메세지도 띄울 수 있도록 해주었다.
const handleFileDownload = () => {
try {
console.log('FileDownBtn Clicked')
} catch (error) {
alert('[ERROR] 파일을 선택해주세요.')
}
}
그리고 업로드 한 파일의 정보를 헷갈리지 않게 하기 위해 새로운 변수에 담아주었다.
Blob 객체를 생성하여 변환되는 파일의 형태를 지정하였다.
const handleFileDownload = () => {
try {
console.log('FileDownBtn Clicked')
let textArea = uploadedInfo
let blob = new Blob([textArea], {
type: 'plain/text',
})
} catch (error) {
alert('[ERROR] 파일을 선택해주세요.')
}
}
그 다음 파일을 핸들링하는 변수를 생성해 window.showSaveFilePicker를 활용하며 파일 이름 지정과 다운로드 가능하도록 준비해주었다. 그리고 아래와 같은 이유로 다운로드 함수를 비동기 처리 해주었다.
비동기 처리를 한 이유
1. 파일 다운로드는 일반적으로 네트워크 요청이나 파일 시스템 접근과 같은 I/O 작업을 하는데, 동기적으로 처리하면 UI가 먹통이 되거나 다른 작업을 진행할 수 없게 될 수 있기 때문이다.
2. window.showSaveFilePicker()와 같은 메서드는 사용자와의 상호 작용이 필요하고, 사용자가 파일을 선택하고 저장할 위치를 지정하는 시간이 필요하기 때문에 비동기 처리가 필요하다.
3. 파일의 내용을 Blob으로 생성하는 작업도 CPU나 메모리와 같은 자원을 사용할 수 있으며, 이 작업이 오래 걸릴 수 있기 때문이다. 비동기 처리를 함으로써 다른 작업을 동시에 해줄 수 있다.
const handleFileDownload = async () => {
try {
console.log('FileDownBtn Clicked')
let textArea = uploadedInfo
console.log(uploadedInfo)
let blob = new Blob([textArea], {
type: 'plain/text',
})
const fileHandle = await window.showSaveFilePicker({
suggestedName: 'MCFG_NEW_HEAT.MBC',
})
} catch (error) {
alert('[ERROR] 파일을 선택해주세요.')
}
}
마지막으로 파일 스트림에 쓰고 닫아주면 메모리 누수 없이 파일 다운로드 작업이 가능하다.
const handleFileDownload = async () => {
try {
console.log('FileDownBtn Clicked')
let textArea = uploadedInfo
let blob = new Blob([textArea], {
type: 'plain/text',
})
const fileHandle = await window.showSaveFilePicker({
suggestedName: 'MCFG_NEW_HEAT.MBC',
})
const fileStream = await fileHandle.createWritable()
await fileStream.write(blob)
await fileStream.close()
} catch (error) {
alert('[ERROR] 파일을 선택해주세요.')
}
}
아래와 같이 정상적으로 다운로드가 되는 것을 볼 수 있다.