카운트 애니메이션 비교 (CSS부터 라이브러리까지)

이현성·2026년 6월 9일
post-thumbnail

이제 숫자 올라가는 애니메이션을 적용해보자.

여기서 색이 있는 숫자 부분에 아래와 같은 애니메이션을 넣고자 한다.

키야.. 물론 우리 서비스는 저정도로 느리진 않겠지.
글 쓰면서 자꾸 보니까 좀 멀미 난다 ㅠ

바로 들어가보자.


일단 잘은 모르지만 왠지 저런 카운트 애니메이션이 많이 있을 것 같았다.

여기저기서 찾아보기도 하고, 라이브러리 없이도 구현 가능한지도 찾은 결과 아래의 후보군이 나왔다.

  1. CSS transition
  2. requestAnimationFrame
  3. Motion
  4. react-countup
  5. react-spring

그리고 표로 보면 다음과 같다.

방식구현 방식장점단점
CSS transitionCSS digit column 또는 CSS counter 기반으로 숫자 표시를 움직임React 렌더 부담이 가장 작음. 추가 의존성 없음. 브라우저가 애니메이션을 처리해 가벼움콤마, 단위, 접근성 텍스트, 동적 포맷 처리에 약함. 숫자 자릿수별 마크업이 필요해질 수 있음
requestAnimationFramerequestAnimationFrame에서 매 프레임 값을 계산하고 React state를 갱신의존성 없음. easing, duration, 종료 시점 제어가 직접 가능함매 프레임 setState가 발생하면 React 렌더가 크게 증가할 수 있음
Motion기존 motionMotionValue로 숫자를 보간하고 표시 텍스트를 변환이미 설치되어 있으면 추가 비용 없음. React 렌더를 거의 늘리지 않음. 콤마, 단위, 접근성 처리와 등장 모션을 같이 다루기 좋음Motion API에 의존함. CSS보다 실행 구조가 조금 더 복잡함
react-countup숫자 카운트업 전용 라이브러리 사용목적에 가장 직접적으로 맞음. 콤마, duration, easing 설정이 쉬움. React 렌더 부담도 낮음새 패키지 설치 필요. 숫자 애니메이션 외의 UI 모션 표현력은 제한적
react-springSpringValue로 숫자와 scale 등을 함께 보간spring 기반 움직임이 자연스러움. 숫자 외 시각 효과를 같이 주기 좋음새 패키지 설치 필요. KPI 숫자만을 위해 쓰기에는 과할 수 있음. 정수/콤마 포맷 처리는 별도 처리 필요

1. CSS transition

CSS는 @propertycounter-reset을 이용하면 숫자 자체를 CSS 값처럼 보간할 수 있다.

@property --count {
  syntax: "<integer>";
  initial-value: 0;
  inherits: false;
}

.count {
  counter-reset: count var(--count);
  transition: --count 1400ms cubic-bezier(0.33, 1, 0.68, 1);
}

.count::after {
  content: counter(count);
}

.count[data-running="true"] {
  --count: 10000;
}
<span className="count" data-running={isRunning} />

조금 생소할 수 있는데, @property는 CSS 커스텀 속성인 --count를 정수로 애니메이션할 수 있게 등록하는 코드다.
그리고 counter-reset은 그 숫자 값을 CSS counter에 넣고, ::after에서 화면에 보여주는 역할을 한다.

즉 React는 data-running만 바꿔주고, 실제 숫자 변화는 브라우저가 처리하는 방식이다.

css 기반이다보니 다른 js 기반의 코드들보다 가볍다는 장점이 있다.

2. requestAnimationFrame

requestAnimationFrame은 매 프레임마다 현재 진행률을 계산해서 값을 갱신한다.

const [value, setValue] = useState(0);

