라이브러리 없이 React로 차트 애니메이션을 직접 구현하면 번들 크기를 줄이고, 원하는 타이밍과 인터랙션을 세밀하게 제어할 수 있습니다. 이 글은 SVG와 Canvas를 사용해 requestAnimationFrame 기반으로 라인/바 차트 애니메이션을 구현하는 실무적인 패턴을 정리했습니다.
1. 목표와 접근
목표는 다음과 같습니다. (1) 첫 렌더에서 라인 차트를 "그려지는" 애니메이션, (2) 데이터가 바뀔 때 부드러운 보간 애니메이션, (3) 많은 요소에서도 버벅임이 적은 패턴입니다. 핵심은 시간 기반 애니메이션과 보간(easing/lerp), 그리고 렌더 비용을 최소화하는 구조입니다.
2. SVG vs Canvas 선택 기준
- SVG: DOM 기반으로 접근성/스타일이 쉬워서 수십~수백 요소의 차트에 적합합니다. path 길이를 이용한 드로잉 애니메이션이 간단합니다.
- Canvas: 수천~수만 요소 등 대량 렌더에 적합합니다. React는 컨테이너만 관리하고, 실제 픽셀 그리기는 imperative 코드로 처리합니다.
3. 애니메이션 기본기: 시간, 보간, 루프
애니메이션은 시간 t에 따른 진행률(progress)을 계산하고, 이를 보간해서 값을 업데이트하는 패턴입니다. requestAnimationFrame으로 60fps 루프를 만들고, 진행률은 (now - start) / duration으로 구합니다. 성급한 setInterval 대신 raf를 쓰는 것이 부드럽고 배터리 친화적입니다.
// easing & lerp 유틸
export const easeInOutCubic = (t) =>
t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2;
export const lerp = (a, b, t) => a + (b - a) * t;
// 선형 스케일: domain -> range
export const createScale = (domain, range) => (v) => {
const [d0, d1] = domain;
const [r0, r1] = range;
const ratio = (v - d0) / (d1 - d0 || 1);
return r0 + ratio * (r1 - r0);
};
// 배열 보간 훅: 데이터가 바뀌면 duration 동안 부드럽게 값 배열을 보간합니다.
import { useEffect, useRef, useState } from "react";
export function useTweenArray(target, duration = 600, easing = easeInOutCubic) {
const [values, setValues] = useState(target);
const ref = useRef({ from: target, to: target, start: 0, raf: 0 });
useEffect(() => {
if (!Array.isArray(target) || target.length === 0) return;
cancelAnimationFrame(ref.current.raf);
ref.current.from = values;
ref.current.to = target;
ref.current.start = performance.now();
const tick = (time) => {
const p = Math.min(1, (time - ref.current.start) / duration);
const e = easing(p);
const next = ref.current.to.map((toV, i) => {
const fromV = ref.current.from[i] ?? toV;
return lerp(fromV, toV, e);
});
setValues(next);
if (p < 1) ref.current.raf = requestAnimationFrame(tick);
};
ref.current.raf = requestAnimationFrame(tick);
return () => cancelAnimationFrame(ref.current.raf);
// 간단화를 위해 string 비교. 실무에선 키/길이 고정이 더 안전합니다.
}, [JSON.stringify(target), duration, easing]);
return values;
}4. SVG 라인 차트: 그려지는 애니메이션 + 업데이트 보간
초기 진입 시 stroke-dasharray/dashoffset으로 선이 그려지는 효과를 만들고, 데이터 변경 시에는 y값을 보간해 path를 부드럽게 바꿉니다. x는 동일한 포인트 개수/순서를 가정합니다.
import React, { useEffect, useMemo, useRef, useState } from "react";
import { createScale, easeInOutCubic, useTweenArray } from "./utils";
// path 문자열 생성
function toPath(points) {
if (!points.length) return "";
const [first, ...rest] = points;
return ["M", first.x, first.y, ...rest.flatMap((p) => ["L", p.x, p.y])].join(" ");
}
// 데모 데이터
function makeData(n = 40) {
const now = Date.now();
return Array.from({ length: n }).map((_, i) => ({
x: now + i * 1000,
y: Math.sin(i / 3) * 20 + 50 + Math.random() * 10,
}));
}
export function AnimatedLineChart({ data, width = 640, height = 320, padding = 32 }) {
const pathRef = useRef(null);
// 스케일 계산
const xVals = data.map((d) => d.x);
const yVals = data.map((d) => d.y);
const xScale = useMemo(
() => createScale([Math.min(...xVals), Math.max(...xVals)], [padding, width - padding]),
[width, padding, JSON.stringify([xVals[0], xVals[xVals.length - 1]])]
);
const yScale = useMemo(
() => createScale([Math.min(...yVals), Math.max(...yVals)], [height - padding, padding]),
[height, padding, Math.min(...yVals), Math.max(...yVals)]
);
// 목표 y 스케일 배열과 보간된 y 배열
const targetYs = useMemo(() => data.map((d) => yScale(d.y)), [data, yScale]);
const tweenYs = useTweenArray(targetYs, 700, easeInOutCubic);
const xs = useMemo(() => data.map((d) => xScale(d.x)), [data, xScale]);
// path 포인트
const points = useMemo(
() => xs.map((x, i) => ({ x, y: tweenYs[i] ?? tweenYs[tweenYs.length - 1] })),
[xs, tweenYs]
);
const d = useMemo(() => toPath(points), [points]);
// 최초 진입: 선이 그려지는 애니메이션
useEffect(() => {
const path = pathRef.current;
if (!path) return;
const len = path.getTotalLength();
path.style.strokeDasharray = `${len}`;
path.style.strokeDashoffset = `${len}`;
let raf = 0;
const start = performance.now();
const duration = 900;
const draw = (t) => {
const p = Math.min(1, (t - start) / duration);
const e = easeInOutCubic(p);
const offset = len * (1 - e); // len -> 0
path.style.strokeDashoffset = `${offset}`;
if (p < 1) raf = requestAnimationFrame(draw);
};
raf = requestAnimationFrame(draw);
return () => cancelAnimationFrame(raf);
}, []);
return (
<svg width={width} height={height} role="img" aria-label="라인 차트">
{/* 축/가이드라인 등은 생략 */}
<path
ref={pathRef}
d={d}
fill="none"
stroke="#4f46e5"
strokeWidth="2.5"
strokeLinejoin="round"
strokeLinecap="round"
/>
</svg>
);
}
// 사용 예시
export default function Demo() {
const [data, setData] = useState(makeData(48));
return (
<div>
<AnimatedLineChart data={data} width={640} height={320} padding={32} />
<button
onClick={() => setData(makeData(48))}
style={{ marginTop: 12 }}
>
데이터 갱신
</button>
</div>
);
}포인트 개수와 x 순서를 유지하면 y만 보간해도 자연스럽게 "모핑"됩니다. 포인트 개수가 바뀌는 경우엔 키 매칭/리샘플링(예: 구간 재분할) 후 보간을 권장합니다.
5. 바 차트: 값 변화 보간으로 높이 애니메이션
바 차트는 각 바의 y 값을 보간하여 height/y 속성을 렌더합니다. 작은 데이터셋은 React re-render로도 충분히 부드럽습니다.
import React, { useMemo } from "react";
import { createScale, easeInOutCubic, useTweenArray } from "./utils";
export function AnimatedBarChart({ data, width = 640, height = 320, padding = 24 }) {
const maxY = Math.max(...data.map((d) => d.y), 1);
const yScale = useMemo(() => createScale([0, maxY], [height - padding, padding]), [height, padding, maxY]);
const targetYs = data.map((d) => yScale(d.y)); // 화면 좌표 상 바의 상단 y
const tweenYs = useTweenArray(targetYs, 600, easeInOutCubic);
const barW = (width - padding * 2) / data.length;
return (
<svg width={width} height={height} role="img" aria-label="바 차트">
{tweenYs.map((yTop, i) => {
const x = padding + i * barW;
const h = height - padding - yTop;
return (
<rect
key={i}
x={x + barW * 0.05}
y={yTop}
width={barW * 0.9}
height={h}
fill="#10b981"
rx="3"
/>
);
})}
</svg>
);
}6. 많은 요소에서는 Canvas 패턴으로
수천 개 이상의 포인트/바를 재렌더링하면 React DOM 업데이트가 병목이 됩니다. 이때는 Canvas에서 한 프레임당 한 번만 픽셀을 그리도록 하고, React는 데이터 변경 트리거만 담당하게 합니다.
import React, { useEffect, useRef } from "react";
import { easeInOutCubic } from "./utils";
export function CanvasBars({ data, width = 640, height = 320 }) {
const ref = useRef(null);
const prevRef = useRef(data);
useEffect(() => {
const canvas = ref.current;
const ctx = canvas.getContext("2d");
let raf = 0;
const start = performance.now();
const duration = 600;
const from = prevRef.current.map((d) => d.y);
const to = data.map((d) => d.y);
const maxY = Math.max(...from, ...to, 1);
const pad = 24;
const barW = (width - pad * 2) / data.length;
const draw = (t) => {
const p = Math.min(1, (t - start) / duration);
const e = easeInOutCubic(p);
ctx.clearRect(0, 0, width, height);
ctx.fillStyle = "#4f46e5";
data.forEach((d, i) => {
const yVal = from[i] + (to[i] - from[i]) * e;
const h = (yVal / maxY) * (height - pad * 2);
const x = pad + i * barW;
const yTop = height - pad - h;
ctx.fillRect(x, yTop, barW * 0.9, h);
});
if (p < 1) raf = requestAnimationFrame(draw);
else prevRef.current = data;
};
raf = requestAnimationFrame(draw);
return () => cancelAnimationFrame(raf);
}, [data, width, height]);
return <canvas ref={ref} width={width} height={height} />;
}7. 성능 최적화 체크리스트
- 렌더 수 최소화: 값 보간은 useRef 내부에서 하고, DOM 업데이트만 필요할 때 한 번에 하거나 Canvas로 그립니다.
- 의존성 관리: 애니메이션 훅에서 대상 배열의 길이/키가 변하지 않도록 설계하면 메모리/GC를 줄일 수 있습니다.
- 정량 테스트: 200, 2,000, 20,000 요소에서 FPS와 입력 지연을 측정해 SVG/Canvas 기준을 정합니다.
- 배치: 여러 시리즈를 동시에 애니메이션할 때 시작 시간을 소폭 분산해 한 프레임의 작업량을 완화합니다.
8. 접근성과 반응형
- aria-label, title, desc를 사용해 스크린 리더 친화적으로 만듭니다.
- 반응형: 부모 컨테이너 크기를 관찰(ResizeObserver)해 width/height/스케일을 재계산합니다.
- 모션 감도: prefers-reduced-motion 미디어 쿼리를 감지해 duration을 줄이거나 애니메이션을 비활성화합니다.
9. 흔한 문제 해결
- path 길이 0: 데이터가 동일 포인트면 getTotalLength가 0이므로 드로잉 애니메이션을 건너뜁니다.
- 점 개수 변경 시 튀는 현상: 키 매칭/리샘플링 후 보간하거나, 업데이트 시 페이드-크로스 전환을 사용합니다.
- layout thrash: 매 프레임 DOM 측정을 피하고, 측정은 초기 1회로 제한합니다.
10. 마무리
React에서 차트 애니메이션을 직접 구현할 때는 (1) 시간 기반 보간, (2) 적절한 렌더링 백엔드(SVG/Canvas) 선택, (3) 렌더 최소화가 핵심입니다. 위 패턴을 토대로 라인, 바 외에도 영역, 산점도, 파이 등 다양한 차트에 쉽게 확장할 수 있습니다.
'React' 카테고리의 다른 글
| React 앱에서 테마 변경 기능 다중 지원하기 (0) | 2026.05.29 |
|---|---|
| React에서 사용자 프로필 편집 페이지 제작하기 (0) | 2026.05.29 |
| React 앱에서 브라우저 Speech Recognition API 사용하기 (0) | 2026.05.28 |
| React에서 모션 센서 API 활용하여 인터랙션 강화하기 (0) | 2026.05.28 |
| React에서 Intersection Observer로 비디오 자동 재생 제어하기 (0) | 2026.05.27 |