[React]useRef를 이용한 컴포넌트 드래그 효과(실패편)

유현우·2022년 2월 27일
0
post-thumbnail

이번에 토이 프로젝트를 진행하면서 나름 중요한 기능으로 생각했던 것이 바로 드래그가 가능한 창을 만드는 것이었다.

맥북의 바탕화면을 최대한 그대로 따라하는 것이 주요 과제였는데, 겉만 맥북과 비슷한 환경을 만드는 걸로 끝나게 되면 css 외에 별다른 기능이 없었기 때문에 이런 인터렉티브가 가능한 기능이 있다면 좀 더 재밌는 프로젝트가 아닐까 하는 생각에 추가를 하기로 했다.

어떻게 기능을 만들까?

일단 이 기능을 만드는 데 꼭 필요한 좌표는 3개이다.

1) 현재 마우스 위치

2) 클릭이 시작되었을 때 마우스의 위치

3) 클릭이 시작되었을 때 창의 좌상단 위치

1번이야 당연히 현재 마우스 위치를 기반으로 드래그가 작동하기 때문에 당연히 필요했고, 2와 3번은 좀 더 정교한 드래그 효과를 위해서 필요로 하게 되었다.

일반적으로 우리가 윈도우, 맥에서 쓰는 드래그는 창을 클릭하는 순간 창 내부의 마우스의 상대 위치는 고정이 된 상태이다.

창 좌측에서 드래그를 하면 마우스를 놓을 때까지 마우스가 창 좌측에 붙어있는 상태로 창이 따라오고, 오른쪽에서 드래그를 하면 마찬가지로 마우스가 창 우측에 붙은 상태로 창이 따라온다. 이를 구현하려면 단순히 마우스 위치나 창의 너비만 가지고는 구현할 수 없다. (어딜 클릭하나 창 중앙/좌측 기준으로 드래그를 구현하려면 현재 마우스 위치만으로도 가능하다.)

때문에 이런 드래그를 구현하기 위해서는 클릭이 시작되었을 때 마우스와 창의 좌상단 위치를 구해 얼마나 거리가 떨어져있는지를 구해놓은 다음 현재 마우스 위치에서 빼주면 창이 있어야할 위치(CSS absolute 상의 left, Top 위치)를 구할 수 있다고 판단하였다.

useState를 이용한 드래그 효과 시도

맨 처음에는 당연하게도 useState를 사용해서 이 기능을 만드려고 했다. 드래그 중일 때 마우스 위치야 마우스 이벤트를 연결하면 바로 알 수 있지만, 2), 3)의 값(이하, 마우스와 창의 간격)은 클릭이 시작되었을 때 구해서 어딘가에 값을 저장해놔야 했기 때문이다.

그래서 위에서 작성한 내용을 기반으로 일단 코드를 짜보았다.

import styled from 'styled-components';
import sideimg from '../image/42memory_folder_side.png';
import titleimg from '../image/42memory_folder_title_option.png';
import fileimg from '../image/42memory_file.png';
import ButtonList from '../common/ButtonList';
import { useState, useCallback, useRef } from 'react';

interface StyledDirectoryProps {
  x: number;
  y: number;
}

const StyledDirectory = styled.div<StyledDirectoryProps>`
  position: absolute;
  left: ${(props) => `${props.x}px`};
  top: ${(props) => `${props.y}px`};
  width: 1000px;
  height: 800px;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: start;
  border-radius: 8px;
  border: 1px solid #beb5b4;
  background-color: #eeeeee;
  ...중략
`;

interface DirectoryBlockProps {
  setVisible: React.Dispatch<React.SetStateAction<boolean>>;
}

