드래그 앤 드랍(1) - DragEvent API 학습과 구현

들어가기

우리 서비스에는 사용자가 방문할 장소들을 등록할 수 있는 페이지가 있다.

이 페이지에서는 등록한 장소들의 순서를 자유롭게 바꿀 수 있는 기능도 제공하는데, 이를 Drag & Drop 방식으로 구현해보려 한다.

본격적인 기능 구현에 앞서, Drag & Drop 이벤트에는 어떤 것들이 있는지 먼저 살펴보자.

Drag & Drop이란?

Drag & Drop은 사용자가 마우스나 터치 등의 입력 장치를 이용해 화면의 요소를 끌어서(drag) 원하는 위치에 놓을 수(drop) 있도록 해주는 직관적인 UI 인터랙션 방식이다.

사용자는 특정 항목을 클릭한 채로 움직인 후 다른 위치에 놓는 동작을 통해 요소의 순서를 변경하거나, 특정 동작을 유도할 수 있다.

대표적인 활용 예시로는 아래와 같다.

  • 파일 업로드 시 파일을 브라우저로 끌어다 놓는 기능

  • 리스트 정렬 기능에서 항목의 순서를 사용자가 직접 변경하는 경우

  • 보드형 UI(예: Trello)에서 카드나 컬럼을 이동시키는 경우

웹에서는 HTML5에서 기본 제공하는 Drag & Drop API로 간단하게 구현할 수 있으며,

더 복잡한 요구사항에는 react-beautiful-dnd, dnd-kit, react-dnd 같은 라이브러리를 활용해 유연하게 처리할 수 있다.

이번에는 Drag & Drop의 동작 원리를 직접 이해하고 구현하는 경험을 쌓는 것이 더 중요하다고 판단하여, 서드파티 라이브러리를 사용하지 않고 HTML5의 기본 API만으로 기능을 구현해볼 계획이다.

먼저 Drag & Drop에는 어떤 이벤트들이 있는지 살펴보자

Drag & Drop의 이벤트 종류

먼저, 드래그 이벤트가 발생하도록 하려면 요소의 속성으로 draggable=”true”를 줘야한다.

<div draggable={true}>
	<p> 드래그 할 아이템 </p>
</div >

draggable="true” 속성을 지정하면, 해당 요소는 드래그 가능한 상태가 되며 이 요소를 드래그할 때 다음과 같은 이벤트들이 순차적으로 발생할 수 있다.

drag 이벤트
설명

dragstart

사용자가 요소를 드래그하기 시작하면 발생

drag

사용자가 요소를 드래그하는 동안 매 수백 밀리초마다 발생

dragenter

드래그된 요소가 유효한 드롭 대상에 들어가면 발생

dragover

드래그된 요소가 유효한 드롭 대상 위로 드래그될 때 수백 밀리초마다 발생

dragleave

드래그된 요소가 유효한 드롭 대상에서 벗어나면 발생

drop

드래그된 요소가 유효한 드롭 대상에 놓이면 발생

dragend

마우스 버튼을 놓거나 이스케이프 키를 눌러 드래그 작업이 끝나게 되면 발생

dragstart

  • dragstart를 지정한 요소의 드래그가 시작되는 순간만 발생

export default function App() {
  const [dragStartEventCount, setDragStartEventCount] = useState(0);

  const handleDragStart = () => {
    setDragStartEventCount((prev) => prev + 1);
  };

  return (
    <>
      <div draggable={true} onDragStart={handleDragStart}>
        드래그 요소
      </div>
      <div>드래그 타겟 요소</div>
      <div>dragStart trigger {dragStartEventCount}</div>
    </>
  );
}

drag

  • drag를 지정한 요소의 드래그가 발생하는 동안 계속 발생

export default function App() {
  const [dragEventCount, setDragEventCount] = useState(0);

  const handleDrag = () => {
    setDragEventCount((prev) => prev + 1);
  };

  return (
    <>
      <div draggable={true} onDrag={handleDrag}>
        드래그 요소
      </div>
      <div>드래그 타겟 요소</div>
      <div>drag trigger {dragEventCount}</div>
    </>
  );
}

