Drag & Drop 구현하기 with dataTransfer

camel·2024년 11월 8일
2
post-thumbnail

✅ 개요

최근 프로젝트에서 DND(Drag And Drop) 컴포넌트를 구현해야 하는 과제가 주어졌습니다.

처음에는 이전에 접했던 복잡한 드래그 슬라이드 구현 경험 때문에 걱정이 앞섰습니다.
하지만 MDN 문서와 여러 기술 블로그들을 참고해본 결과, HTML이 이미 DND 기능과 관련된 풍부한 내장 기능들을 제공하고 있다는 것을 발견했습니다.

이 글은 비슷한 고민을 하고 계신 개발자분들께서 DND 구현의 전체적인 맥락을 쉽게 이해하고, 효율적으로 구현하실 수 있도록 돕고자 작성하게 되었습니다.


❗ 핵심 특성 3가지

DND 구현에는 다음 세 가지 핵심 요소가 있습니다:

draggable
event.dataTransfer
drag & drop event

각 요소의 역할은 다음과 같습니다:

draggable: HTML 요소를 드래그 가능한 상태로 만들어주는 속성입니다.
DataTransfer 객체: 드래그 앤 드롭 작업의 데이터 관리를 담당하는 핵심 객체입니다.
drag & drop event: 드래그 앤 드롭 과정에서 발생하는 다양한 이벤트들입니다.

이제 각 요소들을 자세히 살펴보도록 하겠습니다. 먼저 draggable 속성부터 알아보겠습니다.

👀 draggable 속성

HTML의 모든 요소는 draggable 속성을 통해 드래그 기능을 활성화할 수 있습니다.
이 속성은 이름 그대로 해당 요소의 드래그 가능 여부를 결정합니다.

<div draggable>
  <img src="https://storage.googleapis.com/coderpad_project_template_assets/react.svg" />
  <span>React App</span>
</div>

기본적으로 텍스트 선택만 드래그가 가능하도록 설정되어 있습니다.
하지만 draggable 속성을 true로 설정하면 어떤 요소든 드래그 가능한 상태로 만들 수 있습니다. 예를 들어, React 아이콘 컴포넌트에 draggable 속성을 추가하면 해당 컴포넌트를 자유롭게 드래그할 수 있게 됩니다


🚚 드래그 이벤트 시스템

드래그 앤 드롭 구현을 위해 활용할 수 있는 7가지 핵심 이벤트가 있습니다:

onDragStart: 요소 드래그를 시작하는 시점에 발생
onDrag: 드래그 진행 중에 지속적으로 발생
onDragEnd: 드래그 동작이 종료될 때 발생
onDragEnter: 드래그 중인 요소가 유효한 드롭 대상 영역에 진입할 때 발생
onDragOver: 드래그 중인 요소가 드롭 대상 영역 위에 머무는 동안 발생
onDragLeave: 드래그 중인 요소가 드롭 대상 영역을 벗어날 때 발생
onDrop: 요소가 성공적으로 드롭될 때 발생

이 중 제가 구현에 실제로 활용한 이벤트는 onDragStart, onDragEnter, onDragOver, onDragLeave, onDrop입니다.
이제 각 드래그 관련 이벤트와 dataTransfer 객체의 활용법을 실제 구현하는 과정과 함께 살펴보도록 하겠습니다.

🚀 드래그 앤 드롭 시작

요소의 드래그를 시작하면 onDragStart 이벤트가 발생합니다.
이때 드래그 중인 컴포넌트의 상태 관리를 위해 다음과 같이 상태를 업데이트합니다:

  const [isDragging, setIsDragging] = useState(false);

  const handleDragStart = (e: React.DragEvent) => {
      setIsDragging(true); //추가
  }

이 상태값을 통해 드래그 중인 요소의 스타일을 동적으로 관리할 수 있습니다.
CSS의 :active 의사 클래스를 활용하여 스타일링할 수도 있는데, 드래그 중인 요소도 클릭 상태로 간주되기 때문입니다.

하지만 :active는 드래그 상태를 명확하게 표현하기 어려워 저는 JavaScript를 통한 스타일 관리 방식을 선택했습니다.

이후 다음 과정으로 넘어가려했지만, 한 가지 문제에 직면했습니다.
드래그한 컴포넌트의 정보를 드롭 대상 컴포넌트로 전달해야 했기 때문입니다.
상태 관리를 통해 해결할 수도 있었지만, 순수 JavaScript 기능이 있어서 이를 활용하고자 했습니다.

💾 dataTransfer 객체 활용하기

