드래그 앤 드롭 UX에 진심인 편.. (dnd-kit 덮어쓰기 여정)

LinkDropper·2025년 5월 7일
27

Link Dropper

목록 보기
7/15
post-thumbnail

🧠 우리가 선택한 라이브러리, dnd kit

Link Dropper는 사용자가 링크와 폴더를 자유롭게 구성할 수 있도록, 그리드 기반의 드래그 앤 드롭(Drag and Drop) 인터페이스를 제공합니다.

이를 위해 저희는 오픈소스 라이브러리인 dnd-kit을 선택했어요. 다양한 커스터마이징 포인트를 제공하면서도 React 친화적인 설계 덕분에, 우리가 원하는 UX를 세밀하게 구현하기에 가장 유연한 선택이었습니다.

⚒️ dnd kit의 useSortable

dnd kit의 useSortable은 각 아이템을 sortable로 등록하면서 드래그 가능한 핸들, 이동 가능한 좌표, 변형(transform), transition 스타일 등을 자동으로 설정해줍니다.

또한 내부적으로 아이템의 위치를 트래킹하고, 드래그 중 다른 아이템과 충돌이 감지되면 새로운 순서를 계산해 위치를 갱신하는 역할도 수행합니다.

📦 두 가지 타입의 드래그 대상 아이템

Link Dropper에서 드래그 앤 드롭 가능한 아이템의 유형은 크게 두가지로 나뉩니다.

  • 폴더 아이템

  • 링크 아이템

위 두 가지 아이템에는 다음과 같은 드래그 앤 드롭 UX가 적용되어야 합니다.
1. 모두 그리드에 함께 배치되어 있고, 자유롭게 순서를 바꿀 수 있어야 한다.
2. 특정 아이템을, 특정 폴더 아이템으로 집어 넣을 수 있어야한다. (마치, 맥북의 파인더나 윈도우의 폴더 탐색기처럼..)

1번 조건은 dnd-kit의 기능을 대부분 그대로 사용하면 문제 없었지만,
2번 조건의 경우, 드래그 앤 드롭이라는 동일한 행동에서 비롯되지만, 그 결과가 어떤 특정 기준에 따라 달라져야하기 때문에 까다로웠습니다.

⚙️ dnd kit의 collision Detection 알고리즘

처음에는 dnd-kit의 Collision Detection 알고리즘을 커스터마이징하여 문제를 해결하려 했습니다.

Collision Detection은 드래그 중 어떤 요소와 충돌하고 있는지를 계산하는 로직으로, 어떤 드롭 대상이 선택되어야 하는지를 결정합니다.

여기서 말하는 '충돌(collision)'이란, 드래그 중인 아이템이 다른 요소 위에 '겹쳐져 있는지'를 판단하는 개념입니다.

예를 들어, 드래그한 링크가 어떤 폴더 위에 잠깐 올라가 있는 경우를 생각하면 이해하기 쉬워요. dnd-kit은 이처럼 겹친 요소들을 찾아내서, '어디에 드롭할지'를 결정하게 되죠.

🎯 우리의 목표는?

드래그 앤 드롭 도중, 폴더 위에 아이템을 드래그하여 위치시키면,

  • 해당 폴더가 UI상으로 활성화된다.
  • 이 경우, UI상 드래그 앤 드롭 아이템의 순서 변경(swap)이 일어나지 않는다.

(참고) dnd-kit의 기본 설정인 swap

🧪 커스텀 Collision Detection

이를 구현하기 위해 위에서 언급했던 Collision Detection 알고리즘을 커스터마이징 했습니다.

  • 우선 dnd-kit에서 제공하는 기본 충돌 가능 아이템 후보군들을 받습니다.
  • 현재 드래그하고 있는 active 아이템을 인자에서 받아옵니다.
  • active 아이템과 폴더 아이템의 중심부 좌표를 계산해서 거리를 계산합니다.
  • 계산한 거리가, 특정 임계값 내에 있으면 해당 폴더 아이템을 충돌 가능 아이템 후보군에서 제외합니다.

해당 폴더 아이템을 충돌 가능 아이템 후보군에서 강제로 제거하여,
dnd-kit로 하여금 swap 및 드래그 앤 드롭 순서 변경을 하지 못하도록 하는 것이죠.

