Home Posts About GitHub

검색어를 입력하면 결과가 표시됩니다.

대시보드를 위한 차트 컴포넌트 설계


이 글은 실제 프로젝트 경험을 바탕으로 작성되었어요. 보안 서약으로 인해 실제 화면과 데이터는 공개할 수 없지만, 동일한 설계 원칙을 적용한 데모 페이지를 직접 구현했어요.






Recharts 선택 이유


본격적으로 개발 하기전에 차트 라이브러리 중 D3, ECharts, Recharts를 비교했어요.



요구사항 적합도

ECharts는 Canvas 기반으로 수만 개 이상의 대용량 데이터에서 성능 우위가 있어요. 하지만 이 프로젝트에선 한 차트당 데이터 포인트가 수백 ~ 수천 개 수준이라 Canvas의 성능 이점이 크지 않았죠. 오히려 SVG 기반이 DOM 접근과 스타일링에 유리했어요.

D3는 Force Graph, Sankey Diagram, Choropleth Map 같은 복잡한 시각화에 강점이 있지만, 현재 요구사항은 Line, Bar, Area, Pie 같은 기본 차트 위주라 D3의 강력한 기능까지는 필요하지 않았어요.

Recharts는 이런 기본 차트 타입을 대부분 지원했고, 특히 Composed Chart로 여러 차트 타입을 한 화면에 조합할 수 있어서 요구사항에 잘 맞는다고 판단했어요.


라이브러리별 코드 구조 비교


D3.js: 명령형 API

DOM 요소를 직접 선택하고, 데이터를 바인딩한 뒤 각 속성을 수학적 계산을 통해 부여해요.

import * as d3 from 'd3';
import { useEffect, useRef } from 'react';

function D3BarChart({ data }) {
  const svgRef = useRef(null);

  useEffect(() => {
    const svg = d3
      .select(svgRef.current)
      .attr('width', 500)
      .attr('height', 300);
    svg.selectAll('*').remove();

    const xScale = d3
      .scaleBand()
      .domain(data.map((d) => d.name))
      .range([0, 500])
      .padding(0.5);
    const yScale = d3
      .scaleLinear()
      .domain([0, d3.max(data, (d) => d.value)])
      .range([300, 0]);

    svg
      .selectAll('.bar')
      .data(data)
      .enter()
      .append('rect')
      .attr('x', (d) => xScale(d.name))
      .attr('y', (d) => yScale(d.value))
      .attr('width', xScale.bandwidth())
      .attr('height', (d) => 300 - yScale(d.value))
      .attr('fill', colors.blue500);
  }, [data]);

  return <svg ref={svgRef}></svg>;
}

요소를 어떻게 그릴지 구체적인 절차를 지시해요. React 환경에서는 useRefuseEffect로 렌더링 주기를 우회해 DOM을 직접 조작해야 해요.


ECharts: 옵션 객체 기반

차트 구성 요소와 데이터를 하나의 옵션 객체에 담아 차트 인스턴스에 전달해요.

import * as echarts from 'echarts';
import { useEffect, useRef } from 'react';

function EChartsBarChart({ data }) {
  const chartRef = useRef(null);

  useEffect(() => {
    const myChart = echarts.init(chartRef.current);

    const option = {
      xAxis: {
        type: 'category',
        data: data.map((d) => d.name),
      },
      yAxis: {
        type: 'value',
      },
      series: [
        {
          data: data.map((d) => d.value),
          type: 'bar',
          itemStyle: { color: colors.blue500 },
        },
      ],
    };

    myChart.setOption(option);
    return () => myChart.dispose();
  }, [data]);

  return <div ref={chartRef} style={{ width: 500, height: 300 }}></div>;
}

요구사항이 복잡해질수록 옵션 객체가 비대해지고, 컴포넌트 합성 방식의 React 패러다임과 맞지 않았어요.


Recharts: 선언적 JSX 합성

React 컴포넌트를 조립하는 방식으로 차트를 구성해요.

import { BarChart, Bar, XAxis, YAxis, Tooltip } from 'recharts';

function RechartsBarChart({ data }) {
  return (
    <BarChart width={500} height={300} data={data}>
      <XAxis dataKey="name" />
      <YAxis />
      <Tooltip />
      <Bar dataKey="value" fill={colors.blue500} />
    </BarChart>
  );
}

useRef나 직접적인 DOM 조작 없이, React의 State와 Props에 기반해 렌더링돼요. 각 구성 요소를 독립적인 태그로 관리할 수 있어 가독성이 높고, TypeScript 타입 추론과도 잘 맞아요.

