RangeSlider 구현 도중 발생한 문제 분석 및 이벤트 성능 최적화에 대해

SangBooom·2023년 4월 9일
3
post-thumbnail

환경 : react.js + next.js + typescript

영상이 로딩되면 y축으로 슬라이더를 움직여 영상을 크롭할 수 있는 구간 선택 UI와 x축으로 타임라인이 생성되면 슬라이더를 움직여 해당 시간을 뽑아낼 수 있도록 위치를 조정하는 UI를 만들게 되었다. 먼저, 어떻게 만들지 고민해보자.

먼저 rangeSlider를 봤을 때 가장 먼저 생각난 방법은 마우스 이벤트로 구현하는 방법이였다.
마우스를 클릭했을 때, 해당 clientX값을 저장하고 마우스 드래그 할 때 움직인 거리만큼 저장한 clientX값에 더해줘서 rangeSlider를 움직인다. 마우스를 오버하거나 아웃하면 저장한 clientX값을 null로 처리해주어 더이상 드래그 이벤트가 발생하지 않도록 처리해주면 된다고 생각했다.

mouseDown / mouseMove / mouseup&mouseOut 이벤트로 시도

  const [xPosition, setXPosition] = useState(0);
  const [selectionWidth, setSelectionWidth] = useState(INITIAL_SELECTION_WIDTH);
  const [dragStartPosition, setDragStartPosition] = useState<number | null>(null);
  ...
  
  const handleMouseDown = (mouseDownTarget: MouseDownTarget) => (e: MouseEvent<HTMLDivElement>) => {
    if (e.target === e.currentTarget) setDragStartPosition(e.clientX);
  };

  const handleSelectionMouseMove = (e: MouseEvent<HTMLDivElement>) => {
    if (dragStartPosition) {
      const dx = e.clientX - dragStartPosition;
      setXPosition((prevX) => Math.max(Math.min(prevX + dx, SLIDER_WIDTH - selectionWidth), 0));
      setDragStartPosition(e.clientX);
    }
  };

  const handleMinSliderMouseMove = (e: MouseEvent<HTMLDivElement>) => {
    if (dragStartPosition) {
      const dx = e.clientX - dragStartPosition;
      setSelectionWidth((prevW) => Math.max(Math.min(prevW - dx, sliderMaxWidth), sliderMinWidth));
      setDragStartPosition(e.clientX);
    }
  };

  const handleMaxSliderMouseMove = (e: MouseEvent<HTMLDivElement>) => {
     if (dragStartPosition) {
      const dx = e.clientX - dragStartPosition;
      setSelectionWidth((prevW) => Math.max(Math.min(prevW + dx, sliderMaxWidth), sliderMinWidth));
      setDragStartPosition(e.clientX);
    }
  };

  const handleMouseUp = () => {
    setDragStartPosition(null);
  };

...

<RangeSliderGroup>
  <Selection
	onMouseDownCapture={handleMouseDown}
	onMouseMoveCapture={handleSelectionMouseMove}
	onMouseUpCapture={handleMouseUp}
	onMouseOutCapture={handleMouseUp}
  />
  <MinSlider
	onMouseDownCapture={handleMouseDown}
	onMouseMoveCapture={handleMinSliderMouseMove}
	onMouseUpCapture={handleMouseUp}
	onMouseOutCapture={handleMouseUp}
  />
  <MaxSlider
	onMouseDownCapture={handleMouseDown}
	onMouseMoveCapture={handleMaxSliderMouseMove}
	onMouseUpCapture={handleMouseUp}
	onMouseOutCapture={handleMouseUp}
  />
</RangeSliderGroup>

gif 라서 버벅여보이는 것이고 실제로는 프레임 드랍없이 빠르게 동작한다.

위와 같이 간단히 마우스로 드래그 할 수 있는 슬라이더를 만들었다! 뭐야 슬라이더 쉽네? 라고 생각하면 큰 오산이다! 슬라이더의 여러가지 예외처리를 해주다보면 가독성이 점점 떨어지기 시작할 것이다.. 기본적인 로직 코드를 이해한다면 예외처리는 쉽게 처리할 수 있을 것이라 생각해서 넘어가도록 하겠다.

다시 코드로 돌아가서, 해당 코드에서 문제는 크게 두가지가 있다.

    1. 마우스 이벤트로 처리하다보니 마우스가 아닌 태블릿, 모바일에서는 이벤트가 동작하지 않는다.
    1. MaxSlider와 MinSlider를 조금만 빠르게 드래그하면 따라오다가 마우스 포인터를 따라오지 못하고 멈춘다.

마우스가 아닌 태블릿, 모바일에서는 이벤트가 동작하지 않는 문제 해결하기

1번 문제를 먼저 해결해보자. 일단 태블릿과 모바일의 이벤트는 어떻게 동작하는지 레퍼런스를 찾아보았다.

