본문 바로가기

개발 여정

ApexCharts.js 대신 D3.js로 데이터 시각화하기

배경

이직 후 입사한지 3개월째 되던 해(2024년 2월)에 있었던 일입니다.

당시 우리 팀에서 "관리비 조회" 서비스 개편 작업을 담당하게 되었고 그 중 관리비 내역을 시각화하는 차트 영역을 개발하게 되었습니다.

프로젝트에는 이미 ApexChart.js라는 패키지가 설치되어 있어 해당 패키지로 개발을 진행할 수 있다고 생각했습니다.

ApexChart.js란?

페이지에서 깔끔하게 반응형으로 시각화 UI를 만들 있도록 돕는 고수준의 모던 차트 라이브러리

링크 : https://apexcharts.com/

 

하지만 이후 기획팀으로부터 공유받은 차트 관련 요구사항을 보고 ApexChart.js가 아닌 D3.js로 개발하는 것이 유지보수 측면에서 용이하다고 판단하였습니다.

우선, ApexChart.js의 한계점을 설명 드리자면 아래 두가지로 이유를 들 수 있습니다.

 

ApexChart.js의 한계 1 - 데이터 포인트, 보조선과 같은 세부적인 UI 커스텀 디자인 불가

 

위 이미지는 이번 서비스 개편 작업에 새로 추가되는 관리비 추이 라인 차트이며 요구 사항은 다음과 같았습니다.

1. 일정 시간 간격으로 데이터 포인트를 중심으로 원이 커지며 사라지는 형태의 파동(울림) 애니메이션 추가
2. 데이터 포인트를 기준으로 수평 보조선, 수직 보조선 추가

 

ApexChart.js의 경우 자체 옵션만으로 데이터 포인트 반복적인 애니메이션을 설정할 수 없으며 정해진 구성 옵션으로 빠르게 차트를 만드는 고수준 추상화 제공합니다.

내부 SVG, Canvas 구조와 렌더링 생명주기를 라이브러리가 관리하도록 되어 있어 개발자가 특정 SVG에 직접 접근해 제어하거나 지속 애니메이션을 주기적으로 주입하는 한계가 있다는 것을 알았습니다.

 

설령, 차트가 렌더링된 후 후처리로 커스텀을 하려 생각을 했으나 DOM 구조나 class name 라이브러리 버전에 따라 바뀔 수 있기 때문에 유지보수 안정성 면에서 적합하지 않다고 생각했습니다. 

 

ApexChart.js의 한계 2 - 디자인 시스템과 차트 테마 일관성 결여

 

위 차트는 ApexChart.js의 공식문서 상의 Demo 결과물입니다.

차트에서 제공하는 텍스트의 spacing, typography 등 설정이 제한적이라 프로젝트의 디자인 시스템과 일관성을 맞추기 쉽지 않으며 디자인 시스템 변경 차트만 따로 조정해야 하는 번거로움 생깁니다.

결과로 디자인 시스템과 차트의 테마 일관성 결여될 수 있다고 생각했습니다.

 

위와 같은 한계점으로 인해 ApexChart.js를 대체할 수 있는 새로운 데이터 시각화 라이브러리가 필요했고 D3.js가 위 문제를 해결할 수 있다고 생각하여 팀에 도입 제안을 드렸습니다.

 

D3.js란?

데이터 시각화를 위한 무료 오픈 소스 JavaScript 라이브러리

표준에 기반한 저수준 접근 방식으로 동적인 데이터 중심 그래픽을 만드  매우 훌륭한 유연성 제공

링크 : https://d3js.org/what-is-d3

 

도입 시 장점과 단점을 정리하여 팀과 공유하였고 그 결과 도입은 채택되었습니다.

당시 정리한 내용은 아래와 같습니다.

 

도입 시 장점

1. 데이터 시각화 라이브러리 중 가장 낮은 수준으로 DOM 접근이 가능하며 SVG를 세부적으로 제어 가능

svg.selectAll('circle.point')
  .data(data)
  .join('circle')
  .attr('class', 'point')
  .attr('cx', d => x(d.month))
  .attr('cy', d => y(d.value))
  .attr('r', 4)
  .attr('fill', '#2f6fff')
  .attr('stroke', '#fff')
  .attr('stroke-width', 2);

 

