[Web] requestAnimationFrame vs setInterval (feat. React 숫자 카운트 애니메이션)

unhyif·2023년 7월 16일
43

Web

목록 보기
4/4
post-thumbnail

회사 랜딩 페이지를 제작할 때에 스크롤 시 숫자가 카운트되는 애니메이션을 구현해야 했다. setInterval을 사용하여 정해진 시간마다 숫자를 변화시키려고 했는데, setInterval 사용 시 애니메이션 성능 저하 문제가 생길 수 있다는 것을 알게 되었다. 그리고 requestAnimationFrame 이라는 함수를 사용하면 애니메이션 최적화에 도움이 된다는 것 또한 알게 되어 이를 통해 애니메이션을 구현하게 되었다.


fps

애니메이션 성능에 대해 논하려면 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 성능 비교

rAFsetInterval 간에 성능 차이가 실제로 있을지 궁금해서 개발자 도구의 perfomance 툴로 비교를 해봤다. 결과를 아직 정확히 설명하진 못했다. (_ _)

requestAnimationFrame

  • 규칙적으로 프레임이 생성되었다.

cf) fpsReductionFactor를 2로 설정했기 때문에 30fps로 프레임이 생성되었다.

setInterval

  • 중간중간 idle frame(빈 공간)이 생겼다. idle frame은 아무런 활동이 없어서 프레임을 생성하지 않은 상태라고 하는데, 클릭해 보면 프레임이 바뀌긴 해서 무슨 의미인지 잘 모르겠다... 😅
    • 어쨌든 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 보다 프레임 생성 주기가 크게 불규칙해졌고, 매우 버벅임을 경험하기도 했다.
  • 유실된 frame(빨간색)들이 존재한다.
  • rAF 보다 상단의 CPU long task 막대들이 많다.
    • 이유를 추측하건데, rAF의 경우 브라우저가 렌더링 준비가 안 됐을 땐 callback이 호출되지 않는다. 그런데 setInterval의 경우 곧이 곧대로 16.7ms 마다 callback을 호출했기 때문에, callback과 렌더링 task가 쌓이면서 CPU usage가 높아진 것 아닐까 싶다. 🤔
    • 그리고 그 callback을 처리할 때 렌더링이 오래 블로킹돼서 프레임 생성 주기가 불규칙해진 것 아닐까?
    • (의식의 흐름) 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/

3개의 댓글

comment-user-thumbnail
2023년 7월 17일

많은 도움이 되었어요!

답글 달기
comment-user-thumbnail
2023년 7월 19일

좋은글감사합니다!!

답글 달기
comment-user-thumbnail
2023년 7월 25일

크 감사합니다!

답글 달기