본문 바로가기

React

React로 차트 애니메이션 직접 구현하기

라이브러리 없이 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) 렌더 최소화가 핵심입니다. 위 패턴을 토대로 라인, 바 외에도 영역, 산점도, 파이 등 다양한 차트에 쉽게 확장할 수 있습니다.