특정 DOM 선택(select 혹은 selectAll) 데이터와의 결합(data, join, enter, update, exit) 통해 특정 SVG 요소의 속성 직접 지정할 수 있습니다.

위의 지정을 통해 좌표 변환, x 혹은 y축의 스케일 원하는 방식으로 구성할 있습니다.

고수준 차트 라이브러리처럼 미리 정해진 레이아웃에 맞추는 것이 아니라, path 태그 d 속성, circle 태그 r, cx, cy 속성등 속성 단위로 제어 가능합니다.

위와 같은 이점으로 복잡한 비즈니스 로직, 디자인 요구사항에 유연하게 대응할 수 있습니다.

 

2. 차트에 다양한 이벤트 리스너와 부드러운 애니메이션, 전이 효과 등 설정 가능

svg.selectAll('circle.point')
  .on('mouseover', function () {
    select(this)
      .transition().duration(180).ease(easeCubicOut)
      .attr('r', 6).attr('opacity', 1);
  })
  .on('mouseout', function () {
    select(this)
      .transition().duration(220).ease(easeCubicIn)
      .attr('r', 4).attr('opacity', 0.8);
  });

 

D3.js selection에는 on(event명, event handler)와 같은 DOM event listener 등록 있고, transition으로 속성 값의 변화에 지연을 적용해서 부드러운 상태 값 변경이 가능합니다.

즉, 스케일과 레이아웃을 직접 정의하므로 상태 값에 따른 조건부 애니메이션을 쉽게 구현 가능합니다.

 

3. 활발한 커뮤니티와 방대한 레퍼런스 보유

오랜 기간 축적된 예제와 튜토리얼, 블로그 문제 해결 레퍼런스 찾기 쉽습니다.

차트뿐 아니라 다양한 도메인의 패턴과 스니펫 공유되어 있어, 필요 기능을 조합해 재사용하기 좋았습니다.

특히 개인적으로 공식 홈페이지에서 제공하는 예제 사이트(D3 Gallery)가 차트를 구현하는데 많은 힌트를 얻을 수 있었습니다.

생태계가 크기 때문에 모범 사례 학습하기도 수월하며, 유지보수 대안을 빠르게 모색하는 장점이라고 생각했습니다.

 

도입 시 단점

하지만 장점이 있으면 단점이 따르는 법입니다.

단점은 아래와 같지만 팀내에서 충분히 극복 가능하다고 결론이 나왔습니다.

 

러닝 커브 및 생산성

D3.js는 저수준 라이브러리라 스케일, , 레이아웃, 데이터 결합까지 직접 설계해야 합니다.

간단한 차트 요구라도 처음부터 레이아웃을 구축해야 하므로 초기 구현 시간이 타 데이터 시각화 라이브러리에 비해 길고, 복잡도가 커질수록 생산성이 떨어질 수 있습니다.

하지만, 사용자 편의성을 위한 다양한 기능과 복잡한 비즈니스 요구사항에 유연하게 대응하기 위해서는 저수준 라이브러리만한 것이 없다고 결론이 나왔습니다.

적용 간 이슈

우리 팀에서 "관리비 조회" 서비스 개편 작업 담당은 선임 개발자분이셨고 그 중 차트 영역은 제가 담당하였습니다.

한편, 차트 개발 중 겪었던 기술적 이슈는 아래와 같이 2가지가 있었습니다.

 

1. 컴포넌트(DrawCircle) 리렌더링 시 SVG "덧그리기 발생

컴포넌트의 설계를 알기 쉽게 간단한 동그라미를 그리는 코드와 설명을 아래와 같이 나타냈습니다.

import { select } from 'd3';
import { useEffect, useRef } from 'react';

interface ICircleItem {
  id: string;
  x: number;
  y: number;
}

interface DrawCircleProps {
  circleList: ICircleItem[];
}

