본문 바로가기

(개인Project)_개발/PLC-PC 연결

[Program][보족] 드래그 구현했는데 빠르게 움직이면 위젯을 놓쳐버린다 : 오버레이 패턴으로 해결

반응형

이런 경험 있으신가요?

드래그를 구현했는데, 마우스를 천천히 움직이면 잘 되다가 빠르게 움직이면 위젯이 멈춰버립니다.

로그를 보니까 mousemove 이벤트가 중간에 끊깁니다.

열심히 찾아보니 document.addEventListener로 이벤트를 등록하면 된다고 해서 해봤는데, React 18 환경에서는 뭔가 이상하게 동작합니다.

setPointerCapture라는 것도 있다고 해서 써봤는데 역시 뭔가 맞지 않는 느낌입니다.

이 글은 그 문제의 원인과 가장 깔끔한 해결 방법을 정리했습니다.

PLCLink 캔버스 HMI를 만들면서 정확히 같은 문제를 겪었고, 세 번 만든 끝에 찾은 패턴입니다.


용어 먼저 짚고 넘어갈게요

드래그&드롭이란

마우스를 누른 채로 움직여서 뭔가를 옮기는 것입니다.

스마트폰에서 앱 아이콘을 꾹 눌러서 다른 자리로 옮기는 것과 같아요.

웹에서는 마우스 버튼을 누른(mousedown), 움직인(mousemove), 뗀(mouseup) 세 동작을 연결해서 만듭니다.

 

이벤트란

브라우저에서 어떤 일이 생겼을 때 신호를 보내는 방식입니다.

"마우스가 클릭됐다", "키보드가 눌렸다" 같은 신호예요. 이 신호를 받아서 뭔가를 하는 코드를 "이벤트 리스너"라고 합니다.

 

React 합성 이벤트란

React가 브라우저 이벤트를 중간에서 한 번 걸러서 전달하는 방식입니다.

우체국 비유로 설명하면 이렇습니다.

편지가 각 집으로 바로 배달되는 게 아니라, 동네 우체국을 한 번 거쳐서 배달됩니다.

React가 그 우체국 역할을 해요.

덕분에 브라우저마다 다른 이벤트 처리 방식을 React가 통일해주는데,

대신 직접 브라우저에 이벤트를 등록하면 우체국을 건너뛰는 일이 생깁니다.


왜 document.addEventListener가 React 18에서 문제가 되나

드래그를 구현하는 가장 직관적인 방법은 이겁니다.

element.addEventListener('mousedown', (e) => {
  document.addEventListener('mousemove', onMove)
  document.addEventListener('mouseup', onUp)
})

"마우스를 누르면 document 전체에 mousemove를 등록해서 마우스가 어디로 움직이든 따라가겠다"는 전략이에요.

논리적으로 맞습니다.

React 17까지는 됐습니다. React 17 이전에는 이벤트가 document에 등록됐어요.

그러니까 내가 document에 등록한 리스너와 React의 리스너가 같은 장소에 있어서 순서가 예측 가능했습니다.

React 18부터 달라졌습니다. React 18은 이벤트를 document가 아닌 React 루트 컨테이너(<div id="root">)에 등록합니다.

이제 두 리스너가 서로 다른 장소에 있어요.

우체국 비유로 설명하면 이렇습니다.

기존에는 동네 우체국(document)이 하나였는데, React 18에서는 아파트 관리실(root)이 먼저 우편물을 받고 각 집으로 나눠주는 구조로 바뀌었습니다.

내가 동네 우체국에 "편지 오면 알려달라"고 해놨는데, 편지가 관리실에서 막혀버리는 상황이에요.

둘의 타이밍이 맞지 않아서 이벤트가 이상하게 동작합니다.


setPointerCapture는 왜 안 됐나

setPointerCapture는 브라우저 표준 기능입니다.

마우스가 요소 밖으로 나가도 계속 이벤트를 받을 수 있게 해줘요.

이론적으로는 드래그 문제를 해결하는 가장 깔끔한 방법이에요.

element.onPointerDown = (e) => {
  e.currentTarget.setPointerCapture(e.pointerId)
  // 이제 마우스가 어디로 가도 이 요소가 이벤트를 받는다
}

그런데 React의 합성 이벤트 처리 타이밍과 맞지 않아서 동작이 불안정했습니다.

정확히는 setPointerCapture를 호출하는 시점과 React가 이벤트를 처리하는 시점이 어긋나서, 빠른 드래그 상황에서 이벤트가 빠지는 문제가 생겼어요.

좋은 도구인데 React 합성 이벤트 시스템과 함께 쓰면 충돌이 생기는 경우입니다.

두 시스템이 같은 이벤트를 서로 다른 시점에 처리하려다가 엇갈리는 거예요.


최종 해결 : 드래그 오버레이 패턴

이 방법이 가장 깔끔하게 동작했고, Figma, Notion 같은 제품에서도 실제로 쓰는 패턴입니다.

