드래그 앤 드랍(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
// 자동 스크롤 로직
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)
은 정확하지 않으며, 부하나 렌더링 타이밍에 따라 지연되거나 중복 호출되어 스크롤이 튀거나 끊길 수 있다.
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;
}
};
스크롤 조건에 따라 트리거
if (offsetY < scrollEdgeThreshold) {
startScroll('up');
} else if (distanceToBottom < scrollEdgeThreshold) {
startScroll('down');
} else {
stopScroll();
}
스크롤 정리
드래그가 끝나거나 중단되면 자동 스크롤도 함께 정리해야 한다:
const reset = () => {
...
stopScroll()
};
2. 커스텀 훅으로 분리
기존 방식에서는 모든 드래그 로직이 컴포넌트 내부에 섞여 있어 재사용성이 떨어졌다. 이를 useDragAndDrop
훅으로 분리하여 어떤 컴포넌트에서든 쉽게 사용할 수 있도록 개선했다.
타입 정의
먼저 훅에서 사용할 타입들을 정의했다.
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
은 비동기로 서버 동기화 지원
훅 구조 설계
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
- 드래그 임계값 확인
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
- 고스트 요소 생성
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
- 고스트 위치 업데이트
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
- 스크롤 시작
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
- 스크롤 중지
const stopScroll = () => {
if (scrollAnimationRef.current) {
cancelAnimationFrame(scrollAnimationRef.current);
scrollAnimationRef.current = null;
}
};
cancelAnimationFrame
으로 스크롤 애니메이션 중지
autoScroll
- 자동 스크롤 처리
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
- 삽입 위치 업데이트
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
- 드래그 상태 초기화
const reset = () => {
draggedIndexRef.current = null;
setInsertionIndex(null);
if (ghostRef.current) {
document.body.removeChild(ghostRef.current);
ghostRef.current = null;
}
stopScroll();
};
모든 드래그 관련 상태를 초기화
고스트 요소를 DOM에서 제거
stopScroll()
호출로 스크롤 애니메이션 정리드래그 완료, 취소, 에러 등 모든 종료 상황에서 호출
getItemRef
- 아이템 DOM 참조 관리
const getItemRef = (index: number) => (el: HTMLDivElement | null) => {
itemRefs.current[index] = el;
};
커링(Currying) 방식으로 설계된 함수
React의 ref 콜백은
(element) => void
형태여야 하지만, 어떤 인덱스인지도 알아야 함첫 번째 호출에서 인덱스를 받고, 두 번째 호출에서 DOM 요소를 받는 구조
사용법:
<div ref={getItemRef(item.index)}>
handlePointerDown
- 드래그 시작 처리
const handlePointerDown = (e: React.PointerEvent, index: number) => {
draggedIndexRef.current = index;
dragStartPosRef.current = { x: e.pageX, y: e.pageY };
};
드래그할 아이템의 인덱스 저장
시작 위치를 페이지 좌표계로 기록하여 나중에 threshold(드래그로 인식하기 위한 최소 이동 거리) 계산에 사용
handlePointerMove
- 드래그 중 처리
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
- 드래그 완료 처리
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 호출
사용 방법
이제 훅을 실제 컴포넌트에서 사용해보자
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;

결과 및 느낀점
이번 구현을 통해 다음과 같은 개선 효과를 얻을 수 있었다:
자동 스크롤 기능 덕분에 긴 리스트에서도 사용자가 원하는 위치로 드래그해 이동하기 수월해졌다.
드래그 동작을
setInterval
에서requestAnimationFrame
으로 리팩토링함으로써 프레임 드랍 없이 부드러운 시각적 피드백을 제공할 수 있었다.커스텀 훅(
useDragAndDrop
)으로 로직을 분리하면서 컴포넌트의 책임이 명확해졌고, 재사용성 또한 크게 향상되었다.ref
와PointerEvent
를 조합해 마우스·터치 모두에서 안정적으로 작동하는 공통 인터페이스를 구현할 수 있었다.
결과적으로 단순한 기능처럼 보였던 드래그 앤 드롭 구현이 사용성·성능·재사용성 측면에서 정교한 설계가 필요한 작업임을 체감할 수 있었다. 이번 경험을 바탕으로 다양한 사용자 입력 이벤트를 다루는 UI에서도 보다 견고한 패턴을 적용할 수 있을 것이라 기대된다.
개선하면 좋을점
현재는 스크롤 컨테이너 가장자리로 드래그 포인터가 진입하면 일정한 속도로 자동 스크롤이 동작한다.
하지만 가까운 거리일수록 느리게, 가장자리에 밀착할수록 빠르게 스크롤 속도를 조절하면 더 직관적이고 사용하기 편한 드래그 인터랙션이 되지 않을까 싶다.
Last updated