D3.js로 차트 만들기: 데이터 바인딩, Scale, 막대·선 차트, 툴팁, React와 함께 쓰기

이 글의 핵심

D3.js의 Selection과 데이터 바인딩 개념부터 Scale로 데이터를 픽셀로 바꾸는 법, 막대·선 차트와 시간축, 툴팁, React에서 D3를 쓸 때 DOM 소유권을 나누는 방법을 정리합니다.

막대나 선 정도만 그리면 된다면 Chart.js 같은 차트 라이브러리가 훨씬 빠릅니다. 하지만 축이 두 겹이고, 브러시로 구간을 잡으면 옆 패널이 함께 움직이고, SVG 요소 하나하나가 조건에 따라 색과 두께가 바뀌는 화면이라면 이야기가 달라집니다. 저는 이런 커스텀 차트라면 D3를 고르는 편입니다. D3.js(Data-Driven Documents)는 “차트 템플릿”이 아니라 데이터 → 스케일 → DOM을 이어 주는 조립 키트에 가깝습니다.

차트 라이브러리를 쓰다 D3로 넘어가게 되는 전형적인 순간은 요구사항이 옵션 목록 밖으로 나갈 때입니다. 예를 들어 시간 축 차트의 툴팁에 원본 데이터의 ID를 넣고, 특정 구간만 하이라이트하고, 같은 데이터로 아래쪽 작은 히스토그램까지 함께 갱신해야 한다면, 래퍼 라이브러리에서는 설정 옵션을 찾아 헤매거나 내부 구현을 우회해야 합니다. D3에서는 DOM을 직접 다루고 scale과 axis만 맞추면 되므로, 요구사항을 코드 조각으로 나눠 붙이는 일이 됩니다. 대신 처음에 시간이 드는 부분은 enter/update/exit 패턴과 React와의 DOM 소유권 문제이고, 이 글은 그 두 가지를 중심으로 설명합니다.

설치와 Selection 기본

D3는 선택(Selection) → 데이터 바인딩 → SVG 순으로 익히면 이해가 쉽습니다. 설치는 다음과 같습니다.

npm install d3
npm install -D @types/d3

D3 v7부터는 패키지가 ES 모듈 전용입니다. 브라우저 번들러(Vite, webpack)에서는 문제가 없지만, CommonJS 기반 Jest 테스트에서 D3를 import하면 “SyntaxError: Cannot use import statement outside a module” 에러가 납니다. 이때는 Jest의 transformIgnorePatterns에서 d3 관련 패키지를 변환 대상으로 포함하거나, ESM을 기본 지원하는 Vitest를 쓰는 것이 일반적입니다. 번들 크기가 신경 쓰이면 d3 전체 대신 d3-scale, d3-selection, d3-shape처럼 필요한 하위 모듈만 설치해 쓸 수도 있습니다.

#chart에 SVG를 올릴 때도, 간단한 div를 다룰 때도 select / selectAll로 요소를 잡습니다. 체이닝으로 attr과 style을 이어서 설정할 수 있어서, 선언적이라기보다 명령형으로 한 줄씩 쌓아 가는 스타일입니다.

select는 매치되는 첫 번째 요소만 잡고, selectAll은 매치되는 전부를 잡습니다. 사소해 보이지만 실무에서 은근히 헷갈리는 지점입니다. select('.bar')로 실수로 단수 선택을 해 놓고 “왜 막대 하나에만 스타일이 적용됐지”를 한참 찾는 일이 흔한데, D3는 선택 개수가 기대와 달라도 에러를 던지지 않고 조용히 첫 번째 요소에만 적용하기 때문에 콘솔에 아무 경고도 뜨지 않습니다. 마찬가지로 선택자가 아무것도 찾지 못하면 빈 선택이 반환되고 이후 체이닝은 아무 일도 하지 않으므로, 차트가 안 보일 때는 선택자가 요소를 실제로 잡았는지(selection.empty())부터 확인하는 편이 빠릅니다.

