위키피디아의 이미지 R‑tree

그래프 시각화 라이브러리: Canvas vs HTML 딜레마를 해결한 방법
그래프 시각화 라이브러리: Canvas vs HTML 딜레마를 해결한 방법
안녕하세요! 저는 안드레이이고, Yandex 인프라 서비스의 User Experience 팀에서 인터페이스 개발자로 일하고 있습니다. 우리는 Gravity UI를 개발하고 있습니다. Gravity UI는 오픈소스 디자인 시스템이자 React 컴포넌트 라이브러리로, 사내외 수십 개 제품에서 사용됩니다. 오늘은 복잡한 그래프를 시각화해야 하는 문제를 어떻게 마주했고, 왜 기존 솔루션들이 만족스럽지 않았는지, 그리고 결국 @gravity‑ui/graph라는 라이브러리가 어떻게 탄생했는지 (그리고 이를 커뮤니티에 공개하기로 한 이유까지) 이야기해 보겠습니다.
이 이야기는 아주 현실적인 문제에서 시작됐습니다. 인터랙티브 컴포넌트를 포함한 10,000+ 요소의 그래프를 렌더링해야 했습니다. Yandex에는 사용자가 복잡한 데이터 처리 파이프라인을 만드는 프로젝트가 많습니다. 간단한 ETL부터 머신러닝까지 다양하죠. 이런 파이프라인이 프로그램적으로 생성되면, 블록 수가 수만 개까지 늘어날 수 있습니다.
기존 솔루션들은 만족스럽지 않았습니다:
- HTML/SVG 라이브러리는 보기 좋고 개발도 편하지만, 수백 개 요소만 넘어도 느려지기 시작합니다.
- Canvas 기반 솔루션은 성능은 좋지만, 복잡한 UI 요소를 만들려면 코드가 너무 많이 필요합니다.
Canvas에서 둥근 모서리와 그라데이션이 있는 버튼을 그리는 건 어렵지 않습니다. 하지만 복잡한 컨트롤이나 레이아웃을 만들려 하면 문제가 생깁니다. 저수준 드로잉 커맨드를 수십 줄씩 작성해야 하거든요. 클릭 처리부터 애니메이션까지, 모든 UI 요소를 처음부터 직접 구현해야 합니다. 그러나 우리가 필요했던 건 완전한 UI 컴포넌트였습니다: 버튼, 셀렉트, 입력 필드, 드래그 앤 드롭 등.
우리는 Canvas와 HTML 중 하나를 고르는 대신, 두 기술의 장점을 모두 활용하기로 했습니다. 아이디어는 단순합니다: 사용자가 그래프를 얼마나 확대해서 보고 있는지에 따라 모드를 자동으로 전환한다.

직접 사용해 보세요
문제는 어디에서 시작됐나
Nirvana와 그 그래프들
Yandex에는 데이터 처리 그래프를 만들고 실행하는 Nirvana 서비스가 있습니다 (우리는 이에 대해 2018년에 이미 글을 썼습니다). 이 서비스는 크고, 인기가 많고, 오래전부터 존재해 왔습니다.
사용자 중 일부는 그래프를 손으로 만듭니다 — 마우스로 블록을 추가하고 연결하죠. 이런 그래프는 문제가 없습니다: 블록 수가 많지 않아 모든 것이 잘 동작합니다. 하지만 그래프를 프로그램적으로 생성하는 프로젝트도 있습니다. 여기서부터 어려움이 시작됩니다: 하나의 그래프에 최대 10,000개의 작업을 넣을 수 있습니다. 그러면 이렇게 됩니다:

그리고 이런 것도:





