# 라이브러리 없이 드래그앤드롭 구현해보기

## **시작하기 전에**

과제로 칸반 형태의 투두리스트를 만들면서 드래그앤드롭까지 구현해야 했는데, 투두리스트에서 CRUD 정도만 구현했지 DND까지는 구현을 해보지 않았고, 당시에는 시간이 좀 많이 걸릴 것 같다고 생각해서 라이브러리를 써서 제출했는데, 이후에 좀 아쉬움이 남아서 동작 원리를 알아보기 위해 이번 기회에 라이브러리 없이 구현해 보려고 한다.

## **드래그 이벤트 알아보기**

기능을 구현하기 앞서서 드래그앤드롭 이벤트에 관해 알아보고자 한다.

* `dragStart` - 이름 그대로 드래그를 시작하는 순간 발생하는 이벤트
    
* `drag` - 요소를 드래그할때마다 발생하는 이벤트
    
* `dragenter` - 드래그 된 요소가 드롭 영역에 들어갈 때 발생하는 이벤트
    
* `dragover` - 드래그하면서 마우스가 대상 객체 위에 있을 때 발생하는 이벤트
    
* `drop` - 이벤트가 달린 요소에 드래그를 끝내면 발생하는 이벤트
    
    * 브라우저의 기본동작을 막기 위해 `e.stopPropagation()` 로 막아야 한다.
        
* `dragleave` - 요소가 객체 위에서 벗어날 때 발생하는 이벤트
    
* `dragend` - 드래그 자체를 끝날때 발생하는 이벤트
    

여기 있는 이벤트를 다 사용하지는 않고, `dragstart`, `dragover`, `dragend`, `drop` 이벤트만 사용했다.

## **보드 간 순서 변경**

먼저, 객체가 드래그 앤 드롭이 가능하도록 하기 위해서는 해당 엘리먼트에 `draggable` 을 반드시 설정해 줘야 한다. (기본값은 true)

### **dragstart 이벤트**

```typescript
    const handleDragStart = (
        e: React.DragEvent<HTMLDivElement>,
        board: Board
    ) => {
        setDraggedBoard(board)
        e.dataTransfer.effectAllowed = "move";
    };
```

### **dragover 이벤트**

```typescript
 const handleDragOver = (e: React.DragEvent<HTMLDivElement>) => {
        e.preventDefault();
        e.dataTransfer.dropEffect = "move";
 };
```

#### **DataTransfer 객체**

먼저 드래그 앤 드롭을 위한 이벤트에 안에는 `DataTransfer` 라는 객체를 반환하게 된다.

