이 예제는 Ant Design의 Upload 컴포넌트를 사용하여 여러 이미지를 업로드하고, 사용자가 드래그 앤 드롭으로 이미지 순서를 변경할 수 있도록 구현하는 방법을 보여줍니다.
필요한 라이브러리 설치
프로젝트에 필요한 라이브러리를 설치합니다.
yarn add react-dnd-html5-backend@14.1.0
yarn add react-dnd@14.0.5
yarn add immutability-helper@3.1.1
구현 코드
1. 드래그 가능한 업로드 컴포넌트 (DragSortingUpload.tsx)
react-dnd 라이브러리를 사용하여 업로드 항목에 드래그 앤 드롭 기능을 추가합니다. `DragableUploadListItem` 컴포넌트는 각 업로드 항목의 드래그 및 드롭 로직을 처리하며, `useDrag`와 `useDrop` 훅을 활용합니다. `moveRow` 함수는 이미지 배열의 순서를 업데이트하는 역할을 합니다.
import React, { useState, useCallback } from 'react';
import { Upload, Tooltip, Modal } from 'antd';
import { DndProvider, useDrag, useDrop } from 'react-dnd';
import { HTML5Backend } from 'react-dnd-html5-backend';
import update from 'immutability-helper';
import { PlusOutlined } from '@ant-design/icons';
import { getBase64 } from '@/utils'; // 이미지 미리보기용 헬퍼 함수
import uploadRequest from '@/utils/uploadRequest'; // 사용자 정의 업로드 요청 함수
import { applyToken } from '../../services/ant-design-pro/qiniu'; // Qiniu 토큰 발급 API
import './ManyUpload.less'; // 스타일링을 위한 CSS 파일
const UPLOAD_ITEM_TYPE = 'DraggableUploadItem'; // 드래그 아이템 타입 정의
// 드래그 가능한 업로드 목록 아이템 컴포넌트
const DraggableUploadListItem = ({ originNode, moveItem, file, fileList }: any) => {
const ref = React.useRef(null);
const index = fileList.indexOf(file);
// 드롭 대상 설정
const [, drop] = useDrop({
accept: UPLOAD_ITEM_TYPE,
collect: (monitor) => {
const { index: dragIndex } = monitor.getItem() || {};
if (dragIndex === index) {
return {};
}
// 드롭될 위치에 따른 클래스 추가
return {
isOver: monitor.isOver(),
dropClassName: dragIndex < index ? ' drop-over-downward' : ' drop-over-upward',
};
},
drop: (item) => {
moveItem(item.index, index); // 아이템 위치 변경
},
});
// 드래그 소스 설정
const [, drag] = useDrag({
type: UPLOAD_ITEM_TYPE,
item: { index },
collect: (monitor) => ({
isDragging: monitor.isDragging(),
}),
});
// ref에 드래그 및 드롭 기능 연결
drop(drag(ref));
// 업로드 오류 시 툴팁 표시
const errorNode = <Tooltip title="Upload Error">{originNode.props.children}</Tooltip>;
return (
<div
ref={ref}
className={`ant-upload-draggable-list-item ${isOver ? dropClassName : ''}`}
style={{ cursor: 'move', height: '100%' }}
>
{file.status === 'error' ? errorNode : originNode}
</div>
);
};
// 업로드 버튼 컴포넌트
const uploadButton = (
<div>
<PlusOutlined />
<div style={{ marginTop: 8 }}>Upload</div>
</div>
);
// 메인 업로드 컴포넌트
const DragSortingUpload = () => {
const [previewVisible, setPreviewVisible] = useState(false);
const [previewImage, setPreviewImage] = useState('');
const [previewTitle, setPreviewTitle] = useState('');
// 초기 파일 리스트 상태
const [fileList, setFileList] = useState<any>([
{
uid: '-1',
name: 'sample_image1.png',
status: 'done',
url: 'https://zos.alipayobjects.com/rmsportal/jkjgkEfvpUPVyRjUImniVslZfWPnJuuZ.png',
},
]);
// 사용자 정의 업로드 요청 함수
const customRequest = async (options: any) => {
try {
const { data } = await applyToken(); // Qiniu 인증 토큰 발급
options.action = data.url; // API 엔드포인트 설정
options.data['token'] = data.token; // 인증 토큰 추가
// RC.upload의 request 함수 로직을 사용하여 파일 업로드
uploadRequest(options);
} catch (error) {
console.error("Error applying token or uploading:", error);
// 에러 처리 로직 추가
options.onError(error);
}
};
// 파일 리스트 순서 변경 함수
const moveItem = useCallback(
(dragIndex, hoverIndex) => {
const dragItem = fileList[dragIndex];
setFileList(
update(fileList, {
$splice: [
[dragIndex, 1], // 드래그된 아이템 제거
[hoverIndex, 0, dragItem], // 새로운 위치에 아이템 삽입
],
}),
);
},
[fileList],
);
// 미리보기 모달 닫기
const handleCancel = () => setPreviewVisible(false);
// 이미지 미리보기 핸들러
const handlePreview = async (file: any) => {
if (!file.url && !file.preview) {
// 로컬 파일인 경우 미리보기 URL 생성
file.preview = await getBase64(file.originFileObj);
}
setPreviewVisible(true);
setPreviewImage(file.url || file.preview);
setPreviewTitle(file.name || file.url.substring(file.url.lastIndexOf('/') + 1));
};
return (
<div className="many-upload-container">
<DndProvider backend={HTML5Backend}>
<Upload
multiple
customRequest={customRequest}
fileList={fileList}
onPreview={handlePreview}
listType="picture-card"
onChange={(e) => {
setFileList(e.fileList); // 파일 리스트 상태 업데이트
}}
onRemove={(removedFile: any) => {
// 파일 제거 시 상태 업데이트
const updatedFileList = fileList.filter((file: any) => removedFile.uid !== file.uid);
setFileList(updatedFileList);
}}
itemRender={(originNode, file, currentFileList) => (
<DraggableUploadListItem
originNode={originNode}
file={file}
fileList={currentFileList}
moveItem={moveItem}
/>
)}
>
{/* 최대 8개 파일까지만 업로드 가능하도록 제한 */}
{fileList.length >= 8 ? null : uploadButton}
</Upload>
</DndProvider>
{/* 이미지 미리보기 모달 */}
<Modal visible={previewVisible} title={previewTitle} footer={null} onCancel={handleCancel}>
<img alt="preview" style={{ width: '100%' }} src={previewImage} />
</Modal>
</div>
);
};
export default DragSortingUpload;
2. 사용자 정의 업로드 요청 로직 (uploadRequest.ts)
uploadRequest.ts 파일은 Ant Design의 Upload 컴포넌트가 사용하는 파일 업로드 요청을 처리하는 커스텀 함수입니다. 이 함수는 `XMLHttpRequest` 객체를 사용하여 파일 업로드 진행 상태, 성공, 오류 등을 관리합니다. Qiniu와 같은 외부 스토리지 서비스로 파일을 업로드할 때 필요한 `action`, `token` 등의 데이터를 설정하고 전송하는 역할을 합니다.
import type { UploadRequestOption, UploadRequestError, UploadProgressEvent } from './interface';
// 오류 응답 생성 함수
function createError(option: UploadRequestOption, xhr: XMLHttpRequest): UploadRequestError {
const msg = `Cannot ${option.method} ${option.action} with status code ${xhr.status}`;
const err = new Error(msg) as UploadRequestError;
err.status = xhr.status;
err.method = option.method;
err.url = option.action;
return err;
}
// 응답 본문 파싱 함수
function parseResponseBody(xhr: XMLHttpRequest) {
const text = xhr.responseText || xhr.response;
if (!text) {
return text;
}
try {
return JSON.parse(text);
} catch (e) {
return text;
}
}
// 파일 업로드 요청 함수
export default function performUpload(option: UploadRequestOption) {
// eslint-disable-next-line no-undef
const xhr = new XMLHttpRequest();
// 진행 상태 업데이트 콜백 설정
if (option.onProgress && xhr.upload) {
xhr.upload.onprogress = function progress(e: UploadProgressEvent) {
if (e.total > 0) {
e.percent = (e.loaded / e.total) * 100;
}
option.onProgress(e);
};
}
// FormData 객체 생성 및 데이터 추가
// eslint-disable-next-line no-undef
const formData = new FormData();
if (option.data) {
Object.keys(option.data).forEach(key => {
const value = option.data[key];
if (Array.isArray(value)) {
// 배열 데이터 처리
value.forEach(item => {
formData.append(`${key}[]`, item);
});
return;
}
formData.append(key, value as string | Blob);
});
}
// 파일 데이터 추가
if (option.file instanceof Blob) {
formData.append(option.filename, option.file, (option.file as any).name);
} else {
formData.append(option.filename, option.file);
}
// 오류 발생 시 콜백
xhr.onerror = function error(e) {
option.onError(e);
};
// 요청 완료 시 콜백
xhr.onload = function onload() {
// 2xx 상태 코드만 성공으로 간주
if (xhr.status < 200 || xhr.status >= 300) {
return option.onError(createError(option, xhr), parseResponseBody(xhr));
}
return option.onSuccess(parseResponseBody(xhr), xhr);
};
// HTTP 요청 초기화 및 전송
xhr.open(option.method, option.action, true);
// withCredentials 설정
if (option.withCredentials && 'withCredentials' in xhr) {
xhr.withCredentials = true;
}
const headers = option.headers || {};
// 기본 XHR 헤더 설정 (필요시 비활성화 가능)
if (headers['X-Requested-With'] !== null) {
xhr.setRequestHeader('X-Requested-With', 'XMLHttpRequest');
}
// 사용자 정의 헤더 설정
Object.keys(headers).forEach(h => {
if (headers[h] !== null) {
xhr.setRequestHeader(h, headers[h]);
}
});
xhr.send(formData);
// 요청 취소 함수 반환
return {
abort() {
xhr.abort();
},
};
}
3. 인터페이스 정의 (interface.ts)
interface.ts 파일은 Ant Design Upload 컴포넌트와 관련된 타입 정의를 포함합니다. `UploadProps`, `UploadRequestOption` 등 다양한 타입이 정의되어 있어 코드의 타입 안정성을 높이고 개발 편의성을 제공합니다.
import type * as React from 'react';
// 파일 타입 정의
export type BeforeUploadFileType = File | Blob | boolean | string;
// 업로드 액션 URL 또는 함수 타입
export type Action = string | ((file: RcFile) => string | PromiseLike<string>);
// Upload 컴포넌트 Props 타입 정의
export interface UploadProps
extends Omit {
name?: string;
style?: React.CSSProperties;
className?: string;
disabled?: boolean;
component?: React.JSXElementConstructor<any>;
action?: Action;
method?: UploadRequestMethod;
directory?: boolean;
data?: Record<string, unknown> | ((file: RcFile | string | Blob) => Record<string, unknown>);
headers?: UploadRequestHeader;
accept?: string;
multiple?: boolean;
onBatchStart?: (
fileList: { file: RcFile; parsedFile: Exclude<BeforeUploadFileType, boolean> }[],
) => void;
onStart?: (file: RcFile) => void;
onError?: (error: Error, ret: Record<string, unknown>, file: RcFile) => void;
onSuccess?: (response: Record<string, unknown>, file: RcFile, xhr: XMLHttpRequest) => void;
onProgress?: (event: UploadProgressEvent, file: RcFile) => void;
beforeUpload?: (
file: RcFile,
fileList: RcFile[],
) => BeforeUploadFileType | Promise<void | BeforeUploadFileType>;
customRequest?: (option: UploadRequestOption) => void;
withCredentials?: boolean;
openFileDialogOnClick?: boolean;
prefixCls?: string;
id?: string;
onMouseEnter?: (e: React.MouseEvent<HTMLDivElement>) => void;
onMouseLeave?: (e: React.MouseEvent<HTMLDivElement>) => void;
onClick?: (e: React.MouseEvent<HTMLDivElement> | React.KeyboardEvent<HTMLDivElement>) => void;
}
// 업로드 진행 상태 이벤트 타입
export interface UploadProgressEvent extends Partial<ProgressEvent> {
percent?: number;
loaded: number
total: number
}
// HTTP 요청 메소드 타입
export type UploadRequestMethod = 'POST' | 'PUT' | 'PATCH' | 'post' | 'put' | 'patch';
// 요청 헤더 타입
export type UploadRequestHeader = Record<string, string>;
// 업로드 요청 오류 타입
export interface UploadRequestError extends Error {
status?: number;
method?: UploadRequestMethod;
url?: string;
}
// 업로드 요청 옵션 타입
export interface UploadRequestOption<T = any> {
onProgress: (event: UploadProgressEvent) => void;
onError: (event: UploadRequestError | ProgressEvent, body?: T) => void;
onSuccess: (body: T, xhr?: XMLHttpRequest) => void;
data: Record<string, unknown>;
filename: string;
file: Exclude<BeforeUploadFileType, File | boolean> | RcFile;
withCredentials: boolean;
action: string;
headers?: UploadRequestHeader;
method: UploadRequestMethod;
}
// RcFile 타입 (File 인터페이스 확장)
export interface RcFile extends File {
uid: string;
}