드래그 앤 드롭에서 요소가 튀는 이유: offset을 이해하지 못했을 때 생기는 문제
드래그 앤 드롭을 구현할 때, 왜 어떤 요소는 클릭하자마자 엉뚱한 위치로 튀어버릴까요?
처음에는 마우스 클릭 지점과 이동 거리만 알면 충분하다고 생각했습니다. 하지만 크롬 익스텐션의 floating button과 tactile fader를 구현하는 과정에서, 드래그의 핵심은 ‘이동량’이 아니라 ‘기준점(offset)’이라는 사실을 뒤늦게 깨달았습니다. 본 글에서는 과거에 이해하지 못한 채 넘어갔던 드래그 이슈를 다시 복기하고, offset 개념이 어떻게 UI의 자연스러움을 결정하는지, 두 구현을 비교하며 정리해봅니다.
문제 상황
한스푼(하이라이트 크롬 익스텐션) 프로젝트에는 화면 우측에 floating button이 존재하고, 이 버튼을 클릭하면 사이드 패널이 펼쳐진다. 또한 이 floating button은 드래그 앤 드롭이 가능해, 화면 우측을 기준으로 상·하 이동할 수 있도록 구현되어 있었다.
문제는 floating button을 클릭하는 순간이었다. 드래그를 시작하려는 의도와 달리, 버튼이 갑자기 쌩뚱맞은 위치로 튀어버리는 현상이 발생했다. 당시에는 UI 동작만 맞추는 선에서 임시로 해결하고 넘어갔고, 왜 이런 현상이 발생했는지에 대해서는 제대로 이해하지 못한 상태였다.
이후 tactile fader 컴포넌트를 구현하면서 비슷한 요구사항을 다시 마주하게 되었다. tactile fader 역시 손잡이를 상하로 드래그하여 값을 조절하는 UI로, 한스푼의 floating button과 구조적으로 매우 유사한 인터랙션을 가진다.
이 컴포넌트를 구현하는 과정에서, 과거 한스푼 floating button에서 발생했던 드래그 이슈의 원인을 명확히 이해할 수 있게 되었다.
내가 처음 한 생각
드래그 앤 드롭을 구현하려면 다음 세 가지만 알면 된다고 생각했다.
- 마우스 클릭 지점
- 마우스 이동 지점
- 드래그 대상 요소의 현재 위치
이 생각을 단순화하면 아래와 같은 수도 코드가 된다.
드래그 요소 위치 = 드래그 요소 위치 + (마우스 이동 지점 - 마우스 클릭 지점)
즉, 마우스 이동량만큼 요소를 같이 움직이면 된다고 판단했다.
실제로 해 본 선택
이 판단을 바탕으로 한스푼 초기 드래그 코드를 작성했다.
const initialY = startPositionRatioRef.current * window.innerHeight;
const deltaY = e.clientY - startYRef.current;
const newY = initialY + deltaY;
드래그 요소의 시작 위치(initialY)와 마우스 이동량(deltaY)을 더해 새로운 위치를 계산하는 방식이었다.
하지만 이 방식으로 구현한 결과, 클릭하는 순간 요소가 갑자기 튀는 현상이 발생했다.
원인은 단순했다. offset의 존재를 고려하지 않았기 때문이다.
드래그 대상은 점(point)이 아니라 높이를 가진 요소다. 즉, 요소의 윗부분을 클릭했는지, 아랫부분을 클릭했는지는 중요한 정보다.
하지만 당시 코드에서는 어디를 클릭했든 항상 요소의 top을 기준으로 드래그를 시작한 것처럼 계산되고 있었다. 그 결과, 사용자가 요소의 하단을 잡고 드래그를 시작하면 UI 상으로는 위치가 맞지 않아 버튼이 갑자기 다른 위치로 이동해 보이게 된다.
이 문제를 해결한 tactile fader의 핵심 코드는 다음 한 줄이었다.
dragStartY.current = e.clientY - y.get();
이 코드는 마우스 클릭 지점과 이미 이동해 있는 요소의 위치(y)를 기준으로 offset을 계산한다.
즉,
- 사용자가 요소의 어느 지점을 잡았든
- 그 지점을 기준으로 요소가 자연스럽게 따라오도록
드래그의 기준점을 명확히 고정한 것이다.
결과 (성공 / 실패)
- 한스푼 초기 구현: 실패
- 드래그 구현에 필요한 기준값 중, 클릭 지점 offset을 빠뜨림
- UI 상 튀는 현상 발생
- tactile fader 구현: 성공
- 요소의 현재 위치 + 마우스 클릭 지점의 offset을 함께 고려
- 클릭 위치에 관계없이 자연스러운 드래그 동작 구현
비고
실제 drag and drop 라이브러리인 dnd-kit 역시 드래그 시작 시 pointer 좌표를 initialCoordinates로 고정하고, 이후 이동은 항상 초기 좌표 대비 delta(offset)를 계산하는 방식으로 구현되어 있다. (관련 코드 ) 이는 드래그 중 pointer와 요소 간의 상대적인 위치 관계를 불변으로 유지하기 위한 것으로 이해하면 될 것 같다.