Published on

Django 프로젝트에 D3.js 시각화 붙이기 — 워드클라우드, 히트맵, 데이터 맵

Authors
  • avatar
    Name
    Hyo814
    Twitter

Django 프로젝트에 D3.js 시각화 붙이기 — 워드클라우드, 히트맵, 데이터 맵

jQuery + Django 템플릿 위주의 레거시 프로젝트에 D3.js 기반 시각화를 붙이는 작업을 했습니다. 단일 차트 하나 붙이는 정도가 아니라, 워드클라우드·데이터 맵·데이터셋 지형도(히트맵)·통계 대시보드를 공통 유틸 위에서 돌리도록 정리한 경험을 기록합니다.


1. 왜 D3인가 — Chart.js로는 부족했던 이유

초기에는 Chart.js로도 충분할 줄 알았지만, 요구사항이 쌓이면서 한계에 부딪혔습니다.

  • 데이터셋 × 메타클래스 히트맵 — Chart.js의 matrix 플러그인은 있지만 상호작용(클릭→네비게이션)이 빈약
  • 워드클라우드 — Chart.js 기본 지원 안 됨
  • 계층형 데이터 맵(트리맵+강조) — 줌/팬·커스텀 인터랙션이 많아 캔버스 기반으로는 한계

결론은 "기존 Chart.js 페이지는 그대로 두고, 신규 시각화는 D3로" 였습니다.


2. 공통 그래프 유틸 설계 — d3-graph-common

여러 시각화가 공유하는 로직(SVG 컨테이너 생성, 줌·팬, 레이블 렌더링, 중앙 정렬)은 하나의 공통 유틸로 뽑아냈습니다.

// static/js/d3-graph-common.js
window.D3GraphCommon = {
    createContainer(selector, { width, height, margin }) {
        const svg = d3.select(selector)
            .append('svg')
            .attr('viewBox', `0 0 ${width} ${height}`)
            .attr('preserveAspectRatio', 'xMidYMid meet');

        const g = svg.append('g')
            .attr('transform', `translate(${margin.left},${margin.top})`);

        return { svg, g, innerWidth: width - margin.left - margin.right };
    },

    enableZoom(svg, g, { min = 0.5, max = 5 } = {}) {
        const zoom = d3.zoom()
            .scaleExtent([min, max])
            .on('zoom', (event) => g.attr('transform', event.transform));
        svg.call(zoom);
        return zoom;
    },

    centerInitial(svg, zoom, bounds) {
        // 그래프 경계의 중심을 viewport 중앙에 오도록 초기 transform 적용
        const { x, y, width, height } = bounds;
        const svgNode = svg.node();
        const vw = svgNode.clientWidth;
        const vh = svgNode.clientHeight;
        const tx = vw / 2 - (x + width / 2);
        const ty = vh / 2 - (y + height / 2);
        svg.call(zoom.transform, d3.zoomIdentity.translate(tx, ty));
    }
};

핵심: viewBox + preserveAspectRatio반응형 SVG를 보장하고, 줌은 그래프별로 따로 구현하지 않도록 공통화했습니다.


3. 워드클라우드 — d3.layout.cloud 번들링

워드클라우드는 d3.layout.cloud 라이브러리를 번들에 추가하고, 샘플 데이터 폴백을 함께 붙였습니다.

// static/js/d3-wordcloud.js
function renderWordCloud(selector, words, options = {}) {
    const { width = 800, height = 500 } = options;

    d3.layout.cloud()
        .size([width, height])
        .words(words.map(w => ({ text: w.term, size: 10 + w.count * 2 })))
        .padding(5)
        .rotate(() => (~~(Math.random() * 2)) * 90)
        .font('Noto Sans KR')
        .fontSize(d => d.size)
        .on('end', draw)
        .start();

    function draw(words) {
        const svg = d3.select(selector).append('svg')
            .attr('viewBox', `0 0 ${width} ${height}`);

        svg.append('g')
            .attr('transform', `translate(${width / 2},${height / 2})`)
            .selectAll('text')
            .data(words)
            .enter().append('text')
            .style('font-size', d => `${d.size}px`)
            .style('fill', (_, i) => d3.schemeTableau10[i % 10])
            .attr('text-anchor', 'middle')
            .attr('transform', d => `translate(${d.x},${d.y})rotate(${d.rotate})`)
            .text(d => d.text);
    }
}

