드래그 앤 드랍(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)으로 로직을 분리하면서 컴포넌트의 책임이 명확해졌고, 재사용성 또한 크게 향상되었다.

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

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

개선하면 좋을점

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

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

Last updated