import * as d3 from 'd3';
d3.select('#chart');
d3.selectAll('.bar');
d3.select('#chart')
  .attr('width', 500)
  .attr('height', 300)
  .style('background', '#f0f0f0');
d3.select('#title').text('My Chart');

데이터 바인딩: data()와 enter()

데이터를 붙일 때 쓰는 data() → enter() 패턴은 처음에는 이상해 보입니다. 아직 존재하지 않는 div를 selectAll('div')로 선택하기 때문입니다. 이 빈 선택에 data(data)로 배열을 붙이면 D3는 “데이터는 5개인데 요소는 0개”라는 차이를 계산하고, 요소가 부족한 5개 자리를 enter() 선택으로 돌려줍니다. 여기에 append('div')를 하면 그 자리마다 요소가 새로 만들어지고, 각 요소에는 해당 데이터가 __data__로 연결되어 이후 콜백의 d로 전달됩니다. 반대로 요소가 데이터보다 많으면 남는 요소는 exit() 선택에 들어가며, 이미 짝이 맞는 요소는 update 선택이 됩니다. 아래는 숫자 배열로 div 폭을 늘리는 아주 작은 예제입니다.

const data = [10, 20, 30, 40, 50];
d3.select('#chart')
  .selectAll('div')
  .data(data)
  .enter()
  .append('div')
  .style('width', (d) => `${d * 10}px`)
  .style('height', '20px')
  .style('background', 'steelblue')
  .style('margin', '2px')
  .text((d) => d);

D3 v5.8부터는 enter/update/exit를 한 번에 처리하는 selection.join()이 추가되어, 위 코드의 .enter().append('div')를 .join('div')로 바꾸면 데이터가 바뀌어 다시 실행했을 때도 요소가 중복으로 쌓이지 않습니다. enter().append()만 쓰는 코드는 한 번 그리고 끝나는 정적 차트에서는 문제가 없지만, 같은 코드를 데이터 갱신 때마다 다시 실행하면 기존 요소는 그대로 둔 채 새 요소만 계속 추가됩니다. 또 data(data, (d) => d.id)처럼 두 번째 인자로 키 함수를 주면 배열 순서가 아니라 ID로 요소와 데이터를 짝지어서, 정렬이 바뀌거나 중간 항목이 빠질 때 엉뚱한 막대가 애니메이션되는 문제를 막을 수 있습니다.

Scale: 데이터를 픽셀로 변환하기

Scale은 D3의 핵심입니다. 데이터 값의 범위(도메인)를 픽셀이나 색의 범위(레인지)로 바꾸는 함수로, 선형, 밴드(막대 간격과 패딩), 시간축, 서수 색상 등 데이터 성격에 맞는 것을 고르면 됩니다. 도메인을 정할 때는 d3.max와 d3.extent가 자주 함께 쓰입니다.

scaleLinear와 scaleBand를 헷갈려 쓰는 경우가 흔합니다. 연속형 숫자(매출, 온도처럼 값 사이의 “중간값”이 의미 있는 데이터)는 scaleLinear, 카테고리(이름·요일처럼 순서는 있어도 사이 값이 없는 데이터)는 scaleBand가 맞습니다. scaleLinear를 카테고리 축에 억지로 쓰면 막대 위치가 뒤틀리는데, 에러 없이 그래프가 어긋난 채로 렌더링되므로 원인을 찾기가 까다롭습니다. 또 스케일은 도메인 밖의 값을 넣어도 기본적으로 범위를 넘어 외삽하므로, 도메인보다 큰 값이 들어오면 막대가 SVG 밖으로 튀어나갑니다. 값을 범위 안에 묶으려면 .clamp(true)를 쓰고, 축 끝을 깔끔한 숫자로 맞추려면 .nice()를 호출합니다.

const xScale = d3.scaleLinear()
  .domain([0, 100])
  .range([0, 500]);
