React에서 Drag & Drop을 이용한 파일 업로드 하기 📃

yiyb0603·2021년 1월 30일
52

React

목록 보기
6/15
post-thumbnail

안녕하세요! 오늘은 React.js에서 드래그 앤 드롭을 이용한 파일 업로드 하는법을 알아보겠습니다.

REST API 서버통신을 진행하다가 글을 작성하거나, 혹은 프로필 사진을 변경할때 등의 상황에서는 파일 업로드를 진행해야 합니다. 이때 웹에서 파일 업로드를 구현할 때 드래그 앤 드롭이 있다면, 사용자 입장에서 엄청 편하게 파일 업로드가 가능합니다.

1. 🛠 컴포넌트 생성

가장 먼저 첫번째로, 저희가 드래그 앤 드롭 기능을 구현할 컴포넌트를 만들어 줍시다! 🛠🛠
저는 DragDrop.tsx 파일과 DragDrop.scss 파일을 각각 생성하였고, 간단한 퍼블리싱을 추가하였습니다. 간단하게 구현만 하기 때문에, 저는 하나의 TSX 파일에 모두 작성 할 것이지만, View비즈니스 로직을 분리하고 싶으신 분들은 분리해도 무방합니다. 🙂

// DragDrop.tsx
import React, {
  useState,
  useCallback,
  useEffect,
  ChangeEvent,
  useRef
} from 'react';

const DragDrop = (): JSX.Element => {
  // 드래그 중일때와 아닐때의 스타일을 구분하기 위한 state 변수
  const [isDragging, setIsDragging] = useState<boolean>(false);
  
  // 각 선택했던 파일들의 고유값 id
  const fileId = useRef<number>(0);
  
  // 드래그 이벤트를 감지하는 ref 참조변수 (label 태그에 들어갈 예정)
  const dragRef = useRef<HTMLLabelElement | null>(null);
  
  return (
    <div className="DragDrop">
      <input
        type="file"
        id="fileUpload"
        style={{ display: "none" }} // label을 이용하여 구현하기에 없애줌
        multiple={true} // 파일 다중선택 허용
      />

      <label
        className={isDragging ? "DragDrop-File-Dragging" : "DragDrop-File"}
        // 드래그 중일때와 아닐때의 클래스 이름을 다르게 주어 스타일 차이
        
        htmlFor="fileUpload"
        ref={dragRef}
      >
        <div>파일 첨부</div>
      </label>
    </div>
  );
}

아래는 DragDrop.scss 파일 입니다.

// DragDrop.scss
@mixin filledStyle() {
  background-color: black;
  color: white;
}

@mixin alignCenter() {
  display: flex;
  display: -webkit-flex;
  flex-direction: column;
  -ms-flex-direction: column;
  justify-content: center;
  align-items: center;
}

.DragDrop {
  width: 100%;
  height: 100vh;
  @include alignCenter();
  
  &-File {
    width: 400px;
    height: 200px;
    border: 2px solid black;
    border-radius: 10px;
    
    @include alignCenter();
    cursor: pointer;
    transition: 0.12s ease-in;

    &-Dragging {
      @include filledStyle();
    }
  }
}

이 과정이 모두 끝나고 나서, 해당 컴포넌트를 호출하고 나서 화면을 확인해보세요.

저는 위와 같이 scss 코드를 설계하여 간단한 퍼블리싱을 하였습니다. 이제 UI는 어느정도 완성되었으니까 기능을 구현 해보겠습니다.

2. 이벤트 처리하기

두번째로, 드래그 앤 드롭 이벤트를 구현하기 위해서는 addEventListener에 총 4개의 이벤트 (dragenter, dragleave, dragover, drag) 처리를 해주어야 하고, 컴포넌트가 언마운트 되었을 때는 등록된 이벤트들을 지워주어야 합니다. DragDrop.tsx에 다음 코드를 추가해주세요.

잠깐! 4개의 이벤트 공통으로 들어가는 코드입니다.

e.preventDefault(); // 브라우저 이벤트 방지
e.stopPropagation(); // 부모 이벤트 방지

