최근에 드래그 앤 드롭 기능을 만들어보면서 느낀 점은 정말 재미있는 기능이라는 점인 것 같다! 이번에는 간단한 드래그앤드롭,DND 예제를 만들어보고, 그 후에 어떤 최적화를 할 수 있을까에 대해서 고민해보려고 한다.
드래그 앤 드롭 기능 예제와 성능 최적화 🚀
일단 dnd 예제를 만들어야하는데, 초기에 필요한 세팅은 클로드의 도움을 받아서 작성했다. 프롬프트를 작성한 예시는 아래와 같다.
간단한 드래그앤드롭을 만들기 위한 초기 html과 css만 만들어줘, 기능 구현은 하지 않고 만들어줘
이렇게 해서 만들어진 예시 화면이다. 디자인은 대충 디스코드 느낌 나게 만들어봤다. 색깔만
간단하게 설명하면 왼쪽에 있는 draggable한 아이템들을 드래그앤드랍으로 오른쪽의 dropzone으로 끌어다 놓으면 아이템을 움직일 수 있도록 구현되어 있다. 그리고 드래그한 상태로 마우스를 dropzone에 가져다 두면 미리보기가 반투명하게 생기도록 하는 기능을 구현해 뒀다.
위 예제를 가지고 최적화를 해볼 계획이다. 일반적으로 드래그앤드랍에서는 성능적인 문제가 없을 것 같다. 하지만 미리보기를 띄우는 기능에서 dragover 이벤트를 활용하는데 이 이벤트는 클릭과 같이 단발성으로 끝나는 이벤트가 아니다.
드래그한 상태라면 무수히 많이 호출되는 이벤트가 바로 dragover이다. 다른 방법이 있을 수도 있지만 우선 내 예제에서는 dragover 이벤트 핸들러에서 계속해서 마우스가 위치한 좌표값을 비교하는 함수를 사용하기 때문에 이벤트 호출이 되면 될수록 성능적인 부담이 되는 구조이다.
Drag 최적화 방법 1. throttle
다른 것보다 가장 먼저 떠오른 방법이다. 쓰로틀링과 같은 말을 어디선가 들어본 적 있을 수 있는데 컴퓨터 하드웨어 등의 분야에서 온도가 너무 올랐을 때 발열량을 줄이기 위해 일부러 클럭(작동속도)을 낮추는 등의 기술을 말한다.
이것처럼 드래그 앤 드롭 과정에서 발생하는 수많은 이벤트 핸들러의 호출을 강제로 일정 간격을 두고 실행이 되게끔하는 것이 throttle 최적화의 핵심이다.
리액트의 lodash 같은 라이브러리에서도 많이 사용하는 함수인데, 직접 구현해 보면 아래와 같다. 처음 보면 이해하기 어려울 수 있는데, 그냥 매개변수로 함수와 시간간격을 받아서 시간간격마다 함수가 실행되도록 제한하는 함수이다.
const throttle = (func: (...args: any[]) => void, gapTime: number) => {
// func 지연할 함수, gapTime 지연시간 간격
let lastTime: NodeJS.Timeout | null = null;
return (...args: any[]) => {
if (lastTime) return;
lastTime = setTimeout(() => {
func(...args);
lastTime = null;
}, gapTime);
};
};
그렇다면, 어떻게 적용할 수 있을까 아래 예시코드처럼 적용할 수 있다. 처음에는 전체 핸들러를 throttle
함수로 감싸서 이벤트 핸들러 발생을 지연시켰다. 이렇게 하면 dragover 이벤트 핸들러를 100ms (예시)에 한번 실행하도록 할 수는 있지만, 정작 중요한 Drop 이벤트가 발생하지 않는 참사가 일어난다.
이벤트 핸들러 자체를 지연시키면, 지연되는 동안(throttle에서 강제로) drop 이벤트가 호출돼도 drop 핸들러가 발동하지 않는다.
그렇다면 아래의 예시코드와는 어떤 차이가 있을까? 아래의 예시코드는 e.preventDefault()
와 e.stopPropagation()
을 제외한 나머지 미리보기 렌더링 로직을 별도의 함수로 분리하고 해당하는 함수에 throttle을 적용시킨 모습이다.
이렇게 하면 이벤트 핸들러는 그대로 많이 호출이 되지만, 실질적인 성능을 요구하는 작업은 딜레이를 할 수 있기 때문에 drop 이벤트의 발생도 막지 않으면서 성능을 요구하는 작업은 최적화를 진행할 수 있었다.
const dragOverHandler = (e: DragEvent) => {
e.preventDefault();
e.stopPropagation();
throttledDragOver(e);
};
const throttledDragOver = throttle((e: DragEvent) => {
const target = e.target as HTMLElement;
const closest = target.closest(".drop-zone");
if (closest) {
// dragover 되고 있는 위치가 drop-zone일 때
const elements = [...closest.querySelectorAll(".item")] as HTMLElement[];
// findIndex는 현재 마우스의 위치에 따라 어디에 미리보기를 넣을지 그 인덱스를 계산하는 함수
const index = findIndex(elements, e.y);
// 미리보기 Element
const preview = document.querySelector(".place") as HTMLElement;
// 미리보기 넣기
if (elements.length - 1 === index) {
closest.insertBefore(document.querySelector(".place"), null);
} else {
closest.insertBefore(document.querySelector(".place"), closest.children[index] || null);
if (preview instanceof HTMLElement) {
if (elements.length - 1 === index) {
closest.appendChild(preview);
} else {
closest.insertBefore(preview, closest.children[index] || null);
}
}
}
};
// 16ms 마다 한번 실행되도록 제한
}, 16);
그렇다면 최적화 후의 모습은 어떨까? 확연하게 호출량이 줄어든 것을 볼 수 있다.
쓰로틀의 지연 시간을 16ms로 한 이유는, 일반적으로 60hz 모니터는 1초에 60번의 화면을 그린다고 한다. 그렇다면 16ms * 60번이면 대충 1초가 되기 때문에 16ms가 넘지 않으면 화면이 전혀 끊겨보이지 않는다. 물론 60hz 모니터 이상이면 다를 수 있다.
Drag 최적화 방법 2. requestAnimationFrame
이 메서드는 이전에 대충 따라서만 써봐서 학습이 필요했다. 알아보니 throttle처럼 작용한다. throttle은 개발자가 임의의 시간 간격을 넣어줘서 제한한다면, 이 requestAnimationFrame
은 사용자의 주사율에 맞게 알잘딱으로 화면을 그려준다는 점이 큰 차이점이다.
그 외에도 백그라운드나 해당 요소가 숨겨지는 경우에 애니메이션을 멈추기 때문에 에너지 측면에서도 절약된다는 점이나, 브라우저 최적화로 가장 최적의 타이밍에 콜백함수를 실행하는 점이 특징이다.
그렇다면 이 기능을 써서 최적화를 해보자. 리퀘스트 애니메이션 프레임을 사용하면 쓰로틀 기법처럼 e.preventDefault()
와 e.stopPropagation()
을 따로 빼줄 필요가 없다.
const dragOverHandler = (e: DragEvent) => {
e.preventDefault();
e.stopPropagation();
renderPreview(e);
};
let raf: number | null = null;
const renderPreview = (e: DragEvent) => {
if (raf !== null) {
cancelAnimationFrame(raf);
}
raf = requestAnimationFrame(() => {
const target = e.target as HTMLElement;
const closest = target.closest(".drop-zone");
if (closest) {
const elements = [...closest.querySelectorAll(".item")] as HTMLElement[];
const index = findIndex(elements, e.y);
console.log(index);
const preview = document.querySelector(".place") as HTMLElement;
// 미리보기 넣기
if (preview instanceof HTMLElement) {
if (elements.length - 1 === index) {
closest.appendChild(preview);
} else {
closest.insertBefore(preview, closest.children[index] || null);
}
}
}
raf = null;
});
};
raf는 requestAnimationFrame의 id 값이 들어가거나 null 값을 가진다. 만약 raf가 id 값을 가지고 있는 상태라면(하나의 프레임이 그려지고 있는 과정) 해당 프레임이 끝날때까지 다른 프레임이 중복을 실행되지 않는다.
그렇게 하나의 프레임이 끝나면 raf를 null 값으로 바꿔서 다음 프레임을 그릴 수 있도록 만든다. 이렇게 최적화해 봤는데, 어떻게 동작할지 확인해 보자
테스트를 좀 더 용이하게 하기 위해서 1초마다 한번 콘솔로그를 찍는 interval함수를 만들어 사용했다. 그렇게 확인한 결과 정말 1초에 60번 정도 실행되는 것을 확인할 수 있었다. 신기하다 😱
throttle은 16ms 이상의 시간간격으로 할 경우에는 버벅거림이 약간 생길 수 있지만 이 리퀘스트 애니메이션 프레임은 화면 주사율에 맞게 실행되기 때문에 더 효율적으로 최적화를 할 수 있는 것 같다.
정말 성능이 중요해서 16ms 이상으로 조절해야하는 경우가 아니라면 requestAnimationFrame
을 사용해서 최적화를 하는 것이 더 효율적이라고 생각이 들었다.
이번 글에서 살펴본 모든 예제와 코드는 아래 깃허브 리포지토리에서 확인할 수 있다. 브랜치별로 코드가 다른데, performance/drag1 과 drag2는 각각 throttle
과 requestAnimationFrame
으로 최적화한 각각의 코드를 볼 수 있다. 그리고 beforeOptimize는 최적화 전의 코드를 볼 수 있다.
최적화에는 정답이 없다고 생각해서 아마 이것보다 더 뛰어난 성능최적화 방법이 있을 것이라고 생각한다. 이번 글에서는 대표적으로 쉽게 활용할 수 있는 2가지 최적화 방법에 대해서 알아봤다. 👍
'Web > Frontend' 카테고리의 다른 글
react-icons 라이브러리로 알아보는 트리 쉐이킹 (0) | 2024.11.03 |
---|---|
React에서 이벤트를 처리하는 방법 with 이벤트 위임과 합성 (6) | 2024.10.06 |
[React] useEffect 역할과 사용 방법 (1) | 2024.09.29 |
간단한 예제와 함께 알아보는 Flux 패턴 (0) | 2024.09.15 |
oraciondev 님의 블로그 입니다.
안녕하세요