View

React와 D3.js 함께 사용하기

hyewon.dev 2023. 7. 19. 16:50

개요

신규 프로젝트에서 React와 D3를 사용한 차트 개발을 담당하게 되면서, 어떻게 하면 좀 더 가독성 좋고 선언적인 D3 코드를 작성할 수 있을지에 대한 고민이 있었습니다.

이전 프로젝트에서 주로 사용된 방식은 DOM 요소를 직접 선택해서 데이터와 속성을 추가하는 방식이었습니다. 하지만 이러한 방식이 React에서는 효과적이지 않으며, 한 줄로 길게 체이닝을 이루는 코드가 가독성 면에서 좋지 않다고 생각하여 이를 해결하기 위한 몇 가지 개선안들을 찾아보게 되었습니다.

 

d3 Code

 

React에서 D3 라이브러리를 효율적으로 활용하기 위한 여러 가지 방법들을 프로젝트에 적용해보며 새롭게 알게된 점들을 예제코드와 함께 간단히 정리해 보았습니다.

 

D3.js란?

D3.js는 차트, 선 그래프 등과 같은 재사용 가능한 데이터 시각화를 만들 수 있도록 하는 자바스크립트 라이브러리입니다.

 

D3는 React와 페어링 해서 많이 사용하지만 두 라이브러리를 함께 사용할 때 고려해야할 점이 있습니다. React는 가상 DOM을 사용하여 DOM 업데이트를 효율적으로 관리하지만, D3는 직접 DOM을 조작합니다. 두 라이브러리가 동일한 DOM을 조작하려고 할 때 충돌이 발생할 수 있기 때문에 충돌을 최소화하기 위한 접근 방식을 고려해야 합니다. DOM의 조작을 D3에 맡기느냐, React에 맡기느냐에 대한 여러 의견들이 있는데 가장 일반적으로 사용되는 방법은 DOM의 렌더는 React가 계산은 D3가 하게 하는 방식입니다.

 

React는 직접 SVG 요소를 처리할 수 있어서 D3의 렌더링을 이용할 필요가 없습니다. 따라서 D3를 Path, Scale, Layout, Transformation 등의 선/도형등의 모양을 계산하는 헬퍼 함수로 사용하고, 렌더링을 처리하는 부분은 React 컴포넌트로 대체할 수 있습니다.

 

React에서 D3를 사용할 때 고려할 점

 

1. 선언적으로 작성할 수 있는 부분에서 ref 사용하지 않기

import { select } from 'd3-selection';