아래는 조금(많이) 난잡하지만 customCollisionDetection의 일부입니다.

const customCollisionDetection = (args) => {
  const { collisionRect } = args;
  
  // 기본 충돌 감지 로직 실행
  const defaultCollisions = closestCenter(args);
  
  // 충돌 후보군을 직접 필터링!
  const filteredCollisions = defaultCollisions.map(collisionItem => {
    // 드래그하고 있는 active 아이템의 중심부 좌표 (x, y) 계산
    const { left, right, top, bottom } = collisionRect;
    const activeCenter = {
      x: (left + right) / 2,
      y: (top + bottom) / 2,
    };
    
    // 폴더 아이템이 아니면, collision 후보군에 포함되도록 true를 반환
    if (isLinkItem(collisionItem)) return true;
    
    ...
    // 폴더 아이템이면, 중심부 좌표 (x,y) 계산
    const collisionTargetRect = collisionTarget.rect;
    const droppableCenter = {
        x: (collisionTargetRect.left + collisionTargetRect.right) / 2,
        y: (collisionTargetRect.top + collisionTargetRect.bottom) / 2,
      };
    
    // 중심부 거리 비교..
    const distance = Math.hypot(
        activeCenter.x - droppableCenter.x,
        activeCenter.y - droppableCenter.y
     );
    
    // 거리가 정해놓은 특정 임계값보다 적으면 아이템이 swap되지 않도록 false반환
    if (distance < 특정임계값) return false;
    return true;
    
  })
  
  return filteredCollisions;
};

...
import { DndContext } from "@dnd-kit/core";

<DndContext 
  ...
  collisionDetection={customCollisionDetection}
/>

이 방식은 일정 수준까지 잘 작동했지만, 실제 사용자 환경에서는 여러가지 시각적 문제와 UX 혼란이 발생했습니다.

예를 들어 조금이라도 계산 거리에서 벗어나면 아이템이 다시 스왑되거나,
폴더와 폴더 사이에 아이템을 드롭하려고 하면
아주 미세한 차이로 활성화되는 폴더가 달라지는 문제 등이 있었죠.

요약하자면 전반적으로 UI가 난잡해졌습니다.

💣 UI가 난잡해지는 주요 범인: transform!

dnd kit의 useSortable을 사용하는 경우,transform이라는 속성을 제공합니다.

import React from 'react';
import {useSortable} from '@dnd-kit/sortable';
import {CSS} from '@dnd-kit/utilities';

function SortableItem(props) {
  const {
    attributes,
    listeners,
    setNodeRef,
    transform,
    transition,
  } = useSortable({id: props.id});
  
  const style = {
    transform: CSS.Transform.toString(transform),
    transition,
  };
  
  return (
    <li ref={setNodeRef} style={style} {...attributes} {...listeners}>
      {/* ... */}
    </li>
  );
}

transform은 드래그 아이템의 이동 정보를 담은 객체입니다.
간단하게 아래와 같은 형태의 객체라고 생각할 수 있습니다.

const transform = {
  x: 20,
  y: 50,
};

이 정보가 CSS.Transform.toString을 통해 아래와 같은 스타일로 변환되는 거죠!

transform: translate3d(20px, 50px)

요약하자면 transform은 아이템이 새 위치로 이동하는 동안 어떻게 시각적으로 표현되어야 하는지를 나타내는 변환 값입니다.
실제로 DOM 순서를 바꾸지 않고도 이동하는 것처럼 보이게 만들죠.

dnd kit에서 제공하는 좋은 속성이지만,
Link Dropper 처럼 특수한 드래그 앤 드롭 동작을 처리해야하는 경우에는 적용하기가 애매했습니다.

⏰ 시간이 곧 의도다 – 드래그 중 잠시 멈춤을 활용한 UX

그래서 방향을 바꿨습니다.
dnd kit의 collision detection을 커스텀하여,
좌표 기준으로 드롭 폴더 활성화 여부를 결정하는 대신
시간을 기준으로 드롭 폴더 활성화를 하기로 결정했습니다.
드래그 도중 폴더 위에 200ms 이상 머무르면 폴더의 drop 활성화하는 것이죠.

다시 마우스가 이동하여 드래그 이벤트가 발생하면 폴더 drop을 다시 비활성화합니다.
최종적으로 dragEnd 시점에만 실제 정렬 또는 폴더 이동을 처리하는 것이죠.