이런 그래프는 일반적인 HTML + SVG 조합으로는 도저히 감당이 안 됩니다. 브라우저가 느려지고, 메모리가 새며, 사용자는 고통받습니다. 우리는 정면 돌파로 HTML 렌더링을 최적화해 보려 했지만, 결국 물리적 한계에 부딪혔습니다 — DOM은 수천 개의 동시에 보이는 떠다니는 인터랙티브 요소를 처리하도록 설계되지 않았습니다.
다른 해결책이 필요했고, 브라우저에서 남은 선택지는 Canvas뿐이었습니다. 필요한 성능을 보장할 수 있는 건 Canvas뿐입니다.
첫 번째 생각은 готовое (기성) 솔루션을 찾는 것이었습니다. 2017–2018년 당시, 우리는 Canvas나 그래프 렌더링용 인기 라이브러리를 샅샅이 뒤졌지만 모두 같은 문제에 부딪혔습니다: Canvas를 쓰면 원시적 요소만, HTML/SVG를 쓰면 성능을 희생.
그런데… 굳이 하나를 선택해야 할까요?
Level of Details: 게임 개발에서 얻은 영감
게임 개발(GameDev)과 지도/지리(카토그래피)에는 Level of Details (LOD)라는 훌륭한 개념이 있습니다. 이 기법은 필요에서 태어났습니다: огром한 세계를 보여주면서도 성능을 망치지 않으려면?
핵심은 간단합니다: 하나의 객체는 관찰 거리(줌)에 따라 여러 디테일 레벨을 가질 수 있습니다. 게임에서는 특히 잘 보입니다:
- 멀리서는 산이 보이는데 — 기본 텍스처를 가진 단순 폴리곤입니다.
- 가까이 다가가면 디테일이 나타납니다: 풀, 돌, 그림자.
- 더 가까이 가면 나뭇잎 하나하나가 보입니다.
플레이어가 산 정상에서 멀리 바라보고 있을 때 풀의 폴리곤을 수백만 개 렌더링하는 사람은 없습니다.
지도도 원리는 동일합니다 — 줌 레벨마다 데이터와 디테일이 다릅니다:
- 대륙 스케일 — 국가만 보임.
- 도시로 확대 — 도로와 구역이 나타남.
- 더 확대 — 번지수, 카페, 버스 정류장.
우리는 깨달았습니다: 10,000개 블록의 그래프를 큰 스케일로 볼 때 사용자에게 인터랙티브 버튼은 필요 없습니다. 어차피 보이지도 않고, 조작도 못 합니다.
더 나아가, 10,000개의 HTML 요소를 동시에 렌더링하려 하면 브라우저가 멈춥니다. 하지만 특정 영역으로 줌하면 가시 블록 수는 10,000에서 예를 들어 50으로 급감합니다. 바로 이때 HTML 컴포넌트의 풍부한 인터랙션을 위한 리소스가 확보됩니다.
우리의 Level of Details 3단계
Minimalistic (스케일 0.1–0.3) — 단순 프리미티브를 사용하는 Canvas
이 모드에서 사용자는 시스템의 전체 아키텍처를 봅니다: 주요 블록 그룹이 어디에 있고 서로 어떻게 연결되는지. 각 블록은 기본적인 색상 코딩만 가진 단순 사각형입니다. 텍스트/버튼/세밀한 아이콘은 없습니다. 대신 수천 개 요소를 편안하게 렌더링할 수 있습니다. 이 레벨에서 사용자는 자세히 볼 영역을 선택합니다.

Schematic (스케일 0.3–0.7) — 디테일이 있는 Canvas
블록 이름, 상태 아이콘, 연결 앵커가 나타납니다. 텍스트는 Canvas API로 렌더링합니다 — 빠르지만 스타일링은 제한적입니다. 블록 간 연결도 더 정보성이 높아집니다: 데이터 흐름 방향, 연결 상태 등을 표시할 수 있습니다. 이는 Canvas 성능과 기본 정보성을 결합한 전환 모드입니다.

Detailed (스케일 0.7+) — 완전한 인터랙션을 제공하는 HTML
여기서 블록은 완전한 UI 컴포넌트가 됩니다: 제어 버튼, 파라미터 입력 필드, 프로그레스 바, 셀렉트 등을 포함합니다. HTML/CSS의 모든 기능을 사용할 수 있고 UI 라이브러리도 연결할 수 있습니다. 이 모드에서는 보통 viewport에 20–50개 블록만 들어오므로 상세 작업에 적합합니다.