dragenter

  • dragenter를 지정한 요소에 드래그된 요소가 들어가면 발생

export default function App() {
  const [dragEnterCount, setDragEnterCount] = useState(0);

  const handleDragEnter = () => {
    setDragEnterCount((prev) => prev + 1);
  };

  return (
    <div className="App">
      <div draggable={true}>드래그 요소</div>
      <div onDragEnter={handleDragEnter}>드래그 타겟 요소</div>
      <div>dragEnter trigger {dragEnterCount}</div>
    </div>
  );
}

dragover

  • dragover를 지정한 요소에 드래그된 요소가 위치하면 계속 발생

export default function App() {
  const [dragOverCount, setDragOverCount] = useState(0);

  const handleDragOver = () => {
    setDragOverCount((prev) => prev + 1);
  };

  return (
    <div className="App">
      <div draggable={true}>드래그 요소</div>
      <div onDragOver={handleDragOver}>드래그 타겟 요소</div>
      <div>dragEnter trigger {dragOverCount}</div>
    </div>
  );
}

dragleave

  • dragleave를 지정한 요소에 드래그된 요소가 벗어나면 발생

export default function App() {
  const [dragLeaveCount, setDragLeaveCount] = useState(0);

  const handleDragLeave = () => {
    setDragLeaveCount((prev) => prev + 1);
  };

  return (
    <div className="App">
      <div draggable={true}>드래그 요소</div>
      <div onDragLeave={handleDragLeave}>드래그 타겟 요소</div>
      <div>dragEnter trigger {dragLeaveCount}</div>
    </div>
  );
}

drop

  • dragover와 drop을 지정한 요소에 드래그된 요소가 놓이면 발생

  • onDragOver에서 event.preventDefault()를 호출해야만 drop이 허용됨

    • 브라우저는 안전을 위해 drop 이벤트를 기본적으로 막고 있다. 의도하지 않은 drop으로 인한 파일 업로드 등을 방지하기 위함이다. 따라서 event.preventDefault로 drop을 허용해줘야한다.

export default function App() {
  const [dropCount, setDropCount] = useState(0);

  const handleDrop = () => {
    setDropCount((prev) => prev + 1);
  };

  return (
    <div className="App">
      <div draggable={true}>드래그 요소</div>
      <div
        onDrop={handleDrop}
        onDragOver={(e) => e.preventDefault()} // drop을 허용! 
      >
        드래그 타겟 요소
      </div>
      <div>drop trigger {dropCount}</div>
    </div>
  );
}

dragend

  • dragend를 지정한 요소의 드래그가 끝났을 때 발생

export default function App() {
  const [dragEndCount, setDragEndCount] = useState(0);

  const handleDragEnd = () => {
    setDragEndCount((prev) => prev + 1);
  };

  return (
    <div className="App">
      <div draggable={true} onDragEnd={handleDragEnd}>
        드래그 요소
      </div>
      <div>드래그 타겟 요소</div>
      <div>dragEnd trigger {dragEndCount}</div>
    </div>
  );
}

DragEvent: dataTransfer 객체

드래그 앤 드랍 관련 이벤트들은 드래그 이벤트의 데이터를 포함하는 dataTransfer 객체를 가지고 있다.

각각이 어떤 걸 의미하는지 살펴보자

  const handleDrag = (event) => {
    console.log(event.dataTransfer);
  };

속성

1. dropEffect

역할

  • 드래그 도중 커서 모양을 바꾸고 어떤 작업이 일어날지 사용자에게 피드백을 줌

이벤트별 dropEffect값

이벤트
설명

dragenter / dragover

dropEffect는 OS + 사용자 키 입력(Alt 등)에 따라 기본 설정됨→ 필요 시 JS에서 명시적으로 다시 설정해야 함

drop / dragend

마지막 dragover/dragenter에서 설정된 dropEffect 값과 effectAllowed 값, 그리고 브라우저/플랫폼의 정책 등을 종합하여 브라우저가 실제로 수행한 드래그 작업의 종류를 나타냄