😐 만약 위의 코드가 없다면?
가장 대표적인 상황으로, 파일을 드래그 앤 드롭으로 끌어다 놓을시, 브라우저에 기본으로 등록된 이벤트 동작이 발생합니다. (새탭이 열리면서 화면에 파일 정보가 뜨게됩니다)

  const handleDragIn = useCallback((e: DragEvent): void => {
    e.preventDefault();
    e.stopPropagation();
  }, []);

  const handleDragOut = useCallback((e: DragEvent): void => {
    e.preventDefault();
    e.stopPropagation();

    setIsDragging(false);
  }, []);

  const handleDragOver = useCallback((e: DragEvent): void => {
    e.preventDefault();
    e.stopPropagation();

    if (e.dataTransfer!.files) {
      setIsDragging(true);
    }
  }, []);

  const handleDrop = useCallback(
    (e: DragEvent): void => {
      e.preventDefault();
      e.stopPropagation();

      onChangeFiles(e);
      setIsDragging(false);
    },
    [onChangeFiles]
  );

  const initDragEvents = useCallback((): void => {
    // 앞서 말했던 4개의 이벤트에 Listener를 등록합니다. (마운트 될때)
    
    if (dragRef.current !== null) {
      dragRef.current.addEventListener("dragenter", handleDragIn);
      dragRef.current.addEventListener("dragleave", handleDragOut);
      dragRef.current.addEventListener("dragover", handleDragOver);
      dragRef.current.addEventListener("drop", handleDrop);
    }
  }, [handleDragIn, handleDragOut, handleDragOver, handleDrop]);

  const resetDragEvents = useCallback((): void => {
    // 앞서 말했던 4개의 이벤트에 Listener를 삭제합니다. (언마운트 될때)
    
    if (dragRef.current !== null) {
      dragRef.current.removeEventListener("dragenter", handleDragIn);
      dragRef.current.removeEventListener("dragleave", handleDragOut);
      dragRef.current.removeEventListener("dragover", handleDragOver);
      dragRef.current.removeEventListener("drop", handleDrop);
    }
  }, [handleDragIn, handleDragOut, handleDragOver, handleDrop]);

  useEffect(() => {
    initDragEvents();

    return () => resetDragEvents();
  }, [initDragEvents, resetDragEvents]);

컴포넌트가 마운트 되었을 때, 언마운트 되었을 때의 이벤트 처리를 해줌으로써 이제 브라우저의 이벤트에 방해되는 일은 없습니다.

3. 드래그 또는 선택한 파일을 state 배열에 담기 🛒

이제 세번째로, state를 이용하여 선택했던 파일들을 관리할 state 배열을 만들어 봅시다. 먼저, 아래 코드와 같이 state 배열을 선언해주세요.

// 해당 인터페이스는 컴포넌트 밖에 작성해주세요!
interface IFileTypes {
  id: number; // 파일들의 고유값 id
  object: File;
}

const [files, setFiles] = useState<IFileTypes[]>([]);

이제 state 배열을 만들어 주었으니까, 관리를 해야 할 단계입니다. 위에서 만들었던 드래그 이벤트 및 파일 첨부 버튼을 눌러서 선택했던 파일들을 관리할 함수를 하나 만들어 보겠습니다.

DragDrop.tsx 파일에 아래와 같이 코드를 추가해주세요.

const onChangeFiles = useCallback((e: ChangeEvent<HTMLInputElement> | any): void => {
  let selectFiles: File[] = [];
  let tempFiles: IFileTypes[] = files;
  // temp 변수를 이용하여 선택했던 파일들을 담습니다.

  // 드래그 했을 때와 안했을 때 가리키는 파일 배열을 다르게 해줍니다.
  if (e.type === "drop") {
    // 드래그 앤 드롭 했을때
    selectFiles = e.dataTransfer.files;
  } else {
    // "파일 첨부" 버튼을 눌러서 이미지를 선택했을때
    selectFiles = e.target.files;
  }

  for (const file of selectFiles) {
    // 스프레드 연산자를 이용하여 기존에 있던 파일들을 복사하고, 선택했던 파일들을 append 해줍니다.
    tempFiles = [
      ...tempFiles,
      {
        id: fileId.current++, // fileId의 값을 1씩 늘려주면서 각 파일의 고유값으로 사용합니다.
        object: file // object 객체안에 선택했던 파일들의 정보가 담겨있습니다.
      }
    ];
  }

  setFiles(tempFiles);
}, [files]); // 위에서 선언했던 files state 배열을 deps에 넣어줍니다.