https://rbyers.github.io/eventTest.html
위 사이트에서 개발자 도구를 열고 toggle device toolbar를 열어 mousemode와 touchmode로 테스트 해보면 이벤트 동작이 어떻게 다른지 테스트 해볼 수 있다.

터치 이벤트는 항상 터치가 시작된 요소를 대상으로 하는 반면 마우스 이벤트는 현재 마우스 커서 아래에 있는 요소를 대상으로 한다.
이것이 mouseovermouseout 이벤트가 있지만 해당하는 touchovertouchout 이벤트가 없고 touchend만 있는 이유라고 한다.

그리고 위 사이트에서 테스트 해보면 알 수 있다시피 마우스와 터치스크린을 핸들링하면 pointerEvent가 발생하는 것을 볼 수 있다.

PointerEventmdn 문서를 보면 알다시피 MouseEvent를 상속하고 있다.
즉, 마우스, 펜, 터치스크린 등을 포함한 입력기기를 하드웨어에 상관없이 포인터 입력을 핸들링하기 위한 이벤트라는 것을 알 수 있다.

아래와 같이 사용자 기기를 고려해서 각각 이벤트리스너를 부여할 필요가 없이 PointerEvent로 한번에 이벤트 처리를 할 수 있게 되었다.

// ❌
<RangeSliderGroup>
    <Selection
      // pc
      onMouseDown={handlePointerDown}
      onMouseMove={handleSelectionPointerMove}
      onMouseUp={handlePointerUp}
      onMouseOut={handlePointerUp}
      // tablet, mobile
      onTouchStart={handlePointerDown}
      onTouchMove={handleSelectionPointerMove}
      onTouchEnd={handlePointerUp}
	/>		
   ...
</RangeSliderGroup>


// ✅
<RangeSliderGroup>
  <Selection
    // pc, tablet, mobile
    onPointerDownCapture={handlePointerDown}
    onPointerMoveCapture={handleSelectionPointerMove}
    onPointerUpCapture={handlePointerUp}
    onPointerCancelCapture={handlePointerUp}
  />
  ...
</RangeSliderGroup>

위와 같이 PointerEvent를 다룰 때 유의할 점은 이벤트 처리는 한번에 할 수 있지만 핸들링은 따로 해줘야 한다는 것이다.

아래 스크린샷을 참고하여 MouseEventTouchEvent를 타입가드 처리와 함께 동작에 따라 분기 시켜주었다.


const isTouchEvent = (e: TouchEvent | MouseEvent): e is TouchEvent => {
  return e && 'touches' in e;
};

const isMouseEvent = (e: TouchEvent | MouseEvent): e is MouseEvent => {
  return e && 'screenX' in e;
};

const getClientX = (e: TouchEvent | MouseEvent) => {
  let clientX = 0;
  if (isMouseEvent(e)) {
    clientX = e.nativeEvent.clientX;
  } else if (isTouchEvent(e)) {
    clientX = e.nativeEvent.touches.length > 0 ? e.nativeEvent.touches[0].clientX : e.nativeEvent.changedTouches[0].clientX;
  }
  return clientX;
};


// 사용부
const handleSelectionMouseMove = (e: PointerEvent<HTMLDivElement>) => {
  if (dragStartPosition) {
    const dx = getClientX(e) - dragStartPosition;
    setXPosition((prevX) => Math.max(Math.min(prevX + dx, SLIDER_WIDTH - selectionWidth), 0));
    setDragStartPosition(getClientX(e));
  }
};

Min/MaxSlider를 빠르게 드래그하면 따라오다가 마우스 포인터를 따라오지 못하고 멈추는 문제 해결하기

이제 2번 문제를 해결 할 차례이다.
내가 원하는 솔루션은 mouseDown / mouseMove / mouseUp 이벤트 시에도 touchStart / touchMove / touchEnd 이벤트 동작과 동일하게 항상 터치가 시작된 요소를 대상으로 움직이고 마우스를 놓았을때 이벤트 동작을 제거하도록 하는 것이였다.
열심히 mdn을 찾아보니 PointerEventcurrentTarget 안에 setPointerCapture 라는 메서드가 있었다. 내가 원하는 동작을 정확히 해주는 메서드이다.

  const handlePointerDown = (e: PointerEvent) => {
    ...
    // 포인터가 요소를 벗어나더라도 포인터 이벤트를 계속 수신하도록 설정
    e.currentTarget.setPointerCapture(e.pointerId);
  };

  const handlePointerUp = (e: PointerEvent) => {
    ...
    // 포인터 이벤트 수신 해제
    e.currentTarget.releasePointerCapture(e.pointerId);
  };

setPointerCapture 덕분에 pc에서도 터치동작과 같은 이벤트 동작을 할 수 있게 되었다.

트러블 슈팅

터치스크린 모드에서 드래그가 바로 풀리는 현상이 일어날 때