const Circle = () => {
  const svgRef = useRef<SVGSVGElement>(null);

  useEffect(() => {
    const svgElem = select(svgRef.current);
    svgElem.append('circle').attr('cx', 50).attr('cy', 50).attr('r', 50);
  }, []);

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

기존의 코드는 useRef를 사용하여 SVG 요소에 대한 참조를 생성하고 D3의 select 메소드를 통해 선택한 뒤, 해당 요소에 새로운 circle 요소와 속성을 추가하는 방식이었습니다. 또한, 컴포넌트가 렌더링된 후에 DOM에 접근할 수 있도록 useEffect 문 내부에 이러한 로직이 작성되었습니다.

 

하지만 해당 로직에서는 D3의 복잡한 연산이 필요하지 않기 때문에, D3를 사용하지 않고도 JSX 내에서 간단하게 SVG를 렌더링할 수 있습니다.

 

return (
    <>
      <svg>
        <circle cx="50" cy="50" r="50" />
      </svg>
    </>
  );

 

이렇게 수정하면 코드가 더 간단해지고 불필요한 라이브러리 의존성을 없앨 수 있습니다.

 

요소가 많아져도 동일한 방식으로 작성할 수 있어요.

 

# before

import { select } from 'd3-selection';

const Circle = () => {
  const [list, setList] = useState<number[]>([10, 20, 5, 50, 0, 100]);

  const svgRef = useRef<SVGSVGElement>(null);

  useEffect(() => {
    const svgElem = select(svgRef.current);

    svgElem
      .selectAll('circle')
      .data(list)
      .join('circle')
      .attr('cx', (_, i) => i * 30)
      .attr('cy', (v) => 100 - v)
      .attr('r', 10);
  }, [list]);

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

 

# after

const Circle = () => {
  const [list, setList] = useState<number[]>([10, 20, 5, 50, 0, 100]);

  return (
    <>
      <svg>
        {list.map((v, i) => (
          <circle key={i} cx={i * 30} cy={100 - v} r={10} />
        ))}
      </svg>
    </>
  );
};

 

 

2. 애니메이션 처리하기

 

다음으로 시각적 요소들을 동적으로 처리해봅시다.

위의 바 차트에서처럼 차트 요소가 추가되고 업데이트되고 사라질 때, 애니메이션을 적용하려고 한다면 어떻게 해야할까요?

 

 

(1) d3-transition 활용하기

 

먼저, D3의 내장된 기능을 활용하는 방법이 있습니다.

D3는 기본적으로 데이터에 기반하여 시각적 요소를 업데이트하는데, 데이터가 변경되는 시점에 요소의 위치, 크기, 색상 등을 변화시킬 수 있습니다. 그 중 d3-transition 모듈을 사용하면 타이밍을 설정하여 부드러운 전환 효과를 처리할 수 있어요.

(+ D3에서 요소의 업데이트를 조작하기 위해서는 enter, update, exit 패턴에 대한 이해가 필요합니다.)

 

import { select, type Selection, type BaseType } from 'd3-selection';
import 'd3-transition';

const AnimatedBar = () => {
  const svgRef = useRef<SVGSVGElement>(null);

  const [list, setList] = useState<number[]>([]);

  const removeData = () => {
    setList([]);
  };

  const updateRectangles = (
    svgElem: Selection<SVGSVGElement, number, BaseType, undefined>,
    data: number[]
  ) => {
    const rects = svgElem.selectAll('rect').data(data);

    // Enter
    rects
      .enter()
      .append('rect')
      .attr('x', (_, i) => i * 70)
      .attr('y', (v) => 300 - 5 * v)
      .attr('width', 65)
      .attr('height', (v) => v * 10)
      .attr('fill', 'orange')
      .call((enter) =>
        enter
          .transition()
          .duration(500)
          .attr('y', (v) => 300 - 10 * v)
      );

    // Update
    rects.attr('fill', 'blue');

    // Exit
    rects.exit().transition().style('opacity', 0).remove();
  };

  useEffect(() => {
    setList([1, 10, 2, 7, 7, 10]);

    const timeoutId = setTimeout(() => {
      const addedData = [1, 2, 3];
      setList((prev) => [...prev, ...addedData]);
    }, 2000);

    return () => {
      clearTimeout(timeoutId);
    };
  }, []);

  useEffect(() => {
    if (svgRef.current) {
      const svgElem = select<SVGSVGElement, number>(svgRef.current);
      updateRectangles(svgElem, list);
    }
  }, [list]);

  return (
    <>
      <svg ref={svgRef} width={700} height={300}></svg>
      <button onClick={removeData}>초기화</button>
    </>
  );
};

다만 이렇게 작성한 코드를 보면, 데이터를 요소에 바인딩하고 DOM을 조작하는 일련의 과정이 절차적으로 작성되었음을 알 수 있습니다. 이러한 코드 스타일은 리액트가 추구하는 선언적 프로그래밍 스타일과는 일치하지 않아서 코드의 목적을 파악하기 어렵게 만든다는 단점이 있습니다.

 

 

(2) React Animation 라이브러리 활용하기

 

D3의 코드 복잡성 문제를 해결하기 위해서 React Animation 라이브러리(React Spring, Framer Motion... 등)를 활용하는 방법이 있습니다. React와 보다 원활하게 호환되고, 라이브러리의 간단하고 직관적인 API를 사용해서 좀 더 가독성 좋은 코드를 작성할 수 있습니다.

import { animated, useTransition } from '@react-spring/web';

const AnimatedBar = () => {
  const [list, setList] = useState<number[]>([]);

  let key = 0;

  const config = {
    duration: 500,
  };

  const transitions = useTransition(list, {
    from: {
      y: 5,
      opacity: 1,
    },
    enter: {
      fill: 'orange',
      y: 0,
      config,
    },
    update: { fill: 'blue', config },
    leave: { opacity: 0, config },
    keys: () => key++,
  });

  const removeData = () => {
    setList([]);
  };

  useEffect(() => {
    setList([1, 10, 2, 7, 7, 10]);

    const timeoutId = setTimeout(() => {
      const addedData = [1, 2, 3];
      setList((prev) => [...prev, ...addedData]);
    }, 2000);

    return () => {
      clearTimeout(timeoutId);
    };
  }, []);

  return (
    <>
      <svg width={700} height={300}>
        {transitions((style, item, _, index) => {
          return (
            <animated.rect
              style={style}
              height={item * 10}
              width={65}
              x={index * 70}
              y={300 - 10 * item}
            >
              {item}
            </animated.rect>
          );
        })}
      </svg>
      <button onClick={removeData}>초기화</button>
    </>
  );
};

 

 

3. 축 그리기

 

차트를 그릴 때 정확한 지표를 전달하기 위해 축을 표현하는 경우가 많습니다. D3는 눈금의 범위를 계산하거나 축을 생성하고 모양과 레이아웃을 조정하는 등 축을 그리기 위한 다양한 모듈과 메소드를 제공해줍니다.

 

(1) d3-axis 활용하기

import { select } from 'd3-selection';
import { scaleLinear } from 'd3-scale';
import { axisBottom } from 'd3-axis';

const Axis = () => {
  const domain = [0, 100];
  const range = [10, 300];

  const svgRef = useRef<SVGSVGElement>(null);

  useEffect(() => {
    const svgElem = select(svgRef.current);
    const xScale = scaleLinear().domain(domain).range(range);
    const xAxis = axisBottom(xScale);

    svgElem.append('g').call(xAxis);
  }, []);

  return (
    <>
      <svg ref={svgRef} width={500} height={500}></svg>
    </>
  );
};

데이터의 범위에 맞게 스케일을 정의하고, d3-axis 모듈을 활용하여 축의 위치를 설정한 후 call() 메소드를 호출하여 축을 그릴 수 있습니다. d3-axis 모듈은 축을 그리는 복잡한 작업들을 간단하게 처리할 수 있도록 도와주기 때문에, 빠르게 기본적인 축을 구축할 때에 유용합니다.

 

 

(2) ticks() 함수를 활용하기

 

좀 더 복잡한 요구사항이 있거나 레이아웃에 대한 커스터마이징이 필요한 경우에는 d3-scale 모듈의 ticks() 함수를 사용하는 방법이 있습니다.

import { scaleLinear } from 'd3-scale';
import { path, type Path } from 'd3-path';

const Axis = () => {
  const domain = [0, 100];
  const range = [10, 300];

	const drawPath = (context: Path) => {
    context.moveTo(range[0], 10);
    context.lineTo(range[1], 10);

    return context;
  };

  const ticks = useMemo(() => {
    const xScale = scaleLinear().domain(domain).range(range);

    return xScale.ticks().map((v: number) => ({
      value: v,
      xOffset: xScale(v),
    }));
  }, []);

  return (
    <>
      <svg width={500} height={500}>

        <path d={`${drawPath(path())}`} stroke="black" />

        {ticks.map(({ value, xOffset }) => (
          <g key={value} transform={`translate(${xOffset}, 10)`}>
            <line y1={0} y2={6} stroke="black" />
            <text
              style={{
                fontSize: '10px',
                textAnchor: 'middle',
                transform: 'translateY(20px)',
              }}
            >
              {value}
            </text>
          </g>
        ))}

      </svg>
    </>
  );
};

ticks() 함수는 눈금의 범위를 유연하게 조절할 수 있도록 해주며, 축을 그리는 데 필요한 눈금 데이터를 반환합니다. 이를 기반으로 완전히 새로운 레이아웃을 작성할 수 있다는 장점이 있습니다.

 

정리

D3를 React와 조합하여 사용할 때, D3의 연산 함수를 활용하고 렌더링 부분은 직접 React 컴포넌트로 작성하는 방식을 선택할 수 있습니다. 이렇게 하면 차트의 로직과 DOM 조작을 분리하여 가독성 좋은 코드를 작성할 수 있는 장점이 있습니다.

하지만 요소가 많아지고 동적인 기능이 추가되어야 할 때, 별도의 로직 처리나 재사용 가능한 컴포넌트 생성이 필요한 경우가 있습니다. 이로 인해 코드 양이 늘어나고, 직접 마크업을 처리해야 하는 어려움이 있을 수 있습니다. 여러 상황에 맞게 로직을 모듈화하고 확장성을 고려하여 코드를 작성하는 것이 중요할 것 같습니다.

 

참고

D3.js Docs - D3 in React

Amelia Wattenberger - React + D3.js

D3.js 소개와 React 접목 

Share Link
reply
«   2025/01   »
1 2 3 4
5 6 7 8 9 10 11
12 13 14 15 16 17 18
19 20 21 22 23 24 25
26 27 28 29 30 31
공지사항
최근에 올라온 글
최근에 달린 댓글
Total
Today
Yesterday
글 보관함