useEffect(() => {
  let frameId = 0;
  const startedAt = performance.now();

  const tick = (now: number) => {
    const progress = Math.min((now - startedAt) / 1400, 1);
    const eased = 1 - Math.pow(1 - progress, 3);

    setValue(Math.round(10000 * eased));

    if (progress < 1) {
      frameId = requestAnimationFrame(tick);
    }
  };

  frameId = requestAnimationFrame(tick);

  return () => cancelAnimationFrame(frameId);
}, []);
<span>{value.toLocaleString("ko-KR")}</span>

구현 자체는 직관적인데, 이 방식은 매 프레임 setState가 발생한다.

물론 매 프레임 값을 바꾼다고 해서 무조건 문제가 되는 것은 아니다.
숫자 하나만 단독으로 움직이는 정도라면 크게 티가 안 날 수도 있다.

다만 1400ms 동안 60fps에 가깝게 갱신된다고 생각하면 한 번의 애니메이션에도 수십 번의 렌더가 발생할 수 있다.
그리고 우리가 적용하려는 곳은 KPI가 하나만 있는 화면이 아니라 대시보드다. 색이 있는 숫자 영역이 여러 개 있고, 차트나 데이터 갱신도 같이 있을 수 있다.

그래서 이 방식은 실제 렌더 숫자를 한번 보고 판단하는 게 맞아보였다.

여담으로 이전 회사에서 3D 모델이나 차트 다룰 때 사용한 적이 있는데 여기서 보게 될 줄은 몰랐다 ㅎ;

3. Motion

Motion은 MotionValue를 움직이고, 화면에 보여줄 값만 useTransform으로 변환한다.

const value = useMotionValue(0);
const displayValue = useTransform(value, (latest) =>
  Math.round(latest).toLocaleString("ko-KR")
);

useEffect(() => {
  const controls = animate(value, 10000, {
    duration: 1.4,
    ease: [0.33, 1, 0.68, 1],
  });

  return () => controls.stop();
}, [value]);
<motion.span>{displayValue}</motion.span>

React state를 매 프레임 갱신하는 대신, MotionValue가 값 변화를 관리한다.
화면에 보여줄 문자열은 useTransform에서 변환하면 된다.

비교적 무겁긴 한데, 우리 프로젝트에서 이미 사용 중이라 고려할 만 하다.

4. react-countup

react-countup은 리액트 카운트업이다.

import CountUp from "react-countup";

<CountUp
  start={0}
  end={10000}
  duration={1.4}
  separator=","
/>

카운트업 전용이라 코드가 제일 짧았다.

5. react-spring

react-springuseSpring으로 숫자 값을 만들고, animated.span에서 값을 표시한다.

import { animated, easings, useSpring } from "@react-spring/web";

const [springs, api] = useSpring(() => ({
  value: 0,
}));

useEffect(() => {
  api.start({
    from: { value: 0 },
    to: { value: 10000 },
    config: {
      duration: 1400,
      easing: easings.easeOutCubic,
    },
  });
}, [api]);
<animated.span>
  {springs.value.to((value) =>
    Math.round(value).toLocaleString("ko-KR")
  )}
</animated.span>

숫자 외에도 scale 같은 애니메이션을 같이 주기 좋았다.

react-spring은 react 애니메이션에서 꽤 유명한 것 같았다.


https://npm-compare.com/ko-KR/react-countup,react-spring

이렇게 npm 비교한 링크도 있다. 들어가보면 비교하는 내용도 설명이 아주 세세히 잘 되어있었다.
react-spring이 압도적인 우세로 보이긴 했는데, 아무래도 react-countup은 숫자 카운트업 전용이라 밀린 느낌이다 ㅎ;


여러모로 비교해보긴 했지만 결국 직접 테스트해봐야 이해하고 납득할 수 있다.

이렇게 도움을 받아 빠르게 만들어보았다. 한번 슈루룩 돌려보면..?

시간은 1400ms 기준이긴 하지만 대시보드에서 애니메이션의 정확한 시간이 중요한 건 아니라고 생각하니 넘기고, 일단 육안 상으로는 별 차이가 없어보였다.