디테일 레벨 선택을 FPS로 하면 어떨까?
FPS 기반으로 디테일 레벨을 선택하는 접근도 고려했습니다. 하지만 이 방식은 불안정성을 만들었습니다. 성능이 좋아지면 더 디테일한 모드로 전환하고, 그러면 FPS가 떨어져 다시 덜 디테일한 모드로 전환… 이런 식으로 반복될 수 있습니다.
해결책에 도달한 과정
LOD가 훌륭하다는 건 알겠는데, 구현에는 성능을 위해 Canvas가 필요합니다. 이는 새로운 골칫거리입니다. Canvas에 그리는 것 자체는 크게 어렵지 않지만, 인터랙션을 만들려면 문제가 시작됩니다.
문제: 사용자가 어디를 클릭했는지 어떻게 알까?
HTML에서는 쉽습니다: 버튼을 클릭하면 이벤트가 바로 그 요소로 들어옵니다. Canvas에서는 더 어렵습니다: 캔버스를 클릭했다 — 그 다음은? 사용자가 어떤 요소를 클릭했는지 우리가 직접 알아내야 합니다.
기본적으로 세 가지 접근이 있습니다:
- Pixel Testing (컬러 피킹),
- Geometric approach (모든 요소를 단순 순회),
- Spatial Indexing (공간 인덱스).
Pixel Testing (컬러 피킹)
아이디어는 단순합니다: 두 번째 보이지 않는 canvas를 만들고, 씬을 거기에 복사하되 각 요소를 고유 색으로 채워 그 색을 객체 ID로 사용합니다. 클릭 시 getImageData로 마우스 포인터 아래 픽셀 색을 읽어 요소 ID를 얻습니다.
|
장점 |
단점 |
|
|
작은 씬에서는 괜찮지만 10,000+ 요소에서는 오류율이 감당 불가 수준이 됩니다. Pixel Testing은 보류합니다.
Geometric approach (모든 요소 단순 순회)
아이디어는 단순합니다: 모든 요소를 순회하며 클릭 좌표가 요소 내부인지 검사합니다.
|
장점 |
단점 |
|
|
Spatial Indexing
기하학적 접근의 발전형입니다. 기하학적 접근에서는 요소 수가 병목이었습니다. 공간 인덱스 알고리즘은 주로 트리를 사용해 가까운 요소를 그룹화하여 복잡도를 log n 수준으로 낮추려 합니다.
공간 인덱스 알고리즘은 다양하지만, 우리는 R‑Tree 자료구조를 rbush 라이브러리로 사용했습니다.
R‑Tree는 이름 그대로 트리이며, 각 객체를 최소 크기의 사각형(MBR)에 넣고 그 사각형들을 더 큰 사각형들로 그룹화합니다. 이렇게 각 사각형이 다른 사각형들을 포함하는 트리가 만들어집니다.

