Learning
레슨 7 / 8·25분

실전: 인터랙티브 데이터 시각화

실전 프로젝트: 인터랙티브 데이터 대시보드

지금까지 배운 D3.js 기능을 종합하여 인터랙티브 데이터 시각화 대시보드를 구축합니다. 막대 차트, 선 차트, 파이 차트를 결합하고 필터링, 트랜지션, 툴팁 등을 구현합니다.

1단계: 프로젝트 구조 설정

javascript
// 대시보드 설정
var config = {
  width: 900,
  height: 500,
  margin: { top: 40, right: 30, bottom: 50, left: 60 },
};

config.innerWidth = config.width - config.margin.left - config.margin.right;
config.innerHeight = config.height - config.margin.top - config.margin.bottom;

// 데이터
var dataset = [
  { month: '1월', sales: 150, target: 120, category: 'A' },
  { month: '2월', sales: 230, target: 200, category: 'B' },
  { month: '3월', sales: 180, target: 150, category: 'A' },
  { month: '4월', sales: 310, target: 280, category: 'C' },
  { month: '5월', sales: 270, target: 250, category: 'B' },
  { month: '6월', sales: 350, target: 300, category: 'A' },
  { month: '7월', sales: 290, target: 270, category: 'C' },
  { month: '8월', sales: 400, target: 350, category: 'B' },
  { month: '9월', sales: 330, target: 310, category: 'A' },
  { month: '10월', sales: 450, target: 400, category: 'C' },
  { month: '11월', sales: 380, target: 350, category: 'B' },
  { month: '12월', sales: 500, target: 450, category: 'A' },
];

2단계: 메인 막대 차트 생성

javascript
// SVG 컨테이너
var svg = d3.select('#dashboard')
  .append('svg')
  .attr('width', config.width)
  .attr('height', config.height);

var chartGroup = svg.append('g')
  .attr('transform',
    'translate(' + config.margin.left + ',' + config.margin.top + ')');

// 스케일
var xScale = d3.scaleBand()
  .domain(dataset.map(function(d) { return d.month; }))
  .range([0, config.innerWidth])
  .padding(0.3);

var yScale = d3.scaleLinear()
  .domain([0, d3.max(dataset, function(d) { return d.sales; }) * 1.1])
  .range([config.innerHeight, 0])
  .nice();

var categoryColor = d3.scaleOrdinal()
  .domain(['A', 'B', 'C'])
  .range(['#6366f1', '#10b981', '#f59e0b']);

// 축
chartGroup.append('g')
  .attr('class', 'x-axis')
  .attr('transform', 'translate(0,' + config.innerHeight + ')')
  .call(d3.axisBottom(xScale));

chartGroup.append('g')
  .attr('class', 'y-axis')
  .call(d3.axisLeft(yScale).ticks(8)
    .tickFormat(function(d) { return d + '만원'; }));

// 제목
svg.append('text')
  .attr('x', config.width / 2)
  .attr('y', 25)
  .attr('text-anchor', 'middle')
  .attr('font-size', '18px')
  .attr('font-weight', 'bold')
  .text('월별 매출 분석 대시보드');

3단계: 인터랙티브 막대 그리기

javascript
// 툴팁 요소
var tooltip = d3.select('body')
  .append('div')
  .attr('class', 'tooltip')
  .style('position', 'absolute')
  .style('padding', '10px 14px')
  .style('background', 'rgba(0, 0, 0, 0.85)')
  .style('color', '#fff')
  .style('border-radius', '6px')
  .style('font-size', '13px')
  .style('pointer-events', 'none')
  .style('opacity', 0);

// 목표선 그리기
var targetLine = d3.line()
  .x(function(d) { return xScale(d.month) + xScale.bandwidth() / 2; })
  .y(function(d) { return yScale(d.target); })
  .curve(d3.curveMonotoneX);

chartGroup.append('path')
  .datum(dataset)
  .attr('d', targetLine)
  .attr('fill', 'none')
  .attr('stroke', '#ef4444')
  .attr('stroke-width', 2)
  .attr('stroke-dasharray', '6,4');