세세한 애니메이션이 가능한지도 고려해야하지 않냐 싶을 수 있겠지만 지금 사용하는 목적은 랜딩 페이지 같은 곳에서 인터랙티브하게 증가하는 모습을 보여주는 게 아니다.

대시보드에서 증가되는 숫자를 보여주는 것이다.
애니메이션보다는 숫자에 집중하는 것이고, 이 애니메이션은 숫자가 갱신될 때마다 발생할 수 있다. 그렇기 때문에 과한 애니메이션은 처음 내가 어지러웠던 것과 같은 부작용을 낳을 수 있다.

암튼 먼저 눈에 띈 것은 89라는 렌더 숫자다.

어떻게 저렇게 나올 수 있냐. 바로 이전의 구현 방식 내용처럼 매 프레임 값을 계산하고 state를 갱신하기 때문이다.

1400ms 동안 60fps에 가깝게 갱신된다고 생각하면 대략 80번 이상 렌더가 발생하는 게 이상한 일은 아니다.
문제는 KPI가 하나만 있는 게 아니라는 점이다. 대시보드에는 KPI 카드도 여러 개 있고, 차트도 있고, 데이터 refetch도 일어날 수 있다.

그래서 requestAnimationFrame는 가장 먼저 패쓰.

나머지 4개가 남았는데, react-countup과 react-spring은 새 패키지를 추가해야 했다.
둘 다 잘 만들어진 라이브러리였지만, 지금 필요한 건 KPI 숫자가 슈루룩 올라가는 정도였다.

이건 굳이 라이브러리를 새로 설치하지 않아도 만족스런 애니메이션이 나와서 React의 라이브러리 2개도 패쓰~

남은 건 CSS와 Motion.

Motion도 라이브러리긴 하지만 이미 설치되어 있고 우리 프로젝트에서 사용 중이기 때문에 일관성 측면에서도 맞다.

그런데 일관성 측면이라는 이유로 굳이 써야할까..?
CSS라는 훨씬 가벼운 방법이 있는데?

이렇게 CSS로 채택~~~이 될 뻔 했으나.....?

문득 생각난 콤마 포맷...

실제 KPI 숫자는 10000이 아닌 10,000처럼 보여야 한다.
테스트해보니 CSS는 콤마 처리에 매우 약하고, 억지로 구현해도 매우 복잡하고 자연스럽지 않은 모습이었다 ㅠ

CSS counter 방식은 숫자를 증가시키는 것 자체는 간단하지만, 10,000처럼 포맷된 문자열을 자연스럽게 다루기 어렵다고 한다..
digit column 방식으로 자릿수를 나눠서 만들 수도 있지만, 이 경우에는 숫자 자릿수마다 마크업을 나누고 콤마 위치도 따로 관리해야 한다.

반면 Motion은 MotionValue로 숫자를 보간하고, 표시할 때만 toLocaleString 같은 포맷을 적용하면 된다.

예를 들면 이런 식이다.

const value = useMotionValue(0);
const displayValue = useTransform(value, (latest) =>
  Math.round(latest).toLocaleString("ko-KR")
);

이러면 React state를 매 프레임 갱신하지 않으면서도, 화면에는 10,000처럼 포맷된 숫자를 보여줄 수 있다.

정리하면

  • 숫자에 콤마가 필요하다.
  • , %, 같은 단위가 붙을 수 있다.(안붙긴 함 ㅎ;)
  • 처음 렌더링 시에만 애니메이션을 줄 수도 있어야 한다.
  • 숫자 변경 시점과 애니메이션 실행 여부를 제어해야 한다.
  • aria-label 같은 접근성 텍스트나 title 값은 실제 최종 숫자와 맞아야 한다.

이 조건들을 생각하면 CSS만으로 처리하기에는 우회 코드가 많아진다.

그래서 Motion으로 결정했다!!

0개의 댓글