현재 나는 사진을 기록하는 웹사이트를 사이드 프로젝트를 만들고 있었다. d3 네트워크 그래프를 처음 접하고 그래프가 이뻐보여서 d3 그래프에 사진을 업로드하면 더 이쁠 것 같다는 생각을 들어서 네트워크 그래프를 채택하였다. 하지만 네트워크 그래프를 적용했는데 그래프의 간격을 조정하는 기능을 추가했을 때 아래와 같이 그래프가 버벅이는 현상이 심했다.
Canvas의 성능 저하 이유
SVG의 성능 저하 이유
여기서 나는 canvas를 이용해 네트워크 그래프를 그리기로 결정했다. 왜냐하면 사진의 개수는 정해진것이 아니라 계속 늘어나는 것이라고 생각하고 그래서 svg를 사용하면 그만큼 DOM요소가 많이 추가된다는 것인데 그런것 보다 canvas에서 픽셀로 그리는것이 성능면에서 적합하다고 생각했다.
퍼포먼스 탭을 이용해 성능을 측정하니 아래를 보면 맨위에 빨간색 빗금 표시가 되어 있고 이는 프레임이 드랍되었다는 뜻이다. 보통은 1초에 60프레임 정도가 부드러운 애니메이션인데 일부 프레임이 누락되었다는 뜻이다.

위 사진을 좀 더 분석해 보면 Animation frame fired의 길이가 한눈에봐도 제일 길어 보이는데 이는 raf콜백함수가 실행될때 마다 생성되는것이다. 난 raf를 사용한적이 없는데 일단 더 궁금해서 콜 트리를 한번 확인해 보았다.
콜트리의 좋은점은 Total time을 통해 제일 소요시간이 오래되는 함수를 찾을 수 있다. 쭉 따라 내려갔더니 anoymous함수의 실행이 제일 오래되었다는 것을 발견할 수 있었다. 하지만 anonymous함수가 뭐지.. 걱정할 거 없이 바로 옆에 anonymous함수가 실행되는 위치를 알 수 있다. drawNetworkGraph 파일에서 실행되는 것을 확인할 수 있는데 drawNetworkGraph 함수는 useEffect안에서 실행되고 다음과 같다.
useEffect(() => {
d3.forceSimulation(nodes)
// list of forces we apply to get node positions
.force(
'link',
d3.forceLink<Node, Link>(links).id((d) => d.id)
)
.force('collide', d3.forceCollide().radius(collideRadius).strength(1))
.force('charge', d3.forceManyBody().strength(manyBodyStrength))
.force('center', d3.forceCenter(width / 2, height / 2))
.force('charge', d3.forceY(0).strength(forceYStrength))
// at each iteration of the simulation, draw the network diagram with the new node positions
.on('tick', () => {
drawNetworkGraph(context, width, height, nodes, links);
}
});
},[...])
// drawNetworkGraph.ts
const drawNetworkGraph = (
context: CanvasRenderingContext2D,
width: number,
height: number,
nodes: Node[],
links: d3.SimulationLinkDatum<Node>[]
imageSrc: string
) => {
//... more code
// 이미지 로딩 및 canvas에 그리는 로직
const img = new Image();
img.src = node.imageSrc;
context.save();
context.beginPath();
context.arc(node.x, node.y, RADIUS, 0, 2 * Math.PI);
context.clip();
context.drawImage(
img,
node.x - RADIUS,
node.y - RADIUS,
RADIUS * 2,
RADIUS * 2
);
//...more code
}
위 코드는 내 프로젝트 코드의 일부분을 가지고 온것이다. tick이라는 이벤트가 실행 될 때마다 drawNetwork 함수가 실행되고 이때 이 함수가 프레임 누락의 원인인 것을 파악할 수 있었다. tick을 간단히 설명하자면 d3-force인해 구현된 네트워크 그래프의 노드들이 움직일때마다 콜백 함수를 실행한다. drawNetwork함수는 d3의 여러 노드들을 canvas에다가 그려주는 로직이 작성되어 있는데 나는 노드들이 도형이 아닌 이미지가 담긴 도형을 원했고 이때 위에 콜트리 사진을 참조하면 set src 함수가 실행이 제일 길었는데 이를 통해 이미지를 로딩하고 그리는 로직이 렌더링에 지연된다는 것을 알 수 있었다.
접근 방식은 여러가지가 있었다.
네트워크 그래프의 간격을 조정하는 range type의 input의 상태를 useDebounce를 활용해서 이벤트가 끝날때 딱 한번만 range의 값을 설정하는 setState를 실행한다.
image를 최적화하여 용량을 줄인 다음 실행해본다.
웹 워커를 활용해서 네트워크 그래프의 노드들의 위치 계산은 webWorker에게 맡긴 다음 drawImage맡기고 이미지를 로딩하고 canvas에다가 그리는 것은 js 메인쓰레드에서 처리한다.
1번 방법은 그동안 수없이 해본 방법이라 이번엔 새로운 관점에서 해결하고 싶어 스킵하기로 했고 2번은 지금 하고 있는 프로젝트는 혼자 풀스택으로 구현하는 것이고 나중에 nest js를 활용해서 이미지 리사이징을 해보고 싶어서 건너뛰기로 했다. 3번은 web worker를 한번도 사용해보지 않는 나에겐 새로운 관점에서 문제를 해결할 수 있는 경험이라고 생각들고 당장 프론트 쪽에서 바로 적용해 볼 수 있을 것 같아 3번을 선택했다.
웹 워커(Web worker)는 스크립트 연산을 웹 어플리케이션의 주 실행 스레드와 분리된 별도의 백그라운드 스레드에서 실행할 수 있는 기술이다. 아까 원인을 찾을 때도 봤듯이 우리가 본 콜트리(call tree)가 싱글 쓰레드 환경에서 계속 실행되다 보니 프레임이 누락되는 현상이 나오는 것이다. 만약 네트워크 그래프의 노드들의 위치 계산과 같은 복잡한 계산을 백그라운 스레드에서 실행한다면 전보다 나은 INP 수치를 얻을 것이라고 예상된다. 하지만 의문점이 생겼다. d3-force의 애니메이션은 raf를 실행하는데 웹 워커 환경에서는 윈도우 객체에 window.requestAnimationFrame이 실행이 안된다(DedicatedWorkerGlobalScope.requestAnimationFrame()은 실행됨). 일단 d3가 웹 워커에서 실행이 되는지 안되는지 정확히 판단하기위해 raf가 실행되는 곳이 어딘지 찾기 위해 간단하게 d3 force 오픈소스 코드를 분석 해봤다.
d3 라이브러리에서 우리가 사용하는 force는 다음과 같다.
force코드를 오픈소스에서 보면 아래와 같다.(코드)
force: function(name, _) {
return arguments.length > 1 ? ((_ == null ? forces.delete(name) : forces.set(name, initializeForce(_))), simulation) : forces.get(name);
},
force 함수의 인자인 _은 우리가 force 함수를 사용할 때 전달하는 콜백함수이다 forces는 Map 형식의 구조이며 force의 콜백함수들을 저장하고 있다.