가능한 값

의미

"copy"

원본 복사

"move"

원본을 새 위치로 이동

"link"

새 위치에서 원본에 대한 링크 생성

"none"

드롭 불가능

주의사항

  • js에서 설정하려면 "dragenter" / "dragover" 이벤트에서 설정해야 함

  • dropEffect = "none" 이면 드롭 안 됨

  • 단순히 시각적 피드백만 제공하기에, 실제 로직은 직접 구현해야 함

export default function App() {
  const [dropEffect, setDropEffect] = useState("none");
  const [dropCount, setDropCount] = useState(0);

  const handleDragOver = (event) => {
    event.preventDefault();
    event.dataTransfer.dropEffect = dropEffect;
  };

  const handleDrop = (event) => {
    event.preventDefault();
    setDropCount((prev) => prev + 1);
  };

  return (
    <div className="App">
      <h2>🔧 dropEffect 예제</h2>
      <div>
        <label>dropEffect 설정: </label>
        <select
          value={dropEffect}
          onChange={(e) => setDropEffect(e.target.value)}
        >
          <option value="copy">copy</option>
          <option value="move">move</option>
          <option value="link">link</option>
          <option value="none">none</option>
        </select>
      </div>
      <div
        draggable={true}
        onDragStart={(e) => {
          e.dataTransfer.effectAllowed = "all";
        }}
        style={{ background: "#dcefff", marginBottom: "20px" }}
      >
        🟦 드래그 요소
      </div>

      <div
        onDragOver={handleDragOver}
        onDrop={handleDrop}
        style={{ background: "#f2f2f2" }}
      >
        🎯 드롭 타겟 (여기로 드래그하세요)
      </div>
      <div style={{ marginTop: "20px" }}>💡 Drop 횟수: {dropCount}</div>
    </div>
  );
}

2. effectAllowed

역할

  • 어떤 종류의 드롭 작업을 허용할 것인지를 브라우저에 알림(복사, 이동, 링크 등)

가능한 값

의미

"none"

어떤 작업도 허용하지 않음

"copy"

복사만 허용

"move"

이동만 허용

"link"

링크만 허용

"copyLink"

복사 또는 링크 허용

"copyMove"

복사 또는 이동 허용

"linkMove"

링크 또는 이동 허용

"all"

모든 작업 허용 (copy, move, link)

"uninitialized"

기본값, 사실상 "all"과 동일함

주의사항

  • 반드시 dragstart 이벤트 내에서 설정해야 함, 다른 이벤트에서는 무시됨

  • dropEffect 값은 effectAllowed와 호환되지 않으면 강제로 "none" 처리됨

    • 예: effectAllowed = "copy", dropEffect = "move" → 드롭 불가(none 처리)

3. files

역할

  • 읽기 전용 속성의 드래그 작업 파일 목록

  • 이 속성을 사용하면 사용자의 데스크톱에 있는 파일을 브라우저로 끌어다 놓을 수 있음

  • FileList 형태로 반환되며, 각 항목은 File 객체

주의사항

  • drop 이벤트에서만 접근 가능, 다른 이벤트에서는 항상 빈 리스트

4. items

역할

  • 드래그 작업에 포함된 데이터 항목 목록을 나타내는 읽기 전용 속성

  • 각 항목은 DataTransferItem 객체이며, kind (종류)와 type (MIME 타입)을 가짐

가능한 값

속성명
설명

items.length

드래그된 항목의 개수

item.kind

"string" 또는 "file"

item.type

MIME 타입 (예: "text/plain", "image/png" 등)

item.getAsFile()

kind === "file"일 때 File 객체로 반환

item.getAsString(cb)

kind === "string"일 때 문자열로 읽기

5. types

역할

  • DataTransfer 객체의 읽기 전용 속성으로, 드래그 작업에 포함된 데이터의 MIME 유형 목록을 반환

  • 현재 드래그 작업에 어떤 형식의 데이터가 들어 있는지 확인할 수 있는 속성

  • 형식은 보통 MIME 타입 문자열로 표현됨