이 문제를 해결하기 위해 dataTransfer 객체를 발견하게 되었습니다.

DataTransfer는 드래그 앤 드롭 작업 중에 데이터를 관리하고 전달하기 위한 객체입니다.

이 객체에서 제공하는 여러 메서드 중 다음 4가지를 주로 활용했습니다.

  • setData: 드래그 작업 중 전달할 데이터를 저장하는 메서드
  • getData: 저장한 데이터를 추출하는 메서드
  • effectAllowed: 허용할 드래그 작업의 종류를 지정하는 메서드
  • setDragImag: 드래그 중 표시될 커스텀 이미지를 설정하는 메서드

dataTransfer도 사용한 것에 맞추어 설명하도록 하겠습니다.

📝 event 객체에 데이터 저장하기

드래그 시작 시 setData 메서드를 사용하여 전송할 데이터를 저장합니다.
setData는 다음 4가지 타입의 데이터를 지원합니다:

"text/plain"    // 일반 텍스트
"text/html"     // HTML 형식
"text/uri-list" // URL 링크
"application/json" // JSON 데이터

객체 형태의 데이터 전송을 위해 JSON 형식을 사용했습니다:

  const data = {
    id: 123,
    name: "아이템 이름",
    type: "some-type",
  };

  // 객체를 JSON 문자열로 변환하여 전달
  event.dataTransfer.setData("application/json", JSON.stringify(data));

단순 텍스트만 전달하는 경우에는 다음과 같이 간단하게 작성할 수 있습니다:

const text = "저는 4번 컴포넌트에요"

e.dataTransfer.setData('text/plain', text);

이를 종합하여 다음과 같은 드래그 시작 핸들러를 구현했습니다.

  const handleDragStart = (e: React.DragEvent) => {
    e.stopPropagation();
  	e.dataTransfer.setData("application/json", JSON.stringify(data));// 추가
	setIsDragging(true);
  };

🎯 드래그 동작 제어하기

구현 과정에서 문제가 발생했습니다.
MacOS의 기본 복사 커서로 변경되어서 의도한 것과 달랐습니다.

이를 해결하기 위해 dataTransfer.effectAllowed 속성을 활용했습니다
dataTransfer.effectAllowed는 아래와 같은 목적으로 사용할 수 있습니다.

  // 1. 복사만 허용
  event.dataTransfer.effectAllowed = 'copy';
  
  // 2. 이동만 허용
  event.dataTransfer.effectAllowed = 'move';
  
  // 3. 모든 작업 허용 (기본값)
  event.dataTransfer.effectAllowed = 'all';

copy 커서로 설정되어있었고,
요소 이동이 목적이 move였고, 커서도 제가 의도한 것과 똑같이 나와서 move를 선택했습니다.

  const handleDragStart = (e: React.DragEvent) => {
    e.stopPropagation();
  	e.dataTransfer.setData("application/json", JSON.stringify(data));
	e.dataTransfer.effectAllowed = 'move'; //추가

	setIsDragging(true);
  };

🎨 드래그 이미지 커스터마이징

디자인 개선을 위한 협업 과정
드래그 앤 드롭 구현 중 시각적 피드백과 관련된 문제가 발생했습니다.
드래그 중인 요소의 이미지가 드롭 영역의 하이라이트 효과를 가려버리는 현상이었습니다. 이 문제를 해결하기 위해 디자인팀과 의논하였습니다.

처음에는 드롭 영역의 하이라이트 색상을 더 진하게 만드는 방안이 제시하였으나, 제품의 전반적인 밝은 색상 톤과 맞지 않았습니다.

하이라이트 스타일을 유지하면서 변경해야했기때문에, 대안을 제시했습니다.

  1. 드래그 이미지를 완전히 숨긴다.
  2. 드래그가 시작한 위치에 하이라이트 효과를 적용한다.

시작한 위치에 하이라이트를 주어서 유저가 어떠한 요소가 이동중인지 알 수 있었고, 드롭 영역의 하이라이트도 잘 보이기 때문에 문제를 해결할 수 있었습니다.

이미지를 없애는 방법이 챌린지였습니다.

setDragImage로 구현하기
dataTransfer.setDragImage API에서 이미지를 커스텀 할 수 있다고해서 이를 활용해보기로 했습니다.

setDragImage(image: Element, x: number, y: number): void;

위처럼 element를 받아서 드래그이미지로 보여줄 수 있었습니다.
먼저 투명한 1픽셀 GIF 이미지를 사용해보기로 결정했습니다.

 const img = new Image();
 img.src = 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7';

 e.dataTransfer.setDragImage(img, 0, 0);