console.log(xScale(50)); // 250
const xScaleBand = d3.scaleBand()
  .domain(['A', 'B', 'C', 'D'])
  .range([0, 500])
  .padding(0.1);
const colorScale = d3.scaleOrdinal(d3.schemeCategory10);

막대 차트 만들기

막대 차트는 마진을 먼저 정하고, scaleBand로 x축, scaleLinear로 y축을 만든 뒤 axisBottom과 axisLeft로 눈금을 그립니다. D3를 처음 배운다면 히트맵이나 포스 레이아웃보다 이 조합부터 직접 만들어 보는 것을 권합니다. 스케일, 축, 데이터 바인딩이라는 D3의 핵심 세 가지가 모두 들어 있기 때문입니다.

const data = [
  { name: 'A', value: 30 },
  { name: 'B', value: 80 },
  { name: 'C', value: 45 },
  { name: 'D', value: 60 },
];
const width = 500;
const height = 300;
const margin = { top: 20, right: 20, bottom: 30, left: 40 };
const svg = d3
  .select('#chart')
  .append('svg')
  .attr('width', width)
  .attr('height', height);
const xScale = d3
  .scaleBand()
  .domain(data.map((d) => d.name))
  .range([margin.left, width - margin.right])
  .padding(0.1);
const yScale = d3
  .scaleLinear()
  .domain([0, d3.max(data, (d) => d.value)!])
  .range([height - margin.bottom, margin.top]);
svg
  .append('g')
  .attr('transform', `translate(0,${height - margin.bottom})`)
  .call(d3.axisBottom(xScale));
svg
  .append('g')
  .attr('transform', `translate(${margin.left},0)`)
  .call(d3.axisLeft(yScale));
svg
  .selectAll('.bar')
  .data(data)
  .enter()
  .append('rect')
  .attr('class', 'bar')
  .attr('x', (d) => xScale(d.name)!)
  .attr('y', (d) => yScale(d.value))
  .attr('width', xScale.bandwidth())
  .attr('height', (d) => height - margin.bottom - yScale(d.value))
  .attr('fill', 'steelblue');

이 코드에서 가장 헷갈리는 부분은 y 좌표입니다. SVG 좌표계는 위쪽이 0이고 아래로 갈수록 값이 커지므로, y 스케일의 레인지를 [height - margin.bottom, margin.top]처럼 거꾸로 주어야 큰 값이 위쪽에 그려집니다. 그리고 막대의 y는 막대 윗변의 위치(yScale(d.value))이고, height는 아래 기준선까지의 거리(height - margin.bottom - yScale(d.value))입니다. 이 계산을 잘못하면 막대가 위에서 아래로 매달린 모양이 되거나 음수 높이가 되어 아예 보이지 않습니다(SVG는 음수 height를 에러로 처리하고 그리지 않습니다). 축을 g 요소에 transform으로 옮기는 것도 같은 이유로, axisBottom은 기본적으로 y=0 위치에 그려지므로 차트 아래쪽으로 내려 주어야 합니다.

선 차트는 scaleTime과 d3.line()을 조합합니다. 날짜가 들어 있는 배열이면 extent로 최솟값과 최댓값을 한 번에 구해 도메인으로 쓰는 것이 편합니다.

const data = [
  { date: new Date('2024-01-01'), value: 30 },
  { date: new Date('2024-02-01'), value: 80 },
  { date: new Date('2024-03-01'), value: 45 },
  { date: new Date('2024-04-01'), value: 60 },
];
const xScale = d3
  .scaleTime()
  .domain(d3.extent(data, (d) => d.date) as [Date, Date])
  .range([margin.left, width - margin.right]);
const yScale = d3
  .scaleLinear()
  .domain([0, d3.max(data, (d) => d.value)!])
  .range([height - margin.bottom, margin.top]);
const line = d3
  .line<{ date: Date; value: number }>()
  .x((d) => xScale(d.date))
  .y((d) => yScale(d.value));
svg
  .append('path')
  .datum(data)
  .attr('fill', 'none')
  .attr('stroke', 'steelblue')
  .attr('stroke-width', 2)
  .attr('d', line);

