본문으로 건너뛰기
Previous
Next
D3.js 완벽 가이드 | 데이터 시각화·차트·SVG·인터랙션·실전 활용

D3.js 완벽 가이드 | 데이터 시각화·차트·SVG·인터랙션·실전 활용

D3.js 완벽 가이드 | 데이터 시각화·차트·SVG·인터랙션·실전 활용

이 글의 핵심

D3.js로 강력한 데이터 시각화를 구현하는 완벽 가이드. Selection, Scale, Axis, 차트 생성, 인터랙션까지 실전 예제로 정리. D3.js·Data Visualization·Chart 중심으로 설명합니다.

솔직히 말하면, 딱 막대·선 정도만 그리는 거면 Chart.js나 래퍼 쓰는 게 편하다. 근데 축이 두 겹이고, 브러시로 구간 잡고 옆 패널이 같이 움직이고, SVG 하나하나가 조건에 따라 색·두께가 바뀌는 그런 화면이면? 나는 커스텀 차트면 D3 쪽에 선을 긋는 편이다. D3.js(Data-Driven Documents)는 “차트 템플릿”이 아니라 데이터 → 스케일 → DOM을 이어 주는 조립 키트에 가깝다.

프로젝트 한 톤: 예전에 운영 대시보드에서 “일별로 쌓인 이벤트”를 시간 축으로 보여 줘야 했는데, 요구가 중간에 바뀌어서 툴팁에 원본 row id를 넣고, 특정 구간만 하이라이트하고, 같은 데이터로 아래 작은 히스토그램까지 같이 갱신해야 했다. 그때 쓰던 차트 래퍼는 옵션으로는 안 잡혔다. DOM을 D3로 직접 잡고 scale이랑 axis만 맞추니까, “화면이 복잡해졌다”는 느낌이 아니라 “그냥 요구를 코드로 쪼개서 붙이면 됐다”는 느낌이 났다. 밤새 삽질한 건 enter/update/exit 패턴이랑 React ref 쪽이었지, “라이브러리가 막는다” 쪽은 아니었다.

D3는 선택(Selection) → 데이터 바인딩 → SVG 순으로 감을 잡으면 된다. 설치는 이렇게.

npm install d3
npm install -D @types/d3

#chart에 SVG를 올릴 때도, div 장난감이든 뭐든 select / selectAll로 잡는다. 체이닝으로 attr이랑 style 한 번에 박을 수 있어서, “선언적”보다 명령형으로 한 줄씩 쌓는 맛이 난다.

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() 패턴이 처음엔 엉뚱해 보이는데, 익으면 “없는 노드는 만들고, 있는 건 갱신”이 한 덩어리로 보인다. 아래는 숫자 배열로 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);

Scale이 진짜 심장이다. 데이터 값(도메인)을 픽셀·색(레인지)으로 바꾸는 함수. 선형, 밴드(막대 겹침·패딩), 시간축, 서수 색 — 상황에 맞는 걸 고르면 된다. d3.maxextent는 여기서 자주 붙는다.

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는 이 조합부터 찍는 게 맞다. 히트맵·포스 다 필요 없다.

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');

scaleTime이랑 d3.line() 조합. 날짜 잡힌 배열이면 extent로 domain 잡는 게 편하다. 위에서 말한 “운영 대시보드” 느낌이 여기서 나온다.

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);

툴팁은 bodydiv 하나 깔고 mouseover / mousemove / mouseout으로 좌표만 따라가게 하면 된다. 여기서부터 “래퍼가 안 해주는 UI”를 붙이기 쉬워진다.

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에선 보통 useRef로 SVG 잡고 useEffect 안에서 D3가 그린다. 데이터 바뀔 때 selectAll('*').remove()로 비우고 다시 그리는 식이 제일 직관적이다(복잡해지면 d3는 수학만, React가 SVG를 그린다는 하이브리드도 있다).

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는 답이 너무 잘 맞는다. 뉴욕타임스나 블룸버그 같은 말은 서두르지 말고, 일단 내 팀에서 커스텀 차트면 D3로 결론을 내릴지부터 보면 된다. 러닝 커브는 있어도, SVG 한 장 잡는 건 여전히 웹에서 제일 투명한 디버깅 경험 중 하나다.

Three.js나 3D 쪽, MUI로 대시도깔 끝내는 글은 여기 relatedPosts에 묶여 있으니 같이 읽으면 “화면” 맥락이 이어질 것이다. 키워드로는 D3.js, Data Visualization, Chart, SVG 정도로 찾아 오면 이 글과 잘 맞는다.