가능한 값

  • string[] 형태의 배열이며, 각 요소는 MIME 타입 문자열

  • 예: ["text/plain", "text/html", "Files"]

주의사항

  • 파일의 경우 'image/png'처럼 구체적인 MIME 타입이 아닌, "Files"라는 일반적인 식별자로만 표시되므로 datatTransfer.files를 통해 접근해야 함

메서드

1. clearData

역할

  • 드래그 작업 중 등록된 데이터 중 특정 유형의 데이터를 제거하는 메서드

  • 특정 형식이 없으면 아무 일도 일어나지 않으며, 형식을 생략하거나 빈 문자열을 전달하면 모든 데이터가 제거됨

문법

dataTransfer.clearData();         // 모든 형식의 데이터 제거
dataTransfer.clearData("text/plain"); // "text/plain" 형식 데이터만 제거
매개변수
설명

format (optional)

제거할 데이터의 MIME 타입 문자열. 생략하거나 ""을 전달하면 모든 데이터가 제거됨

주의 사항

  • dragstart 이벤트 핸들러 내에서만 사용 가능 → 이유: dragstart 이벤트 중에만 DataTransfer 객체에 쓰기 권한이 있음.

  • clearData()파일은 제거하지 않음 → 즉, "Files" 타입은 여전히 dataTransfer.types에 남아 있을 수 있음.

2. getData

역할

  • 지정된 형식(format)에 대한 드래그 데이터를 문자열로 반환

문법

getData(format)
매개변수
설명

format (string)

가져올 데이터 형식 (예: "text/plain", "text/uri-list" 등)

주의사항

  • getData()dragstart 또는 drop 이벤트 내에서만 사용해야 함

3. setData

역할

  • 지정한 형식(format)과 데이터(data)를 드래그 작업에 추가하거나, 기존 형식의 데이터를 갱신

  • 만약 아직 없는 형식이면,

    새로 추가되고 types 목록의 맨 끝에 들어감

  • 이미 있는 형식이면,

    내용만 바뀌고, types 안에서 자리(순서)는 그대로 유지됨

  • (동일한 타입의 데이터를 하나만 가질 수 있음)

문법

setData(format, data)
매개변수
설명

format (string)

데이터 형식 (예: "text/plain", "text/uri-list")

data(string)

추가할 드래그 데이터

주의사항

  • dragstart 이벤트 안에서만 사용 가능

  • setData()로 등록된 데이터는 이후 getData()로 읽을 수 있음

4. setDragImage

역할

  • 드래그가 시작될 때, 브라우저는 드래그 대상 요소로부터 반투명한 이미지를 자동 생성하여 마우스 포인터를 따라가게 함

  • 하지만, 사용자 정의 이미지를 사용하고 싶다면 DataTransfer.setDragImage()를 사용하면 됨

문법

setDragImage(imgElement, xOffset, yOffset)
매개변수
설명

imgElement

드래그 시 보여줄 이미지 요소 (<img>, <canvas>, 혹은 다른 HTML 요소)

<img> 라면 이미지 그대로 사용됨

<div> 같은 다른 요소라면 해당 요소를 기반으로 이미지가 만들어짐

xOffset

이미지 내에서의 마우스 포인터의 가로 위치(px)

yOffset

이미지 내에서의 마우스 포인터의 세로 위치(px)

이미지 중앙에 포인터가 오게 하려면, width/2, height/2 설정

예제

dragTarget.addEventListener("dragstart", (e) => {
  const img = document.createElement("img");
  img.src = "example.gif";
  document.body.appendChild(img); // (보여지지 않아도 OK)
  e.dataTransfer.setDragImage(img, 10, 10); // 자바스크립트로 만든 DOM 요소 객체여야 함 
  e.dataTransfer.setDragImage("image.png", 0, 0);  // 문자열은 ❌ 안 됨
  e.dataTransfer.setDragImage(<img src="example.gif" />, 0, 0); // HTML 태그 자체 ❌ 안 됨
});