또한, transform 속성도 버리기로 결정했습니다.
대신, 아이템이 드롭되는 공간에 UI상으로 인디케이터를 보여줘, 예측 가능하도록 했습니다.

✅ 구현 포인트:

이를 위해 크게 3가지 작업이 필요했습니다.
1. handleDragMove에서 200ms 정지 감지 → dropActivateFolder 상태 변경
2. handleDragEnd에서 상태 확인 후 → 폴더 이동 API 호출 or 순서 정렬
3. handleDragOver에서 방향 감지 → UI에서 드롭 인디케이터 표시

간단하게 느낌만 보자면 아래와 같습니다.

 const handleDragMove = (event) => {
    /* dragMove시 활성화 되어 있던 아이템 비활성화 */
    startTimeout(() => {
      setDropActivateFolder(null);
    }, 200);

    ...

    if (폴더가 아닌경우) return;

    /* 폴더인 경우 200ms 이후 drop 가능하도록 활성화 */
    startTimeout(() => {
      setDropActivateFolder({
        id: overIdNum,
      });
    }, 200);
  };

const handleDragEnd = (event) => {
  ...
  // 설정되어 있던 타이머 정리
  clearExistingTimeout();
  
  // 활성화된 폴더가 있는 경우 아이템을 해당 폴더로 이동
  if (dropActivateFolder) {
    ...
    moveToFolder(드래그 아이템);
  }
  ...
}
  
const handleDragOver = (event) => {
  ...
  const activeId = event.active.id;
  const overId = event.over.id;
  ...
  const offsetX = pointer.x - overRect.left;
  const offsetY = pointer.y - overRect.top;
  ...
  // 인디케이터 표시를 위한 방향 계산
  const isFromLeft = offsetX < overRect.width / 2;
  const isFromRight = offsetX > overRect.width / 2;
  const isFromTop = offsetY < overRect.height / 2;
  const isFromBottom = offsetY > overRect.height / 2;
  ...
  setDndDirection({
      fromLeft: isFromLeft,
      fromRight: isFromRight,
      fromTop: isFromTop,
      fromBottom: isFromBottom,
  });
}
import { DndContext } from "@dnd-kit/core";
 <DndContext 
   ...
   onDragEnd={handleDrag} 
   onDragMove={handleDragMove}
   onDragOver={handleDragOVer}
 >
  ...
 </DndContext>

🎯 결과는?

폴더 안으로 넣는 동작이 더 직관적으로 보이게 됐고,
아이템 드래그 앤 드롭시 UI 상의 난잡함(?)이 많이 줄어들었습니다.

또한 시간 기준으로 폴더 활성화 여부를 판단하다보니, 클라이언트 코드 또한 더 간단해졌습니다.

🙏 마치며

dnd kit는 매우 유연한 라이브러리지만, UI/UX를 세밀하게 다듬으려면 라이브러리 내부 로직을 잘 이해하고, 우리 서비스에 맞게 핵심 흐름을 커스터마이징하는 용기가 필요했습니다.

이번 경험을 통해, 단순히 라이브러리를 사용하는 걸 넘어서, 우리가 원하는 UX를 만들어가기 위한 컨트롤 능력을 키울 수 있었습니다.

👉 Link Dropper에서는 앞으로도 사용자 중심의 인터랙션을 위해 계속해서 드래그 UX를 개선해나갈 예정입니다.

여러분의 의견도 언제나 환영합니다 🙌

🧪 링크 드라퍼, 지금 베타 테스트 중입니다

링크 드라퍼는 단순한 저장 툴이 아닙니다.
정리하고, 수정하고, 다시 꺼내보게 만드는 링크 관리 도구를 지향하고 있습니다.

• 🔗 빠르고 간편한 링크 저장
• 🧠 저장한 링크를 폴더별로 정리
• 🌐 폴더를 친구에게 공유 가능
• ⚡ OG 미리보기 자동 불러오기

👉 🔗 링크 드라퍼 베타 체험하러 가기

직접 써보시고, 폴더 드래그 앤 드롭 처리의 의견을 남겨주세요!

profile
“기록하는 습관을 도구로 만들다 — 두 개발자의 링크 드라퍼 구축기”

1개의 댓글