아이디어는 단순합니다.

드래그 시작하는 순간, 화면 전체를 덮는 투명한 유리판을 올립니다.

유리판이 마우스 이벤트를 전부 받아요.

마우스가 위젯 위에 있든, 화면 밖으로 나가든 상관없습니다.

유리판이 항상 거기 있으니까 이벤트를 놓치지 않아요.

드래그가 끝나면(마우스를 놓으면) 유리판을 제거합니다.

컵받침 비유가 잘 맞습니다. 책상에 물이 쏟아지는 걸 막으려고 책상 전체에 방수 매트를 깔아두는 것과 같아요.

컵이 어디에 있든 관계없이 매트가 다 받아줍니다.

// React 코드
const [isDragging, setIsDragging] = useState(false)

const handleMouseDown = (e) => {
  setIsDragging(true)
  // 드래그 시작 정보 저장
}

const handleOverlayMove = (e) => {
  // 위젯 위치 업데이트
}

const handleOverlayUp = (e) => {
  setIsDragging(false)
  // 드래그 끝, 최종 위치 저장
}

return (
  <div>
    {/* 드래그 중일 때만 오버레이 표시 */}
    {isDragging && (
      <div
        style={{
          position: 'fixed',  // 화면 전체를 덮음
          inset: 0,           // 상하좌우 0
          zIndex: 9999,       // 모든 것 위에
          cursor: 'grabbing', // 잡고 있는 커서
        }}
        onMouseMove={handleOverlayMove}
        onMouseUp={handleOverlayUp}
      />
    )}

    {/* 드래그할 위젯 */}
    <div onMouseDown={handleMouseDown}>
      위젯
    </div>
  </div>
)

이 방법이 앞의 두 방법보다 나은 이유가 있습니다.

React의 일반 onMouseMove, onMouseUp (합성 이벤트)만 사용합니다.

document에 직접 리스너를 등록하지 않아요.

React 우체국 시스템 안에서만 움직이니까 타이밍 충돌이 없습니다.

빠르게 드래그해도 마우스가 어디에 있든 오버레이가 이벤트를 받기 때문에 위젯을 놓치지 않아요.


실제 구현에서 신경 쓸 것들

줌이 있으면 좌표 보정이 필요합니다

캔버스에 줌(확대/축소) 기능이 있다면 드래그 좌표를 그냥 쓰면 안 됩니다.

화면을 50%로 축소한 상태에서 마우스를 100px 드래그하면, 캔버스 안에서는 200px을 이동한 것과 같습니다.

화면에서의 거리와 캔버스 안에서의 거리가 다르기 때문이에요.

돋보기 비유로 설명하면 이렇습니다.

돋보기로 글자를 2배 크게 보고 있을 때, 돋보기를 1cm 움직이면 실제 글자 위에서는 0.5cm 움직인 것과 같습니다.

줌 배율로 나눠줘야 실제 이동 거리가 나옵니다.

// 줌 좌표 보정
const dx = (e.clientX - startX) / zoom
const dy = (e.clientY - startY) / zoom

zoom 값은 useRef로 항상 최신값을 유지해야 합니다.

useState를 쓰면 클로저 때문에 드래그 시작 시점의 zoom 값이 고정되는 문제가 생깁니다.

 

드래그, 리사이즈, 고무줄 선택 모두에 적용하세요

오버레이 패턴은 이 세 가지에 모두 적용됩니다.

  • 드래그 : 위젯 위치 이동
  • 리사이즈 : 모서리를 잡아서 크기 조절
  • 고무줄 선택 : 캔버스 빈 곳을 클릭 드래그해서 영역 안의 위젯들을 일괄 선택

세 가지 모두 "마우스를 누른 채 움직이다가 떼는" 패턴이라, 같은 오버레이 방식으로 구현할 수 있습니다.


세 가지 방법 비교

방법 React 18 호환 빠른 드래그 코드 복잡도
document.addEventListener 타이밍 충돌 불안정 보통
setPointerCapture 타이밍 충돌 불안정 보통
드래그 오버레이 패턴 완벽 호환 안정적 보통

마치며

드래그 구현이 처음엔 단순해 보입니다.

마우스다운, 마우스무브, 마우스업 세 이벤트를 연결하면 된다고 생각하니까요.

그런데 실제로 만들다 보면 문제들이 생깁니다.

마우스가 요소 밖으로 나가면 드래그가 멈추고, React 18에서는 이벤트 등록 방식이 달라져서 기존 방법이 통하지 않고, 줌 기능을 추가하면 좌표가 맞지 않고.

드래그 오버레이 패턴은 이 문제들을 가장 단순하게 해결합니다.

화면 전체를 덮는 투명한 유리판 하나로요.

구현해보면 의외로 코드가 짧습니다.

복잡한 것은 아이디어뿐이고, 코드는 단순합니다.

반응형