Recharts로 뚱뚱해진 대시보드 번들, TanStack Charts로 빼는 법
TuBrief 편집팀
2026년 8월 12일
0
컴퓨터/소프트웨어원본 영상을 바탕으로 AI의 도움을 받아 작성했습니다. 원본 영상이 기준입니다.
커뮤니티의 다른 글
댓글 (0)
Log in to leave a comment
아직 작성된 글이 없습니다
원본 영상을 바탕으로 AI의 도움을 받아 작성했습니다. 원본 영상이 기준입니다.
Log in to leave a comment
아직 작성된 글이 없습니다
대시보드 페이지 하나 들어갔는데 차트 로딩하느라 모래시계가 돈다면 십중팔구 Recharts 문제다. 차트 몇 개 얹었다고 번들 크기가 수백 킬로바이트씩 불어나는 걸 보면 한숨부터 나온다. Recharts는 React DOM 조율 주기에 너무 바짝 붙어 있다. UI 구조와 데이터 처리가 엉켜 있어서 가볍게 쓰기가 힘들다.
이럴 때 TanStack Charts가 괜찮은 대안이 된다. 시각화 로직을 그래픽 문법 기반 코어로 따로 떼어냈다. Recharts가 보통 150KiB 넘게 먹는 반면, TanStack Charts는 40KiB 안팎이다. 번들 크기가 70% 가까이 빠진다.
문제는 프로덕션 코드 마이그레이션이다. 잘 돌아가는 화면을 엎는 건 언제나 찜찜하다. 팀원들을 설득하고 고장 없이 전환하려면 명확한 순서가 필요하다.
한 번에 다 바꾸려고 하면 어디서 터졌는지 찾기도 힘들다. 컴포넌트 하나를 잡고 딱 4단계로 쪼개서 넘어가야 한다.
defineChart 선언 객체의 marks 배열로 옮긴다.ResponsiveContainer와 Tooltip을 TanStack의 자동 스케일러와 @tanstack/charts/tooltip 모듈로 바꾼다.useMemo로 감싸고 최상위 Chart 컴포넌트에 넘긴다.이 순서만 지키면 첫 차트 컴포넌트 교체에 걸리는 시간을 절반으로 줄일 수 있다.
import React, { useMemo } from 'react';
import { defineChart, lineY, dot } from '@tanstack/charts';
import { scaleBand } from '@tanstack/charts/scales/band';
import { scaleLinear } from '@tanstack/charts/scales/linear';
import { tooltip } from '@tanstack/charts/tooltip';
import { Chart } from '@tanstack/charts/react';
interface DataRow {
date: string;
value: number;
}
export function MigratedRevenueChart({ data }: { data: DataRow[] }) {
const definition = useMemo(() => {
return defineChart({
marks: [
lineY(data, {
x: 'date',
y: 'value',
stroke: 'var(--ts-chart-1, #2563eb)',
strokeWidth: 2,
}),
dot(data, {
x: 'date',
y: 'value',
fill: 'var(--ts-chart-1, #2563eb)',
r: 4,
}),
],
x: {
scale: () => scaleBand<string>().padding(0.2),
},
y: {
scale: scaleLinear,
nice: true,
grid: true,
},
tooltip: {
use: tooltip,
className: 'custom-tooltip',
},
});
}, [data]);
return (
<div style={{ width: '100%', height: 300 }}>
<Chart definition={definition} />
</div>
);
}
다크모드 토글할 때마다 차트 컴포넌트 전체가 다시 그리는 건 촌스럽다. TanStack Charts는 하드코딩된 색상 값 대신 CSS 커스텀 프로퍼티와 currentColor를 그대로 지원한다.
차트 색상을 자바스크립트 상태로 관리하지 말고 CSS 변수로 밀어 넣자. 차트 기본 팔레트는 --ts-chart-1부터 --ts-chart-6까지 참조하게 만들면 된다.
[data-theme='dark']에 차트용 변수를 미리 정의한다.defineChart 옵션의 stroke나 fill에 var(--ts-chart-1) 형태의 문자열을 넣는다.이렇게 짜두면 디자이너가 차트 색상 바꿔달라고 할 때 TSX 파일을 건드릴 필요가 없다. CSS만 고치면 끝난다.
:root {
--bg-chart-card: #ffffff;
--text-chart-main: #172033;
--border-chart-grid: #e2e8f0;
--ts-chart-1: #2563eb;
--ts-chart-2: #f97316;
}
[data-theme='dark'] {
--bg-chart-card: #0f172a;
--text-chart-main: #f8fafc;
--border-chart-grid: #334155;
--ts-chart-1: #60a5fa;
--ts-chart-2: #fb923c;
}
.custom-tooltip {
background-color: var(--bg-chart-card);
color: var(--text-chart-main);
border: 1px solid var(--border-chart-grid);
padding: 8px 12px;
border-radius: 6px;
}
포인트 10만 개를 SVG로 렌더링하면 DOM 노드가 10만 개 생성된다. 브라우저 스레드가 버틸 수가 없다. 멈칫거리는 게 눈에 보인다.
해결책은 두 가지를 섞는 거다. Canvas 렌더러로 전환해서 DOM 생성 비용을 없애고, 화면 픽셀 너비보다 의미 없이 빽빽한 데이터는 LTTB(Largest Triangle Three Buckets) 알고리즘으로 압축한다. LTTB는 데이터의 뾰족한 특징(최댓값, 최솟값)을 살리면서 점의 개수를 줄여준다.
export function lttbDownsample<T>(
data: T[],
xAccessor: (d: T) => number,
yAccessor: (d: T) => number,
threshold: number
): T[] {
const dataLength = data.length;
if (threshold >= dataLength || threshold === 0) return data;
const sampled: T[] = [];
let sampledIndex = 0;
const bucketSize = (dataLength - 2) / (threshold - 2);
sampled[sampledIndex++] = data[0];
for (let i = 0; i < threshold - 2; i++) {
let avgX = 0;
let avgY = 0;
const avgRangeStart = Math.floor((i + 1) * bucketSize) + 1;
const avgRangeEnd = Math.floor((i + 2) * bucketSize) + 1;
const currentAvgRangeEnd = avgRangeEnd < dataLength ? avgRangeEnd : dataLength;
for (let j = avgRangeStart; j < currentAvgRangeEnd; j++) {
avgX += xAccessor(data[j]);
avgY += yAccessor(data[j]);
}
avgX /= (currentAvgRangeEnd - avgRangeStart);
avgY /= (currentAvgRangeEnd - avgRangeStart);
const rangeOffs = Math.floor((i + 0) * bucketSize) + 1;
const rangeTo = Math.floor((i + 1) * bucketSize) + 1;
const pointAX = xAccessor(sampled[sampledIndex - 1]);
const pointAY = yAccessor(sampled[sampledIndex - 1]);
let maxArea = -1;
let maxAreaPointIndex = rangeOffs;
for (let j = rangeOffs; j < rangeTo; j++) {
const area = Math.abs(
(pointAX - avgX) * (yAccessor(data[j]) - pointAY) -
(pointAX - xAccessor(data[j])) * (avgY - pointAY)
) * 0.5;
if (area > maxArea) {
maxArea = area;
maxAreaPointIndex = j;
}
}
sampled[sampledIndex++] = data[maxAreaPointIndex];
}
sampled[sampledIndex++] = data[dataLength - 1];
return sampled;
}
대용량 시계열을 다룰 때는 LTTB로 1000개 수준까지 줄여서 넘기자. 그리고 spatialIndex: true 옵션을 켜서 마우스 포인터 감지 복잡도를 낮춘다. 마지막으로 @tanstack/charts/react/canvas 패키지의 Canvas 렌더러를 붙이면 60fps가 뽑힌다.
Recharts처럼 JSX 태그가 깊게 중첩된 형태는 AI 어시스턴트가 환각을 잘 일으킨다. 이상한 속성을 만들어내거나 열린 태그를 닫지 않는다.
TanStack Charts처럼 객체 선언식으로 작성되는 라이브러리는 타입 스키마만 잘 쥐여주면 AI가 코드를 깔끔하게 뽑아낸다. 프로젝트에 아래 같은 스키마 타입을 하나 정의해두고 AI 프롬프트나 규칙 파일(.cursorrules)에 넣어두자.
export type ScaleType = 'linear' | 'band' | 'utc' | 'point';
export type MarkType = 'lineY' | 'barY' | 'dot' | 'areaY';
export interface AIScaleConfig {
type: ScaleType;
nice?: boolean;
grid?: boolean;
padding?: number;
}
export interface AIMarkConfig {
type: MarkType;
xKey: string;
yKey: string;
color?: string;
strokeWidth?: number;
}
export interface AIChartGenerationSchema {
data: Record<string, unknown>[];
scales: {
x: AIScaleConfig;
y: AIScaleConfig;
};
marks: AIMarkConfig[];
tooltip?: {
enabled: boolean;
className?: string;
};
}
@tanstack/charts/scales/linear처럼 정확한 서브패스에서 임포트하라는 규칙과 useMemo 사용 규칙까지 프롬프트에 써두면 AI가 뱉은 코드를 수정 없이 바로 쓸 수 있다.