이번 글에서 이야기해볼 것은 드래그이다. 드래그를 통해 원하는 요소를 선택하는 방법에 대해 이야기할 것이다. 결과부터 보면 다음과 같다.
Mouse Event, Drag Event
Mouse Event
MouseEvent는 사용자가 포인팅 장치(마우스 등)를 사용해 상호작용할 때 발생하는 이벤트를 나타낸다. 우리가 웹에서 클릭하고, 움직이고, 드래그하는 모든 행동의 기반이 되는 이벤트라고 보면 된다.
이벤트의 인스턴스 속성에는 다음과 같은 것들이 있다.
마우스 위치를 나타내는 좌표 속성들이 여러 개 있는데, 각각의 기준점이 다르다. 처음에는 좀 헷갈릴 수 있는데, 실제로 써보면 언제 어떤 속성을 써야 할지 감이 온다.
MouseEvent.clientX,MouseEvent.clientY: 마우스 포인터의 뷰포트 내에서의 위치
뷰포트는 브라우저의 현재 표시 영역을 의미하는데, 쉽게 말해 지금 우리가 보고 있는 화면 기준이라고 생각하면 된다. 드래그 기능을 구현할 때는 주로 이 값을 사용하게 되는데, 그 이유는 곧 설명하겠다.
MouseEvent.pageX,MouseEvent.pageY: 스크롤을 포함한 전체 문서 내에서 마우스 포인터의 위치
만약 페이지를 500px 스크롤한 상태에서 화면 최상단을 클릭했다면, clientY는 0이지만 pageY는 500 정도가 될 것이다.
MouseEvent.screenX,MouseEvent.screenY: 전체 화면(모니터) 좌표계에서의 마우스 포인터 위치
브라우저 창의 위치와 상관없이 모니터 전체를 기준으로 한다.
MouseEvent.offsetX,MouseEvent.offsetY: 이벤트 대상 요소의 테두리를 기준으로 한 마우스 포인터의 위치
특정 요소 내에서의 상대적 위치가 필요할 때 유용하다.
MouseEvent.button: 어떤 마우스 버튼이 눌렸는지를 나타낸다.
0은 좌클릭, 1은 가운데 클릭(휠 클릭), 2는 우클릭을 의미한다.
마우스 이벤트에는 어떤게 있을까? 각 이벤트가 발생하는 상황은 다음과 같다.
click: 사용자가 해당 element를 클릭했을 때
mousedown과 mouseup이 같은 요소에서 연속으로 발생할 때 트리거된다.
mousedown: 사용자가 해당 element에서 마우스 버튼을 눌렀을 때mouseup: 사용자가 해당 element에서 눌렀던 마우스 버튼을 떼었을 때dblclick: 사용자가 해당 element에서 마우스 버튼을 더블 클릭했을 때mousemove: 사용자가 해당 element에서 마우스를 움직였을 때
이 이벤트는 마우스가 움직이는 동안 계속해서 발생하기 때문에, 성능을 고려해야 할 때가 많다.
mouseover: 사용자가 마우스를 해당 element 바깥에서 안으로 옮겼을 때mouseout: 사용자가 마우스를 해당 element 안에서 바깥으로 옮겼을 때mouseenter: 사용자가 마우스를 해당 element 바깥에서 안으로 옮겼을 때mouseleave: 사용자가 마우스를 해당 element 안에서 바깥으로 옮겼을 때
mouseenter와 mouseleave는 각각 mouseover, mouseout과 유사하지만 중요한 차이점이 있다. 이 두 이벤트는 버블링이 발생하지 않는다. 즉, 자식 요소에서 발생한 이벤트가 부모 요소로 전파되지 않는다는 의미다. 덕분에 자식에서 이벤트가 튀지 않아 동작이 예측하기 쉬워진다.
contextmenu: 마우스 오른쪽 버튼을 눌렀을 때
커스텀 컨텍스트 메뉴를 만들 때 주로 사용한다.
Drag Event
Drag Event는 사용자가 HTML 요소를 끌어서 이동할 때 발생하는 이벤트이다. MouseEvent를 기반으로 하지만, 드래그 앤 드롭이라는 특정 시나리오에 최적화된 추가 기능들을 제공한다.
이벤트의 인스턴스 속성에는 다음과 같은 것들이 있다.
위에서도 볼 수 있듯이 DragEvent를 사용하려면 해당 요소의 draggable 속성이 true여야 한다. 이미지나 링크 같은 일부 요소들은 기본적으로 draggable이 true로 설정되어 있지만, 대부분의 요소들은 명시적으로 설정해줘야 한다.
DragEvent의 인스턴스 속성은 MouseEvent와 유사하니 생략하도록 하겠다. 대신 DragEvent만의 특별한 속성인 dataTransfer가 있는데, 이를 통해 드래그되는 데이터를 저장하고 전달할 수 있다.
DragEvent에는 다음과 같은 이벤트가 있다.
dragstart: 사용자가 요소를 드래그하기 시작할 때drag: 사용자가 요소를 드래그하는 동안 계속dragend: 드래그 작업이 끝났을 때dragenter: 드래그 중인 요소가 드롭 대상 위로 처음 진입할 때dragover: 드래그 중인 요소가 드롭 대상 위에 있을 때 계속dragleave: 드래그 중인 요소가 드롭 대상에서 벗어날 때drop: 사용자가 드래그 중인 요소를 드롭 대상에 놓았을 때
MouseEvent vs DragEvent
MouseEvent와 DragEvent는 웹 개발에서 사용자의 마우스 인터랙션을 처리하는 두 가지 다른 이벤트 유형이다. MouseEvent는 기본적인 마우스 동작(클릭, 이동 등)을 처리하는 반면, DragEvent는 요소의 드래그 앤 드롭 작업에 특화되어 있다.
주요 차이점을 보면, DragEvent는 draggable 속성이 true로 설정된 요소에서만 발생하며, 드래그 작업의 각 단계(시작, 진행, 종료)에 특화된 이벤트와 추가 데이터 전송 기능을 제공한다는 것이다. 특히 dataTransfer 객체를 통해 드래그되는 데이터를 쉽게 관리할 수 있다는 점이 큰 장점이다.
그럼 언제 무엇을 써야 할까? 파일을 드래그해서 업로드하거나, 리스트 아이템의 순서를 바꾸는 등 명확한 드래그 앤 드롭 시나리오에는 DragEvent가 적합하다. 반면 이번 글에서 구현할 것처럼 영역을 선택하거나, 캔버스에 그림을 그리는 등 더 세밀한 제어가 필요한 경우에는 MouseEvent를 사용하는 것이 낫다. MouseEvent가 더 기본적이고 범용적이며, 우리가 원하는 대로 동작을 커스터마이징하기 쉽기 때문이다.
위치와 관련된 프로퍼티
Element.getBoundingClientRect()
getBoundingClientRect()는 정말 유용한 메서드다. 요소의 크기와 뷰포트 기준 위치 정보를 담은 DOMRect 객체를 반환한다.
여기서 중요한 포인트는 Client, 즉 뷰포트를 기준으로 값을 제공한다는 것이다. 이는 MouseEvent/DragEvent의 clientX, clientY와 동일한 기준점으로 계산이 된다고 할 수 있다. 이런 일관성 덕분에 드래그 이벤트를 구현할 때 clientX와 clientY를 사용하면 계산이 훨씬 간단해진다. 서로 다른 좌표계를 변환할 필요 없이 바로 비교할 수 있으니까.
프로퍼티에는 다음과 같은 것들이 있다.
left(=x): 요소의 왼쪽 좌표 (뷰포트 좌측과 요소의 좌측 사이의 거리)right: 요소의 오른쪽 좌표top(=y): 요소의 위쪽 좌표bottom: 요소의 아래쪽 좌표width: 요소의 너비height: 요소의 높이
width와 height는 padding과 border를 포함하지만 margin은 포함하지 않는다.
실제로 사용해보면, 요소 간의 충돌 감지나 요소가 화면에 보이는지 확인하는 등의 작업을 할 때 정말 편리하다. 이번 드래그 선택 기능에서도 드래그 박스와 요소들의 충돌 여부를 판단하는 데 핵심적으로 사용된다.
스크롤 관련 메서드
Element.scrollBy, Element.scrollTo
Element.scrollBy와 Element.scrollTo는 요소의 스크롤 위치를 제어하는 메서드다. 이름에서도 알 수 있듯이 미묘한 차이가 있다.
scrollBy는 현재 스크롤 위치에서 상대적으로 스크롤한다. 예를 들어 scrollBy({top: 100})을 호출하면 현재 위치에서 100px 아래로 스크롤된다. 반복해서 호출하면 계속 더 아래로 내려간다.
반면 scrollTo는 지정된 절대 위치로 스크롤한다. scrollTo({top: 100})을 호출하면 스크롤 위치가 정확히 100px 지점으로 이동한다. 몇 번을 호출해도 같은 위치로 이동한다.
두 메서드 모두 x와 y 좌표 또는 옵션 객체를 인자로 받을 수 있다. 특히 behavior: 'smooth' 옵션을 주면 부드러운 스크롤 애니메이션 효과를 낼 수 있다. 다만 드래그 중 스크롤을 구현할 때는 즉각적인 반응이 필요하기 때문에 behavior: 'auto'를 사용하는 것이 좋다.
설계
드래그 선택 기능의 설계는 상당히 간단하다. 사용자의 마우스 동작을 세 단계로 나누어 생각해보면 된다.
사용자가 마우스를 누르면 시작하고, 마우스를 움직이는 동안 동작을 지속하고, 마우스를 떼면 동작을 멈춘다. 종이에 펜으로 사각형을 그리는 것과 비슷하다.
구체적으로 들여다보면 다음과 같다.
- 사용자가 마우스를 누를 때(mousedown) 무엇을 해야 할까? 일단 드래그가 시작되었다는 것을 인식하고, 시작 지점의 좌표를 기억해둬야 한다. 그리고 이제부터 마우스의 움직임(mousemove)과 마우스를 뗄 때(mouseup)를 감지할 준비를 해야 한다. 그래서 이 두 이벤트의 리스너를 등록한다.
- 사용자가 마우스를 움직일 때(mousemove)는 가장 바쁜 순간이다. 시작 지점과 현재 마우스 위치를 계산해서 드래그 박스의 위치와 크기를 업데이트해야 한다. 동시에 드래그 박스와 겹치는 요소들을 찾아서 선택 상태로 만들어줘야 한다. 이 과정이 마우스가 움직이는 동안 계속 반복된다.
- 마지막으로 사용자가 마우스를 뗄 때(mouseup)는 정리 시간이다. 더 이상 마우스 움직임을 추적할 필요가 없으니 mousemove 리스너를 제거한다. 드래그 박스도 화면에서 숨기거나 제거한다. 그리고 자기 자신(mouseup 리스너)도 제거한다. 다음 드래그를 위해 깨끗한 상태로 돌아가는 것이다.
이런 구조는 메모리 누수를 방지하는 데도 중요하다. 이벤트 리스너를 적절히 등록하고 제거하지 않으면, 보이지 않는 곳에서 계속 이벤트를 감지하고 있을 수 있다.
특징
transform을 이용한 위치/너비/높이 설정
드래그 박스의 위치와 너비, 높이를 설정하는 방법에는 크게 두 가지가 있다.
첫 번째는 드래그 박스를 position: absolute 또는 position: fixed로 설정하고 width, height, left, top 속성으로 직접 제어하는 방법이다. 이 방법은 직관적이고 이해하기 쉽다. CSS를 처음 배울 때 접하는 방식이라 친숙하기도 하다.
두 번째는 드래그 박스의 transform 속성에서 translate와 scale을 활용해 설정하는 방법이다. 코드로 보면 transform: translate(x, y) scale(width, height) 같은 형태가 된다.
그럼 둘 중 어떤 방법이 더 좋을까? 정답은 상황에 따라 다르지만, 드래그처럼 빈번한 업데이트가 필요한 경우에는 transform을 사용하는 두 번째 방법이 훨씬 효율적이다.
왜 transform이 더 효율적일까?
브라우저가 화면을 그리는 과정을 간단히 살펴보면, Layout(레이아웃) → Paint(페인트) → Composite(합성)의 단계를 거친다.
left,top,width,height같은 속성을 변경하면 레이아웃 단계부터 다시 시작해야 한다. 이것이 바로 리플로우(reflow)다. 요소의 위치나 크기가 바뀌면 주변 요소들의 위치도 다시 계산해야 하니까 비용이 크다.반면
transform은 이미 계산된 레이아웃 위에서 시각적 변환만 수행한다. GPU를 활용한 합성 단계에서만 처리되기 때문에 레이아웃 재계산이 필요 없다. 투명 필름에 그림을 그려서 원본 위에 얹는 셈이다. 원본(레이아웃)은 그대로 두고 그 위에 무언가를 추가하는 것이니까 훨씬 빠르다.물론 첫 번째 방법에 쓰로틀링을 적용해서 일정 주기마다만 실행하도록 최적화할 수는 있다. 하지만 그렇게 하면 사용자의 마우스를 드래그 박스가 실시간으로 따라가지 않게 된다. 마우스를 빠르게 움직였는데 드래그 박스가 뚝뚝 끊기면서 따라온다면? 상당히 부자연스러운 UX가 될 것이다.
따라서 이 프로젝트에서는 transform 방식을 채택했다. 성능도 좋고, 사용자 경험도 부드럽게 만들 수 있으니 일석이조다.
개발 - 기본 드래그 기능
이제 본격적으로 코드를 작성해보자. 앞서 설계한 세 단계를 하나씩 구현해 나갈 것이다.
1) 사용자가 마우스를 누른다 : mousedown
- mousedown 이벤트 리스너를 등록한다.
useEffect(()=>{
const handleMouseDown = (mouseDownEvent: MouseEvent) => {
// ..
}
window.addEventListener('mousedown',handleMouseDown);
return ()=>{
window.removeEventListener('mousedown',handleMouseDown);
}
},[handleMouseDown])
useEffect를 사용해서 컴포넌트가 마운트될 때 이벤트 리스너를 등록한다. 그리고 return에서 클린업 함수를 작성했는데, 이 함수는 컴포넌트가 언마운트될 때 또는 handleMouseDown 함수 참조가 변경되기 직전에 실행된다. 이벤트 리스너를 제거하지 않으면 메모리 누수가 발생할 수 있으니 꼭 정리해줘야 한다.
- 드래그를 시작해도 되는지 체크한다.
const handleMouseDown = (mouseDownEvent: MouseEvent) => {
// ..
const dragStartTarget = mouseDownEvent.target as HTMLElement;
const isMemoItem = dragStartTarget.closest('.memo-item');
const isMemoGrid = dragStartTarget.closest(`#${CONTAINER_ID}`);
if (!isMemoGrid || isMemoItem) return;
}
모든 곳에서 드래그가 시작되어야 하는 건 아니다. 우리 서비스의 경우를 생각해보자. 메모 아이템을 클릭하면 메모의 상세 내용이 열려야 한다. 그런데 만약 메모를 클릭했을 때 드래그가 시작되어버린다면? 사용자는 메모를 열려고 했는데 엉뚱하게 드래그 선택이 시작되는 황당한 상황이 벌어진다.
그래서 closest 메서드를 활용한다. 이 메서드는 현재 요소 또는 그 상위 요소 중에서 주어진 선택자와 일치하는 가장 가까운 조상 요소를 찾아준다. 클릭한 지점이 메모 아이템 내부거나, 메모 그리드 바깥이라면 드래그를 시작하지 않는다.
- 드래그의 시작 위치를 저장한다.
const handleMouseDown = (mouseDownEvent: MouseEvent) => {
// ..
const [dragStartX, dragStartY] = [mouseDownEvent.clientX, mouseDownEvent.clientY];
}
드래그의 시작점을 기억해둬야 나중에 드래그 박스의 크기와 위치를 계산할 수 있다. clientX와 clientY를 사용하는 이유는 앞서 언급했듯이 getBoundingClientRect()와 같은 좌표계를 사용하기 때문이다.
- mousemove 이벤트 리스너를 등록한다.
window.addEventListener(handleMouseMove,'mousemove')
이제 마우스가 움직일 때마다 handleMouseMove 함수가 호출된다. 드래그 박스를 업데이트하고 선택된 요소들을 찾는 작업이 여기서 일어난다.
2) 사용자가 마우스를 움직인다 : mousemove
이 단계가 드래그 기능의 핵심이다. 마우스가 움직일 때마다 계속 호출되면서 드래그 박스를 업데이트하고 선택 영역을 계산한다.
- 이벤트 전파를 중단한다.
const handleMouseMove = (mouseMoveEvent) => {
mouseMoveEvent.stopPropagation();
}
왜 이벤트 전파를 막아야 할까? 드래그 중에 발생하는 마우스 이벤트가 상위 요소로 전파되면, 상위 요소에 등록된 다른 이벤트 핸들러들이 실행될 수 있다. 예를 들어 hover 효과나 다른 클릭 이벤트들이 의도치 않게 트리거될 수 있다. 이를 방지하기 위해 이벤트 전파를 차단한다.
- 드래그의 종료 위치(즉 마우스의 현재 위치)를 변수에 저장한다.
const handleMouseMove = (mouseMoveEvent) => {
// ..
const [dragEndX, dragEndY] = [lastMouseEvent.clientX, lastMouseEvent.clientY];
}
드래그가 끝나는 지점, 즉 현재 마우스의 위치를 변수에 저장한다.
- 드래그 박스의 각 위치를 구한다.
const handleMouseMove = (mouseMoveEvent) => {
// ..
const [left, top, right, bottom] = [
Math.min(dragStartX, dragEndX),
Math.min(dragStartY, dragEndY),
Math.max(dragStartX, dragEndX),
Math.max(dragStartY, dragEndY),
];
}
여기가 처음에 조금 헷갈릴 수 있는 부분이다. 사용자가 항상 왼쪽에서 오른쪽으로, 위에서 아래로 드래그하는 건 아니다. 오른쪽에서 왼쪽으로, 아래에서 위로도 드래그할 수 있다.
예를 들어 (200, 300) 지점에서 마우스를 눌러서 (100, 200) 지점으로 드래그했다고 해보자. 드래그 박스의 left는 100이어야 하고 top은 200이어야 한다. 그래서 시작점과 끝점 중 더 작은 값을 left와 top으로, 더 큰 값을 right와 bottom으로 설정한다.
- 드래그 박스의 너비와 높이를 구한다.
const handleMouseMove = (mouseMoveEvent) => {
// ..
const [width, height] = [Math.abs(dragEndX - dragStartX), Math.abs(dragEndY - dragEndY)];
}
너비와 높이는 간단하다. 시작점과 끝점의 차이에 절대값을을 취하면 된다. 어느 방향으로 드래그하든 항상 양수 값이 나온다.
- 드래그 박스를 이동시킨다.
const handleMouseMove = (mouseMoveEvent) => {
// ..
dragBoxRef.current.style.transform = `translate(${left}px, ${top}px) scale(${width}, ${height})`;
}
드디어 드래그 박스를 화면에 그린다. transform을 사용하는 이유는 앞서 설명했듯이 성능 때문이다. translate로 위치를 이동하고, scale로 크기를 조절한다.
여기서 한 가지 주의할 점이 있다. scale은 원래 크기를 기준으로 배율을 조절하는 속성이다. 그래서 드래그 박스의 기본 크기를 1px × 1px로 설정해두고, width와 height 값을 그대로 scale에 넣으면 원하는 크기의 박스가 만들어진다.
- 선택된 요소를 찾는다.
const handleMouseMove = (mouseMoveEvent) => {
// ..
const memoElements = document.querySelectorAll('.memo-item');
const selectedIds = [...memoElements];
.filter(element => {
const rect = element.getBoundingClientRect();
return rect.left < right && rect.right > left && rect.top < bottom && rect.bottom > top;
})
.map(element => Number(element.id));
setSelectedMemoIds(selectedIds);
}
이제 드래그 박스 안에 있는 요소들을 찾아야 한다. 모든 메모 아이템을 가져와서 하나씩 검사한다.
각 요소의 getBoundingClientRect()를 호출해서 화면상의 위치를 파악한다. 그리고 드래그 박스와 겹치는지 확인하는데, 이 충돌 감지 로직이 핵심이다.
두 사각형이 겹치는지 확인하는 방법은 의외로 간단하다. 겹치지 않는 경우를 생각해보면, 한 사각형이 다른 사각형의 완전히 왼쪽에 있거나, 오른쪽에 있거나, 위에 있거나, 아래에 있을 때다. 이 모든 경우가 아니라면 겹치는 것이다.
코드로 표현하면:
- `rect.left < right`: 요소의 왼쪽이 드래그 박스의 오른쪽보다 왼쪽에 있고
- `rect.right > left`: 요소의 오른쪽이 드래그 박스의 왼쪽보다 오른쪽에 있고
- `rect.top < bottom`: 요소의 위쪽이 드래그 박스의 아래쪽보다 위에 있고
- `rect.bottom > top`: 요소의 아래쪽이 드래그 박스의 위쪽보다 아래에 있다면
이 네 조건을 모두 만족하면 두 영역이 겹치는 것이다. 이렇게 선택된 요소들의 ID를 모아서 상태로 관리하면, 화면에 선택 표시를 할 수 있다.
3) 사용자가 마우스를 뗀다 : mouseup
드래그가 끝나는 순간이다. 깔끔하게 정리해줘야 한다.
- mousedown 이벤트 리스너를 제거한다.
const handleMouseUp = () => {
document.body.removeEventListener('mousemove', handleMouseMove);
}
더 이상 마우스 움직임을 추적할 필요가 없으니 리스너를 제거한다. 이걸 안 하면 마우스를 뗀 후에도 계속 이벤트가 발생해서 성능 문제가 생길 수 있다.
- 드래그 박스를 제거한다.
if (dragBoxRef.current) dragBoxRef.current.style.transform = '';
transform을 빈 문자열로 설정해서 드래그 박스를 화면에서 숨긴다. 드래그가 끝났으니 박스도 사라져야 한다.
- mouseup 이벤트 리스너(자신)를 제거한다.
const handleMouseUp = () => {
// ..
document.body.removeEventListener('mouseup', handleMouseUp);
}
마지막으로 자기 자신도 제거한다. 이렇게 하면 다음 드래그를 위해 완전히 깨끗한 상태가 된다. 다시 mousedown이 발생하면 새롭게 시작하는 것이다.
개발 - 심화 드래그 기능 : 드래그 & 스크롤
기본 드래그 기능만 있으면 한 화면 안에서만 선택할 수 있다. 하지만 실제 서비스를 생각해보면, 페이지가 길어서 스크롤을 해야 하는 경우가 많다. 이럴 때 드래그하면서 스크롤도 같이 되면 훨씬 편리하다.
노션을 써본 사람이라면 알 것이다. 여러 블록을 선택하려고 드래그할 때, 마우스를 화면 최상단이나 최하단으로 가져가면 자동으로 스크롤이 된다. 정말 자연스럽고 직관적인 UX다.
이런 기능을 구현해보자.
- 마우스 위치와 스크롤 상태를 확인한다.
const SCROLL_INTERVAL = 50;
// ..
const handleMouseMove = (mouseMoveEvent) => {
// ..
const isNearBottom = window.innerHeight - dragEndY < SCROLL_INTERVAL;
const isAtBottom = container.scrollTop + container.clientHeight >= container.scrollHeight;
}
먼저 마우스가 화면 하단에 가까이 있는지 확인한다. window.innerHeight는 현재 브라우저 창의 뷰포트 높이다. 이 값에서 마우스의 Y 위치를 빼면 마우스가 하단에서 얼마나 떨어져 있는지 알 수 있다.
SCROLL_INTERVAL은 스크롤을 트리거할 임계값이다. 50px로 설정했는데, 이 값은 취향에 따라 조절할 수 있다. 너무 크면 스크롤이 너무 일찍 시작되고, 너무 작으면 정확히 맞추기 어려워진다.
동시에 스크롤이 이미 최하단에 있는지도 확인한다. 더 이상 스크롤할 공간이 없는데 계속 스크롤하려고 시도하면 비효율적이니까. container.scrollTop은 현재 스크롤된 높이, container.clientHeight는 보이는 영역의 높이, container.scrollHeight는 전체 콘텐츠의 높이다. 이 세 값을 조합하면 스크롤이 끝에 도달했는지 알 수 있다.
- 연속적으로 스크롤한다.
const handleMouseMove = (mouseMoveEvent) => {
// ..
if (isNearBottom && !isAtBottom && !bottomTimeoutRef.current) {
bottomTimeoutRef.current = setInterval(() => {
if (container.scrollTop + container.clientHeight >= container.scrollHeight) {
clearInterval(bottomTimeoutRef.current!);
bottomTimeoutRef.current = null;
return;
}
container.scrollBy({ top: SCROLL_UNIT, behavior: 'auto' });
}, 50);
}
}
조건이 맞으면 setInterval을 사용해서 주기적으로 스크롤한다. 왜 setInterval을 쓸까? 한 번만 스크롤하면 부자연스럽기 때문이다. 마우스를 계속 하단에 대고 있는 동안에는 계속 스크롤되어야 한다.
50ms마다 콜백이 실행되면서 스크롤을 조금씩 이동시킨다. 콜백 내부에서도 스크롤이 끝에 도달했는지 확인하고, 도달했으면 setInterval을 정리한다.
bottomTimeoutRef.current를 체크하는 이유는 중복 실행을 방지하기 위해서다. 이미 스크롤이 진행 중이면 새로운 setInterval을 시작하지 않는다.
- 스크롤을 중단한다.
const handleMouseMove = (mouseMoveEvent) => {
// ..
else if (!isNearBottom && bottomTimeoutRef.current) {
clearInterval(bottomTimeoutRef.current);
bottomTimeoutRef.current = null;
}
}
마우스가 하단에서 벗어나면 스크롤을 멈춰야 한다. setInterval을 정리하고 ref를 null로 초기화한다. 이렇게 하면 다음에 다시 조건이 충족될 때 새로운 스크롤을 시작할 수 있다.
- 마우스가 최상단인 경우에도 동일하게 적용한다.
const handleMouseMove = (mouseMoveEvent) => {
// ..
const isNearTop = dragEndY < SCROLL_INTERVAL;
const isAtTop = container.scrollTop === 0;
if (isNearTop && !isAtTop && !topTimeoutRef.current) {
topTimeoutRef.current = setInterval(() => {
if (container.scrollTop === 0) {
clearInterval(topTimeoutRef.current!);
topTimeoutRef.current = null;
return;
}
container.scrollBy({ top: -SCROLL_UNIT, behavior: 'auto' });
}, 50);
} else if (!isNearTop && topTimeoutRef.current) {
clearInterval(topTimeoutRef.current);
topTimeoutRef.current = null;
}
}
상단 스크롤은 하단과 거의 동일하다. 차이점은 마우스가 화면 상단에 가까운지 확인하고, 스크롤을 위로 올린다는 것뿐이다. scrollBy의 top 값을 음수로 주면 위로 스크롤된다.
이제 사용자는 드래그하면서 자연스럽게 페이지를 위아래로 스크롤할 수 있다. 마우스를 화면 끝에 가져다 대기만 하면 된다.
결과
드디어 완성이다! 드래그로 요소를 선택할 수 있고, 스크롤까지 자동으로 된다. 실제로 사용해보면 꽤 부드럽고 자연스럽게 동작한다.
맺으며
이번 글에서는 드래그 기능 구현에 대해 살펴보았다. 마우스 이벤트와 드래그 이벤트의 차이점, 위치 관련 속성들, 그리고 이를 활용한 드래그 기능 구현 방법까지 다뤄봤다.
처음에는 단순해 보이는 드래그 기능이지만, 막상 구현하려고 하니 생각보다 고려할 것이 많았다. mousedown, mousemove, mouseup이라는 세 가지 이벤트의 조합으로 시작해서, 좌표 계산, 충돌 감지, 성능 최적화, 그리고 자동 스크롤까지. 하나하나 쌓아가면서 완성도 있는 기능을 만들어냈다.
특히 transform을 활용한 부분이 인상적이었다. 처음에는 그냥 left, top을 쓰면 되지 않나 싶었는데, 성능 차이를 직접 경험하고 나니 왜 transform을 써야 하는지 확실히 알게 됐다. 리플로우를 피하고 GPU 가속을 쓴다는 게 이런 거구나 싶었다.
getBoundingClientRect()를 활용한 충돌 감지 로직도 재미있었다. 두 사각형이 겹치는지 확인하는 알고리즘이 생각보다 간단하다는 게 신기했다. 네 가지 조건만 체크하면 되는데, 처음에는 복잡하게 생각했다가 나중에 "아, 이렇게 간단한 거였구나" 하고 깨달았다.
심화 기능으로 추가한 드래그 중 스크롤은 사용자 경험을 한 단계 끌어올려줬다. setInterval을 사용해서 연속적인 스크롤을 구현하는 방법도 배웠고, ref를 활용해서 타이머를 관리하는 패턴도 익혔다. 이런 디테일 하나가 체감 편의를 가른다.
실제로 개발을 하다 보니 사용자 경험(UX)에 대해 많이 고민하게 됐다. 기능이 되게만 하는 게 아니라, 어떻게 하면 더 편할지 계속 생각했다. 노션 같은 잘 만들어진 서비스들을 참고하면서 "아, 이래서 이런 기능이 있었구나" 하고 깨닫는 순간들이 많았다.
드래그를 구현하면서 시간이 어떻게 흘러갔는지 모르겠다. 단순히 서버로부터 전달받은 데이터를 보여주고, 사용자가 입력한 데이터를 서버에 전달해주는 작업을 주로 개발하다가 드래그같은 사용자 인터렉션을 개발하는 것은 참으로 흥미로운 시간이었다. 앞으로도 이런 인터렉션 요소들을 더 많이 탐구하고 구현해보고 싶다.