위의 함수에서 files state 배열의 상태를 업데이트 해주었으며, 드래그 앤 드롭 또는 파일 첨부 버튼을 눌러서 파일을 첨부할때, files 배열의 길이는 파일을 선택한만큼 증가하게 됩니다.
이제 업데이트 된 배열을 렌더링 해주는 작업을 해보도록 하겠습니다!
아래의 코드를 DragDrop.tsx JSX 부분에 추가해주세요.

// DragDrop.tsx
<div className="DragDrop-Files">
{files.length > 0 &&
  files.map((file: IFileTypes) => {
    const {
      id,
      object: { name }
    } = file;

    return (
      <div key={id}>
        <div>{name}</div>
        <div
          className="DragDrop-Files-Filter"
        >
          X
        </div>
      </div>
    );
  })}
</div>

className가 추가 되었으므로 DragDrop.scss 파일에도 스타일 코드를 추가 하겠습니다.

// DragDrop.scss
// 치환의 대상은 .DragDrop이 되어야 합니다.

&-Files {
  margin-top: 1rem;

  & > div {
    width: 300px;
    padding: 8px;
    border: 1px solid black;
    margin-bottom: 10px;
    display: flex;
    justify-content: space-between;
  }

  &-Filter {
    cursor: pointer;

    &:hover {
      opacity: 0.7;
    }
  }
}

이제 드래그 앤 드롭 또는 파일 직접 선택을 이용하여 파일을 추가해보세요! 아래의 사진과 같이 뜬다면 성공입니다.

4. 선택한 파일을 목록에서 지우기 ⚔

마지막으로, 위의 사진에서 파일 목록마다 있는 X 표시가 보이시나요? 이제 저 X 표시를 누르면, 해당 파일이 목록에서 지워 지도록 구현 해보겠습니다. 이 과정을 진행하기 위해서 fileId 라는 ref 변수를 이용하여 각각의 파일에 고유값을 심어주었습니다.

DragDrop.tsx에 아래의 함수를 추가 및 수정 해주세요!

const handleFilterFile = useCallback((id: number): void => {
  // 매개변수로 받은 id와 일치하지 않는지에 따라서 filter 해줍니다.
  setFiles(files.filter((file: ISelectFile) => file.id !== id));
}, [files]);

위의 함수를 추가시켜주고 나서, 아래의 코드를 files.map 부분에서 수정해주세요!

<div className="DragDrop-Files">
{files.length > 0 &&
  files.map((file: IFileTypes) => {
    const {
      id,
      object: { name }
    } = file;

    return (
      <div key={id}>
        <div>{name}</div>
        <div
          className="DragDrop-Files-Filter"
          onClick={() => handleFilterFile(id)}
          // onClick 속성에 위처럼 함수를 추가시켜 줍니다.
        >
          X
        </div>
      </div>
    );
  })}
</div>

코드를 다 작성하고 나서 X 버튼을 눌러서 지워지는지 테스트 해봅시다.


하나씩 잘 지워 지나요? 잘 지워진다면 성공입니다.🙂 파일 state 배열을 추가 할 때마다 고유값을 심어주다 보니까 filter 해줄때 편리함을 느꼈던 것 같습니다.

이제 드래그 앤 드롭 및 파일 선택 과정을 모두 처리 해주었으므로 모두 끝이 났습니다. 궁금한 점이 있으시면 댓글 달아주세요! 아래의 전체코드를 끝으로 마무리 하겠습니다. 🙂

5. 전체 코드 💻

DragDrop.tsx 전체 코드

import React, {
  ChangeEvent,
  useCallback,
  useRef,
  useState,
  useEffect
} from "react";
import "./DragDrop.scss";

interface IFileTypes {
  id: number;
  object: File;
}

