발행일

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

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

8. 다시 보면 — 표의 장점 하나가 스스로와 충돌한다

6절 비교표에 이렇게 적었습니다.

항목BeforeAfter
텍스트 검색불가Ctrl+F 가능

그런데 바로 다음 절에서 이렇게 씁니다.

루트만 펼치고 나머지는 접힘 상태로 시작하세요.

접힌 노드는 DOM에 없습니다. d.children = null로 만들면 렌더링 대상에서 빠지니까, Ctrl+F로 찾을 수 있는 건 지금 펼쳐진 노드뿐이에요. 노드가 수백 개인 트리에서는 대부분이 접혀 있으니, 실질적으로 검색되는 범위는 PNG 시절보다 조금 나은 정도입니다.

PNG 대비 진짜 이점은 "검색이 된다" 가 아니라 "펼친 부분은 검색된다" 였어요. 표에 ⭕만 찍으면서 조건을 안 적은 게 과장이 됐습니다.

같은 문제를 이용안내를 캐러셀로 바꿀 때도 만났는데, 그때는 "숨긴 슬라이드는 검색에 안 잡힌다" 를 감수한 트레이드오프로 명시했어요. 여기서는 같은 성질을 장점 칸에 적어버린 셈입니다.


9. 글에 실은 코드를 그대로 쓰면 안 되는 부분

3절의 update()는 흐름을 보여주려고 줄인 코드인데, 줄이는 과정에서 D3 갱신 패턴의 핵심이 빠졌습니다. 그대로 옮기면 동작하지 않으니 짚어둡니다.

① 노드에 exit가 없습니다.

const nodes = g.selectAll('g.node').data(root.descendants(), d => d.data.id);
const nodeEnter = nodes.enter().append('g')   // enter 만 처리

링크는 .join('path')로 enter·update·exit를 한 번에 처리하는데, 노드는 enter()만 씁니다. 그러면 접었을 때 사라져야 할 g.node가 DOM에 그대로 남아요. 노드도 .join()을 쓰거나 nodes.exit().remove()를 붙여야 합니다.

source.x0 / source.y0을 아무도 저장하지 않습니다.

.attr('transform', d => `translate(${source.y0 || 0},${source.x0 || 0})`)

x0/y0은 D3 collapsible tree 예제에서 "직전 위치" 를 기억하려고 매 갱신 끝에 d.x0 = d.x; d.y0 = d.y로 저장해두는 값입니다. 그 저장 코드가 없으니 항상 || 0이 걸려 모든 노드가 (0,0)에서 시작하고, 실제 위치로 옮기는 갱신도 없습니다.

정리하면 갱신 사이클이 enter → (update 없음) → (exit 없음) 이라, 접기·펼치기가 제대로 안 그려집니다. 글에서 DURATION 상수를 선언해놓고 어디에도 안 쓴 것도 같은 이유예요 — transition 단계가 통째로 빠져 있습니다.


10. 그 밖에 남은 것

  • 툴팁 div가 호출할 때마다 쌓입니다. d3.select('body').append('div')로 만드는데, container.innerHTML = ''는 컨테이너만 비울 뿐 body에 붙은 툴팁은 안 지웁니다. 트리를 다시 그릴 때마다 .asn1-tree-tooltip이 하나씩 늘어요. 기존 것을 재사용하거나 컨테이너 안에 넣어야 합니다.
  • 줌 유틸이 없으면 조용히 줌 없이 동작합니다. typeof global.attachZoomControls === 'function' ? ... : null그 유틸 글에서도 같은 지점을 지적했는데, 소비처에도 똑같이 퍼져 있습니다. 방어 코드가 실패를 감추는 패턴이 프로젝트 전반에 복제된 셈이에요.
  • 서버 부하를 없앤 게 아니라 옮겼습니다. 6절 표의 "서버 CPU 거의 사용 안 함" 은 맞지만, 레이아웃 계산은 사용자 브라우저로 갔습니다. 노드가 수백 개인 트리를 저사양 기기에서 열면 그쪽이 느려요. 초기 접기가 그 완충 장치인데, 그게 8절의 검색 문제를 만든 원인이기도 합니다.

정리

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

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