NextJS에서의 requestAnimationFrame 연구
requestAnimationFrame는 브라우저에서 애니메이션을 효율적으로 실행하기 위해 제공하는 메서드다. 이는 rAF 메서드가 window 객체에 속해있다는 의미이며, node 환경에서는 접근할 수 없다는 뜻이기도 하다. rAF는 다음 리페인트 전에 지정된 콜백 함수를 호출해 애니메이션이 화면 재생 빈도에 맞춰 부드럽게 실행될 수 있도록 한다. 모니터가 60hz라면 초당 60번, 144hz라면 초당 144번 호출된다. 또한, rAF는 setTimeout보다 성능이 뛰어나며, 비활성 탭에서는 호출이 중지되어 리소스를 절약할 수 있다.
MDN의 예시 코드만 봐도 바닐라 JS에서는 재귀함수의 형태로 requestAnimationFrame를 사용하는 듯하다. 그러나 리액트에서는 몇 가지 이유로 인해 다른 방식으로 처리하기로 했다. 가장 큰 이유는 리액트가 화면에 새로 무언가를 그려내기 위해서는 반드시 state를 사용해야 한다는 점 때문이었다. 재귀 함수 안에 state를 넣어버리고 이를 useEffect로 처리하게 되면 빠른 속도로 콜스택을 폭☆파 해버리게 된다.
rAF 메서드가 window 객체에 속해있기에 NextJS 환경에서는 반드시 useEffect를 통해 접근해야 한다. 따라서 나는 재귀 함수 없이 state와 useEffect 만으로 애니메이션 호출 코드를 작성했다. 대강의 코드는 아래와 같으며, 코드의 재사용성을 위해 애니메이션 로직을 커스텀 훅으로 분리하였다.
export const useFollowMouse = () => {
const [target, setTarget] = useState({x: 0, y: 0})
const [points, setPoints] = useState({x: 0, y: 0})
const speed = useRef(0.1)
useEffect(() => {
const update = () => {
const x = target.x + (points.x - target.x) * speed.current
const y = target.y + (points.y - target.y) * speed.current
setTarget({x: Number(x.toFixed(2)), y: Number(y.toFixed(2))})
}
const a = requestAnimationFrame(update)
return () => {
cancelAnimationFrame(a)
}
}, [target, points])
useEffect(() => {
const handleMouseMove = (e: MouseEvent) => {
setPoints({x: e.clientX, y: e.clientY})
}
window.addEventListener('mousemove', handleMouseMove)
return () => {
window.removeEventListener('mousemove', handleMouseMove)
}
}, [])
return target
}
export default function B () {
const {x, y} = useFollowMouse()
return <div style={{
width: "2rem",
height: "2rem",
backgroundColor: "red",
position: "absolute",
top: `${target.y}px`,
left: `${target.x}px`}}
/>
}
만약 rAF 없이 useEffect 내에서 직접 update 함수를 호출하면 어떻게 될까? state가 업데이트 되면서 useEffect가 update 함수를 호출하고 ➠ update 함수가 state를 업데이트 하고 ➠ 다시 state가 업데이트 되었으니... 이렇게 무한히 반복하며 화면에 애니메이션을 그려줄 것 같고 실제로도 애니메이션을 그려주기는 한다.
하지만 여기에는 몇 가지 문제가 있다. 재귀 함수를 사용하는 것과 정확히 같은 방식으로 콜스택은 폭☆파 되며, 더 큰 문제는 speed ref 값 역시 의도한 대로 적용되지 않는다는 것이다. 이는 state가 아주 빠른 속도로 반복해서 다른 값으로 업데이트 되는 경우, 리액트가 배치 처리(batch processing) 메커니즘을 사용하여 다수의 상태 업데이트를 모아 한 번에 렌더링하기 때문이다.
반면에 useEffect에 의해 호출된 rAF는 즉시 state를 업데이트 하지 않고, 화면 재생 빈도에 맞춰 update 함수의 호출을 지연시킬 수 있다. 덕분에 rAF를 사용하는 경우 state는 정직하게 60번(혹은 120번이나 144번)만 변경되며, 이를 통해 브라우저의 자원 및 기기의 리소스(특히 모바일 기기의 배터리 등)를 효과적으로 절약하도록 도움을 준다.
+ 실제로 확인해본 결과 rAF를 사용할 때는 초당 55번 state를 업데이트하고, rAF 없이 useEffect가 update 함수를 직접 호출하는 경우에는 (배치 처리를 하고 있음에도) 대략 초당 400번 정도 state가 업데이트되었다. 불필요한 리랜더링을 350번이나 줄일 수 있다면 당연히 rAF를 사용하는 게 맞지 음음 그렇고 말고.
블로그의 정보
Ayden's journal
Beard Weard Ayden