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()
이 생성되는 문제를 해결할 수 있었다.