Published on

ASN.1 트리 렌더링을 graphviz PNG에서 D3.js로 갈아탄 이유

Authors
  • avatar
    Name
    Hyo814
    Twitter

ASN.1 트리 렌더링을 graphviz PNG에서 D3.js로 갈아탄 이유

표준데이터의 ASN.1 구조를 트리로 시각화하는 화면이 있습니다. 원래는 서버에서 graphviz로 PNG를 그려 <img>로 내려주는 방식이었는데, 이걸 D3.js 기반 Collapsible Tree로 전면 전환했습니다. "왜 바꿨고, 어떻게 바꿨는지" 정리합니다.


1. 기존 방식 — 서버 graphviz + PNG

# std_data/views/graphviz_utils.py (삭제됨)
from graphviz import Digraph

def render_asn1_png(root_node):
    dot = Digraph(format='png')
    walk(root_node, dot)
    png_path = dot.render(f'/tmp/asn1_{root_node.id}', cleanup=True)
    return png_path

뷰에서 호출해서 <img src="...">로 렌더링했습니다.

<img src="{{ asn1_png_url }}" alt="ASN.1 트리">

이 방식의 문제

문제설명
서버 의존성배포 환경마다 graphviz 바이너리 설치 필요. Docker 이미지가 무거워짐
인터랙션 불가PNG라 클릭·확장·축소·툴팁이 원천 불가
렌더링 시간노드 수가 많으면 서버 CPU를 먹고 초 단위 지연
스케일링 이슈PNG 확대하면 픽셀이 깨짐
텍스트 검색 불가PNG 속 텍스트는 Ctrl+F로 못 찾음

특히 "노드 수가 많은 표준 데이터는 응답이 느리다" 는 피드백이 결정타였습니다.


2. 전환 방식 — 서버는 JSON만, 그리기는 브라우저에서

방향을 뒤집었습니다. 서버는 계층 JSON만 내려주고, 렌더링은 클라이언트에서 D3가 담당.

서버 변경 — PNG 대신 JSON 덤프

# std_data/views/dataset/std_data.py
def asn1_tree_view(request, dataset_id):
    dataset = get_object_or_404(Dataset, pk=dataset_id)
    manager = Asn1Manager(dataset)

    context = {
        'dataset': dataset,
        'asn1_json': manager.get_json_dump(),   # 트리 JSON
    }
    return render(request, 'std_data/std_data_asn1_tree_png.html.j2', context)

get_json_dump()는 트리를 재귀적으로 직렬화합니다.

class Asn1Manager:
    def get_json_dump(self):
        return self._serialize(self.root_node)

    def _serialize(self, node):
        return {
            'id': node.id,
            'data': {'name': node.name, 'value': node.value},
            'children': [self._serialize(c) for c in node.children.all()],
        }

requirements.txt에서 graphviz 제거

- graphviz==0.20.1

배포 이미지가 30MB 이상 가벼워졌습니다.


3. D3 Collapsible Tree 구현

클라이언트 측 렌더링은 D3 v7의 d3.tree() 레이아웃을 사용했습니다.

// static/js/asn1TreeD3.js
(function (global) {
    "use strict";

    const MARGIN = { top: 20, right: 180, bottom: 20, left: 120 };
    const NODE_HEIGHT = 26;
    const NODE_WIDTH = 220;
    const DURATION = 250;

    function toHierarchy(node) {
        const d = node.data || {};
        return {
            id: node.id,
            name: d.name || '(unnamed)',
            value: d.value == null ? '' : String(d.value),
            children: Array.isArray(node.children)
                ? node.children.map(toHierarchy)
                : undefined,
        };
    }

    function renderAsn1Tree(opts) {
        const container = document.querySelector(opts.containerSelector);
        if (!container || !opts.data) return;

        container.innerHTML = '';

        const width = container.clientWidth || 1024;
        const height = container.clientHeight || 720;

        const svg = d3.select(container)
            .append('svg')
            .attr('width', '100%')
            .attr('height', '100%')
            .attr('viewBox', [0, 0, width, height])
            .attr('preserveAspectRatio', 'xMinYMin meet');

        const zoomRoot = svg.append('g').attr('class', 'asn1-zoom-root');
        const g = zoomRoot.append('g')
            .attr('transform', `translate(${MARGIN.left},${MARGIN.top})`);

        const root = d3.hierarchy(toHierarchy(opts.data));
        const tree = d3.tree().nodeSize([NODE_HEIGHT, NODE_WIDTH]);

        // 초기에는 루트만 펼치고 나머지는 접기
        root.descendants().forEach((d, i) => {
            if (d.depth >= 1) {
                d._children = d.children;
                d.children = null;
            }
        });

        update(root);

        function update(source) {
            tree(root);

            // 노드 렌더링
            const nodes = g.selectAll('g.node').data(root.descendants(), d => d.data.id);

            const nodeEnter = nodes.enter().append('g')
                .attr('class', 'node')
                .attr('transform', d => `translate(${source.y0 || 0},${source.x0 || 0})`)
                .on('click', (event, d) => {
                    // 확장/축소 토글
                    if (d.children) {
                        d._children = d.children;
                        d.children = null;
                    } else {
                        d.children = d._children;
                        d._children = null;
                    }
                    update(d);
                });

            nodeEnter.append('circle').attr('r', 5);
            nodeEnter.append('text')
                .attr('dy', '0.31em')
                .attr('x', d => d._children ? -8 : 8)
                .attr('text-anchor', d => d._children ? 'end' : 'start')
                .text(d => d.data.name);

            // 링크(엣지) 렌더링
            const link = d3.linkHorizontal().x(d => d.y).y(d => d.x);
            g.selectAll('path.link')
                .data(root.links(), d => d.target.data.id)
                .join('path')
                .attr('class', 'link')
                .attr('d', link);
        }
    }

    global.renderAsn1Tree = renderAsn1Tree;
})(window);