위와 같은 데이터 규모, 요구사항, 개발 경험 등을 종합적으로 고려해 Recharts를 선택했어요.



문제 상황

모니터링 시스템 특성상 10개 이상의 차트 타입이 필요했어요. Line, Bar, Pie, Radar, Scatter, Guage… 요구사항에 맞춰 차트들을 구현하다 보니, UI 관련 코드와 데이터 가공 로직이 한 곳에 혼재되었어요.

// 모든 로직이 한 컴포넌트에 혼재
function MonitoringLineChart({ data, threshold, showAnimation, ... }) {
  // 데이터 포맷팅
  const formattedData = useMemo(() => formatData(data), [data]);

  // 툴팁 스타일
  const tooltipStyle = { backgroundColor: '#fff', ... };

  // 임계값 색상 로직
  const getColor = (value) => value > threshold ? 'red' : 'green';

  return (
    <LineChart>
      <Tooltip contentStyle={tooltipStyle} />
      <Legend />
      {/* ... */}
    </LineChart>
  );
}

수정할 때 다음과 같은 문제들이 발생했어요.

  1. 중복 수정 필요: 툴팁 디자인을 변경하려면 10개 차트를 모두 수정해야 했어요.
  2. 사이드이펙트 리스크: 비슷한 로직이 여러 차트에 복사되어 있어서, 한 곳을 수정하면 다른 차트도 동일하게 수정해야 했고, 놓치면 차트마다 동작이 달라지는 버그가 발생했어요.
  3. 협업의 어려움: 한 파일에 여러 관심사가 섞여 있어 코드 리뷰가 복잡해지고, 동시에 작업하면 충돌이 빈번했어요.

문제가 생긴 원인은 관심사가 분리되지 않았기 때문이라 판단했어요.



해결방안


서버 응답을 차트 데이터로 변환

우선 관심사 분리에 앞서, 백엔드에서는 DB 조인 결과를 그대로 내려주고 있었기 때문에 서버 응답을 차트에서 바로 사용할 수 있는 구조로 변환하는 작업이 필요했어요.

// 서버 응답
{
  "chartData": [
    { "space_id": "sp_001", "space_name": "회의실A", "timestamp": "2024-03-01T09:00:00", "value": 42 },
    { "space_id": "sp_001", "space_name": "회의실A", "timestamp": "2024-03-01T10:00:00", "value": 38 },
    { "space_id": "sp_002", "space_name": "회의실B", "timestamp": "2024-03-01T09:00:00", "value": 55 },
    ...
  ]
}

이 구조로 차트를 그리려면 프론트에서 다음과 같은 가공 작업이 필요했어요.

  • space_id별로 그룹핑
  • 타임스탬프 기준으로 정렬
  • 시리즈 이름 매핑
  • 빈 시간 구간 채우기(시계열 데이터용)
  • 대칭 도메인 계산(양방향 차트용) 등

이 변환 로직을 useChartData 훅 안에 차트별 transform 함수(transformTimeSeries, transformToMirrorData 등)로 구현해서, 각 차트 타입이 요구하는 구조로 가공했어요.

아래는 시계열 차트(Line, Area)용 변환 예시예요. Bar, Pie, Mirror 등 다른 차트 타입은 Recharts가 요구하는 구조에 맞게 각각 다른 transform 함수로 변환해요.

// 변환 후 차트 데이터 (시계열 차트용)
{
  "meta": {
    "sp_001": { "id": "sp_001", "name": "회의실A", "alias": "1층 대회의실" },
    "sp_002": { "id": "sp_002", "name": "회의실B", "alias": null }
  },
  "timeSeries": [
    {
      "timestamp": "2024-03-01T09:00:00",
      "values": { "sp_001": 42, "sp_002": 55 }
    },
    {
      "timestamp": "2024-03-01T10:00:00",
      "values": { "sp_001": 38, "sp_002": 61 }
    }
  ]
}

변환 후 구조의 특징:

  • meta로 시리즈 정보를 분리 (이름, alias 등)
  • timeSeries는 타임스탬프 기준으로 정렬
  • 각 타임스탬프에 모든 시리즈 값이 포함

이렇게 하면 차트 컴포넌트는 렌더링에만 집중할 수 있고, 데이터 변환 로직은 훅에서 관리돼요.



Render Props로 공용 요소 분리

그 다음으로 공용 요소(Tooltip, Legend, Axis style)를 분리했어요.


Render Props 패턴이란?