간단한 Drag & Drop 예제

이제 각각의 이벤트가 어떤 것을 의미하는지 알았으므로 간단한 드래그앤 드랍 기능을 만들어보자

요소 옮기기

export default function App() {
  const [boxIndex, setBoxIndex] = useState(0); // 현재 박스 위치를 추적

  const handleDragStart = (event) => {
    event.dataTransfer.effectAllowed = "move";
  };

  const handleDragOver = (event) => {
    event.preventDefault(); // 드롭 가능하게 함 
  };

  const handleDrop = (index) => {
    setBoxIndex(index); // 드롭된 위치로 박스 위치 상태 업데이트
  };

  return (
    <div className="App">
      <h2>요소 옮기기 예제</h2>
      <div style={{ display: "flex", gap: "20px" }}>
        {[0, 1].map((_, idx) => (
          <div
            key={idx}
            onDragOver={handleDragOver}
            onDrop={() => handleDrop(idx)}
            style={...}
          >
            {boxIndex === idx && (
              <div
                draggable
                onDragStart={handleDragStart}
                style={...}
              ></div>
            )}
          </div>
        ))}
      </div>
    </div>
  );
}

요소 위치 서로 바꾸기

export default function App() {
  // 드래그할 수 있는 아이템 리스트 
  const [items, setItems] = useState([
    { id: "a", label: "🍎" },
    { id: "b", label: "🍌" },
    { id: "c", label: "🍇" },
  ]);

  // 드래그 중인 아이템 ID 상태
  const [draggedId, setDraggedId] = useState(null);
 
  // 드래그 시작 시 아이템 아이디 지정 
  const handleDragStart = (id) => {
    setDraggedId(id);
  };
  
  const handleDragOver = (event) => {
    event.preventDefault(); // 드롭 가능하게 함 
  };

  // 드롭됐을 때 아이템 위치 스위칭
  const handleDrop = (targetId) => {
    // 드래그 중인 아이템이 없거나 같은 아이템에 드롭할 경우 무시
    if (draggedId === null || draggedId === targetId) return;

    // 드래그한 아이템과 드롭 대상의 인덱스 찾기
    const draggedIndex = items.findIndex((item) => item.id === draggedId);
    const targetIndex = items.findIndex((item) => item.id === targetId);

    // 인덱스를 못 찾으면 중단
    if (draggedIndex === -1 || targetIndex === -1) return;

    // 새로운 배열을 복사한 뒤 위치 교체
    const newItems = [...items];
    [newItems[draggedIndex], newItems[targetIndex]] = [
      newItems[targetIndex],
      newItems[draggedIndex],
    ];

    // 상태 업데이트
    setItems(newItems);
  };

  return (
    <div style={...}>
      {items.map((item) => (
        <div
          key={item.id}
          onDragOver={handleDragOver}
          onDrop={() => handleDrop(item.id)}    
          style={...}
        >
          <div
            draggable
            onDragStart={() => handleDragStart(item.id)} 
            style={...}
          >
            {item.label}
          </div>
        </div>
      ))}
    </div>
  );
}

요소 순서 변경 (사이에 끼우기)

import { useState, Fragment } from "react";