막대 차트와 달리 선 차트는 data()가 아니라 datum()을 씁니다. 막대는 데이터 한 개당 요소 하나지만, 선은 배열 전체가 path 하나로 그려지기 때문입니다. d3.line()은 배열을 받아 SVG 경로 문자열(M...L...)을 만들어 주는 생성기이고, attr('d', line)이 그 생성기에 바인딩된 배열을 넘겨 경로를 계산합니다. fill을 none으로 두지 않으면 경로 안쪽이 검게 채워지는 것도 처음에 자주 보는 증상입니다. 날짜를 다룰 때는 new Date('2024-01-01')이 UTC 자정으로 해석되어 한국 시간대에서는 오전 9시로 표시되는 등 시간대 차이로 눈금이 어긋날 수 있으므로, 서버에서 받은 날짜 문자열은 d3.utcParse나 scaleUtc로 일관되게 처리하는 편이 안전합니다. 값이 없는 구간이 있다면 line.defined((d) => d.value != null)로 선을 끊어 그릴 수 있습니다.

툴팁과 인터랙션

툴팁은 body에 div를 하나 만들어 두고 mouseover / mousemove / mouseout 이벤트로 표시 여부와 위치를 바꾸는 방식이 가장 단순합니다. 이런 인터랙션부터가 차트 라이브러리로는 맞추기 어려운 UI를 직접 만들 수 있는 영역입니다.

⚠️ TypeScript로 작성할 때 걸리는 점이 있습니다. 아래 svg.selectAll('.bar')는 위에서 .data(data)로 데이터를 바인딩한 요소와 실제로 같은 DOM을 가리키지만, 타입 시스템 입장에서는 새로 만든 선택이라 데이터 타입 정보가 이어지지 않습니다. 그래서 콜백의 d가 unknown으로 추론되어 strict 모드에서는 d.name/d.value 접근이 컴파일 에러가 됩니다. svg.selectAll<SVGRectElement, typeof data[number]>('.bar')처럼 제네릭을 명시하거나, 막대를 그리는 체인에 바로 .on(...)을 이어 붙여 타입이 끊기지 않게 하는 편이 낫습니다.

이벤트 핸들러의 인자 순서도 주의해야 합니다. D3 v6부터 핸들러는 (event, d) 순서로 이벤트 객체와 데이터를 받습니다. v5 이전 예제는 (d, i)를 받고 전역 d3.event로 이벤트에 접근했기 때문에, 오래된 튜토리얼 코드를 그대로 쓰면 d에 이벤트 객체가 들어가 툴팁에 “undefined”가 찍히거나 d3.event가 undefined라는 에러가 납니다. 또 예제의 .html()에 사용자가 입력한 데이터를 그대로 넣으면 스크립트가 삽입될 수 있으므로, 서식이 필요 없으면 .text()를 쓰는 것이 안전합니다.

const tooltip = d3
  .select('body')
  .append('div')
  .style('position', 'absolute')
  .style('background', 'white')
  .style('padding', '5px')
  .style('border', '1px solid #ccc')
  .style('display', 'none');
svg
  .selectAll('.bar')
  .on('mouseover', (event, d) => {
    tooltip
      .style('display', 'block')
      .html(`${d.name}: ${d.value}`);
  })
  .on('mousemove', (event) => {
    tooltip
      .style('left', `${event.pageX + 10}px`)
      .style('top', `${event.pageY + 10}px`);
  })
  .on('mouseout', () => {
    tooltip.style('display', 'none');
  });

React와 함께 쓰기

React에서는 보통 useRef로 SVG 요소를 잡고 useEffect 안에서 D3가 그립니다. 데이터가 바뀔 때 selectAll('*').remove()로 비우고 다시 그리는 방식이 가장 직관적입니다. 차트가 복잡해지면 D3는 스케일과 경로 계산 같은 수학만 맡고 SVG 요소는 React가 JSX로 그리는 하이브리드 방식도 많이 씁니다.

