Ant Design을 이용한 다중 이미지 업로드 및 드래그 앤 드롭 정렬

이 예제는 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;
}

참고 자료

태그: React antd react-dnd upload drag-and-drop

8월 5일 15:44에 게시됨