Render Props는 컴포넌트가 무엇을 렌더링할지 직접 결정하지 않고, 함수 형태의 props를 호출해서 UI를 반환받는 패턴이에요. 공용 로직은 컴포넌트 내부에서 관리하고, UI는 사용하는 쪽에서 결정하는 구조이죠.

// 공용 요소를 관리하는 컴포넌트
function BaseChart({ render }) {
  const axisStyle = {
    /* 공용 Axis 스타일 */
  };
  const TooltipComponent = <Tooltip /* 공용 설정 */ />;

  return render({ axisStyle, TooltipComponent }); // UI는 외부에서 결정
}

// 사용하는 쪽
<BaseChart
  render={({ axisStyle, TooltipComponent }) => (
    <LineChart>
      <YAxis {...axisStyle} />
      {TooltipComponent}
      <Line dataKey="value" />
    </LineChart>
  )}
/>;

요즘은 Custom Hooks가 이 역할을 대체하는 경우가 많지만, 공용 UI를 주입하는 상황에서는 여전히 유용해요. 차트 컴포넌트처럼 공용 Tooltip, Legend, Axis style을 여러 차트에 일관되게 적용해야 할 때 맞는 패턴이라 판단했어요.


BaseChart 설계

공용 Tooltip/Legend/Axis style을 생성하고, Render Props 패턴으로 각 차트에 주입하는 구조예요.

// BaseChart.tsx
export function BaseChart({ data, colors, render, ...props }: BaseChartProps) {
  // 공용 Axis style
  const axisStyle = {
    tick: { fill: colors.gray500, fontSize: 12 },
    axisLine: { stroke: colors.gray200 },
    tickFormatter: (value: number) =>
      new Intl.NumberFormat('ko-KR', { notation: 'compact' }).format(value),
  };

  // 공용 Tooltip
  const TooltipComponent = (
    <Tooltip
      contentStyle={{
        backgroundColor: colors.white,
        border: `1px solid ${colors.gray200}`,
        borderRadius: 8,
      }}
    />
  );

  // 공용 Legend
  const LegendComponent = <Legend iconType="circle" iconSize={8} />;

  return (
    <div className={className}>
      <ResponsiveContainer width="100%" height={dimensions?.height ?? 300}>
        {render({ axisStyle, TooltipComponent, LegendComponent, colors })}
      </ResponsiveContainer>
    </div>
  );
}

차트에서 사용하기

각 차트는 BaseChart로부터 공용 UI를 받아서 렌더링에만 집중해요.

// LineChart.tsx
export function LineChart({ data, dataKeys, ... }) {
  return (
    <BaseChart
      data={data}
      render={({ axisStyle, TooltipComponent, LegendComponent }) => (
        <RechartsLineChart data={data}>
          <YAxis {...axisStyle} />
          {TooltipComponent}
          {LegendComponent}
          {dataKeys.map((key) => <Line key={key} dataKey={key} />)}
        </RechartsLineChart>
      )}
    />
  );
}

이제 다른 차트도 동일한 패턴으로 BaseChart를 사용해요.


공용 UI 수정의 효과

예를 들어 툴팁 스타일을 변경하고 싶다면, 한 곳만 수정하면 돼요. BaseChart를 사용하는 모든 차트에 일괄 적용되서 각각의 파일을 수정할 필요가 없어요.

// BaseChart.tsx - 여기만 수정하면 일괄 적용
const TooltipComponent = (
  <Tooltip
    contentStyle={{
      backgroundColor: colors.gray800,
      color: colors.white, // text 색상 변경
      borderRadius: 12,
    }}
  />
);



컴포넌트에서 변환 로직 분리

앞서 설명한 transform 함수들을 useChartData 훅으로 묶어서, 차트 컴포넌트에서 변환 로직을 분리했어요.


AS-IS: 컴포넌트에 변환 로직 혼재

// 차트 컴포넌트 안에서 데이터 변환
function MonitoringLineChart({ serverData, mode }) {
  // 서버 응답 → 차트 형태 변환 로직이 컴포넌트에 섞여 있음
  const nameMap = new Map();

  Object.keys(serverData?.meta ?? {}).forEach((id) => {
    const item = serverData.meta[id];
    nameMap.set(id, item.alias || item.name);
  });

  const chartData = serverData?.timeSeries?.map((point) => ({
    time: point.timestamp,
    ...Object.fromEntries(
      Object.entries(point.values).map(([id, val]) => [nameMap.get(id), val]),
    ),
  }));

  return <LineChart data={chartData} />;
}