![notion image](https://www.notion.so/image/attachment%3A027b0081-e351-4e4a-a103-95a600604121%3A%E1%84%89%E1%85%B3%E1%84%8F%E1%85%B3%E1%84%85%E1%85%B5%E1%86%AB%E1%84%89%E1%85%A3%E1%86%BA_2025-03-02_16.53.57.png?table=block&id=1aa6a0db-ebc2-803b-9bad-d831d009c5b3&cache=v2 align="left")

dataTransfer 객체 안에는 또 다른 프로퍼티가 있는데, **dropEffect**와 **effectAllowed**다.

* `dropEffect` - 드래그 중인 요소가 이동 가능하다는 것을 시각적으로 알려주는 프로퍼티다.
    
* `effectAllowed` - 사용자에게 이 객체는 이동이 가능하다고 알려주는 프로퍼티다.
    

이 두 프로퍼티는 기능과 관련된 부분은 아니기에 지워줘도 크게 문제될 부분은 아니다.

### **drop**

**일단 브라우저는 기본적으로 drop 이벤트를 허용하지 않는다.** 공식 문서에서도 웹 페이지의 대부분 영역은 데이터를 드롭하기에 적절하지 않다고 설명하고 있다.

![notion image](https://www.notion.so/image/attachment%3A28c2bb86-8565-4744-963b-5b808ebc84d9%3Aimage.png?table=block&id=1aa6a0db-ebc2-80d4-aed1-f652df9c059e&cache=v2 align="left")

이러한 기본 동작 때문에 `stopPropagation` 이나 `preventDefault` 를 통해서 드롭이 가능하도록 해야한다.

또한 drop 이벤트는 단독으로 동작하지 않고 `dragover` 이벤트도 있어야 올바른 드래그 앤 드롭이 구현된다.

```typescript
const handleDrop = (e: React.DragEvent<HTMLDivElement>, targetBoard: Board) => {
        e.stopPropagation()
        
        // 드래그 될 보드의 id
        const oldIndex = boards.findIndex(
            (board) => board.id === draggedBoard.id
        );
        // 드래그 할 위치에 있는 보드의 id
        const newIndex = boards.findIndex(
            (board) => board.id === targetBoard.id
        );

        if (oldIndex !== newIndex) {
            const newBoards = [...boards];
            newBoards.splice(oldIndex, 1);
            newBoards.splice(newIndex, 0, draggedBoard);
            boardActions.reorderBoards(newBoards);
        }
    };
```

## **보드 내 태스크 순서 변경과 보드 간 태스크 이동**

#### **dragstart 이벤트**

```typescript
const handleTaskDragStart = (
        e: React.DragEvent<HTMLDivElement>,
        task: Task
    ) => {
        e.stopPropagation();
        e.dataTransfer.effectAllowed = "move";
        e.dataTransfer.setData(
            "application/json",
            JSON.stringify({
                taskId: task.id,
                sourceBoardId: boardId,
                task
            })
        );
    };
```

#### **drop 이벤트**

```typescript
const handleTaskDrop = (
        e: React.DragEvent<HTMLDivElement>,
        targetTask: Task | null
    ) => {
        e.preventDefault();
        e.stopPropagation();
        try {
            const data = JSON.parse(e.dataTransfer.getData("application/json"));
        } catch (error) {
            console.error("태스크 드롭 처리 중 오류 발생:", error);
        }
    };
```

보드 내 태스크 순서 변경과 보드 간 태스크 이동은 약간 까다로웠다. 보드 이동과 같은 방식으로 구현하면 되겠거니 했지만 원하는대로 되지 않았다.

여기서는 dataTransfer 내 **getData**, **setData** 를 활용해야 했다.

dragstart 이벤트가 일어날 때 setData 메소드로 드래그 할 데이터를 전달하고, drop 이벤트가 일어날 때 getData로 데이터를 받으면 된다.

![notion image](https://www.notion.so/image/attachment%3A7ec51b75-3137-429b-94dd-65a6232b17cf%3Adnd-kit_%E1%84%8A%E1%85%B3%E1%86%AB_%E1%84%89%E1%85%A1%E1%86%BC%E1%84%90%E1%85%A2.png?table=block&id=1aa6a0db-ebc2-8000-8acf-ca16b7fa70b4&cache=v2 align="left")

![notion image](https://www.notion.so/image/attachment%3A52dfecec-5840-4722-9000-f73f1142f261%3Adnd-kit_%E1%84%8C%E1%85%B5%E1%84%8B%E1%85%AE%E1%86%AB_%E1%84%89%E1%85%A1%E1%86%BC%E1%84%90%E1%85%A2.png?table=block&id=1aa6a0db-ebc2-803d-97fe-fd4b2331943e&cache=v2 align="left")

번들 사이즈를 측정해서 비교해 본 결과, dnd-kit 라이브러리를 써서 구현했던 것에 비하면 번들 사이즈를 16% 가량 줄일 수 있었다.

## **구현해 보면서 느낀 점**

드래그앤드롭을 제대로 구현해 본 적이 한 번도 없어서 라이브러리를 적용했는데, 문서가 딱히 친절하지 않아서 오히려 애를 먹었었다. 더군다나 이 dnd-kit이라는 라이브러리가 다른 라이브러리와는 달리 Drag and Drop API가 기반한 라이브러리가 아니라고 한다.

위 문서에서 가이드가 잘 되어있었고, 덕분에 DataTransfer 객체가 무엇인지도 알게 된 것 같다. 이후에는 지금 하고 있는 팀 프로젝트에도 적용해 볼 생각이다.

## **모바일에도 적용하기**

웹에서는 **마우스 이벤트**를 이용해서 드래그앤드롭을 진행하지만, 모바일에서는 이와 달리 ontouchstart 같은 **터치 이벤트**를 이용해야 한다.

모바일웹에서도 PC에서처럼 동일하게 적용했지만 드래그앤드롭을 진행하는 과정에서 불필요한 드래그가 발생하는 문제가 있었다.

드래그가 적용된 부분에 select-none 클래스를 적용해봤지만 소용이 없었다.

![notion image](https://www.notion.so/image/attachment%3A2ee78503-3a59-4109-b4b4-730f59bf73d1%3Aimage.png?table=block&id=1b56a0db-ebc2-80d0-b4f2-fa466e9cff34&cache=v2 align="left")

```typescript
const handleTouchStart = (e: React.TouchEvent, task: Task) => {
        e.preventDefault(); // 기본 동작 방지
        e.stopPropagation();

        // 롱 프레스 감지를 위한 타임아웃 설정
        touchTimeout.current = setTimeout(() => {
            setDraggedTask(task);
            setIsDragging(true);
            if (draggedTaskRef.current) {
                draggedTaskRef.current.style.opacity = "0.5";
            }
        }, 500); // 500ms 롱 프레스
    };
```

그래서 열심히 구글링 해 본 결과, setTimeout API와 useRef를 활용해서 touchstart 이벤트가 발생했을 때, 롱프레스 이벤트가 일어나도록 했다.

```typescript
const handleTouchMove = (e: React.TouchEvent) => {
        e.preventDefault();
        
        const touch = e.touches[0];
        const element = document.elementFromPoint(touch.clientX, touch.clientY);

        // 보드 요소 찾기
        const boardElement = element?.closest("[data-board-id]") as HTMLElement | null;
        const taskElement = element?.closest("[data-task-id]") as HTMLElement | null;

        if (boardElement) {
            const targetBoardId = boardElement.getAttribute("data-board-id");

            // 다른 보드로 이동하는 경우
            if (targetBoardId) {
                if (taskElement) {
                    const taskId = taskElement.getAttribute("data-task-id") || undefined;
                    const rect = taskElement.getBoundingClientRect();
                    const position = touch.clientY < rect.top + rect.height / 2 ? "before" : "after";

                    setDropPosition({ taskId, position });
                } else {
                    // 보드의 마지막으로 이동
                    setDropPosition({ taskId: undefined, position: "after" });
                }
            }
            // 같은 보드 내에서 이동하는 경우
            else if (taskElement) {
                const taskId =
                    taskElement.getAttribute("data-task-id") || undefined;
                const rect = taskElement.getBoundingClientRect();
                const position = touch.clientY < rect.top + rect.height / 2 ? "before" : "after";
            }
        }
    };
```

onTouchMove 이벤트 발생 시, 터치 위치에서 가장 가까운 보드와 태스크를 찾고, 이후에 태스크 요소 존재 여부에 따라 드롭 위치를 설정하면 된다.

```typescript
 const handleTouchEnd = () => {
        if (touchTimeout.current) {
            clearTimeout(touchTimeout.current);
        }

        if (isDragging && draggedTask && dropPosition) {
            const element = document.elementFromPoint(window.innerWidth / 2, window.innerHeight / 2);
            const boardElement = element?.closest("[data-board-id]") as HTMLElement | null;
            const targetBoardId = boardElement?.getAttribute("data-board-id");

            // 다른 보드와 같은 보드 내에 대한 경우 처리 로직
        }
    };
```

### **이벤트 관련 문제**

PC 화면에서와 동일하게 `e.preventDefault()` 를 적용하였으나, 터치 이벤트가 발생할 때마다 다음과 같은 오류가 발생하고 있었다.

`Unable to preventDefault inside passive event listener invocation.`

브라우저는 스크롤 성능을 최적화 하기 위해 passive라는 속성을 설정한다고 한다. 이 이벤트 리스너는 터치 이벤트가 발생할 때 브라우저 측에서 true로 설정되는데 이는 성능 최적화 때문에 그렇게 설계되었다고 한다.

false로 설정할 수도 있지만, 이는 성능에 안 좋은 영향을 미친다. 그렇기 때문에 preventDefault 호출을 최소화 하는 게 더 낫다.

그리고 터치되는 영역에 `touch-none`을 적용해야 한다. 이외에 다른 속성을 사용하면 불필요한 스크롤이 발생하여 성능이 나빠질 수 있다.

## **Reference**

[https://developer.mozilla.org/ko/docs/Web/API/DataTransfer](https://developer.mozilla.org/ko/docs/Web/API/DataTransfer)

[https://developer.mozilla.org/en-US/docs/Web/API/HTML\_Drag\_and\_Drop\_API/Drag\_operations#specifying\_drop\_targets](https://developer.mozilla.org/en-US/docs/Web/API/HTML_Drag_and_Drop_API/Drag_operations#specifying_drop_targets)