RTree에서 검색하려면 트리를 내려가며(사각형 내부로 더 깊이 들어가며) конкрет한 요소에 도달해야 합니다. 경로는 검색 사각형이 MBR과 교차하는지 검사해 선택됩니다. Bounding-box가 검색 사각형을 아예 건드리지도 않는 가지는 즉시 버려집니다 — 그래서 탐색 깊이는 보통 3–5 레벨로 제한되며, 수만 개 요소에서도 검색은 마이크로초 단위로 끝납니다.
이 방식은 pixel testing보다(최선 O (log n), 최악 O (n)) 느릴 수 있지만, 더 정확하고 메모리 요구도도 낮습니다.
이벤트 모델
이제 RTree를 기반으로 이벤트 모델을 구축할 수 있습니다. 사용자가 클릭하면 히트 테스트(hit‑test)를 수행합니다: 커서 좌표에 1×1 픽셀 크기의 사각형을 만들고 R‑Tree에서 교차를 찾습니다. 이 사각형이 들어가는 요소를 얻으면 해당 요소에게 이벤트를 위임합니다. 요소가 이벤트를 중단하지 않으면 부모로 전달되고, 루트까지 계속됩니다. 이 모델의 동작은 브라우저의 이벤트 모델과 유사합니다. 이벤트는 가로채거나(prevent), 버블링을 중단할 수 있습니다.
앞서 말했듯 히트 테스트에서 1×1 픽셀 사각형을 만들지만, 이는 임의 크기의 사각형도 만들 수 있다는 뜻입니다. 이는 또 하나의 매우 중요한 최적화 — Spatial Culling — 에 도움이 됩니다.
Spatial Culling
Spatial Culling은 보이지 않는 것을 그리지 않는 렌더링 최적화 기법입니다. 예를 들어 카메라 밖의 객체나 다른 요소에 가려진 객체를 그리지 않습니다. 우리 그래프는 2D 공간에서 그려지므로 카메라의 가시 영역(viewport) 밖의 객체만 그리지 않으면 충분합니다.
동작 방식:
- 카메라 이동/줌마다 현재 viewport와 동일한 사각형을 만든다.
- R‑Tree에서 그 사각형과 교차하는 요소를 찾는다.
- 결과로 실제로 보이는 요소 리스트를 얻는다.
- 그 요소들만 렌더링하고 나머지는 건너뛴다.
이 기법은 전체 요소 수와 거의 무관하게 성능을 유지하게 해 줍니다: 프레임에 40개 블록만 보이면 라이브러리는 정확히 40개만 그립니다. 화면 밖에 숨은 수만 개까지 그리지 않습니다. 먼 스케일에서는 viewport에 많은 요소가 들어오므로 가벼운 Canvas 프리미티브를 그리고, 가까이 확대하면 요소 수가 줄어들어 확보된 리소스로 완전 디테일의 HTML 모드로 전환할 수 있습니다.
정리하면 간단한 구조가 됩니다:
- Canvas는 속도,
- HTML은 인터랙션,
- R‑Tree와 Spatial Culling은 둘을 하나의 시스템으로 자연스럽게 결합해, 어떤 요소를 HTML 레이어에 그릴 수 있는지 빠르게 판단한다.
카메라가 움직이는 동안, 작은 viewport는 R‑Tree에 화면에 실제로 있는 객체만 요청합니다. 이 접근 덕분에 우리는 정말 큰 그래프를 그릴 수 있고, 또는 사용자가 viewport를 제한하기 전까지 충분한 성능 여유를 확보할 수 있습니다.
결과적으로 라이브러리 코어는 다음을 포함합니다:
- 단순 프리미티브를 사용하는 Canvas 모드;
- 완전 디테일을 제공하는 HTML 모드;
- 성능 최적화를 위한 R‑Tree와 Spatial Culling;
- 익숙한 이벤트 모델.
하지만 프로덕션에서는 이것만으로 부족합니다. 라이브러리를 확장하고 요구에 맞게 커스터마이징할 수 있어야 합니다.
커스터마이징
라이브러리는 동작을 확장/변경하기 위한 두 가지 상호보완적 방법을 제공합니다:
- 기본 컴포넌트 재정의: 표준 Block, Anchor, Connection의 로직을 바꾼다.
- 레이어(Layers)로 확장: 기존 씬 위/아래에 근본적으로 새로운 기능을 추가한다.
컴포넌트 재정의
기존 요소의 외형이나 동작을 수정해야 할 때는 기본 클래스에서 상속받아 핵심 메서드를 오버라이드합니다. 그리고 컴포넌트를 자신의 이름으로 등록합니다.
블록 커스터마이징
예를 들어 파이프라인 내 작업 실행 상태를 보여주기 위해 블록에 프로그레스 바가 있는 그래프를 만들고 싶다면, 표준 블록을 손쉽게 커스터마이징할 수 있습니다:
import { CanvasBlock } from "@gravity‑ui/graph";
class ProgressBlock extends CanvasBlock {
// 둥근 모서리를 가진 블록의 기본 형태
public override renderBody(ctx: CanvasRenderingContext2D): void {
ctx.fillStyle = "#ddd";
ctx.beginPath();
ctx.roundRect(this.state.x, this.state.y, this.state.width, this.state.height, 12);
ctx.fill();
ctx.closePath();
}
public renderSchematicView(ctx: CanvasRenderingContext2D): void {
const progress = this.state.meta?.progress || 0;
// 블록의 기본 형태를 그린다
this.renderBody(ctx);
// 색상 표시가 있는 프로그레스 바
const progressWidth = (this.state.width - 20) * (progress / 100);
ctx.fillStyle = progress < 50 ? "#ff6b6b" : progress < 80 ? "#feca57" : "#48cae4";
ctx.fillRect(this.state.x + 10, this.state.y + this.state.height - 15, progressWidth, 8);
// 프로그레스 바 테두리
ctx.strokeStyle = "#ddd";
ctx.lineWidth = 1;
ctx.strokeRect(this.state.x + 10, this.state.y + this.state.height - 15, this.state.width - 20, 8);
// 퍼센트와 이름 텍스트
ctx.fillStyle = "#2d3436";
ctx.font = "12px Arial";
ctx.textAlign = "center";
ctx.fillText(`${Math.round(progress)}%`, this.state.x + this.state.width / 2, this.state.y + 20);
ctx.fillText(this.state.name, this.state.x + this.state.width / 2, this.state.y + 40);
}
}
연결(커넥션) 커스터마이징
마찬가지로 연결의 동작과 모양을 바꿔야 한다면 — 예를 들어 블록 간 데이터 흐름의 강도를 표시하고 싶다면 — 커스텀 연결을 만들 수 있습니다:
import { BlockConnection } from "@gravity-ui/graph";
class DataFlowConnection extends BlockConnection {
public override style(ctx: CanvasRenderingContext2D) {
// 연결된 블록에서 흐름 데이터를 가져온다
const sourceBlock = this.sourceBlock;
const targetBlock = this.targetBlock;
const sourceProgress = sourceBlock?.state.meta?.progress || 0;
const targetProgress = targetBlock?.state.meta?.progress || 0;
// 블록 진행률에 기반해 흐름 강도를 계산
const flowRate = Math.min(sourceProgress, targetProgress);
const isActive = flowRate > 10; // 진행률 > 10%이면 흐름 활성
if (isActive) {
// 활성 흐름 -- 두꺼운 초록 선
ctx.strokeStyle = "#00b894";
ctx.lineWidth = Math.max(2, Math.min(6, flowRate / 20));
} else {
// 비활성 흐름 -- 회색 점선
ctx.strokeStyle = "#ddd";
ctx.lineWidth = this.context.camera.getCameraScale();
ctx.setLineDash([5, 5]);
}
return { type: "stroke" };
}
}
커스텀 컴포넌트 사용
생성한 컴포넌트를 그래프 설정에 등록합니다:
const customGraph = new Graph({
blocks: [
{
id: "task1",
is: "progress",
x: 100,
y: 100,
width: 200,
height: 80,
name: "Data Processing",
meta: { progress: 75 },
},
{
id: "task2",
is: "progress",
x: 400,
y: 100,
width: 200,
height: 80,
name: "Analysis",
meta: { progress: 30 },
},
{
id: "task3",
is: "progress",
x: 700,
y: 100,
width: 200,
height: 80,
name: "Output",
meta: { progress: 5 },
},
],
connections: [
{ sourceBlockId: "task1", targetBlockId: "task2" },
{ sourceBlockId: "task2", targetBlockId: "task3" },
],
settings: {
// 커스텀 블록을 등록
blockComponents: {
'progress': ProgressBlock,
},
// 모든 연결에 대해 커스텀 커넥션 등록
connection: DataFlowConnection,
useBezierConnections: true,
},
});
customGraph.setEntities({
blocks: [
{
is: 'progress',
id: '1',
name: "progress block',
x: 10,
y: 10,
width: 10,
height: 10,
anchors: [],
selected: false,
}
]
})
customGraph.start();
결과
결과로 다음을 만족하는 그래프가 만들어집니다:
- 블록은 현재 진행률을 색상 인디케이션과 함께 표시한다;
- 연결은 데이터 흐름을 시각화한다: 활성 흐름은 초록색이고 두껍게, 비활성 흐름은 회색 점선;
- 줌 시 블록은 자동으로 완전한 인터랙션을 제공하는 HTML 모드로 전환된다.
레이어로 확장
레이어는 그래프의 “공간”에 삽입되는 추가 Canvas 또는 HTML 요소입니다. 사실상 각 레이어는 독립된 렌더링 채널이며, 빠른 그래픽을 위한 자체 canvas나 복잡한 인터랙티브 요소를 위한 HTML 컨테이너를 가질 수 있습니다.
참고로, 우리 라이브러리의 React 통합은 바로 레이어를 통해 동작합니다: React 컴포넌트는 React Portal을 통해 HTML 레이어에 렌더링됩니다.
레이어 아키텍처
레이어는 Canvas vs HTML 딜레마를 해결하는 또 하나의 핵심 해법입니다. 레이어는 Canvas와 HTML 요소의 위치를 동기화하여 올바른 오버레이를 보장합니다. 이를 통해 같은 공간 안에서 Canvas와 HTML을 매끄럽게 전환할 수 있습니다. 그래프는 서로 독립된 레이어들이 겹쳐진 구조입니다:

