드래그 앤 드랍(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
자동 스크롤을 위한
setIntervalID 저장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)으로 로직을 분리하면서 컴포넌트의 책임이 명확해졌고, 재사용성 또한 크게 향상되었다.ref와PointerEvent를 조합해 마우스·터치 모두에서 안정적으로 작동하는 공통 인터페이스를 구현할 수 있었다.
결과적으로 단순한 기능처럼 보였던 드래그 앤 드롭 구현이 사용성·성능·재사용성 측면에서 정교한 설계가 필요한 작업임을 체감할 수 있었다. 이번 경험을 바탕으로 다양한 사용자 입력 이벤트를 다루는 UI에서도 보다 견고한 패턴을 적용할 수 있을 것이라 기대된다.
개선하면 좋을점
현재는 스크롤 컨테이너 가장자리로 드래그 포인터가 진입하면 일정한 속도로 자동 스크롤이 동작한다.
하지만 가까운 거리일수록 느리게, 가장자리에 밀착할수록 빠르게 스크롤 속도를 조절하면 더 직관적이고 사용하기 편한 드래그 인터랙션이 되지 않을까 싶다.
Last updated