회사 랜딩 페이지를 제작할 때에 스크롤 시 숫자가 카운트되는 애니메이션을 구현해야 했다. setInterval
을 사용하여 정해진 시간마다 숫자를 변화시키려고 했는데, setInterval
사용 시 애니메이션 성능 저하 문제가 생길 수 있다는 것을 알게 되었다. 그리고 requestAnimationFrame
이라는 함수를 사용하면 애니메이션 최적화에 도움이 된다는 것 또한 알게 되어 이를 통해 애니메이션을 구현하게 되었다.
애니메이션 성능에 대해 논하려면 fps 개념을 이해해야 한다.
fps: 1초 동안 보여주는 프레임 수로, 대부분의 브라우저는 60fps를 지원한다.
우리가 보는 화면은 프레임의 연속이다. 아래처럼 개발자도구 > 성능 탭에서 record 하는 동안 페이지를 스크롤하면, 16.7ms 마다 프레임이 생성된 것을 확인할 수 있다. 브라우저가 1초에 60frame을 보여줄 수 있으므로 약 16.7ms 동안 1frame이 생성된 것이고, 우리는 각 frame을 마치 영화처럼 연속적으로 보고 있는 것이다.
달리 말하면, 브라우저가 정상적으로 60fps를 보여주려면 16.7ms 안에 1frame이 화면에 그려져야 한다. 그렇지 않으면 애니메이션이 버벅댈 수 있기 때문이다. 1frame이 정상적으로 그려지지 않는다면, 16.7ms가 skip되는 셈이 되므로 애니메이션이 부드럽게 연결되지 않고 버벅대게 된다.
setTimeout
/setTimeInterval
로 구현한 애니메이션의 문제점그런데 setTimeout
/setTimeInterval
을 사용하면 그러한 상황이 발생할 가능성이 rAF
보다 상대적으로 높다. setTimeout
/setTimeInterval
은 실행 시점을 설정할 수 없기 때문이다. 브라우저 frame 생성 시점에 callback을 실행할 수 없다면, 렌더링 과정(Layout > Paint > Composite)을 모두 끝마쳤을 때 16.7ms를 초과할 수도 있으므로 frame이 지연/유실될 수 있는 것이다.
requestAnimationFrame
그런데 setTimeout
/setTimeInterval
과 달리, requestAnimationFrame
은 브라우저가 frame을 렌더링할 준비가 됐을 때 frame 생성 시점마다 callback을 실행시킴으로써 frame 유실을 방지할 수 있고, 16.7ms 내에 렌더링을 완료하는 것을 좀 더 보장할 수 있다! 그리고 페이지 비활성화 시 (ex: 탭 이동) 실행이 중단되어 배터리 수명에도 도움이 된다.
사용 방법으로는, parameter로 애니메이션을 업데이트할 callback을 전달해야 한다. 그리고 callback 내에서 원하는 시점까지 requestAnimationFrame
을 재호출하는 코드를 작성하면 애니메이션을 연속적으로 업데이트할 수 있다.
const animateSomething = function () {
// make some change
// Next call
requestAnimationFrame(animateSomething)
}
// First manual call to start the animation
requestAnimationFrame(animateSomething)
그런데 callback의 실행 시간이 길다면 setTimeout
/setTimeInterval
과 비슷하게 frame 지연이 발생할 수 있으므로, 로직을 쪼개는 등 가볍게 작성해야 rAF
의 이점을 누릴 수 있다.
useCountAnimation
그럼 rAF
를 이용하여 카운트 애니메이션을 구현해 보자.
끝으로 갈수록 카운팅이 느려지는 효과를 원해서 https://easings.net/ 의 ease out 효과들을 참고하여 카운팅 진행률 함수를 만들었다.
import { useEffect, useState } from 'react';
function getEasedOutProgressRate(ratio: number) {
return 1 - Math.pow(1 - ratio, 3);
}
const BROWSER_FPS = 60;
export interface UseCountAnimationProps {
startNumber?: number; // count 시작 숫자
endNumber: number; // count 종료 숫자
durationMS: number; // 애니메이션 시간
fpsReductionFactor?: number; // fps를 n배 줄이고 싶을 때의 n
canStart?: boolean; // 애니메이션을 시작할 타이밍 (ex: 특정 영역이 화면이 보이기 시작할 때)
}
16.7ms 보다 느린 주기로 카운팅을 하고 싶어서 fpsReductionFactor
prop을 추가했다. 예를 들어 2배 느리게 한다면 30fps로 애니메이션이 실행될 것이다.
export const useCountAnimation = (props: UseCountAnimationProps) => {
const {
startNumber = 0,
endNumber,
durationMS,
fpsReductionFactor = 1,
canStart = true,
} = props;
const fps = BROWSER_FPS / fpsReductionFactor;
const [count, setCount] = useState<number>(startNumber); // 변화할 숫자
useEffect(() => {
if (!canStart) return;
let currentFrame = 0; // 만들어진 프레임 개수
let lastRafExecutionTimestamp = 0; // 마지막으로 숫자를 카운팅했던 timestamp
let rafId; // rAF ID
const handleCount = (currentTimestamp: number) => {
// 원하는 주기가 아직 돌아오지 않았으면 숫자 카운팅을 skip 한다.
if (
Math.round(currentTimestamp - lastRafExecutionTimestamp) <
Math.round(1000 / fps)
) {
rafId = requestAnimationFrame(handleCount);
return;
}
// fps에 따라 duration 동안 만들어질 총 프레임 개수
const totalFrames = Math.ceil(durationMS / (1000 / fps));
// ease out 효과가 적용된 카운팅 진행률
const progressRate = getEasedOutProgressRate(currentFrame / totalFrames);
// 1프레임마다 count를 증가시킨다.
setCount(
Math.floor(startNumber + (endNumber - startNumber) * progressRate),
);
currentFrame++;
lastRafExecutionTimestamp = currentTimestamp;
// 카운팅이 끝났으면 rAF 실행을 종료한다.
if (progressRate === 1) {
cancelAnimationFrame(rafId);
return;
}
// rAF를 재실행하여 카운팅을 이어 나간다.
rafId = requestAnimationFrame(handleCount);
};
// 첫 rAF 실행
rafId = requestAnimationFrame(handleCount);
return () => cancelAnimationFrame(rafId);
}, [canStart]);
return count;
};
만든 hook은 이런 식으로 사용한다.
<Quantity
endNumber={record.quantity}
durationMS={2000}
fpsReductionFactor={2}
canStart={appeared}
/>
const Quantity: React.FC<UseCountAnimationProps> = props => {
const count = useCountAnimation(props);
return <Text>{count.toLocaleString()}</Text>;
};
requestAnimationFrame
vs setInterval
성능 비교rAF
와 setInterval
간에 성능 차이가 실제로 있을지 궁금해서 개발자 도구의 perfomance 툴로 비교를 해봤다. 결과를 아직 정확히 설명하진 못했다. (_ _)
requestAnimationFrame
cf) fpsReductionFactor
를 2로 설정했기 때문에 30fps로 프레임이 생성되었다.
setInterval
rAF
보다 불규칙적인 주기로 프레임이 생성되었다.위 비교는 간단한 로직의 callback일 때 진행됐기 때문에, 실행 시마다 1~500을 console에 찍는 로직을 추가하고 fpsReductionFactor
를 1로 설정한 후 다시 비교해 보았다. 짧은 실행 주기에서 callback 로직이 복잡해지면 어떤 차이를 보일까?
requestAnimationFrame
setInterval
보다 규칙적으로 프레임이 생성됐다.setInterval
보다 partially presented frame이 많은데, 이유는 아직 잘 모르겠다. 관련 아티클에 따르면 main thread는 frame 생성 주기 내에 업데이트되지 못했지만 compositor thread는 업데이트 된 케이스 같은데, 그런 케이스가 어떤 경우인지 아직 모르겠다. 아신다면 댓글 남겨주세요.. 🥹setInterval
과 달리 상단의 CPU long task 막대(빨간색)들이 없다.setInterval
rAF
보다 프레임 생성 주기가 크게 불규칙해졌고, 매우 버벅임을 경험하기도 했다.rAF
보다 상단의 CPU long task 막대들이 많다.rAF
의 경우 브라우저가 렌더링 준비가 안 됐을 땐 callback이 호출되지 않는다. 그런데 setInterval
의 경우 곧이 곧대로 16.7ms 마다 callback을 호출했기 때문에, callback과 렌더링 task가 쌓이면서 CPU usage가 높아진 것 아닐까 싶다. 🤔rAF
는 프레임 생성 주기가 규칙적이라고 볼 수 있는데, 이 경우 렌더링이 오래 블로킹되지 않은 것은 callback 실행을 delay 할 수 있기 때문인가? 그럼 결과적으로 애니메이션 총 duration이 조금 늘어났을까?Date.now()
를 찍어서 재보니 800ms가 늘어나긴 했다.cf) setInterval
을 이용한 코드는 아래와 같다.
export const useCountAnimation = (props: UseCountAnimationProps) => {
const {
startNumber = 0,
endNumber,
durationMS,
fpsReductionFactor = 1,
canStart = true,
} = props;
const fps = BROWSER_FPS / fpsReductionFactor;
const [count, setCount] = useState<number>(startNumber);
useEffect(() => {
if (!canStart) return;
let currentFrame = 0;
let intervalId;
const handleCount = () => {
for (let i = 0; i <= 500; i++) {
console.log(i);
}
const totalFrames = Math.ceil(durationMS / (1000 / fps));
const progressRate = getEasedOutProgressRate(currentFrame / totalFrames);
setCount(
Math.floor(startNumber + (endNumber - startNumber) * progressRate),
);
currentFrame++;
if (progressRate === 1) {
clearInterval(intervalId);
return;
}
};
intervalId = setInterval(handleCount, 1000 / fps);
return () => clearInterval(intervalId);
}, [canStart]);
return count;
};
결과적으로, 일반적인 카운트 애니메이션의 경우는 rAF
를 써도, setInterval
을 써도 성능 차이가 크게 벌어지진 않는 것 같다. 하지만 rAF
를 공부하면서 fps, 렌더링 과정, 성능 최적화에 대해 공부할 수 있었기에 유익했다. 복잡한 애니메이션을 구현할 일이 생긴다면 그때도 rAF
를 사용할 것 같다. 그리고 성능 최적화는 CS 개념을 많이 필요로 해서 CS 공부의 필요성을 또 다시 느끼게 되었다.. 🔥
Reference:
https://velog.io/@0715yk/HTML-requestAnimationFrame
https://simsimjae.tistory.com/402
https://www.freecodecamp.org/news/web-animation-performance-fundamentals/
많은 도움이 되었어요!