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

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

키야.. 물론 우리 서비스는 저정도로 느리진 않겠지.
글 쓰면서 자꾸 보니까 좀 멀미 난다 ㅠ
바로 들어가보자.
일단 잘은 모르지만 왠지 저런 카운트 애니메이션이 많이 있을 것 같았다.
여기저기서 찾아보기도 하고, 라이브러리 없이도 구현 가능한지도 찾은 결과 아래의 후보군이 나왔다.
그리고 표로 보면 다음과 같다.
| 방식 | 구현 방식 | 장점 | 단점 |
|---|---|---|---|
| CSS transition | CSS digit column 또는 CSS counter 기반으로 숫자 표시를 움직임 | React 렌더 부담이 가장 작음. 추가 의존성 없음. 브라우저가 애니메이션을 처리해 가벼움 | 콤마, 단위, 접근성 텍스트, 동적 포맷 처리에 약함. 숫자 자릿수별 마크업이 필요해질 수 있음 |
| requestAnimationFrame | requestAnimationFrame에서 매 프레임 값을 계산하고 React state를 갱신 | 의존성 없음. easing, duration, 종료 시점 제어가 직접 가능함 | 매 프레임 setState가 발생하면 React 렌더가 크게 증가할 수 있음 |
| Motion | 기존 motion의 MotionValue로 숫자를 보간하고 표시 텍스트를 변환 | 이미 설치되어 있으면 추가 비용 없음. React 렌더를 거의 늘리지 않음. 콤마, 단위, 접근성 처리와 등장 모션을 같이 다루기 좋음 | Motion API에 의존함. CSS보다 실행 구조가 조금 더 복잡함 |
| react-countup | 숫자 카운트업 전용 라이브러리 사용 | 목적에 가장 직접적으로 맞음. 콤마, duration, easing 설정이 쉬움. React 렌더 부담도 낮음 | 새 패키지 설치 필요. 숫자 애니메이션 외의 UI 모션 표현력은 제한적 |
| react-spring | SpringValue로 숫자와 scale 등을 함께 보간 | spring 기반 움직임이 자연스러움. 숫자 외 시각 효과를 같이 주기 좋음 | 새 패키지 설치 필요. KPI 숫자만을 위해 쓰기에는 과할 수 있음. 정수/콤마 포맷 처리는 별도 처리 필요 |
CSS는 @property와 counter-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 기반의 코드들보다 가볍다는 장점이 있다.
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 모델이나 차트 다룰 때 사용한 적이 있는데 여기서 보게 될 줄은 몰랐다 ㅎ;
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에서 변환하면 된다.
비교적 무겁긴 한데, 우리 프로젝트에서 이미 사용 중이라 고려할 만 하다.
react-countup은 리액트 카운트업이다.
import CountUp from "react-countup";
<CountUp
start={0}
end={10000}
duration={1.4}
separator=","
/>
카운트업 전용이라 코드가 제일 짧았다.
react-spring은 useSpring으로 숫자 값을 만들고, 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처럼 포맷된 숫자를 보여줄 수 있다.
정리하면
명, %, 건 같은 단위가 붙을 수 있다.(안붙긴 함 ㅎ;)이 조건들을 생각하면 CSS만으로 처리하기에는 우회 코드가 많아진다.
그래서 Motion으로 결정했다!!