하지만 이미지 로딩 타이밍 문제로 인해 문제가 생겼습니다.
이미지가 로드되지 않은 상태에서 이미지를 사용하면, 지구본 이미지를 보여주며 엑박 처리를 합니다.

이를 해결하기 위해 이미지 로드 이벤트를 활용해보았습니다.

 img.onload = () => {
        e.dataTransfer.setDragImage(img, rect.width / 2, rect.height / 2);
      };

그래서 위처럼 로드한 이후 실행할 수 있도록 세팅을 해주었습니다.
하지만 지구본이 똑같이 커서에 들어왔고, 다른 방법을 시도했습니다.

DOM 요소를 활용한 해결책

// ❌ 작동하지 않음 - JSX.Element는 실제 DOM 요소가 아님
const element: JSX.Element = <div>Hello</div>;

다음으로 React Div 요소를 사용하려 했으나, setDragImage가 실제 DOM 요소를 필요로 하는 것을 발견했습니다:

이번에는 Real DOM 요소를 생성하여 넣어주는 방법을 택했습니다.

    const ghostElement = document.createElement('div');
    ghostElement.style.width = '1px';
    ghostElement.style.height = '1px';
    ghostElement.style.backgroundColor = 'transparent';
    ghostElement.style.visibility = 'hidden';
    ghostElement.id = 'drag-ghost';

    e.dataTransfer.setDragImage(ghostElement, 0, 0);

아쉽게도 의도한 것처럼 동작하지 않았습니다.
실제 DOM에 할당하지 않아서 문제가 발생한 것이었습니다.

    const ghostElement = document.createElement('div');
    ghostElement.style.width = '1px';
    ghostElement.style.height = '1px';
    ghostElement.style.backgroundColor = 'transparent';
    ghostElement.style.visibility = 'hidden';
    ghostElement.id = 'drag-ghost';
    document.body.appendChild(ghostElement);// 실제 DOM에 추가
  
    e.dataTransfer.setDragImage(ghostElement, 0, 0);

실제 DOM에 추가하여 문제를 해결할 수 있었습니다.

이 접근 방식으로 드래그 이미지를 최소화하여 드롭 영역의 시각적 피드백이 명확하게 보이도록 만들 수 있었습니다.

  const handleDragStart = (e: React.DragEvent) => {
    e.stopPropagation();
  	e.dataTransfer.setData("application/json", JSON.stringify(data));
	e.dataTransfer.effectAllowed = 'move'; //추가

	setIsDragging(true);
    
    //추가
    const ghostElement = document.createElement('div');
    ghostElement.style.width = '1px';
    ghostElement.style.height = '1px';
    ghostElement.style.backgroundColor = 'transparent';
    ghostElement.style.visibility = 'hidden';
    ghostElement.id = 'drag-ghost';
    document.body.appendChild(ghostElement);
  
    e.dataTransfer.setDragImage(ghostElement, 0, 0);
  };

🎯 드롭 대상 상태 관리

드래그 를 구현한 후, 드롭 대상의 상태 관리가 필요했습니다. 특히 드래그된 요소가 드롭 대상 위에 있을 때의 시각적 피드백이 중요했습니다.

계층 구조 처리하기
구현 과정에서 특별한 요구사항이 있었습니다. 요소들은 계층 구조를 가지고 있었고, 두 가지 유형의 드롭 영역이 존재했습니다:

content: 드롭 시 형제 요소로 배치
divider: 드롭 시 자식 요소로 배치

두 영역 모두 div 요소로 구현되어 있어 구분이 필요했습니다. 이를 위해 데이터 속성을 활용했습니다:

<div data-drag-type='content' />
<div data-drag-type='divider' />

드래그 오버 상태 관리
드롭 영역 위에서의 상태 변화를 추적하기 위해 다음과 같은 이벤트 핸들러를 구현했습니다:

  const handleDragOver = (e: React.DragEvent) => {

    const target = ((e.currentTarget as HTMLElement).dataset.dragType as DragType) ?? '';
    setDragOverTarget(target);// 'content' | 'divider'
  };

이를 통해 어떤 계층에 존재하는지 확인하고 핸들링을 해줄 수 있었습니다.

다양한 드래그 상태 처리
드래그 앤 드롭 과정의 여러 상황이 존재하였고, 시나리오에 맞추어서 처리해주어야해서 다양한 핸들러가 필요했습니다.

  const handleDragLeave = () => {
    setDragOverTarget('none');
  };
  const handleDrop = () => {
    setDragOverTarget('none');
  };
  const handleDragEnd = () => {
    setIsDragging(false);
  };