4. 템플릿 — <img>에서 <div>

{# 이전 #}
<img src="{{ asn1_png_url }}" alt="ASN.1 트리">

{# 이후 #}
<div id="asn1-tree" style="width:100%; height:720px;"></div>
<script src="{{ static('js/asn1TreeD3.js') }}"></script>
<script>
    renderAsn1Tree({
        containerSelector: '#asn1-tree',
        data: {{ asn1_json | tojson }},
        rootLabel: '{{ dataset.name }}'
    });
</script>

5. 부가 기능 — 툴팁과 줌

D3로 오면서 손쉽게 붙일 수 있게 된 기능들입니다.

툴팁 — 노드 위 값 표시

const tooltip = d3.select('body').append('div')
    .attr('class', 'asn1-tree-tooltip')
    .style('position', 'absolute')
    .style('visibility', 'hidden')
    .style('background', 'rgba(0, 0, 0, 0.85)')
    .style('color', '#fff')
    .style('padding', '8px 10px')
    .style('border-radius', '4px')
    .style('pointer-events', 'none');

nodeEnter.on('mouseover', (event, d) => {
    tooltip.style('visibility', 'visible')
        .html(`<strong>${d.data.name}</strong><br>${d.data.value}`)
        .style('top', `${event.pageY + 10}px`)
        .style('left', `${event.pageX + 10}px`);
}).on('mouseout', () => tooltip.style('visibility', 'hidden'));

줌/팬 — 공통 유틸 재사용

다른 D3 시각화와 공유하는 attachZoomControls 유틸을 그대로 썼습니다.

const zoomControls = (typeof global.attachZoomControls === 'function')
    ? global.attachZoomControls({
        svg: svg,
        zoomTarget: zoomRoot,
        scaleExtent: [0.2, 3],
        container: container,
    })
    : null;

6. 전환 후 비교

항목Before (graphviz PNG)After (D3 JSON)
서버 의존성graphviz 바이너리 필요없음
응답 시간노드 수에 비례해 증가서버는 JSON만, 거의 고정
인터랙션불가확장/축소, 툴팁, 줌
텍스트 검색불가Ctrl+F 가능
모바일 대응픽셀 깨짐SVG라 선명
서버 CPU렌더링마다 증가거의 사용 안 함

7. 주의점

JSON 스키마를 먼저 명확히 정의

처음에는 서버에서 childrenchild_nodes로 보냈는데, D3 기본 규약은 children입니다. D3 관례에 맞춰 서버 직렬화를 하는 편이 변환 코드 한 겹을 줄여줍니다.

# 좋음 — D3 관례에 맞춤
{'name': ..., 'children': [...]}

# 나쁨 — 클라이언트에서 변환 필요
{'label': ..., 'child_nodes': [...]}

대용량 트리는 초기 접기

노드가 수백 개 넘으면 모두 펼친 상태로 초기 렌더링하면 레이아웃 계산이 느립니다. 루트만 펼치고 나머지는 접힘 상태로 시작하세요.

root.descendants().forEach(d => {
    if (d.depth >= 1) {
        d._children = d.children;
        d.children = null;
    }
});

정리

서버 사이드 그래프 이미지 생성은 빠르게 만들기엔 편하지만, 인터랙션 요구가 조금이라도 생기면 갈아타야 하는 방식입니다. ASN.1 트리처럼 "계층이 깊고 사용자가 탐색하고 싶어하는" 데이터는 D3 + JSON 조합이 거의 정답에 가깝습니다.

graphviz가 여전히 유용한 케이스는 "보고서용 정적 이미지"처럼 인터랙션 없는 산출물을 생성할 때 정도입니다. 웹 화면용이라면 초기부터 D3로 시작하는 걸 권장합니다.