export default function App() {
  // 드래그할 아이템 목록 상태
  const [items, setItems] = useState([
    { id: "a", label: "🍎" },
    { id: "b", label: "🍌" },
    { id: "c", label: "🍇" },
  ]);
  
  // 현재 드래그 중인 아이템의 ID
  const [draggedId, setDraggedId] = useState(null);
  
  // 드롭할 위치의 인덱스
  const [insertionIndex, setInsertionIndex] = useState(null);

  // 드래그 시작 시 호출되는 함수
  const handleDragStart = (event, id) => {
    // 드래그 효과 설정 (이동)
    event.dataTransfer.dropEffect = "move";
    event.dataTransfer.effectAllowed = "all";
    
    // 드래그 중인 아이템 ID 저장
    setDraggedId(id);
  };

  // 드래그 중인 요소가 다른 요소 위에 있을 때 호출되는 함수
  const handleDragOver = (event, index) => {
    // 기본 동작 방지 (필수, 이 코드가 없으면 drop 이벤트가 발생하지 않음)
    event.preventDefault();

    // 드래그 중인 아이템과 현재 위치한 아이템이 같으면 무시
    const draggedIndex = items.findIndex((item) => item.id === draggedId);
    if (draggedIndex === index) return;

    // 요소의 위치와 크기 정보 가져오기
    const rect = event.currentTarget.getBoundingClientRect();
    
    // 마우스 Y 좌표 - 요소 상단 위치 = 요소 내에서의 Y 위치
    const offsetY = event.clientY - rect.top;

    // 마우스가 요소의 위쪽 절반에 있으면 해당 위치 앞에 삽입
    // 아래쪽 절반에 있으면 해당 위치 뒤에 삽입
    setInsertionIndex(offsetY < rect.height / 2 ? index : index + 1);
  };

  // 드롭 시 호출되는 함수
  const handleDrop = (event) => {
    // 기본 동작 방지
    event.preventDefault();
    
    // 필요한 정보가 없으면 종료
    if (draggedId == null || insertionIndex == null) return;

    // 드래그 중인 아이템의 인덱스 찾기
    const draggedIndex = items.findIndex((item) => item.id === draggedId);
    if (draggedIndex === -1) return;

    // 아이템 목록의 복사본 생성
    const updatedItems = [...items];
    
    // 드래그 중인 아이템을 원래 위치에서 제거
    const [draggedItem] = updatedItems.splice(draggedIndex, 1);

    // 삽입 위치 조정 (드래그한 아이템이 제거된 후의 인덱스)
    let targetIndex = insertionIndex;
    if (draggedIndex < insertionIndex) targetIndex--;

    // 새 위치에 아이템 삽입
    updatedItems.splice(targetIndex, 0, draggedItem);
    
    // 업데이트된 목록으로 상태 갱신
    setItems(updatedItems);
    
    // 드래그 상태 초기화
    resetDragState();
  };

  // 드래그 종료 시 호출되는 함수 (드롭 여부와 관계없이)
  const handleDragEnd = () => {
    // 드래그 상태 초기화
    resetDragState();
  };

  // 드래그 관련 상태 초기화 함수
  const resetDragState = () => {
    setDraggedId(null);
    setInsertionIndex(null);
  };

  // 삽입 위치를 시각적으로 표시하는 컴포넌트
  function InsertionLine({ isVisible }) {
    return (
      <div style={...} />
    );
  }

  return (
    <div
      style={...}
      onDragOver={(e) => e.preventDefault()} // 컨테이너에서도 드래그 오버 허용
      onDrop={handleDrop} // 컨테이너에서의 드롭 처리
      onDragEnd={handleDragEnd} // 드래그 종료 처리
    >
      {/* 첫 번째 위치에 삽입될 때 표시되는 선 */}
      <InsertionLine isVisible={insertionIndex === 0} />

      {/* 아이템 목록 렌더링 */}
      {items.map((item, index) => (
        <Fragment key={item.id}>
          {/* 드래그 가능한 아이템 */}
          <div
            draggable={true} // HTML5 드래그 앤 드롭 API 활성화
            onDragStart={(e) => handleDragStart(e, item.id)} // 드래그 시작 이벤트 처리
            onDragOver={(e) => handleDragOver(e, index)} // 드래그 오버 이벤트 처리
            style={...}
          >
            <div>{item.label}</div>
            <div>Item {item.id}</div>
          </div>
          
          {/* 현재 아이템 다음 위치에 삽입될 때 표시되는 선 */}
          <InsertionLine isVisible={insertionIndex === index + 1} />
        </Fragment>
      ))}
    </div>
  );
}

참고 자료

https://developer.mozilla.org/ko/docs/Web/API/HTMLElement/drag_event

https://developer.mozilla.org/en-US/docs/Web/API/DataTransfer

Last updated