실데이터 부재 시 샘플 폴백

데모 환경이나 신규 배포 직후에는 실데이터가 없습니다. 서버에서 빈 배열을 내려주면 정적 JSON으로 폴백하도록 했습니다.

async function loadWordCloudData(apiUrl) {
    try {
        const res = await fetch(apiUrl);
        const data = await res.json();
        if (!data.words || data.words.length === 0) {
            // 실데이터 없으면 샘플 JSON으로 대체
            const fallback = await fetch('/static/data/sample-wordcloud.json');
            return (await fallback.json()).words;
        }
        return data.words;
    } catch (e) {
        console.warn('실데이터 로드 실패, 샘플로 폴백', e);
        const fallback = await fetch('/static/data/sample-wordcloud.json');
        return (await fallback.json()).words;
    }
}

4. 데이터셋 지형도 — 히트맵 + 클릭 네비게이션

Dataset × Tag, Dataset × MetaClass 두 축의 교차 빈도를 히트맵으로 그렸습니다.

// static/js/datasetTopographyDashboard.js
function renderHeatmap(selector, matrix, { rows, cols }) {
    const cellSize = 28;
    const { g } = D3GraphCommon.createContainer(selector, {
        width: cols.length * cellSize + 200,
        height: rows.length * cellSize + 100,
        margin: { top: 80, right: 20, bottom: 20, left: 180 }
    });

    const color = d3.scaleSequential(d3.interpolateBlues)
        .domain([0, d3.max(matrix.flat())]);

    g.selectAll('rect')
        .data(matrix.flatMap((row, i) =>
            row.map((v, j) => ({ i, j, v }))
        ))
        .enter().append('rect')
        .attr('x', d => d.j * cellSize)
        .attr('y', d => d.i * cellSize)
        .attr('width', cellSize - 2)
        .attr('height', cellSize - 2)
        .attr('fill', d => d.v > 0 ? color(d.v) : '#f5f5f5')
        .style('cursor', d => d.v > 0 ? 'pointer' : 'default')
        .on('click', (event, d) => {
            if (d.v === 0) return;
            // 셀 클릭 시 해당 조건의 데이터셋 목록 페이지로 이동
            const url = `/std-data/datasets/?tag=${cols[d.j]}&meta=${rows[d.i]}`;
            window.location.href = url;
        });
}

포인트: 셀을 단순 표시로 끝내지 않고 클릭 네비게이션을 붙인 순간 "단순 차트"에서 "데이터 진입 UI"로 성격이 바뀌었습니다.


5. 통계 대시보드 — 차트 클릭 네비게이션

대시보드에서도 같은 원칙을 적용했습니다. 막대그래프를 클릭하면 해당 카테고리의 상세 리스트로 이동합니다.

function renderBarChart(selector, data, { drilldownUrl }) {
    const { g, innerWidth } = D3GraphCommon.createContainer(selector, {
        width: 800, height: 400,
        margin: { top: 20, right: 20, bottom: 60, left: 60 }
    });

    const x = d3.scaleBand().domain(data.map(d => d.label)).range([0, innerWidth]).padding(0.2);
    const y = d3.scaleLinear().domain([0, d3.max(data, d => d.value)]).nice().range([340, 0]);

    g.selectAll('rect')
        .data(data)
        .enter().append('rect')
        .attr('x', d => x(d.label))
        .attr('y', d => y(d.value))
        .attr('width', x.bandwidth())
        .attr('height', d => 340 - y(d.value))
        .style('cursor', 'pointer')
        .on('click', (_, d) => {
            window.location.href = drilldownUrl.replace(':key', d.key);
        });
}

6. 초기 중앙 정렬 — 생각보다 까다로운 포인트

줌을 적용한 SVG에서 처음 화면에 그래프가 예쁘게 보이도록 하는 게 의외로 까다로웠습니다. 데이터 크기에 따라 그래프가 viewport 밖에 그려지거나 구석에 몰려 있는 일이 잦았습니다.

해결은 "렌더링 직후 그래프 경계를 측정해서 viewport 중앙으로 이동"입니다.

