> For the complete documentation index, see [llms.txt](https://here-we.gitbook.io/here-we/llms.txt). Markdown versions of documentation pages are available by appending `.md` to page URLs; this page is available as [Markdown](https://here-we.gitbook.io/here-we/frontend/3.md).

# 드래그 앤 드랍(3): 프로젝트 실전 적용

[드래그 앤 드랍(1) - DragEvent API 학습과 구현](/here-we/frontend/1-dragevent-api.md)

[드래그 앤 드랍(2): MouseEvent, TouchEvent, PointerEvent 학습과 구현](/here-we/frontend/2-mouseevent-touchevent-pointerevent.md)

## 들어가기

이전 글에서는 HTML5 Drag & Drop API와 PointerEvent를 활용한 기본적인 드래그 앤 드롭 구현을 살펴보았다. 이번에는 실제 React 프로젝트에 적용해보자

실제 사용할 때는 아래와 같은 요구사항들이 추가로 필요했다.

* **스크롤 지원**: 긴 리스트에서 자동 스크롤 기능
* **재사용 가능한 커스텀 훅**으로 분리

이번 글에서는 이러한 요구사항들을 모두 충족하는 `useDragAndDrop` 훅을 구현하고, 실제 사용 예시를 살펴보겠다.

## 1. 자동 스크롤 기능

긴 리스트에서 드래그할 때 가장 중요한 기능이 자동 스크롤이다. 사용자가 컨테이너 가장자리로 드래그하면 자동으로 스크롤되어야 한다.

### 스크롤 영역 감지

```tsx
const container = scrollContainerRef.current;
if (!container) return;

const containerRect = container.getBoundingClientRect();
const offsetY = event.clientY - containerRect.top;
const distanceToBottom = containerRect.bottom - event.clientY;
```

**`getBoundingClientRect()`**

* 컨테이너의 뷰포트 내 위치와 크기 반환
* 반환값: `{ top, left, bottom, right, width, height }`
* 스크롤이나 줌에 상관없이 실제 화면에서의 픽셀 위치

**`offsetY = event.clientY - containerRect.top`**

* 컨테이너 상단으로부터 마우스까지의 거리
* `event.clientY`: 뷰포트 기준 마우스 Y 좌표
* `containerRect.top`: 뷰포트 기준 컨테이너 상단 Y 좌표
* 결과: 컨테이너 내부에서 마우스의 상대적 위치

**`distanceToBottom = containerRect.bottom - event.clientY`**

* 마우스에서 컨테이너 하단까지의 거리
* 음수가 나오면 마우스가 컨테이너 밖에 있다는 의미
* 이 값이 작을수록 하단 가장자리에 가까움

### 스크롤 로직: setInterval

```tsx
// 자동 스크롤 로직
if (offsetY < scrollEdgeThreshold) {
  // 상단 가장자리 - 위로 스크롤
  if (!scrollIntervalRef.current) {
    scrollIntervalRef.current = setInterval(() => {
      container.scrollTop -= scrollSpeed;
    }, 16);
  }
} else if (distanceToBottom < scrollEdgeThreshold) {
  // 하단 가장자리 - 아래로 스크롤
  if (!scrollIntervalRef.current) {
    scrollIntervalRef.current = setInterval(() => {
      container.scrollTop += scrollSpeed;
    }, 16);
  }
} else {
  // 스크롤 영역 벗어남 - 스크롤 중지
  if (scrollIntervalRef.current) {
    clearInterval(scrollIntervalRef.current);
    scrollIntervalRef.current = null;
  }
}
```

**상단 스크롤 조건: `offsetY < scrollEdgeThreshold`**

* 마우스가 컨테이너 상단에서 40px 이내에 있을 때
* `container.scrollTop -= scrollSpeed`: 위로 스크롤 (값 감소)

**하단 스크롤 조건: `distanceToBottom < scrollEdgeThreshold`**

* 마우스가 컨테이너 하단에서 40px 이내에 있을 때
* `container.scrollTop += scrollSpeed`: 아래로 스크롤 (값 증가)

**스크롤 중지 조건:**

* 마우스가 양쪽 가장자리에서 모두 40px 이상 떨어져 있을 때
* 기존 인터벌이 있다면 정리하고 null로 초기화

**16ms 간격의 의미:**

* 1초 = 1000ms, 1000ms ÷ 16ms = 62.5fps
* 대부분 모니터의 주사율인 60fps에 맞춘 부드러운 애니메이션

### 스크롤 로직: setInterval → requestAnimationFrame 리팩토링

초기에는 `setInterval`을 사용해 구현했지만, **스크롤의 시각적 응답성과 성능 최적화**를 위해 `requestAnimationFrame`(rAF)으로 리팩토링했다.

* 대부분의 디스플레이는 초당 60프레임(=16.66ms)으로 동작한다.
* `requestAnimationFrame`은 **이 주기를 정확히 따라가도록 보장**하며, 프레임 드롭 없이 부드러운 스크롤을 만든다.
* 반면, `setInterval(16)`은 **정확하지 않으며**, 부하나 렌더링 타이밍에 따라 지연되거나 중복 호출되어 **스크롤이 튀거나 끊길 수 있다.**

```tsx
const scrollAnimationRef = useRef<number | null>(null);

const startScroll = (direction: 'up' | 'down') => {
  const container = scrollContainerRef.current;
  if (!container) return;

  const step = () => {
    if (direction === 'up') {
      container.scrollTop -= scrollSpeed;
    } else {
      container.scrollTop += scrollSpeed;
    }

    scrollAnimationRef.current = requestAnimationFrame(step);
  };

  if (!scrollAnimationRef.current) {
    scrollAnimationRef.current = requestAnimationFrame(step);
  }
};

const stopScroll = () => {
  if (scrollAnimationRef.current) {
    cancelAnimationFrame(scrollAnimationRef.current);
    scrollAnimationRef.current = null;
  }
};
```

### 스크롤 조건에 따라 트리거

```tsx
if (offsetY < scrollEdgeThreshold) {
  startScroll('up');
} else if (distanceToBottom < scrollEdgeThreshold) {
  startScroll('down');
} else {
  stopScroll();
}
```

### 스크롤 정리

드래그가 끝나거나 중단되면 자동 스크롤도 함께 정리해야 한다:

```tsx
const reset = () => {
	...

  stopScroll()
};
```

## 2. 커스텀 훅으로 분리

기존 방식에서는 모든 드래그 로직이 컴포넌트 내부에 섞여 있어 재사용성이 떨어졌다. 이를 `useDragAndDrop` 훅으로 분리하여 어떤 컴포넌트에서든 쉽게 사용할 수 있도록 개선했다.

### 타입 정의

먼저 훅에서 사용할 타입들을 정의했다.

```tsx
export interface DragItem {
  index: number;
}

export interface DragAndDropOptions<T extends DragItem> {
  items: T[];
  onDrop: (originalIndex: number, newIndex: number) => Promise<void>;
  scrollContainerRef: React.RefObject<HTMLElement>;
  threshold?: number;
  scrollSpeed?: number;
  scrollEdgeThreshold?: number;
}
```

#### **각 옵션별 상세 설명:**

**`threshold` (기본값: 5px)**

* 드래그로 인식하기 위한 최소 이동 거리
* 단순 클릭에서 발생할 수 있는 미세한 move 이벤트는 드래그로 인식하지 않도록 하기 위함
* 너무 크면: 드래그 시작이 둔하게 느껴짐
* 너무 작으면: 의도치 않은 드래그가 자주 발생

**`scrollSpeed` (기본값: 5px)**

* 자동 스크롤 시 한 번에 이동할 픽셀 수
* 5px: 느린 스크롤, 정밀한 위치 조정 가능
* 15px: 빠른 스크롤, 긴 리스트 빠르게 이동
* 20px 이상: 너무 빨라서 제어하기 어려움

**`scrollEdgeThreshold` (기본값: 40px)**

* 컨테이너 가장자리에서 몇 픽셀 이내에 들어와야 자동 스크롤이 시작되는지
* 40px: 마우스가 상단/하단 40px 영역에 들어오면 스크롤 시작
* 너무 크면: 의도치 않은 스크롤이 자주 발생
* 너무 작으면: 스크롤 영역을 찾기 어려움

**핵심 설계 포인트:**

* `DragItem` 인터페이스로 최소 요구사항 정의 (index 필수)
* 제네릭 `<T extends DragItem>`으로 타입 안전성 확보
* `onDrop`은 비동기로 서버 동기화 지원

### 훅 구조 설계

```tsx
	export function useDragAndDrop<T extends DragItem>({
  items,
  onDrop,
  scrollContainerRef,
  threshold = 5,
  scrollSpeed = 5,
  scrollEdgeThreshold = 40,
}: DragAndDropOptions<T>) {
  // 상태 관리
  const [insertionIndex, setInsertionIndex] = useState<number | null>(null);
  
  // ref들 - 리렌더링 없이 값 추적
  const draggedIndexRef = useRef<number | null>(null);
  const ghostRef = useRef<HTMLElement | null>(null);
  const dragStartPosRef = useRef({ x: 0, y: 0 });
  const itemRefs = useRef<Record<number, HTMLDivElement | null>>({});
  const scrollAnimationRef = useRef<number | null>(null);

  // 헬퍼 함수들
  const checkDrag = () => { /* ... */ };
  const createGhost = () => { /* ... */ };
  const updateGhostPosition = () => { /* ... */ };
  const startScroll = () => { /* ... */ };
  const stopScroll = () => { /* ... */ };
  const autoScroll = () => { /* ... */ };
  const updateInsertionIndex = () => { /* ... */ };
  const reset = () => { /* ... */ };
  const getItemRef = () => { /* ... */ };
  
  // 이벤트 함수들 
  const handlePointerDown = () => { /* ... */ };
  const handlePointerMove = () => { /* ... */ };
  const handlePointerUp = () => { /* ... */ };
   
  
  // 이벤트 부착 및 클린업 
  useEffect(() => {
    if (!items) return;

    document.addEventListener('pointermove', handlePointerMove);
    document.addEventListener('pointerup', handlePointerUp);
    return () => {
      document.removeEventListener('pointermove', handlePointerMove);
      document.removeEventListener('pointerup', handlePointerUp);
    };
  }, [items, handlePointerMove, handlePointerUp]);
  
  return {
    insertionIndex,
    handlePointerDown,
    getItemRef,
  };
}
```

#### **state와 ref들의 역할을 자세히 살펴보자:**

**`insertionIndex` state**

* 현재 삽입될 위치를 추적하는 상태

**`draggedIndexRef`**

* 현재 드래그 중인 아이템의 인덱스
* null: 드래그 안 함, 숫자: 해당 인덱스 아이템 드래그 중
* state로 하지 않는 이유: 렌더링에 영향 없음

**`ghostRef`**

* 드래그 중 따라다니는 복제 요소 참조
* `document.body`에 추가된 DOM 요소를 정리할 때 필요

**`dragStartPosRef`**

* 드래그 시작 위치 `{ x, y }` 저장
* threshold (드래그로 인식하기 위한 최소 이동 거리) 계산에 사용: 현재 위치와 시작 위치 간 거리 측정
* 페이지 좌표계(`pageX`, `pageY`) 로 거리 계산

**`itemRefs`**

* 각 아이템의 DOM 요소들을 `Record<index, HTMLElement>` 형태로 저장
* 삽입 위치 계산 시 각 아이템의 `getBoundingClientRect()` 호출에 필요

**`scrollIntervalRef`**

* 자동 스크롤을 위한 `setInterval` ID 저장
* cleanup 시 `clearInterval`로 메모리 누수 방지

#### 로직을 함수로 분리

기존의 복잡한 `pointermove` 핸들러를 여러 개의 작은 함수로 분리하여 가독성과 유지보수성을 높였다:

**`checkDrag` - 드래그 임계값 확인**

```tsx
const checkDrag = (event: PointerEvent) => {
  const dx = event.pageX - dragStartPosRef.current.x;
  const dy = event.pageY - dragStartPosRef.current.y;
  const distance = Math.sqrt(dx * dx + dy * dy);
  return distance > threshold;
};
```

* 피타고라스 정리로 시작점과 현재점 사이의 직선 거리 계산
* `threshold`보다 크면 진짜 드래그로 인정
* 단순한 boolean 반환으로 명확한 역할

**`createGhost` - 고스트 요소 생성**

```tsx
const createGhost = (draggedId: number) => {
  const draggedElement = itemRefs.current[draggedId];
  if (!draggedElement) return;

  const ghost = draggedElement.cloneNode(true) as HTMLElement;
  ghost.style.position = 'fixed';
  ghost.style.opacity = '0.8';
  ghost.style.zIndex = '1000';
  ghost.style.transform = 'translate(-50%, -50%)';
  ghost.style.width = `${draggedElement.offsetWidth}px`;

  document.body.appendChild(ghost);
  ghostRef.current = ghost;
};
```

* 원본 요소를 똑같이 복사해 드래그할 때 자연스러운 시각 효과를 주기 위함
* `position: fixed`로 화면에 고정, 스크롤과 무관하게 동작
* `transform: translate(-50%, -50%)`로 마우스 중앙에 위치

**`updateGhostPosition` - 고스트 위치 업데이트**

```tsx
const updateGhostPosition = (event: PointerEvent) => {
  if (!ghostRef.current) return;
  ghostRef.current.style.left = `${event.pageX}px`;
  ghostRef.current.style.top = `${event.pageY}px`;
};
```

* 마우스를 따라다니는 고스트 요소 위치 실시간 업데이트
* `pageX`, `pageY` 사용으로 스크롤과 상관없이 정확한 위치

**`startScroll` - 스크롤 시작**

```tsx
const startScroll = (direction: 'up' | 'down') => {
  const container = scrollContainerRef.current;
  if (!container) return;

  const scroll = () => {
    if (direction === 'up') container.scrollTop -= scrollSpeed;
    else container.scrollTop += scrollSpeed;

    scrollAnimationRef.current = requestAnimationFrame(scroll);
  };

  if (!scrollAnimationRef.current) {
    scrollAnimationRef.current = requestAnimationFrame(scroll);
  }
};
```

* `requestAnimationFrame`을 사용한 부드러운 스크롤
* 재귀적으로 자기 자신을 호출하여 연속적인 스크롤 구현
* 중복 애니메이션 방지: 이미 스크롤 중이면 새로 시작하지 않음

**`stopScroll` - 스크롤 중지**

```tsx
const stopScroll = () => {
  if (scrollAnimationRef.current) {
    cancelAnimationFrame(scrollAnimationRef.current);
    scrollAnimationRef.current = null;
  }
};
```

* `cancelAnimationFrame`으로 스크롤 애니메이션 중지

**`autoScroll` - 자동 스크롤 처리**

```tsx
  const autoScroll = (event: PointerEvent) => {
    const container = scrollContainerRef.current;
    if (!container) return;

    const rect = container.getBoundingClientRect();
    const offsetY = event.clientY - rect.top;
    const distanceToBottom = rect.bottom - event.clientY;

    if (offsetY < scrollEdgeThreshold) startScroll('up');
    else if (distanceToBottom < scrollEdgeThreshold) startScroll('down');
    else stopScroll();
  };
```

* 컨테이너 가장자리 감지하여 자동 스크롤 시작/중지

**`updateInsertionIndex` - 삽입 위치 업데이트**

```tsx
const calculateInsertionIndex = (event: PointerEvent) => {
  const container = scrollContainerRef.current;
  if (!container) return;

  const rect = container.getBoundingClientRect();
  if (
    event.clientX < rect.left ||
    event.clientX > rect.right ||
    event.clientY < rect.top ||
    event.clientY > rect.bottom
  ) {
    setInsertionIndex(null);
    return;
  }

  for (let i = 0; i < items.length; i++) {
    const item = items[i];
    const child = itemRefs.current[item.index];
    if (!child) continue;

    const childRect = child.getBoundingClientRect();
    if (event.clientY < childRect.top + childRect.height / 2) {
      return setInsertionIndex(item.index);
    }
  }

  const lastItem = items[items.length - 1];
  setInsertionIndex(lastItem.index + 1);
};

```

* 컨테이너 영역 벗어나면 삽입 위치 null로 설정
* 각 아이템의 중점 기준으로 삽입 위치 결정

**`reset` - 드래그 상태 초기화**

```tsx
const reset = () => {
  draggedIndexRef.current = null;
  setInsertionIndex(null);

  if (ghostRef.current) {
    document.body.removeChild(ghostRef.current);
    ghostRef.current = null;
  }

  stopScroll();
};
```

* 모든 드래그 관련 상태를 초기화
* 고스트 요소를 DOM에서 제거
* `stopScroll()` 호출로 스크롤 애니메이션 정리
* 드래그 완료, 취소, 에러 등 모든 종료 상황에서 호출

**`getItemRef` - 아이템 DOM 참조 관리**

```tsx
const getItemRef = (index: number) => (el: HTMLDivElement | null) => {
  itemRefs.current[index] = el;
};
```

* 커링(Currying) 방식으로 설계된 함수
* React의 ref 콜백은 `(element) => void` 형태여야 하지만, 어떤 인덱스인지도 알아야 함
* 첫 번째 호출에서 인덱스를 받고, 두 번째 호출에서 DOM 요소를 받는 구조
* 사용법: `<div ref={getItemRef(item.index)}>`

**`handlePointerDown` - 드래그 시작 처리**

```tsx

const handlePointerDown = (e: React.PointerEvent, index: number) => {
  draggedIndexRef.current = index;
  dragStartPosRef.current = { x: e.pageX, y: e.pageY };
};
```

* 드래그할 아이템의 인덱스 저장
* 시작 위치를 페이지 좌표계로 기록하여 나중에 threshold(드래그로 인식하기 위한 최소 이동 거리) 계산에 사용

**`handlePointerMove` - 드래그 중 처리**

```tsx

const handlePointerMove = (event: PointerEvent) => {
  const draggedId = draggedIndexRef.current;
  if (draggedId == null) return;

  if (!ghostRef.current) {
    if (!checkDrag(event)) return;
    createGhost(draggedId);
  }

  updateGhostPosition(event);
  autoScroll(event);
  calculateInsertionIndex(event);
};
```

* 드래그 중인 아이템이 없으면 처리하지 않음
* 고스트가 없으면 threshold 확인 후 생성
* 고스트 위치, 자동 스크롤, 삽입 위치를 순차적으로 업데이트

**`handlePointerUp` - 드래그 완료 처리**

```tsx
const handlePointerUp = async () => {
  if (!ghostRef.current) return reset();

  const draggedIndex = draggedIndexRef.current;
  if (draggedIndex == null || insertionIndex == null) return reset();

  const newIndex = draggedIndex < insertionIndex ? insertionIndex - 1 : insertionIndex;
  if (draggedIndex === newIndex) return reset();

  try {
    await onDrop(draggedIndex, newIndex);
  } finally {
    reset();
  }
};
```

* 고스트가 없으면 실제 드래그가 발생하지 않은 것으로 판단
* 인덱스 조정: 드래그한 아이템의 기존 위치가 삽입 위치보다 앞(위)에 있으면 -1
* `try-finally`로 에러가 발생해도 반드시 reset 호출

## 사용 방법

이제 훅을 실제 컴포넌트에서 사용해보자

```tsx
function MomentPlaceColumn({ handleClickPlace, places }: MomentPlaceColumnProps) {
  // 스크롤 컨테이너 
  const containerRef = useRef<HTMLDivElement>(null);

  const handleDrop = async (originalIndex: number, newIndex: number) => {
    await patchMomentPlace({
      momentId: Number(momentId),
      originalIndex,
      newIndex,
    });
  };

  const { insertionIndex, handlePointerDown, getItemRef } = useDragAndDrop({
    items: places,
    onDrop: handleDrop,
    scrollContainerRef: containerRef,
  });

  return (
    <div ref={containerRef} className={styles.scrollContainer}>
      {places.map((place, i) => {
        // 첫 번째 요소 (만남 장소, 드래그 불가능)
        ...

        // 나머지 요소 (드래그 가능)
        return (
          <Fragment key={place.index}>
	          {/* 각 아이템 앞에 삽입 라인 */}
            <InsertionLine isActive={insertionIndex === place.index} />
            
            {/* 드래그 가능한 아이템 */}
            <div ref={getItemRef(place.index)}>
              <MomentPlaceEditCard
                data={place}
                handleClickPlace={() => handleClickPlace(place)}
                handleGrabPlace={(e) => handlePointerDown(e, place.index)}
              />
            </div>
          </Fragment>
        );
      })}

      {/* 마지막 위치에 삽입선 */}
      <InsertionLine isActive={insertionIndex === places[places.length - 1].index + 1} />
    </div>
  );
}

export default MomentPlaceColumn;

```

<figure><img src="/files/7G9hryfCJBOwVxGxuhVy" alt=""><figcaption></figcaption></figure>

## 결과 및 느낀점

이번 구현을 통해 다음과 같은 개선 효과를 얻을 수 있었다:

* **자동 스크롤** 기능 덕분에 긴 리스트에서도 사용자가 원하는 위치로 드래그해 이동하기 수월해졌다.
* 드래그 동작을 `setInterval`에서 `requestAnimationFrame`으로 리팩토링함으로써 **프레임 드랍 없이 부드러운 시각적 피드백**을 제공할 수 있었다.
* 커스텀 훅(`useDragAndDrop`)으로 로직을 분리하면서 **컴포넌트의 책임이 명확해졌고, 재사용성 또한 크게 향상**되었다.
* `ref`와 `PointerEvent`를 조합해 **마우스·터치 모두에서 안정적으로 작동하는 공통 인터페이스**를 구현할 수 있었다.

결과적으로 단순한 기능처럼 보였던 드래그 앤 드롭 구현이 **사용성·성능·재사용성 측면에서 정교한 설계가 필요한 작업**임을 체감할 수 있었다. 이번 경험을 바탕으로 다양한 사용자 입력 이벤트를 다루는 UI에서도 보다 견고한 패턴을 적용할 수 있을 것이라 기대된다.

## 개선하면 좋을점

현재는 스크롤 컨테이너 가장자리로 드래그 포인터가 진입하면 일정한 속도로 자동 스크롤이 동작한다.&#x20;

하지만 가까운 거리일수록 느리게, 가장자리에 밀착할수록 빠르게 스크롤 속도를 조절하면 더 직관적이고 사용하기 편한 드래그 인터랙션이 되지 않을까 싶다.