TO-BE: useChartData 훅으로 분리

// 변환 로직은 훅 안에, 차트는 data만 받아서 렌더링
function MonitoringLineChart({ serverData, mode }) {
  const { data, dataKeys } = useChartData(mode, serverData);

  return <LineChart data={data} dataKeys={dataKeys} />;
}


useChartData 훅 설계

10개 이상의 차트 타입을 지원하면서도, 각 타입별 데이터 변환 로직을 한 곳에서 관리해요.

type ChartMode =
  | 'line' // 라인 차트
  | 'bar' // 바 차트
  | 'stackedBar' // 스택 바
  | 'mirrorBar' // 양방향 바 (IN/OUT)
  | 'area' // 영역 차트
  | 'pie' // 파이 차트
  | 'donut' // 도넛 차트
  | 'radar' // 레이더 차트
  | 'scatter' // 산점도
  | 'gauge' // 게이지
  | 'matrix' // 히트맵/매트릭스
  | 'symmetric'; // 대칭 라인 (사용/미사용)

const useChartData = (mode: ChartMode, serverData?: ServerResponseDto) => {
  return useMemo(() => {
    if (!serverData) return { data: [], dataKeys: [] };

    // 1. meta 정보 → 표시 이름 매핑
    const nameMap = buildNameMap(serverData.meta);

    // 2. 차트 타입별 데이터 변환
    switch (mode) {
      case 'matrix':
        return { data: serverData.matrix, dataKeys: [] };

      case 'pie':
      case 'donut':
        return { data: transformToPieData(serverData, nameMap), dataKeys: [] };

      case 'mirrorBar':
        return {
          data: transformToMirrorData(serverData),
          dataKeys: ['in', 'out'],
        };

      case 'symmetric':
        return {
          positiveData: transformTimeSeries(serverData.usage, nameMap),
          negativeData: transformTimeSeries(serverData.nonUsage, nameMap),
          dataKeys: Array.from(nameMap.values()),
        };

      // line, bar, area, radar, scatter 등 시계열 기반
      default:
        return {
          data: transformTimeSeries(serverData.timeSeries, nameMap),
          dataKeys: Array.from(nameMap.values()).map((name) => ({
            dataKey: name,
            name,
          })),
        };
    }
  }, [mode, serverData]);
};

차트 타입이 늘어나도 switch 분기만 추가하면 돼요. 차트 컴포넌트는 변환된 data만 받아서 렌더링해요.



Recharts 미지원 기능 직접 구현

Recharts는 기본 차트 타입은 잘 지원하지만, Zoom/Pan이나 양방향 Bar 같은 기능은 제공하지 않아요. 이런 기능들은 직접 구현해야 했어요.


Zoom/Pan

Recharts에는 줌/팬 기능이 없어서 useEffect로 마우스 휠 이벤트를 감지하고, 스크롤 좌표를 계산해서 차트 영역을 동적으로 조정했어요.

useEffect(() => {
  const handleWheel = (e: WheelEvent) => {
    e.preventDefault();
    const zoomFactor = e.deltaY > 0 ? 0.9 : 1.1;
    setDomain((prev) => calculateNewDomain(prev, zoomFactor, e.clientX));
  };

  chartRef.current?.addEventListener('wheel', handleWheel, { passive: false });
  return () => chartRef.current?.removeEventListener('wheel', handleWheel);
}, []);

MirrorBar (양방향 Bar)

IN/OUT을 양쪽으로 비교하는 차트도 Recharts 기본 기능으로는 불가능해요. layout="vertical"과 음수 도메인을 조합해서 직접 구현했어요.

<BarChart data={data} layout="vertical">
  <XAxis type="number" domain={[-maxValue, maxValue]} />
  <Bar dataKey="in" fill={inColor} />
  <Bar dataKey="out" fill={outColor}>
    {/* out 값을 음수로 변환해서 왼쪽에 표시 */}
  </Bar>
</BarChart>

이런 커스텀 차트들은 ResponsiveContainer를 직접 사용해서 BaseChart의 공용 UI를 건드리지 않고 독립적으로 구현할 수 있었어요.



그 외의 작업

차트를 정확하게 그리려면 원본 데이터를 차트에 맞게 가공하는 과정이 필요해요. 서버에서 받은 데이터를 그대로 쓰면 축이 치우치거나 빈 구간이 생기는 문제가 발생하죠.


양방향 차트의 도메인 계산

양방향 차트에서는 0을 중심으로 대칭인 도메인이 필요해요. 단순히 [min, max]로 설정하면 한쪽으로 치우친 차트가 그려지죠.

