2023-05-04

JS Animation 성능 이슈 해결하기

올해(2023년) 초 개인 웹사이트를 개발하며, Landing 페이지에 개인 로고가 애니메이션을 통해 등장하는 식으로 구현을 했다.

처음 개발을 할 때에는 CSS에서 conic-gradient의 각도에 @keyframes를 먹여서 시간에 따라 서서히 로고가 펼쳐지는 식으로 구현하려 했지만, 아쉽게도 CSS에서 이 방법은 지원하지 않았다.(@property를 사용하면 어찌어찌 구현할 수 있는 것 같긴 했는데, 내가 메인으로 쓰는 브라우저인 Safari에서 이를 지원하지 않기 때문에 사실상 이 방법은 쓰지 않는게 좋다고 판단했다.)

그래서 부득이하게 JS로 애니메이션을 구현하게 되었다.

setInterval()로 Animation 구현

const [angle, setAngle] = useState<number>(-0.4)

useEffect(() => {
  const c = setInterval(() => {
    if (1 >= angle) setAngle(angle + 0.0025)
  }, 1)

  return () => {
    clearInterval(c)
  }
}, [angle])

일단 처음 작성한 코드의 일부는 위와 같은데, useEffect Hook로 angle이 변할 때 마다 setInterval()을 실행시키고? 바로 clearInterval()해버리는 식으로 코드를 작성한 것 같은데, 지금 보니 왜 이렇게 짰는지 모르겠다..

주요 문제는 다음과 같다.

  • 디바이스의 성능에 따라 Animation의 속도가 다르다.
  • angle의 값이 바뀔 때마다 새로운 setInterval()이 실행된다.

그 중, 디바이스 성능에 따라 Animation의 속도가 다르다는 점이 (사용자의 입장에서는) 굉장히 큰 문제였는데, setInterval()로 매 시간 단위마다 angle의 값에 상수 값을 더해주는 식으로 구현하였지만, setInterval()이 정확히 특정 시간이 지난 후 실행됨을 보장하지 못하므로, 디바이스의 성능에 따라 angle의 값이 바뀌는 주기가 다르다.

아무튼 작동이 되기는 하니깐 냅뒀는데, 이 방법엔 문제가 조금 많아 리팩토링을 하기로 했다.

requestAnimationFrame()으로 Animation 구현

requestAnimationFrame()은 애니메이션과 같은 화면 갱신 작업을 처리하기 위해 사용된다. setInterval()과 비슷한 기능을 하지만, requestAnimationFrame()은 브라우저의 렌더링 엔진에게 애니메이션을 수행하도록 요청하는 방식으로 작동한다. 이 방식은 브라우저가 최적화된 타이밍으로 애니메이션을 처리할 수 있도록 하며, 브라우저가 현재 시간에 맞추어 애니메이션을 갱신하는 것보다 더 부드러운 애니메이션을 구현할 수 있다.

그에 반해 setInterval()은 일정한 시간 간격으로 지정된 작업을 반복적으로 실행하는 함수인데, setInterval()은 정확한 간격으로 작업을 실행하지 않을 수 있으며, 브라우저가 작업을 처리하는 동안 지연이 발생할 수 있다. 이러한 지연은 애니메이션에 사용하는 데는 적합하지 않으며, 부드러운 애니메이션을 구현하기 위해 requestAnimationFrame()을 사용하기로 했다.

const refAngle = useRef<number>(-0.4)
const [angle, setAngle] = useState<number>(-0.4)
var then: Date
var startTime: Date
var frameCount: number = 0

const animate = () => {
  if (refAngle.current > 1) return
  var now: Date = new Date()

  then = new Date(now.getTime() - (elapsed % FPS))

  var sinceStart = now.getTime() - startTime.getTime()
  var currentFps = Math.round((1000 / (sinceStart / ++frameCount)) * 100) / 100

  setAngle(refAngle.current + 1.25 / currentFps)

  requestAnimationFrame(animate)
}

useEffect(() => {
  then = new Date()
  startTime = then
  var animationFrame = requestAnimationFrame(animate)

  return () => {
    cancelAnimationFrame(animationFrame)
  }
}, [])

useEffect(() => {
  refAngle.current = angle
}, [angle])

위 소스는 requestAnimationFrame()을 사용하여 애니메이션을 구현한 것이다. 현재 애니메이션의 FPS를 구하고, 그 FPS값에 맞게 angle의 값이 변하는 정도를 조절해 프레임 드랍이 일어나더라도 애니메이션의 속도를 일정하게 유지하도록 헀다.

처음에 requestAnimationFrame()을 사용하여 애니메이션을 구현할 때, useRef() 훅을 사용하지 않고, 바로 setAngle(angle + 1.25 / currentFps)과 같은 식으로 State의 값을 변경했는데, 이렇게 작성하면 현재 상태와 이전 상태를 구분할 수 없기 때문에, angle의 값이 정상적으로 바뀌지 않았고, 이를 해결하기 위해 useRef() 훅을 사용하였다.

결과

이렇게 구현한 결과, 처음 로딩할 때처럼 리소스가 많이 필요해 setInterval()이 정상적인 인터벌을 두고 실행을 보장할 수 없어 애니메이션이 느려지던 문제와, 매번 새로운 setInterval()이 생성되는 문제를 해결할 수 있었다.


© 2023 Yulwon Rhee, Built with Gatsby