터치스크린 모드에서 드래그시에 바로 pointerUp or pointerCancel 이 되는 경우가 있다. 다른 이벤트 동작들 (스크롤, 컨텍스트 메뉴 - 대부분 마우스 우클릭하면 나옴) 로 인해 해당 이벤트가 취소되는 것이다. 이럴떈 세가지 방법이 있다.

    1. e.preventDefault()로 기본 이벤트를 막자.
    1. document.body.style.overflow = 'hidden'; 으로 스크롤을 막고 pointUp 할때 unset으로 풀어주자.
    1. touch-action: none 으로 터치 액션을 막자.

드래그시 버벅이는 현상이 일어날 때, 또는 처음에는 드래그가 빠르게 잘되다가 점점 느려질 때

// ❌
const RangeSliderGroup = styled.div<{ xPosition: number; selectionWidth: number }>`
  ...
  left: ${({ xPosition }) => xPosition}%;
  width: ${({ selectionWidth }) => selectionWidth}%;
`

// ✅
const RangeSliderGroup = styled.div.attrs<{ xPosition: number; selectionWidth: number }>(({ xPosition, selectionWidth }) => ({
  style: {
    left: `${xPosition}%`,
    width: `${selectionWidth}%`,
  },
}))<{ xPosition: number; selectionWidth: number }>`
  ...
`

결론부터 말하자면, 해당 문제는 css-in-js(styled-component, emotion)에서 슬라이더 드래그시에 요소의 style로 속성을 넘기지 않고 props로 넘기면 발생하는 문제이다. 왜 그럴까?

그 이유는 바로 메모리 누수 때문이다. rangeSlider를 드래그 할때마다 새로운 className을 부여한 styled-component를 만든다.

엄청난 양의 노드가 생성되고 삭제되지 않아 시간이 지나면 버벅이게 되고 느려지는 현상이 발생하게 된다. 그리고 아래와 같은 경고를 뱉어낸다.

그렇다면 한번 성능 분석을 해보자. 이번에는 더 빠르게 무수한 드래그 이벤트를 발생시켜 보았다.


위 성능 분석 결과 스크린샷에서 메모리 탭을 간단하게 분석해보면, JS 힙이 시작된 것보다 높게 끝나고 있고, 노드 크기 및 리스너 크기가 증가하는 것을 볼 수 있다. 특히 노드가 계속해서 증가하는 것으로 보아 엄청난 메모리 누수의 징후이다.

위에서 말했던 것 처럼 props대신 요소의 style로 속성을 넘기고 다시 성능을 측정해보자.

이번엔 노드가 계속해서 생성 되지 않았고 JS힙과 리스너가 처음 시작된 것보다 더 낮게 끝났다. 지금도 JS힙(파란색 선)이 자주 오르락 내리락하는것으로 보니 빈번한 가비지 컬렉션이 되고 있는 것 같고 좋은 신호가 아니긴하지만 그래도 전보다는 훨씬 개선되었고 버벅임 문제가 사라졌다!

추가 성능 개선 방법

left 대신 transform: translate3d(x, 0, 0) 사용하기

left 대신 transform: translateX() 를 사용하면 성능 개선이 될 것이다.
left같은 포지셔닝 경우는 다른 엘리먼트에 영향을 끼치기 때문에 당연히 랜더링과 페인팅이 발생하게 된다.
그로인해, 무거워질수록 CPU 계산이 늘어나기 때문에, 그 과정에서 속도 저하 및 끊김을 경험하게 된다.
반대로 transform: translateX()는 GPU에서 처리하기 떄문에 효율적으로 처리할 수 있게 된다.
trade-off로 모바일, 태블릿 기기에서는 배터리가 빠르게 소모될 수 있다는 단점이 있다.

requestAnimationFrame (RAF)

이건 좋은 레퍼런스들이 많기 때문에 자세한 설명은 생략하겠다.
브라우저가 언제 업데이트를 할지 알게해줌으로써 프레임 손실을 방지해준다. 이에 따라 리소스도 균등하게 분배해서 사용할 수 있고, 간격도 균등하게 가져갈 수 있다.
하지만, requestAnimationFrame()의 호출할 함수가 16ms 이상이 걸린다면 그 다음에 호출될 RAF가 생략되는 문제가 있다.
그래서 프레임 유실을 최대한 막기위해 RAF의 콜백에 넣는 함수는 최대한 가볍게 작성하거나 쪼개서 작성해야 한다.
이름을 보고 오해할 수도 있는데 반드시 애니메이션만을 위한 함수는 아니라는 것을 기억하자.

RangeSlider Playground

마지막으로 지금까지 설명한 rangeSlider를 테스트 해볼 수 있는 playground를 공유하고 마치도록 하겠다.

참고한 레퍼런스 :
https://web.dev/mobile-touchandmouse/##3-touchmove-and-mousemove-arent-the-same-thing
https://web.dev/add-touch-to-your-site/
https://googlesamples.github.io/web-fundamentals/fundamentals/design-and-ux/input/touch/touch-demo-2.html
https://itchallenger.tistory.com/806

profile
끊임없이 떨어지는 물방울이 바위를 뚫는다

0개의 댓글