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

드래그 앤 드랍(1) - DragEvent API 학습과 구현

드래그 앤 드랍(2): MouseEvent, TouchEvent, PointerEvent 학습과 구현

들어가기

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

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

  • 스크롤 지원: 긴 리스트에서 자동 스크롤 기능

  • 재사용 가능한 커스텀 훅으로 분리

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

1. 자동 스크롤 기능

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

스크롤 영역 감지

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

상단 스크롤 조건: 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)정확하지 않으며, 부하나 렌더링 타이밍에 따라 지연되거나 중복 호출되어 스크롤이 튀거나 끊길 수 있다.

스크롤 조건에 따라 트리거

스크롤 정리

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

2. 커스텀 훅으로 분리

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

타입 정의

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

각 옵션별 상세 설명:

threshold (기본값: 5px)

  • 드래그로 인식하기 위한 최소 이동 거리

  • 단순 클릭에서 발생할 수 있는 미세한 move 이벤트는 드래그로 인식하지 않도록 하기 위함

  • 너무 크면: 드래그 시작이 둔하게 느껴짐

  • 너무 작으면: 의도치 않은 드래그가 자주 발생

scrollSpeed (기본값: 5px)

  • 자동 스크롤 시 한 번에 이동할 픽셀 수

  • 5px: 느린 스크롤, 정밀한 위치 조정 가능

  • 15px: 빠른 스크롤, 긴 리스트 빠르게 이동

  • 20px 이상: 너무 빨라서 제어하기 어려움

scrollEdgeThreshold (기본값: 40px)

  • 컨테이너 가장자리에서 몇 픽셀 이내에 들어와야 자동 스크롤이 시작되는지

  • 40px: 마우스가 상단/하단 40px 영역에 들어오면 스크롤 시작

  • 너무 크면: 의도치 않은 스크롤이 자주 발생

  • 너무 작으면: 스크롤 영역을 찾기 어려움

핵심 설계 포인트:

  • DragItem 인터페이스로 최소 요구사항 정의 (index 필수)

  • 제네릭 <T extends DragItem>으로 타입 안전성 확보

  • onDrop은 비동기로 서버 동기화 지원

훅 구조 설계

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 - 드래그 임계값 확인

  • 피타고라스 정리로 시작점과 현재점 사이의 직선 거리 계산

  • threshold보다 크면 진짜 드래그로 인정

  • 단순한 boolean 반환으로 명확한 역할

createGhost - 고스트 요소 생성

  • 원본 요소를 똑같이 복사해 드래그할 때 자연스러운 시각 효과를 주기 위함

  • position: fixed로 화면에 고정, 스크롤과 무관하게 동작

  • transform: translate(-50%, -50%)로 마우스 중앙에 위치

updateGhostPosition - 고스트 위치 업데이트

  • 마우스를 따라다니는 고스트 요소 위치 실시간 업데이트

  • pageX, pageY 사용으로 스크롤과 상관없이 정확한 위치

startScroll - 스크롤 시작

  • requestAnimationFrame을 사용한 부드러운 스크롤

  • 재귀적으로 자기 자신을 호출하여 연속적인 스크롤 구현

  • 중복 애니메이션 방지: 이미 스크롤 중이면 새로 시작하지 않음

stopScroll - 스크롤 중지

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

autoScroll - 자동 스크롤 처리

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

updateInsertionIndex - 삽입 위치 업데이트

  • 컨테이너 영역 벗어나면 삽입 위치 null로 설정

  • 각 아이템의 중점 기준으로 삽입 위치 결정

reset - 드래그 상태 초기화

  • 모든 드래그 관련 상태를 초기화

  • 고스트 요소를 DOM에서 제거

  • stopScroll() 호출로 스크롤 애니메이션 정리

  • 드래그 완료, 취소, 에러 등 모든 종료 상황에서 호출

getItemRef - 아이템 DOM 참조 관리

  • 커링(Currying) 방식으로 설계된 함수

  • React의 ref 콜백은 (element) => void 형태여야 하지만, 어떤 인덱스인지도 알아야 함

  • 첫 번째 호출에서 인덱스를 받고, 두 번째 호출에서 DOM 요소를 받는 구조

  • 사용법: <div ref={getItemRef(item.index)}>

handlePointerDown - 드래그 시작 처리

  • 드래그할 아이템의 인덱스 저장

  • 시작 위치를 페이지 좌표계로 기록하여 나중에 threshold(드래그로 인식하기 위한 최소 이동 거리) 계산에 사용

handlePointerMove - 드래그 중 처리

  • 드래그 중인 아이템이 없으면 처리하지 않음

  • 고스트가 없으면 threshold 확인 후 생성

  • 고스트 위치, 자동 스크롤, 삽입 위치를 순차적으로 업데이트

handlePointerUp - 드래그 완료 처리

  • 고스트가 없으면 실제 드래그가 발생하지 않은 것으로 판단

  • 인덱스 조정: 드래그한 아이템의 기존 위치가 삽입 위치보다 앞(위)에 있으면 -1

  • try-finally로 에러가 발생해도 반드시 reset 호출

사용 방법

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

결과 및 느낀점

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

  • 자동 스크롤 기능 덕분에 긴 리스트에서도 사용자가 원하는 위치로 드래그해 이동하기 수월해졌다.

  • 드래그 동작을 setInterval에서 requestAnimationFrame으로 리팩토링함으로써 프레임 드랍 없이 부드러운 시각적 피드백을 제공할 수 있었다.

  • 커스텀 훅(useDragAndDrop)으로 로직을 분리하면서 컴포넌트의 책임이 명확해졌고, 재사용성 또한 크게 향상되었다.

  • refPointerEvent를 조합해 마우스·터치 모두에서 안정적으로 작동하는 공통 인터페이스를 구현할 수 있었다.

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

개선하면 좋을점

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

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

Last updated