// 대칭 도메인 계산
const domain = useMemo(() => {
  const allValues = data.flatMap((d) => [
    Math.abs(Number(d.in) || 0),
    Math.abs(Number(d.out) || 0),
  ]);
  const max = Math.max(...allValues);
  const padding = max * 0.15; // 여백
  return [-(max + padding), max + padding];
}, [data]);

모든 값의 절대값 중 최대값을 구하고, 양쪽에 동일하게 적용해요. 이렇게 하면 0이 정확히 중앙에 위치해요.


시계열 빈 구간 채우기

서버에서 데이터가 없는 시간대가 있으면 차트가 끊어져 보여요. 09:00, 10:00, 12:00 데이터만 있으면 11:00 구간이 비어서 선이 직접 연결되죠.

// 빈 구간을 0으로 채우기
export function fillTimeSeriesGaps(data, interval, fillValue = 0) {
  const intervalMs = { hour: 3_600_000, day: 86_400_000 }[interval];
  const result = [];

  let currentTime = startTime;
  while (currentTime <= endTime) {
    const existing = dataMap.get(currentTime);
    result.push(existing ?? { timestamp: currentTime, value: fillValue });
    currentTime += intervalMs;
  }

  return result;
}

이 함수로 일정한 간격의 연속적인 데이터를 보장할 수 있게 했어요.



결과


1. 관심사 분리

useChartData 훅이 서버 응답을 차트 데이터로 변환하고, BaseChart가 공용 UI(Tooltip, Legend, Axis style)를 담당하고, 각 차트 컴포넌트는 렌더링에만 집중해요.


2. 공용 UI 수정 지점 단일화

툴팁이나 범례 스타일을 수정할 때 BaseChart 한 곳만 변경하면 20개 이상의 차트에 일괄 적용돼요. 개별 차트 파일을 수정할 필요가 없어요.


3. 확장 가능한 구조

새 차트 타입이 필요할 때 공용 레이어 수정 없이 컴포넌트만 추가하면 돼요. 데이터 소스가 바뀌어도 차트 컴포넌트는 그대로고, UI를 변경해도 데이터 로직에 영향이 없어요.



구조 요약

src/
├── types/
│   └── charts.ts                  # 차트 관련 타입 정의

├── hooks/charts/
│   └── useChartData.ts            # 서버 응답 → 차트 데이터 변환

└── components/charts/
    ├── base/
    │   └── BaseChart.tsx          # Render Props로 공용 UI 주입

    ├── common/                    # 기본 차트
    │   ├── LineChart.tsx
    │   ├── BarChart.tsx
    │   ├── AreaChart.tsx
    │   ├── PieChart.tsx
    │   ├── RadarChart.tsx
    │   └── GaugeChart.tsx

    └── custom/                    # 커스텀 차트
        ├── MirrorBarChart.tsx     # 양방향 바 (IN/OUT)
        ├── SymmetricLineChart.tsx # 대칭 라인 (사용/미사용)
        ├── ScatterChart.tsx       # 사분면 산점도
        └── SpaceBridgeChart.tsx   # 네트워크 그래프



마치며

차트 컴포넌트를 설계하면서 관심사 분리에 대해 더 깊이 생각하게 됐어요.

개발을 하면서 수없이 들어봤지만, 이번 작업을 통해 그게 실제로 어떤 의미인지 더 구체적으로 느꼈어요. 단순히 파일을 나눈다거나 함수를 쪼갠다가 아니라, 이 코드가 변경되는 이유가 무엇인가를 기준으로 경계를 긋는 것이더라고요.

  • 데이터 포맷이 바뀌면? → useChartData만 수정
  • 툴팁 디자인이 바뀌면? → BaseChart만 수정
  • 새로운 차트 타입이 필요하면? → 새 컴포넌트만 추가

각 레이어가 자기 역할에만 집중하니까, 문제가 생겼을 때 어디를 봐야 하는지 바로 알 수 있었어요. 그리고 한 곳을 고쳐도 다른 곳이 깨질 걱정을 안 해도 됐고요.

실제로 이 구조를 적용한 뒤로 차트 관련 수정 요청이 들어와도 어디를 건드려야 하는지 고민하는 시간이 줄었어요. 수정 범위가 명확하니까 코드를 바꾸는 게 어렵지 않더라고요. 결국 좋은 설계란 수정하기 쉬운 코드를 만드는 것이고, 그 핵심이 관심사 분리라는 걸 이번에 배웠어요.