const DragDrop = () => {
  const [isDragging, setIsDragging] = useState<boolean>(false);
  const [files, setFiles] = useState<IFileTypes[]>([]);

  const dragRef = useRef<HTMLLabelElement | null>(null);
  const fileId = useRef<number>(0);

  const onChangeFiles = useCallback(
    (e: ChangeEvent<HTMLInputElement> | any): void => {
      let selectFiles: File[] = [];
      let tempFiles: IFileTypes[] = files;

      if (e.type === "drop") {
        selectFiles = e.dataTransfer.files;
      } else {
        selectFiles = e.target.files;
      }

      for (const file of selectFiles) {
        tempFiles = [
          ...tempFiles,
          {
            id: fileId.current++,
            object: file
          }
        ];
      }

      setFiles(tempFiles);
    },
    [files]
  );

  const handleFilterFile = useCallback(
    (id: number): void => {
      setFiles(files.filter((file: IFileTypes) => file.id !== id));
    },
    [files]
  );

  const handleDragIn = useCallback((e: DragEvent): void => {
    e.preventDefault();
    e.stopPropagation();
  }, []);

  const handleDragOut = useCallback((e: DragEvent): void => {
    e.preventDefault();
    e.stopPropagation();

    setIsDragging(false);
  }, []);

  const handleDragOver = useCallback((e: DragEvent): void => {
    e.preventDefault();
    e.stopPropagation();

    if (e.dataTransfer!.files) {
      setIsDragging(true);
    }
  }, []);

  const handleDrop = useCallback(
    (e: DragEvent): void => {
      e.preventDefault();
      e.stopPropagation();

      onChangeFiles(e);
      setIsDragging(false);
    },
    [onChangeFiles]
  );

  const initDragEvents = useCallback((): void => {
    if (dragRef.current !== null) {
      dragRef.current.addEventListener("dragenter", handleDragIn);
      dragRef.current.addEventListener("dragleave", handleDragOut);
      dragRef.current.addEventListener("dragover", handleDragOver);
      dragRef.current.addEventListener("drop", handleDrop);
    }
  }, [handleDragIn, handleDragOut, handleDragOver, handleDrop]);

  const resetDragEvents = useCallback((): void => {
    if (dragRef.current !== null) {
      dragRef.current.removeEventListener("dragenter", handleDragIn);
      dragRef.current.removeEventListener("dragleave", handleDragOut);
      dragRef.current.removeEventListener("dragover", handleDragOver);
      dragRef.current.removeEventListener("drop", handleDrop);
    }
  }, [handleDragIn, handleDragOut, handleDragOver, handleDrop]);

  useEffect(() => {
    initDragEvents();

    return () => resetDragEvents();
  }, [initDragEvents, resetDragEvents]);

  return (
    <div className="DragDrop">
      <input
        type="file"
        id="fileUpload"
        style={{ display: "none" }}
        multiple={true}
        onChange={onChangeFiles}
      />

      <label
        className={isDragging ? "DragDrop-File-Dragging" : "DragDrop-File"}
        htmlFor="fileUpload"
        ref={dragRef}
      >
        <div>파일 첨부</div>
      </label>

      <div className="DragDrop-Files">
        {files.length > 0 &&
          files.map((file: IFileTypes) => {
            const {
              id,
              object: { name }
            } = file;

            return (
              <div key={id}>
                <div>{name}</div>
                <div
                  className="DragDrop-Files-Filter"
                  onClick={() => handleFilterFile(id)}
                >
                  X
                </div>
              </div>
            );
          })}
      </div>
    </div>
  );
};

export default DragDrop;

DragDrop.scss 전체 코드

@mixin filledStyle() {
  background-color: black;
  color: white;
}

@mixin alignCenter() {
  display: flex;
  display: -webkit-flex;
  flex-direction: column;
  -ms-flex-direction: column;
  justify-content: center;
  align-items: center;
}

.DragDrop {
  width: 100%;
  height: 100vh;
  @include alignCenter();
  
  &-File {
    width: 400px;
    height: 200px;
    border: 2px solid black;
    border-radius: 10px;
    
    @include alignCenter();
    cursor: pointer;
    transition: 0.12s ease-in;

    &-Dragging {
      @include filledStyle();
    }
  }

  &-Files {
    margin-top: 1rem;

    & > div {
      width: 300px;
      padding: 8px;
      border: 1px solid black;
      margin-bottom: 10px;
      display: flex;
      justify-content: space-between;
    }

    &-Filter {
      cursor: pointer;
      
      &:hover {
        opacity: 0.7;
      }
    }
  }
}
profile
블로그 이전: https://yiyb-blog.vercel.app

2개의 댓글

comment-user-thumbnail
2021년 5월 18일

감사합니다!

답글 달기
comment-user-thumbnail
2022년 2월 14일

https://gist.github.com/tw4204/c9fbac9fab576170bfd0c4d9c1692597
본 글의 내용을 hook으로 만들어봤습니다.

답글 달기