const DrawCircle = ({ circleList }: DrawCircleProps): React.ReactElement => {
  const canvasRef = useRef<SVGSVGElement>(null);

  useEffect(() => {
    const canvas = canvasRef.current;

    if (!canvas) return;

    const svgRoot = select(canvas);
    const circles = svgRoot.selectAll<SVGCircleElement, ICircleItem>(
      'circle.point',
    );

    circles
      .data(circleList, (d) => d.id)
      .enter()
      .append('circle')
      .attr('class', 'point')
      .attr('cx', (d) => d.x)
      .attr('cy', (d) => d.y)
      .attr('r', 4);
  }, [circleList]);

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

export default DrawCircle;

 

  • SVG 컨테이너 초기 렌더링
    • 컴포넌트가 마운트될 때, 데이터 시각화의 기반이 될 <svg> 태그를 React의 선언적 방식으로 초기 렌더링합니다.
    • <svg> 태그는 D3.js가 그림을 그릴 '캔버스' 역할을 합니다.
  • DOM 요소에 대한 참조 확보
    • useRef를 사용하여 변경 가능한 참조 객체를 생성합니다.
    • 이 참조 객체를 <svg> 태그의 ref prop에 연결함으로써, 컴포넌트 렌더링 후 해당 <svg> DOM 요소의 실제 인스턴스를 ref.current에 할당합니다.
    • 이를 통해 D3.js가 직접 접근할 수 있는 DOM 노드를 확보합니다.
  • useEffect 내 데이터 시각화 로직 실행
    • useEffect 훅 내부에서 데이터 시각화 로직을 실행합니다.
    • 이는 컴포넌트가 마운트되어 <svg> DOM 요소가 실제로 존재함을 보장하기 위함입니다.
    • d3.select(ref.current)를 통해 useRef로 얻은 실제 <svg> DOM 노드를 D3.js의 selection 객체로 변환합니다.
    • 이후 선택된 <svg> 요소 내부에서 동그라미 시각화 작업을 수행
    • dependency array의 요소가 바뀔때마다 컴포넌트 리렌더링

이슈 :

  • circleList의 요소의 갯수만큼 동그라미를 그리는 구조에서 circleList를 6개에서 1개로 업데이트한다고 가정
  • 이때 동그라미가 1개로 줄어드는 것이 아닌 기존 6개에서 1개가 추가로 덧그려지는 이슈

원인 :

  • enter().append()의 경우 데이터 결합 후 기존 DOM 요소는 건드리지 않고 해당 데이터만큼 실제 DOM 요소를 만들어 추가
  • circleList의 요소 갯수만큼 동그라미를 업데이트하는 것이 아닌 새로운 동그라미 추가만 처리

해결 1 - 상태 값이 바뀔 경우 기존 SVG 초기화 로직 추가

circleList의 값이 바뀌면 svg.selectAll('circle.point').remove()를 먼저 실행하여 기존 svg를 제거 후 동그라미를 다시 그릴 수 있는 방법이 있습니다.

circle을 그리기 전에 다음과 같이 한줄만 추가하면 됩니다.

circles.remove();

 

이를 통해 덧그림을 막을 수 있지만 다음과 같은 단점이 있습니다.

  • 기존 svg의 모든 노드를 제거하고 새로 생성하면 DOM 변경량, layout, paint 등의 부담이 있습니다.
  • 관리할 상태값의 양이 늘거나 모바일 환경에서 프레임 저하·지연이 발생할 수 있습니다.
  • 설령 기존 svg의 모든 노드를 제거하는 것이 아닌 업데이트가 필요한 svg를 구분해서 삭제하려 할 경우 삭제 로직을 관리해야 하는 번거로움이 생깁니다.

성능적인 이슈와 DOM의 변경을 최소로 하고 싶다고 생각하여 다른 방법을 찾게 되었고 두번째 해결 방법은 아래와 같습니다.

 

해결 2 - enter를 join 메서드로 대체

위 이슈를 해결하기 위한 메서드로 join이 제공되며 역할은 아래와 같습니다.

  • 상태값의 길이보다 동그라미의 갯수가 적을 때 동그라미를 상태값의 길이만큼 추가 (enter)
  • 상태값의 위치 값이 기존 동그라미의 위치와 다르다면 위치를 갱신(update)
  • 상태값의 길이보다 동그라미 갯수가 많으면 사라진 요소만큼 동그라미 제거(exit)

이를 통해 불필요한 DOM 제거를 방지하여 DOM 변경을 최소한으로 할 수 있습니다.

코드로 나타내면 아래와 같습니다.

import { select } from 'd3';
import { useEffect, useRef, useState } from 'react';

interface ICircleItem {
  id: string;
  x: number;
  y: number;
}

interface DrawCircleProps {
  circleList: ICircleItem[];
}

const DrawCircle = ({ circleList }: DrawCircleProps): React.ReactElement => {
  const canvasRef = useRef<SVGSVGElement>(null);

  useEffect(() => {
    const canvas = canvasRef.current;

    if (!canvas) return;

    const svgRoot = select(canvas);

    const circles = svgRoot.selectAll<SVGCircleElement, ICircleItem>(
      'circle.point',
    );

    circles
      .data(circleList, (d) => d.id)
      // .enter() <- 제거
      // .append('circle') <- 제거
      .join('circle') // <- 추가
      .attr('class', 'point')
      .attr('cx', (d) => d.x)
      .attr('cy', (d) => d.y)
      .attr('r', 4);
  }, [circleList]);

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

export default DrawCircle;

 

위 소스코드 수정을 통해 DOM 변경을 최소한하여 상태값과 view간의 동기화를 챙길 수 있게 되었습니다.

하지만 개인적인 아쉬움으로 D3.js를 처음 접하거나 학습한지 얼마 안 된 팀원분이 해당 소스코드 파악 시 join 메서드 역할을 직관적으로 파악하기 어렵다고 생각하였습니다.

그래서 join의 소스 코드의 가독성을 위해 아래와 같이 리팩터링하였습니다.

 

해결 3 - join 메서드의 역할 명시적 분기

import { select } from 'd3';
import { useEffect, useRef, useState } from 'react';

interface ICircleItem {
  id: string;
  x: number;
  y: number;
}

interface DrawCircleProps {
  circleList: ICircleItem[];
}

const DrawCircle = ({ circleList }: DrawCircleProps): React.ReactElement => {
  const canvasRef = useRef<SVGSVGElement>(null);

  useEffect(() => {
    const canvas = canvasRef.current;

    if (!canvas) return;

    const svgRoot = select(canvas);

    const circles = svgRoot.selectAll<SVGCircleElement, ICircleItem>(
      'circle.point',
    );

    circles
      .data(circleList, (d) => d.id)
      // .join('circle') // <- 제거
      .join(
        (enter) => // <- 동그라미 추가 로직
          enter
            .append('circle')
            .attr('class', 'point')
            .attr('cx', (d) => d.x)
            .attr('cy', (d) => d.y)
            .attr('r', 4),
        // 아래는 동그라미 위치 업데이트 로직
        (update) => update.attr('cx', (d) => d.x).attr('cy', (d) => d.y),
        (exit) => exit.attr('r', 0).remove(), // <- 동그라미 제거 로직
      .attr('class', 'point')
      .attr('cx', (d) => d.x)
      .attr('cy', (d) => d.y)
      .attr('r', 4);
  }, [circleList]);

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

export default DrawCircle;

 

위 join 메서드의 인자로 콜백함수를 3개 할당하였습니다.

콜백함수별로 명시적으로 의도가 분명하며 join에서 3개의 역할을 담당한다는 것을 직관적으로 파악할 수 있습니다.

또한 enter, update, exit 별로 동그라미 svg 제어 시 각기 다른 애니메이션 상세 적용이 가능하다는 이점이 있습니다.

물론, 해결2에 비해 코드 양이 늘어났지만 가독성면에서는 해결3이 더 용이하다고 판단하여 해결3을 채택하여 적용했습니다.

2. 차트의 y축 등분 갯수와 라벨 처리

이슈 :

  • 차트의 y축을 5등분하여 균등한 값을 y축 라벨로 노출하고자 axis.ticks(5)를 사용
  • 의도하지 않은 등분으로 나뉘는 이슈

원인 : 

  • axis.ticks의 경우 인자에 넣은 숫자만큼 정확히 축을 나누는 것이 아님
  • axis.ticks(n) 도메인 범위에 맞춰 예쁜 간격 고르고 대략 n 수준의 등분 진행

위의 원인 파악이 맞는지 검증을 진행하기 위해 간단한 y축 생성 후 눈금을 생성하는 로직을 만들어봤습니다.

import { select, scaleLinear, axisLeft, format } from 'd3';

const minTicklabel = 0;
const maxTicklabel = 2_500;
const chartBottom = 0;
const chartHeight = 500;

const yScale = scaleLinear()
  .domain([minTicklabel, maxTicklabel])
  .range([chartBottom, chartHeight]);

const ticksCount = 5;
const yAxis = axisLeft(yScale).ticks(ticksCount);

const ticksList = select(canvasRef.current)
  .append('g')
  .call(yAxis)
  .selectAll('.tick text')
  .nodes()
  .map((n) => (n as HTMLElement).textContent);

console.log(ticksList); // ["0", "500", "1,000", "1,500", "2,000", "2,500"]

 

정해진 도메인 범위는 0과 2,500이며 이것을 5등분으로 설정하기 위해 ticks(n)의 n을 5로 할당하였습니다.

그리고 tick들을 생성하여 해당 DOM 참조를 요소로 갖는 배열을 만든 후 tick의 값을 요소를 갖는 배열로 가공하여 ticksList를 로그로 찍어보면 5등분으로 잘 나뉘진 것을 확인할 수 있습니다.

 

하지만, y축을 10등분으로 나누고자 ticksCount를 10으로 할당하여 ticksList를 로그로 찍어보면 아래와 같이 10등분 그 이상으로 나누게 됩니다.

// 총 13등분
[
    "0",
    "200",
    "400",
    "600",
    "800",
    "1,000",
    "1,200",
    "1,400",
    "1,600",
    "1,800",
    "2,000",
    "2,200",
    "2,400"
]

 

즉, 요구사항이 y축을 5등분하는 것이라면 깔끔하게 500을 간격으로 나눠지게 되지만 요구사항이 10등분으로 변경된다면 더이상 ticks를 사용할 수 없으며 다른 방법으로 구현해야 합니다.

 

해결 : 

  • 축의 등분과 명시적인 라벨 값을 설정하려면 axis.tickValues를 사용

d3 공식문서를 보면 다음과 같이 설명하고 있습니다.

To set the tick values explicitly, use axis.tickValues.

 

axis.ticks를 axis.tickValues로 변경하면 아래와 같이 나타낼 수 있습니다.

const minTicklabel = 0;
const maxTicklabel = 2_500;
const chartBottom = 0;
const chartHeight = 500;

const yScale = scaleLinear()
  .domain([minTicklabel, maxTicklabel])
  .range([chartBottom, chartHeight]);

// 삭제
// const ticksCount = 10;
// const yAxis = axisLeft(yScale).ticks(ticksCount);

// 추가
const tickLabels = [0, 500, 1_000, 1_500, 2_000, 2_500];
const tickWidth = 0;
const yAxis = axisLeft(yScale)
  .tickValues(tickLabels)
  .tickSize(tickWidth)


const ticksList = select(canvasRef.current)
  .append('g')
  .call(yAxis2)
  .selectAll('.tick text')
  .nodes()
  .map((n) => (n as HTMLElement).textContent);

console.log(ticksList2);

 

tickValues에 배열을 할당하여 배열의 갯수가 tick의 갯수이며 각 tick의 라벨을 요소의 값으로 설정할 수 있습니다.

 

마무리

기존 ApexChart.js가 아닌 D3.js를 도입하여 차트의 UI를 직접 구현하였습니다.

"관리비 조회" 서비스 개편에서 새로 생긴 차트 4개의 복잡한 요구사항을 100% 유연하게 대응할 수 있는 경험을 하였습니다.

기술 검토 및 문제 직면, 문제 해결 과정을 통해 개발에 자신감을 더해가는 값진 경험을 할 수 있어 뿌듯합니다.

 

반응형