// 막대 그리기 (애니메이션 포함)
chartGroup.selectAll('.bar')
  .data(dataset)
  .join('rect')
  .attr('class', 'bar')
  .attr('x', function(d) { return xScale(d.month); })
  .attr('width', xScale.bandwidth())
  .attr('y', config.innerHeight)
  .attr('height', 0)
  .attr('fill', function(d) { return categoryColor(d.category); })
  .attr('rx', 3)
  .on('mouseover', function(event, d) {
    d3.select(this)
      .transition().duration(150)
      .attr('opacity', 0.8);

    var diff = d.sales - d.target;
    var status = diff >= 0 ? '달성' : '미달';

    tooltip.transition().duration(200).style('opacity', 1);
    tooltip.html(
      '<strong>' + d.month + '</strong><br>' +
      '매출: ' + d.sales + '만원<br>' +
      '목표: ' + d.target + '만원<br>' +
      '차이: ' + (diff >= 0 ? '+' : '') + diff + '만원 (' + status + ')'
    )
    .style('left', (event.pageX + 15) + 'px')
    .style('top', (event.pageY - 20) + 'px');
  })
  .on('mouseout', function() {
    d3.select(this)
      .transition().duration(150)
      .attr('opacity', 1);
    tooltip.transition().duration(300).style('opacity', 0);
  })
  // 등장 애니메이션
  .transition()
  .duration(800)
  .delay(function(d, i) { return i * 60; })
  .ease(d3.easeCubicOut)
  .attr('y', function(d) { return yScale(d.sales); })
  .attr('height', function(d) {
    return config.innerHeight - yScale(d.sales);
  });

4단계: 카테고리 필터 구현

javascript
// 필터 버튼 생성
var categories = ['전체', 'A', 'B', 'C'];

var filterGroup = d3.select('#filters');

filterGroup.selectAll('.filter-btn')
  .data(categories)
  .join('button')
  .attr('class', 'filter-btn')
  .style('padding', '6px 16px')
  .style('margin', '0 4px')
  .style('border', '1px solid #ddd')
  .style('border-radius', '4px')
  .style('cursor', 'pointer')
  .style('background', function(d) {
    return d === '전체' ? '#6366f1' : '#fff';
  })
  .style('color', function(d) {
    return d === '전체' ? '#fff' : '#333';
  })
  .text(function(d) {
    return d === '전체' ? '전체 보기' : '카테고리 ' + d;
  })
  .on('click', function(event, category) {
    // 버튼 스타일 업데이트
    filterGroup.selectAll('.filter-btn')
      .style('background', '#fff')
      .style('color', '#333');
    d3.select(this)
      .style('background', '#6366f1')
      .style('color', '#fff');

    // 필터링된 데이터
    var filtered = category === '전체'
      ? dataset
      : dataset.filter(function(d) { return d.category === category; });

    // 차트 업데이트 (트랜지션)
    updateBars(filtered);
  });

function updateBars(data) {
  // 스케일 업데이트
  xScale.domain(data.map(function(d) { return d.month; }));
  yScale.domain([0, d3.max(data, function(d) { return d.sales; }) * 1.1]).nice();

  // 축 애니메이션
  chartGroup.select('.x-axis')
    .transition().duration(500)
    .call(d3.axisBottom(xScale));

  chartGroup.select('.y-axis')
    .transition().duration(500)
    .call(d3.axisLeft(yScale).ticks(8)
      .tickFormat(function(d) { return d + '만원'; }));

  // 막대 업데이트
  var bars = chartGroup.selectAll('.bar').data(data, function(d) { return d.month; });

  bars.exit()
    .transition().duration(300)
    .attr('y', config.innerHeight)
    .attr('height', 0)
    .attr('opacity', 0)
    .remove();

  bars.enter()
    .append('rect')
    .attr('class', 'bar')
    .attr('x', function(d) { return xScale(d.month); })
    .attr('width', xScale.bandwidth())
    .attr('y', config.innerHeight)
    .attr('height', 0)
    .attr('fill', function(d) { return categoryColor(d.category); })
    .attr('rx', 3)
    .merge(bars)
    .transition().duration(600)
    .attr('x', function(d) { return xScale(d.month); })
    .attr('width', xScale.bandwidth())
    .attr('y', function(d) { return yScale(d.sales); })
    .attr('height', function(d) {
      return config.innerHeight - yScale(d.sales);
    })
    .attr('opacity', 1);
}
💡

실전 프로젝트에서는 데이터 로딩(d3.csv/json), 반응형 디자인(ResizeObserver), 접근성(aria 속성) 등을 추가로 고려해야 합니다. 재사용 가능한 차트 컴포넌트로 만들면 유지보수가 편리합니다.