forces에 저장된 우리의 콜백함수들은 어디서 실행되는지 보면 tick이라는 함수에서 forces를 forEach문을 사용해 소비되는것으로 보인다. 그리고 이러한 tick함수는 step이라는 함수에서 실행된다.(step 함수 코드)
timer(timer 실행 위치 코드) timer라는 함수의 콜백 인자로 위에서 보았던 step함수가 전달 되어진다(timer(step)). timer 함수는 d3-timer라는 또 다른 라이브러리에 의존하고 있는데 timer 함수 코드를 보면 t.restart 함수를 실행시키고 이 함수는 sleep이라는 함수를 실행시킨다. 초기에는 delay가 설정이 안되서 맨 아랫부분의 setFrame이 실행 되는데 이때 setFrame을 살펴보면 아래와 같다.
setFrame = typeof window === "object" && window.requestAnimationFrame ? window.requestAnimationFrame.bind(window) : function(f) { setTimeout(f, 17); };
d3 실행하는 setFrame은 window.requestAnimationFrame이 없을 때는 setTimeout 함수를 실행시킨다.웹 워커에서는 setTimeout이 작동하므로 결론적으로forceSimulation은 웹워커에 의해 최적화가 가능하다. 사실 그렇게 어려운 코드는 아니지만 raf를 사용하는 d3가 웹워커에서 작동할 수 있을까? 에 대한 답을 찾기위해 오픈소스 코드를 짧게나마 분석 해보았고 3번의 방법을 이용해서 해결했다.
onmessage = (event) => {
const { type, data } = event.data;
switch (type) {
case "START_SIMULATION": {
const {
nodes,
links,
width,
height,
collideRadius,
manyBodyStrength,
forceYStrength,
} = data;
// Stop existing simulation if any
if (simulation) {
simulation.stop();
}
// Create new simulation
simulation = d3
.forceSimulation(nodes)
.force(
"link",
d3.forceLink(links).id((d) => d.id),
)
.force("collide", d3.forceCollide().radius(collideRadius).strength(1))
.force("charge", d3.forceManyBody().strength(manyBodyStrength))
.force("center", d3.forceCenter(width / 2, height / 2))
.force("y", d3.forceY(0).strength(forceYStrength))
.on("tick", () => {
postMessage({
type: "TICK",
data: { nodes, links },
});
});
postMessage({
type: "FINAL_NODES",
data: simulation.nodes(),
});
break;
}
case "STOP": {
if (simulation) {
simulation.stop();
}
break;
}
}
};
사실상 tick 이벤트 때마다 drawNetworkGraph 함수를 실행 시키므로 on 콜백 함수 안에 postMessage를 활용하여 네트워크 그래프 노드들의 위치를 메인 쓰레드로 전송해준다.
useEffect(() => {
workerRef.current = new Worker(
new URL('./forceSimulation.worker.js', import.meta.url)
);
workerRef.current.onmessage = (event) => {
const { type, data } = event.data;
if (type === 'TICK') {
drawNetwork(
context,
width,
height,
[...data.nodes] as Node[],
[...data.links] as Link[]
);
}
}, [])
useEffect를 활용해 onmessage 메서드를 활용해서 웹 워커가 전달한 노드들의 위치를 받을 때마다 drawNetwork함수를 실행시키는 이벤트 리스너를 등록시켜준다.
useEffect(() => {
if (!worker) return;
const canvas = canvasRef.current;
const width = canvas!.getBoundingClientRect().width;
const height = canvas!.getBoundingClientRect().height;
const nodes = data.nodes.map((d) => ({ ...d }));
const links = data.links.map((d) => ({ ...d }));
workerRef.current!.postMessage({
type: 'START_SIMULATION',
data: {
nodes,
links,
width,
height,
collideRadius,
manyBodyStrength,
forceYStrength,
},
});
}, [
collideRadius,
data.links,
data.nodes,
forceYStrength,
manyBodyStrength,
worker,
]);
또한 postMessage를 활용하여 노드들의 간격이(collideRadius) 변경될 때마다 웹 워커를 호출해 노드들의 다시 위치를 계산시키게 함으로써 웹 워커를 이용해 INP의 퍼포먼스를 최적화 하였다. 대략 80%의 최적화를 이뤘다.
![]() | ![]() |
|---|