레이어는 두 가지 좌표계에서 동작할 수 있습니다:
-
그래프에 고정 (
transformByCameraPosition: true):- 요소가 카메라와 함께 움직임,
- 블록, 연결, 그래프 요소.
-
화면에 고정 (
transformByCameraPosition: false):- 패닝 시에도 제자리에 유지,
- 툴바, 범례, UI 컨트롤.
React 통합은 어떻게 동작하나
React 통합 레이어는 레이어가 무엇인지 보여주기에 좋은 예입니다. 먼저 카메라의 가시 영역 안에 있는 블록 목록을 강조 표시하는 컴포넌트를 보겠습니다. 이를 위해 카메라 변경을 구독하고, 변경마다 카메라 viewport와 요소 hitbox의 교차 검사를 수행해야 합니다.
import { Graph } from "@gravity-ui/graph";
const BlocksList = ({ graph, renderBlock }: { graph: Graph, renderBlock: (graph: Graph, block: TBlock) => React.JSX.Element }) => {
const [blocks, setBlocks] = useState([]);
const updateVisibleList = useCallback(() => {
const cameraState = graph.cameraService.getCameraState();
const CAMERA_VIEWPORT_TRESHOLD = 0.5;
const x = -cameraState.relativeX - cameraState.relativeWidth * CAMERA_VIEWPORT_TRESHOLD;
const y = -cameraState.relativeY - cameraState.relativeHeight * CAMERA_VIEWPORT_TRESHOLD;
const width = -cameraState.relativeX + cameraState.relativeWidth * (1 + CAMERA_VIEWPORT_TRESHOLD) - x;
const height = -cameraState.relativeY + cameraState.relativeHeight * (1 + CAMERA_VIEWPORT_TRESHOLD) - y;
const blocks = graph
.getElementsOverRect(
{
x,
y,
width,
height,
}, // 블록 목록을 찾을 영역을 정의
[CanvasBlock] // 카메라 가시 영역에서 찾을 요소 타입을 정의
).map((component) => component.connectedState); // 블록 모델 목록을 가져옴
setBlocks(blocks);
});
useGraphEvent(graph, "camera-change", ({ scale }) => {
if (scale >= 0.7) {
// 스케일이 0.7보다 크면 블록 목록을 업데이트
updateVisibleList()
return;
}
setBlocks([]);
});
return blocks.map(block => <React.Fragment key={block.id}>{renderBlock(graphObject, block)}</React.Fragment>)
}
이제 이 컴포넌트를 사용할 레이어의 정의를 보겠습니다.
import { Layer } from '@gravity-ui/graph';
class ReactLayer extends Layer {
constructor(props: TReactLayerProps) {
super({
html: {
zIndex: 3, // 레이어를 다른 레이어 위로 올림
classNames: ["no-user-select"], // 텍스트 선택을 비활성화하는 클래스 추가
transformByCameraPosition: true, // 카메라에 바인딩: 카메라와 함께 움직임
},
...props,
});
}
public renderPortal(renderBlock: <T extends TBlock>(block: T) => React.JSX.Element) {
if (!this.getHTML()) {
return null;
}
const htmlLayer = this.getHTML() as HTMLDivElement;
return createPortal(
React.createElement(BlocksList, {
graph: this.context.graph,
renderBlock: renderBlock,
}),
htmlLayer,
);
}
}
이제 애플리케이션에서 이 레이어를 사용할 수 있습니다.
import { Flex } from "@gravity-ui/uikit";
const graph = useMemo(() => new Graph());
const containerRef = useRef<HTMLDivElement>();
useEffect(() => {
if (containerRef.current) {
graph.attach(containerRef.current);
}
return () => {
graph.detach();
};
}, [graph, containerRef]);
const reactLayer = useLayer(graph, ReactLayer, {});
const renderBlock = useCallback((graph, block) => <Block graph={graph} block={block}>{block.name}</Block>)
return (
<div>
<div style={{ position: "absolute", overflow: "hidden", width: "100%", height: "100%" }} ref={containerRef}>
{graph && reactLayer && reactLayer.renderPortal(renderBlock)}
</div>
</div>
);
전반적으로 꽤 간단합니다. 위에서 설명한 것 중 어느 것도 직접 작성할 필요가 없습니다. 이미 모두 구현되어 있고 바로 사용할 수 있습니다.
그래프 라이브러리: 장점과 사용 방법
라이브러리 개발을 시작할 때 가장 큰 질문은 이것이었습니다: 개발자가 성능과 개발 편의성 사이에서 선택하지 않게 하려면 어떻게 해야 할까? 답은 그 선택을 자동화하는 것이었습니다.
장점
성능 + 편의성
@gravity‑ui/graph는 스케일에 따라 Canvas와 HTML 사이를 자동으로 전환합니다. 즉, 다음을 얻을 수 있습니다:
- 수천 개 요소 그래프에서도 안정적인 60 FPS.
- 상세 보기에서는 풍부한 인터랙션을 가진 완전한 HTML 컴포넌트를 사용할 수 있음.
- 렌더링 방식과 무관한 단일 이벤트 모델 — click, mouseenter가 Canvas와 HTML에서 동일하게 동작.
UI 라이브러리와의 호환성
중요한 장점 중 하나는 어떤 UI 라이브러리와도 호환된다는 점입니다. 팀이 다음을 사용하더라도:
- Gravity UI,
- Material‑UI,
- Ant Design,
- 커스텀 컴포넌트.
… 포기할 필요가 없습니다! 그래프를 확대하면 자동으로 HTML 모드로 전환되며, 원하는 컬러 테마로 Button, Select, DatePicker 같은 привыч한 컴포넌트가 일반 React 앱과 똑같이 동작합니다.
프레임워크 비의존(Framework agnostic)
기본 HTML 렌더러는 React로 구현했지만, 라이브러리 자체는 가능한 한 프레임워크에 의존하지 않도록 설계했습니다. 필요하다면 любим한 프레임워크를 위한 통합 레이어를 비교적 쉽게 구현할 수 있습니다.
대안은 없을까?
현재 그래프를 그리는 솔루션은 시장에 꽤 많습니다. 유료 솔루션으로는 yFiles, JointJS가 있고, 오픈소스로는 Foblex Flow, baklavajs, jsPlumb 등이 있습니다. 하지만 비교 대상으로는 가장 대중적인 도구인 @antv/g6와 React Flow를 살펴보겠습니다. 각각 고유한 특징이 있습니다.
React Flow는 node‑based 인터페이스를 구축하는 데 최적화된 좋은 라이브러리입니다. 기능은 매우 강력하지만 SVG와 HTML을 사용하기 때문에 성능은 비교적 제한적입니다. 그래프가 100–200 블록을 넘지 않을 것이라는 확신이 있을 때 좋습니다.
반면 @antv/g6는 기능이 매우 많고 Canvas를 지원하며 특히 WebGL도 지원합니다. @antv/g6와 @gravity‑ui/graph를 직접 비교하는 것은 아마 적절하지 않을 수 있습니다. 그쪽은 그래프와 다이어그램 구축에 더 초점을 맞추고 있지만 node‑based UI도 지원합니다. 그래서 node‑based UI뿐 아니라 차트/다이어그램까지 그리고 싶다면 antv/g6가 مناسب할 수 있습니다.
@antv/g6는 canvas/webgl과 html/svg를 모두 할 수 있지만, 모드 전환 규칙은 직접 관리해야 하며, 이를 올바르게 구성해야 합니다. 성능 측면에서는 React Flow보다 훨씬 빠르지만 여전히 의문점이 있습니다. WebGL 지원을 표방하지만 그들의 스트레스 테스트를 보면 60k 노드에서 동적인 성능을 제공하지 못하는 것이 보입니다 — MacBook M3에서 한 프레임 렌더링이 4초 걸렸습니다. 비교를 위해, 동일한 Macbook M3에서 우리 스트레스 테스트는 111k 노드와 109k 연결을 포함하며, 전체 그래프 씬 렌더링이 ~60ms로 ~15–20FPS를 제공합니다. 아주 높은 수치는 아니지만 Spatial Culling 덕분에 viewport를 제한해 반응성을 개선할 수 있습니다. maintainer들이 100k 노드를 30 FPS로 렌더링하고 싶다고 밝혔지만, 현재로선 달성하지 못한 것으로 보입니다.
또 하나 @gravity‑ui/graph가 이기는 지점은 번들 크기입니다.
|
Bundle size Minified |
Bundle size Minified + Gzipped |
|
|
@antv/g6 bundlephobia |
1.1 MB |
324.5 kB |
|
react flow bundlephobia |
181.2 kB |
56.4 kB |
|
@gravity-ui/graph bundlephobia |
2.2 kB |
672 B |
두 라이브러리는 각각 성능 또는 통합 편의성 측면에서 강력하지만, @gravity‑ui/graph는 여러 장점이 있습니다 — 매우 큰 그래프에서도 성능을 보장하면서 사용자 UI/UX를 유지하고 개발을 단순화할 수 있습니다.
향후 계획
이미 지금도 라이브러리는 대부분의 작업에 충분한 성능 여유를 가지고 있습니다. 그래서 가까운 시일 내에는 라이브러리 주변 생태계 확장에 더 집중하려 합니다. 레이어(플러그인) 개발, 다른 라이브러리/프레임워크(Angular/Vue/Svelte 등)용 통합, 터치 디바이스 지원, 모바일 브라우저 대응, 그리고 전반적인 UX/DX 개선을 진행할 예정입니다.
직접 써보고 함께하세요
저장소에는 완전히 동작하는 라이브러리가 들어 있습니다:
- Canvas + R‑Tree 기반 코어(≈ 코드 30K 라인),
- React 통합,
- 예제가 있는 Storybook.
설치는 한 줄이면 됩니다:
npm install @gravity-ui/graph
지금 @gravity‑ui/graph로 불리는 이 라이브러리는 오랫동안 Nirvana 내부 도구였고, 선택한 접근은 실전에서 잘 검증되었습니다. 이제 우리는 우리의 성과를 공유해, 외부 개발자들이 자신의 그래프를 더 쉽게, 더 빠르게, 더 높은 성능으로 그릴 수 있도록 돕고 싶습니다.
오픈소스 커뮤니티에서 복잡한 그래프 표시 방식을 표준화하고 싶습니다. 너무 많은 팀이 바퀴를 다시 만들거나, 맞지 않는 도구로 고생하고 있습니다.
그래서 여러분의 피드백이 매우 중요합니다. 프로젝트마다 서로 다른 엣지 케이스가 있고, 그것이 라이브러리 발전에 도움이 됩니다. 이는 라이브러리를 개선하고 Gravity UI 생태계를 더 빠르게 성장시키는 데 도움이 될 것입니다.

안드레이 셰체티닌
시니어 인터페이스 개발자
이 글에서 다루는 내용: