Drag and Drop은 사용자 경험에서 자주 쓰이는 인터랙션입니다. HTML5 네이티브 API를 사용하면 복잡한 라이브러리 없이도 드래그 앤 드롭을 구현할 수 있습니다.

기본 드래그 앤 드롭

HTML
<div id="draggable" draggable="true">드래그 가능</div>
<div id="dropzone">여기에 놓으세요</div>
JS
const draggable = document.getElementById("draggable");
const dropzone = document.getElementById("dropzone");

// 드래그 시작
draggable.addEventListener("dragstart", (e) => {
  e.dataTransfer.setData("text/plain", draggable.id);
  e.dataTransfer.effectAllowed = "move";
  draggable.classList.add("dragging");
});

// 드래그 끝
draggable.addEventListener("dragend", () => {
  draggable.classList.remove("dragging");
});

// 드롭존 이벤트
dropzone.addEventListener("dragover", (e) => {
  e.preventDefault(); // 필수! 이걸 해야 drop 이벤트 발생
  e.dataTransfer.dropEffect = "move";
  dropzone.classList.add("drag-over");
});

dropzone.addEventListener("dragleave", () => {
  dropzone.classList.remove("drag-over");
});

dropzone.addEventListener("drop", (e) => {
  e.preventDefault();
  const id = e.dataTransfer.getData("text/plain");
  const element = document.getElementById(id);
  dropzone.appendChild(element);
  dropzone.classList.remove("drag-over");
});

이벤트 흐름

드래그 앤 드롭의 이벤트 순서를 이해하는 것이 중요합니다.

PLAINTEXT
드래그 대상:  dragstart → drag(반복) → dragend
드롭 대상:   dragenter → dragover(반복) → dragleave 또는 drop
JS
// 모든 이벤트 정리
// dragstart — 드래그 시작
// drag      — 드래그 중 (반복)
// dragenter — 드롭 영역에 진입
// dragover  — 드롭 영역 위에 있음 (반복, preventDefault 필수)
// dragleave — 드롭 영역에서 벗어남
// drop      — 드롭됨
// dragend   — 드래그 종료 (성공 여부 무관)

리스트 정렬 (Sortable)

JS
function makeSortable(list) {
  let dragItem = null;

  list.addEventListener("dragstart", (e) => {
    dragItem = e.target;
    e.target.classList.add("dragging");
    e.dataTransfer.effectAllowed = "move";
  });

  list.addEventListener("dragend", (e) => {
    e.target.classList.remove("dragging");
    dragItem = null;
  });

  list.addEventListener("dragover", (e) => {
    e.preventDefault();
    const afterElement = getDragAfterElement(list, e.clientY);
    if (afterElement) {
      list.insertBefore(dragItem, afterElement);
    } else {
      list.appendChild(dragItem);
    }
  });
}

function getDragAfterElement(container, y) {
  const elements = [...container.querySelectorAll("li:not(.dragging)")];

  return elements.reduce(
    (closest, child) => {
      const box = child.getBoundingClientRect();
      const offset = y - box.top - box.height / 2;
      if (offset < 0 && offset > closest.offset) {
        return { offset, element: child };
      }
      return closest;
    },
    { offset: Number.NEGATIVE_INFINITY }
  ).element;
}

파일 드롭

JS
const dropArea = document.getElementById("file-drop");

// 기본 브라우저 동작 방지
["dragenter", "dragover", "dragleave", "drop"].forEach((eventName) => {
  dropArea.addEventListener(eventName, (e) => {
    e.preventDefault();
    e.stopPropagation();
  });
});

// 시각적 피드백
["dragenter", "dragover"].forEach((eventName) => {
  dropArea.addEventListener(eventName, () => {
    dropArea.classList.add("highlight");
  });
});

["dragleave", "drop"].forEach((eventName) => {
  dropArea.addEventListener(eventName, () => {
    dropArea.classList.remove("highlight");
  });
});

// 파일 처리
dropArea.addEventListener("drop", (e) => {
  const files = [...e.dataTransfer.files];

  files.forEach((file) => {
    console.log(`파일: ${file.name} (${file.size} bytes, ${file.type})`);

    // 이미지 미리보기
    if (file.type.startsWith("image/")) {
      const reader = new FileReader();
      reader.onload = (e) => {
        const img = document.createElement("img");
        img.src = e.target.result;
        dropArea.appendChild(img);
      };
      reader.readAsDataURL(file);
    }
  });
});

커스텀 드래그 이미지

JS
draggable.addEventListener("dragstart", (e) => {
  // 커스텀 드래그 이미지 설정
  const ghost = document.createElement("div");
  ghost.textContent = "이동 중...";
  ghost.style.cssText = "padding: 8px; background: #3498db; color: white; border-radius: 4px;";
  document.body.appendChild(ghost);

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

  // 다음 프레임에서 제거 (브라우저가 스냅샷을 찍은 후)
  requestAnimationFrame(() => ghost.remove());
});

포인터 이벤트 기반 커스텀 드래그

네이티브 DnD API의 제약이 있을 때 (모바일 지원, 세밀한 제어), 포인터 이벤트로 직접 구현합니다.

JS
function makeDraggable(element) {
  let isDragging = false;
  let startX, startY, initialX, initialY;

  element.addEventListener("pointerdown", (e) => {
    isDragging = true;
    startX = e.clientX;
    startY = e.clientY;
    initialX = element.offsetLeft;
    initialY = element.offsetTop;
    element.setPointerCapture(e.pointerId);
    element.style.cursor = "grabbing";
  });

  element.addEventListener("pointermove", (e) => {
    if (!isDragging) return;
    const dx = e.clientX - startX;
    const dy = e.clientY - startY;
    element.style.left = `${initialX + dx}px`;
    element.style.top = `${initialY + dy}px`;
  });

  element.addEventListener("pointerup", () => {
    isDragging = false;
    element.style.cursor = "grab";
  });
}

네이티브 DnD vs 커스텀 구현

항목네이티브 DnD포인터 이벤트
구현 난이도보통높음
모바일 지원제한적완전
드래그 이미지제한적 커스텀완전 자유
파일 드롭내장 지원직접 구현
성능브라우저 최적화직접 최적화

**기억하기 **: dragover에서 preventDefault()를 호출해야 drop 이벤트가 발생합니다. 이걸 빼먹으면 드롭이 동작하지 않습니다. 모바일 지원이 필요하면 포인터 이벤트 기반으로 직접 구현하거나, 라이브러리(SortableJS 등)를 사용하는 것이 좋습니다.

댓글 로딩 중...