드래그 앤 드랍(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”
속성을 지정하면, 해당 요소는 드래그 가능한 상태가 되며 이 요소를 드래그할 때 다음과 같은 이벤트들이 순차적으로 발생할 수 있다.
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