const DirectoryBlock: React.FC<DirectoryBlockProps> = ({ setVisible }: DirectoryBlockProps) => {
  const [x, setX] = useState(0); //현재 마우스 위치
  const [y, setY] = useState(0);
  const [prevDistanceX, setPrevDistanceX] = useState(0); //마우스 다운 시 directory-header 기준 마우스의 상대 위치
  const [prevDistanceY, setPrevDistanceY] = useState(0);

  const update = useCallback(
    (e: MouseEvent): void => {
      setX(e.x);
      setY(e.y);
    },
    [setX, setY],
  );
  return (
    <StyledDirectory x={x - prevDistanceX} y={y - prevDistanceY}>
      <div
        className="directory-header"
        onMouseDown={(e) => {
          setPrevDistanceY(e.pageX - headerRef.current.offsetTop);
          setPrevDistanceX(e.pageY - headerRef.current.offsetLeft);
          window.addEventListener('mousemove', update);
        }}
        onMouseUp={() => {
          setPrevDistanceY(0);
          setPrevDistanceX(0);
          window.removeEventListener('mousemove', update);
        }}
      >
        ...중략
      </div>
      <div className="directory-content">
        ...중략
      </div>
    </StyledDirectory>
  );
};

export default DirectoryBlock;

코드의 진행과정은 다음과 같다.

  1. directory-header 영역에서 마우스를 클릭하여 onMouseDown 이벤트를 발생시킨다. 이 때, 현재 마우스 위치(pageX, pageY)와 창의 좌상단과의 거리를 useState()를 이용해 x, y 따로 저장한다.

  2. 이후 창 전체에 mousemove 이벤트로 update 함수를 연결한다. 이 때부터 현재 마우스의 위치가 x, y 상태로 저장이 되어서 리렌더링이 일어나고, Styled Component에 props를 제공하게 되어 새로 생성된 창의 위치가 변하게 된다. 따라서 창이 마우스를 따라다닐 것이다.

  3. 마우스 드래그를 끝내고 버튼을 떼게 되면 저장해 뒀던 state를 0으로 복구시킨다.

  4. mousemove 이벤트도 다시 해제해서 더 이상 마우스를 따라다니지 않도록 한다.

이후 작성한 코드를 실행해 보았더니 화면이 끊기고 창이 마우스를 따라다니기는 하지만, 빠르게 움직이면서 놓게 되면 directory-header 영역 밖에서 마우스를 놓게 되기 때문에 onMouseUp 함수도 제대로 작동이 되지 않았다.

왜 실패를 했는가?

일단 동작은 하는걸 보니 방법이 틀린 건 아니고 렌더링 과정에서 문제가 있는 것이라고 판단을 했다. 그래서 React Devtools extension을 통해 확인을 해보니 DirectoryBlock의 렌더링이 상당히 오래걸리는 것을 파악할 수 있었다.

mousemove 함수의 호출은 ms보다 더 빠를텐데 렌더링에 130ms나 소모되니 상태 변화를 받쳐주지 못해서 계속 밀리게 되어 렉이 발생하는 것으로 보였다.

그럼 이제 이게 왜 렌더링이 이렇게 늦게 되는지를 파악해야했다. 현재 DirectoryBlock 내부에서 리렌더링을 유발한 값은 아래와 같이 3개였고, 사실상 x,y 상태 값이 mousemove 이벤트에 의해 반복적으로 변한게 제일 클 것이라 생각했다.

1차 수정(2개의 상태를 1개의 상태로 합치기)

마우스는 평행, 수직 한쪽으로만 움직이는게 아니라 2차원적으로 움직이기 때문에 x, y의 값을 따로 저장하게 되면 2번의 state 변경이 일어나게 되서 더 자주 state가 변경되는 것이 아닐까 하는 생각이 들었다. 렌더링 자체를 막을 수 있으면 좋겠지만, 일단은 계속 변하게 되는 state를 줄여볼 생각으로 x, y의 상태를 하나의 배열로 묶어보았다.

...생략 
 const [position, setPosition] = useState([0, 0]); //x,y를 배열로 생성!
...중략
  const update = useCallback(
    (e: MouseEvent): void => {
      setPosition([e.pageX, e.pageY]);
    },
    [setPosition],
  );
	return (
    <StyledDirectory x={position[0] - prevDistanceX} y={position[1] - prevDistanceY}>
		</StyledDirectory>
	);

하지만 이러한 시도에도 큰 성능의 향상은 보이지 않았다.(130ms에서 120~5ms 정도)

결국 useState를 이용한 방법은 리렌더링으로 인해 사용할 수 없다고 판단하여 폐기하고 다른 방법을 찾기로 했다.

<다음 글에 계속>

profile
노력하도록 노력하자

0개의 댓글