> 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;

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


---

# Agent Instructions
This documentation is published with GitBook. GitBook is the documentation platform designed so that both humans and AI agents can read, navigate, and reason over technical content effectively. Learn more at gitbook.com.

## Querying This Documentation
If you need additional information that is not directly available in this page, you can query the documentation dynamically by asking a question.

Perform an HTTP GET request on the current page URL with the `ask` query parameter, and the optional `goal` query parameter:

```
GET https://here-we.gitbook.io/here-we/frontend/3.md?ask=<question>&goal=<endgoal>
```

`ask` is the immediate question: it should be specific, self-contained, and written in natural language.
`goal` is optional and describes the broader end goal you are ultimately trying to accomplish on behalf of the user. GitBook uses it to tailor the answer towards what is most useful for that goal.

The response will contain a direct answer to the question and relevant excerpts and sources from the documentation.

Use this mechanism when the answer is not explicitly present in the current page, you need clarification or additional context, or you want to retrieve related documentation sections.