각 핸들러는 다음과 같은 역할을 수행합니다:

  • handleDragLeave: 드래그가 대상 영역을 벗어날 때 상태 초기화
  • handleDrop: 드롭 완료 시 오버 상태 초기화
  • handleDragEnd: 드래그 종료 시 드래깅 상태 초기화

이러한 이벤트들은 각각 독립적인 목적을 가지고 있어 별도의 핸들러로 분리했습니다.

더 적은 함수를 만들어 관리할 수도 있었지만, 각 상태의 관리 목적을 명확히 하고 유지보수성을 높이기 위해 분리된 구조를 선택했습니다.

처음에는 드래그가 나가는 것과 끝나는 것이 동일하지 않나? 라는 착각도 했었고,
여러 시나리오를 고려하여 상태가 복잡해지니 많이 혼란스러웠습니다.
그래서 이를 단순하게 구성하는 것이 중요했습니다.

🏁 드래그 앤 드롭 완료 처리

드래그 종료 처리 방식
드래그 앤 드롭 작업의 완료를 처리하는 데는 두 가지 접근 방식이 있습니다:

1. dragOver 이벤트 활용

  • 드래그 요소가 특정 영역 위에 있을 때 지속적으로 발생
  • 마지막 dragOver 위치를 기억하여 드롭 처리

2. drop 이벤트 활용
1. 사용자가 드래그 요소를 놓은 정확한 위치에서 발생
2. 더 직관적이고 정확한 처리 가능

브라우저 기본 동작 제어
브라우저는 보안상의 이유로 기본적으로 드롭 동작을 차단합니다.
이는 다음과 같은 잠재적 문제를 방지하기 위함입니다:

  • 의도하지 않은 텍스트 이동
  • 실수로 인한 파일 이동
  • 기타 예기치 않은 데이터 조작

이를 해결하기 위해 preventDefault()를 사용하여 명시적으로 드롭을 허용해야 합니다:

  const handleDragOver = (e: React.DragEvent) => {
  	e.preventDefault(); // 추가, 드롭을 허용하기 위해 필요
    const target = ((e.currentTarget as HTMLElement).dataset.dragType as DragType) ?? '';
    setDragOverTarget(target);// 'content' | 'divider'
  };

💾 드롭 데이터 처리하기

preventDefault()를 통해 드롭을 허용한 후, 실제 데이터 처리 단계로 넘어갈 수 있습니다.

드롭 이벤트에서 데이터 추출
이전에 dataTransfer에 저장했던 데이터를 이제 추출하여 활용할 수 있습니다:

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

    //json을 사용하는 경우
    const jsonData = e.dataTransfer.getData("application/json");
    const data = JSON.parse(jsonData);
    //text를 사용하는 경우
    const data= e.dataTransfer.getData('text');
  }

임시 요소 정리
드래그 이미지용으로 생성했던 고스트 요소를 제거합니다:

  const handleDrop = (e: React.DragEvent) => {
    e.preventDefault();
    e.stopPropagation();
    const ghostElement = document.getElementById('drag-ghost');
    if (ghostElement) {
      ghostElement.remove();
    }
    //json을 사용하는 경우
    const jsonData = e.dataTransfer.getData("application/json");
    const data = JSON.parse(jsonData);
    //text를 사용하는 경우
    const data= e.dataTransfer.getData('text');
  }

위처럼 데이터를 가져오고 함수를 실행시켜 드래그앤 드랍에 성공할 수 있었습니다.
하면서 퓨어한 자바스크립트에 관한 이해도가 부족해서 많이 고생했지만,
끝가지 기준점을 낮추지않고 도전한 덕분에 문제를 해결할 수 있었습니다.


🎓 구현을 통해 배운 점

이번 드래그 앤 드롭 구현을 통해 순수 JavaScript에 많은 기능이 있다는 것을 다시 한번 배울 수 있었습니다.

처음에는 생소한 개념들이라서 어려웠지만, 포기하지 않고 도전해서 구현할 수 있어서 뿌듯했습니다.

또한 이 과정에서 디자이너분과 의견을 나누고 더 좋은 방법을 도출했다는 점도 만족스러운 결과였습니다.
앞으로도 적극적으로 이야기해서 더 좋은 제품을 만들어보고 싶네요.

긴 글 읽어주셔서 감사합니다~

출처 :
https://developer.mozilla.org/ko/docs/Web/HTML/Global_attributes/draggable
https://developer.mozilla.org/ko/docs/Web/API/DataTransfer

profile
잘부탁드립니다.

0개의 댓글

관련 채용 정보