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

- Name
- Hyo814
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 스키마를 먼저 명확히 정의
처음에는 서버에서 children을 child_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로 시작하는 걸 권장합니다.