칸반 보드 같은 걸 만들 때 드래그 앤 드롭은 필수다. 라이브러리를 쓰면 편하긴 한데, 간단한 기능이면 직접 구현하는 게 번들 사이즈도 줄고 커스터마이징도 자유롭다. HTML5 Drag and Drop API가 있긴 하지만 모바일에서 제대로 작동하지 않아서, 마우스/터치 이벤트를 직접 다루는 방식으로 구현했다.

HTML 구조

<div class="container">
  <div class="column" id="todo">
    <h2>할 일</h2>
    <div class="card" draggable="true">카드 1</div>
    <div class="card" draggable="true">카드 2</div>
  </div>
  <div class="column" id="doing">
    <h2>진행 중</h2>
    <div class="card" draggable="true">카드 3</div>
  </div>
  <div class="column" id="done">
    <h2>완료</h2>
  </div>
</div>

CSS

드래그 중인 요소에 시각적 피드백을 주는 게 중요하다:

.container {
  display: flex;
  gap: 1rem;
  padding: 1rem;
}

.column {
  flex: 1;
  min-height: 300px;
  background: #f0f0f0;
  border-radius: 8px;
  padding: 1rem;
}

.card {
  background: white;
  padding: 1rem;
  margin-bottom: 0.5rem;
  border-radius: 4px;
  box-shadow: 0 1px 3px rgba(0, 0, 0, 0.12);
  cursor: grab;
  user-select: none;
  transition: transform 0.15s, box-shadow 0.15s;
}

.card.dragging {
  opacity: 0.5;
  transform: rotate(3deg);
  box-shadow: 0 5px 15px rgba(0, 0, 0, 0.2);
}

.column.over {
  background: #e0e7ff;
}

마우스 이벤트로 구현

기본적인 드래그 로직이다:

let draggedCard = null;

document.querySelectorAll('.card').forEach(card => {
  card.addEventListener('mousedown', startDrag);
});

function startDrag(e) {
  draggedCard = e.target;
  draggedCard.classList.add('dragging');

  const shiftX = e.clientX - draggedCard.getBoundingClientRect().left;
  const shiftY = e.clientY - draggedCard.getBoundingClientRect().top;

  // 복제본을 만들어서 커서 따라다니게
  const clone = draggedCard.cloneNode(true);
  clone.style.position = 'fixed';
  clone.style.zIndex = '1000';
  clone.style.pointerEvents = 'none';
  clone.style.width = draggedCard.offsetWidth + 'px';
  document.body.appendChild(clone);

  function moveAt(clientX, clientY) {
    clone.style.left = clientX - shiftX + 'px';
    clone.style.top = clientY - shiftY + 'px';
  }

  moveAt(e.clientX, e.clientY);

  function onMouseMove(e) {
    moveAt(e.clientX, e.clientY);
    highlightDropZone(e.clientX, e.clientY);
  }

  document.addEventListener('mousemove', onMouseMove);

  document.addEventListener('mouseup', function onMouseUp(e) {
    document.removeEventListener('mousemove', onMouseMove);
    document.removeEventListener('mouseup', onMouseUp);

    clone.remove();
    draggedCard.classList.remove('dragging');

    const dropTarget = getDropTarget(e.clientX, e.clientY);
    if (dropTarget && dropTarget !== draggedCard.parentElement) {
      dropTarget.appendChild(draggedCard);
    }

    clearHighlights();
    draggedCard = null;
  }, { once: true });
}

드롭 대상을 찾는 함수:

function getDropTarget(x, y) {
  const columns = document.querySelectorAll('.column');
  for (const col of columns) {
    const rect = col.getBoundingClientRect();
    if (x >= rect.left && x <= rect.right && y >= rect.top && y <= rect.bottom) {
      return col;
    }
  }
  return null;
}

function highlightDropZone(x, y) {
  clearHighlights();
  const target = getDropTarget(x, y);
  if (target) target.classList.add('over');
}

function clearHighlights() {
  document.querySelectorAll('.column').forEach(c => c.classList.remove('over'));
}

모바일 터치 이벤트 대응

모바일에서는 mousedown 대신 touchstart를 써야 한다. 이벤트 구조가 살짝 다르다:

document.querySelectorAll('.card').forEach(card => {
  card.addEventListener('touchstart', startTouchDrag, { passive: false });
});

function startTouchDrag(e) {
  e.preventDefault(); // 스크롤 방지
  const touch = e.touches[0];
  draggedCard = e.target;
  draggedCard.classList.add('dragging');

  const shiftX = touch.clientX - draggedCard.getBoundingClientRect().left;
  const shiftY = touch.clientY - draggedCard.getBoundingClientRect().top;

  const clone = draggedCard.cloneNode(true);
  clone.style.position = 'fixed';
  clone.style.zIndex = '1000';
  clone.style.pointerEvents = 'none';
  clone.style.width = draggedCard.offsetWidth + 'px';
  document.body.appendChild(clone);

  function onTouchMove(e) {
    const touch = e.touches[0];
    clone.style.left = touch.clientX - shiftX + 'px';
    clone.style.top = touch.clientY - shiftY + 'px';
    highlightDropZone(touch.clientX, touch.clientY);
  }

  function onTouchEnd(e) {
    document.removeEventListener('touchmove', onTouchMove);
    document.removeEventListener('touchend', onTouchEnd);

    clone.remove();
    draggedCard.classList.remove('dragging');

    const touch = e.changedTouches[0];
    const dropTarget = getDropTarget(touch.clientX, touch.clientY);
    if (dropTarget && dropTarget !== draggedCard.parentElement) {
      dropTarget.appendChild(draggedCard);
    }

    clearHighlights();
    draggedCard = null;
  }

  document.addEventListener('touchmove', onTouchMove, { passive: false });
  document.addEventListener('touchend', onTouchEnd);
}

핵심은 e.preventDefault()다. 이걸 안 하면 드래그 대신 페이지 스크롤이 된다. { passive: false } 옵션도 빼먹으면 안 된다.

정렬 기능 추가

같은 컬럼 안에서 카드 순서를 바꾸고 싶다면, 드롭 위치를 기준으로 카드 앞뒤를 판단해야 한다:

function getInsertPosition(column, y) {
  const cards = [...column.querySelectorAll('.card:not(.dragging)')];

  for (const card of cards) {
    const rect = card.getBoundingClientRect();
    const midY = rect.top + rect.height / 2;
    if (y < midY) return card;
  }

  return null; // 맨 뒤에 추가
}

드롭할 때 appendChild 대신 insertBefore를 쓰면 된다:

const beforeCard = getInsertPosition(dropTarget, clientY);
if (beforeCard) {
  dropTarget.insertBefore(draggedCard, beforeCard);
} else {
  dropTarget.appendChild(draggedCard);
}

라이브러리 없이도 충분히 쓸만한 드래그 앤 드롭을 만들 수 있다. 코드가 좀 길어 보이지만 원리를 이해하면 커스터마이징이 자유로워서 오히려 편하다.