핵심 원칙은 같은 DOM을 두 주인이 건드리지 않는 것입니다. React는 자신이 렌더링한 DOM이 가상 DOM과 일치한다고 가정하므로, React가 만든 요소를 D3가 지우거나 옮기면 다음 렌더에서 “Failed to execute ‘removeChild’ on ‘Node’” 같은 에러가 나거나 화면이 꼬입니다. 위 예제처럼 React는 빈 <svg> 껍데기만 그리고 그 안쪽은 전부 D3에 맡기거나, 반대로 D3는 계산만 하고 DOM은 전부 React가 그리거나, 둘 중 하나로 경계를 분명히 정하는 것이 안전합니다.

selectAll('*').remove() 방식은 직관적인 대신 비용이 있습니다. 데이터가 조금만 바뀌어도 SVG 전체를 지우고 다시 그리므로, 트랜지션이 매번 처음부터 시작되고 요소가 많은 차트에서는 렌더마다 DOM을 통째로 재구성하는 비용이 듭니다. 부드러운 업데이트 애니메이션이 필요하거나 요소 수가 많아지면 data().join()으로 바뀐 것만 갱신하는 방식으로 넘어가야 합니다. 개발 모드의 React StrictMode는 effect를 두 번 실행하는데, 이 예제처럼 매번 비우고 그리는 구조는 그래도 문제가 없지만 append만 하는 코드라면 요소가 두 벌 생기므로 이 점도 알아 두면 좋습니다. 또 예제는 설명을 위해 축을 생략했고, 너비와 높이를 상수로 박아 두었기 때문에 반응형이 필요하면 ResizeObserver로 컨테이너 크기를 측정해 스케일의 레인지를 다시 계산해야 합니다.

import { useEffect, useRef } from 'react';
import * as d3 from 'd3';
export default function BarChart({ data }) {
  const svgRef = useRef<SVGSVGElement>(null);
  useEffect(() => {
    if (!svgRef.current) return;
    const svg = d3.select(svgRef.current);
    svg.selectAll('*').remove();
    const width = 500;
    const height = 300;
    const margin = { top: 20, right: 20, bottom: 30, left: 40 };
    const xScale = d3
      .scaleBand()
      .domain(data.map((d) => d.name))
      .range([margin.left, width - margin.right])
      .padding(0.1);
    const yScale = d3
      .scaleLinear()
      .domain([0, d3.max(data, (d) => d.value)!])
      .range([height - margin.bottom, margin.top]);
    svg
      .selectAll('.bar')
      .data(data)
      .enter()
      .append('rect')
      .attr('class', 'bar')
      .attr('x', (d) => xScale(d.name)!)
      .attr('y', (d) => yScale(d.value))
      .attr('width', xScale.bandwidth())
      .attr('height', (d) => height - margin.bottom - yScale(d.value))
      .attr('fill', 'steelblue');
  }, [data]);
  return <svg ref={svgRef} width={500} height={300} />;
}

마무리

Chart.js 같은 라이브러리를 쓰다가 D3로 옮기면 처음 며칠은 같은 차트를 만드는 데 훨씬 오래 걸립니다. 대신 “이 막대만 빨갛게, 이 구간만 굵게” 같은 제품 쪽의 가변 요구에는 D3가 가장 잘 맞습니다. 그래서 도입 여부는 “우리 팀 화면에 템플릿 밖의 커스텀 차트가 얼마나 있는가”로 판단하면 됩니다. 결과물이 평범한 SVG라서 브라우저 개발자 도구로 요소 하나하나를 직접 들여다보며 디버깅할 수 있다는 점도 D3의 큰 장점입니다. 데이터가 수만 개를 넘어가 SVG 요소가 감당하기 어려워지면, 같은 스케일 코드를 유지한 채 그리기만 Canvas로 바꾸는 방식으로 확장할 수 있습니다.

같이 보면 좋은 글