// 렌더링 후 실제 그려진 영역의 경계를 측정
const bounds = g.node().getBBox();
D3GraphCommon.centerInitial(svg, zoom, bounds);

getBBox()는 SVG가 DOM에 삽입된 후에만 정확한 값을 반환합니다. requestAnimationFrame 또는 setTimeout(0)으로 한 틱 기다린 뒤 호출하면 안정적입니다.


7. 레거시 jQuery 환경과의 공존

Django 템플릿 곳곳에 jQuery가 박혀 있는 환경이라, D3 스크립트도 jQuery 이벤트와 충돌하지 않도록 격리했습니다.

// 시각화 전용 네임스페이스로 분리
(function (global) {
    const Viz = global.Viz = global.Viz || {};

    Viz.renderDashboard = function ({ container, apiUrl }) {
        // jQuery와 독립적으로 fetch + d3 사용
        fetch(apiUrl).then(r => r.json()).then(data => {
            renderBarChart(container, data.bars, { ... });
        });
    };
})(window);

템플릿에서는:

<div id="chart-container"></div>
<script>
    Viz.renderDashboard({
        container: '#chart-container',
        apiUrl: '{{ url("std_data:api_stats") }}'
    });
</script>

8. 다시 보니 — 공통 유틸에 버그가 두 개 있다

개고하며 위 코드를 다시 읽었는데, 하필 모든 차트가 공유하는 공통 유틸에 문제가 둘 보입니다.

  • 줌이 마진을 지웁니다. createContainergtranslate(margin.left, margin.top)을 걸어두는데, enableZoom의 핸들러는 g.attr('transform', event.transform)으로 transform을 통째로 교체합니다. 첫 줌 이벤트가 발생하는 순간 마진 이동이 사라져 그래프가 좌상단으로 툭 튑니다. D3 줌의 고전 실수로, 마진용 g 안에 줌용 g를 한 겹 더 두는 게 정석입니다. 공통 유틸의 버그는 차트 전부가 물려받는다는 점에서, "공통화가 비용을 낮춘다"의 뒷면 — 버그도 공통화된다 — 를 보여주는 사례예요.
  • 좌표계를 섞고 있습니다. centerInitialsvgNode.clientWidth(렌더된 CSS 픽셀)와 getBBox()(viewBox 사용자 좌표)를 같은 식에서 더하고 빼는데, 이 둘은 viewBox 크기와 실제 표시 크기가 같을 때만 우연히 맞습니다. 반응형으로 SVG가 줄어드는 순간 중앙이 어긋나요. viewport 크기는 clientWidth가 아니라 viewBox에 준 width/height로 계산해야 좌표계가 일치합니다.

작은 것 하나 더 — 히트맵 클릭의 ?tag=${cols[d.j]}encodeURIComponent 없이 값을 URL에 끼웁니다. 태그에 &#이 들어오면 조용히 다른 페이지가 열립니다.

그리고 3절의 샘플 폴백은 이후에 성격이 문제가 됐습니다. 데모에는 필수였지만, 운영에서 API가 실패해도 사용자에게 샘플이 진짜 데이터처럼 보인다는 뜻이거든요. 워드클라우드는 이후 "이 화면이 없으면 못 아는 게 뭔가"를 다시 물으며 검색어 빈도 기반으로 개편했고, 관계 그래프 쪽의 좌표·힘 시뮬레이션 삽질은 따로 정리했습니다.


정리

  • 공통 유틸(d3-graph-common)을 먼저 만들고 시각화를 얹으면, 신규 차트 추가 비용이 급격히 떨어집니다. 단, 위 8절처럼 버그도 같이 공통화되니 유틸일수록 검증이 먼저입니다.
  • 실데이터 부재 시 샘플 폴백은 데모·초기 배포 환경에서 필수입니다.
  • 차트 자체보다 차트 클릭 → 상세 진입 경로가 사용자 가치를 결정합니다.
  • 초기 중앙 정렬getBBox() + zoom.transform으로 렌더링 후 보정이 답입니다.

Chart.js로 시작해서 D3로 넘어갈지 고민 중이라면, "인터랙션이 핵심이냐" 를 기준으로 판단하면 명확합니다. 단순 표시면 Chart.js, 사용자가 차트를 "조작"해야 하면 D3가 맞습니다.