KChart 개발 (Codex)

차트 하나 그리려고 SVG에서 Canvas로, 다시 WebGL까지 간 이야기

Kenneth Kang · 자유 · 3시간 전

차트 하나 그리려고 SVG에서 Canvas로, 다시 WebGL까지 간 이야기

TypeScript로 차트 엔진을 하나 만들어 오래 유지하고 있습니다(@keneth80/k-chart). 처음엔 D3 기반 SVG로 시작했는데, 데이터가 늘어나면서 렌더링 방식을 두 번 갈아엎게 됐습니다. SVG → Canvas → WebGL. 각 단계에서 왜 벽을 만났고 왜 다음으로 넘어갔는지 적어봅니다.

1. SVG — 예쁘지만 DOM이 발목을 잡는다

처음은 SVG였습니다. 이유는 분명합니다. 깔끔하고, 다루기 쉽습니다. 점 하나가 <circle>, 선 하나가 <path>. 브라우저가 알아서 렌더하고, CSS로 스타일 주고, 이벤트도 DOM에 그대로 붙습니다. 축·범례·툴팁 같은 걸 붙이기에 SVG만큼 편한 게 없습니다.

문제는 데이터 하나당 DOM 노드 하나라는 점입니다.

점 100개는 아무 문제 없습니다. 1,000개도 버팁니다. 그런데 수천 개를 넘어가면서 브라우저가 눈에 띄게 느려집니다. DOM 노드가 수천, 수만 개가 되면 브라우저는 그걸 전부 관리해야 합니다. 레이아웃 계산, 리페인트, 이벤트 바인딩 — 전부 노드 수에 비례해 무거워집니다. 데이터를 갱신할 때마다 DOM을 다시 만지니 갱신도 느려집니다.

즉 SVG의 편함이 그대로 한계가 됐습니다. "요소 하나 = 노드 하나"라서 편했던 게, 요소가 많아지자 "노드가 너무 많아서" 느려진 겁니다.

2. Canvas — DOM은 사라졌지만, 이번엔 루프가 발목을 잡는다

그래서 Canvas로 넘어갔습니다.

Canvas는 접근이 완전히 다릅니다. DOM 노드를 만들지 않습니다. 캔버스 하나에 명령으로 그림을 그립니다. context.beginPath(), context.arc(...), context.fill(). 점이 만 개든 십만 개든 DOM 노드는 캔버스 하나뿐입니다. SVG에서 노드 수에 눌리던 문제가 사라졌습니다.

수만 개 단위에서는 훨씬 잘 버텼습니다. 그런데 데이터가 계속 커졌습니다. 몇십만, 몇백만 건을 그려야 하는 상황이 오자 Canvas도 한계가 보였습니다.

Canvas는 결국 점 하나하나를 루프로 돌면서 그리기 API를 호출하는 구조입니다.

data.forEach((point) => {
    context.beginPath();
    context.arc(x(point), y(point), r, 0, Math.PI * 2);
    context.fill();
});

몇백만 건이면 이 루프가 몇백만 번 돕니다. 그리고 그 안에서 매번 그리기 API를 호출합니다. 이게 메인 스레드에서, 한 번의 렌더링마다 일어납니다. 데이터를 갱신하거나 화면을 확대할 때마다 이 몇백만 번의 루프를 다시 돌아야 하니, 프레임이 뚝뚝 끊깁니다.

DOM 노드 문제는 풀렸는데, 이번엔 **"CPU가 점을 하나씩 순회하며 그린다"**는 게 병목이 된 겁니다.

3. WebGL — 데이터를 GPU에 넘긴다

그래서 마지막으로 WebGL로 넘어갔습니다.

여기서 관점이 바뀝니다. Canvas 2D는 CPU가 점을 하나씩 그렸습니다. WebGL은 좌표 데이터를 통째로 GPU 버퍼에 올리고, GPU가 병렬로 그립니다. 수십만 개 점을 버퍼 하나로 올리면, draw call 한 번으로 GPU가 한꺼번에 처리합니다.

k-chart에서 12만 개짜리 라인을 WebGL LINE_STRIP으로 그리는 게 이 이유입니다. Canvas 2D로는 매 프레임 12만 번 루프를 돌며 그리기 API를 호출해야 하지만, WebGL은 좌표를 미리 clip space로 변환해 버퍼 하나로 올린 뒤 draw call 한 번으로 끝냅니다.

// Canvas 2D — 점을 하나씩 루프로 그린다
data.forEach((point) => {
    context.lineTo(x(point), y(point));
});
// 12만 개면 12만 번, 그것도 매 프레임마다

// WebGL — 좌표를 clip space로 변환해 버퍼 하나로 올린다
const vertices = resolveWebglLineVertices(renderPoints, canvas.width, canvas.height);

const positionBuffer = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, positionBuffer);
gl.bufferData(gl.ARRAY_BUFFER, vertices, gl.STATIC_DRAW);
// ...attribute 연결...
gl.drawArrays(gl.LINE_STRIP, 0, vertices.length / 2);  // draw call 한 번

차이는 마지막에 있습니다. Canvas는 점 수만큼 그리기 호출이 일어나지만, WebGL은 좌표를 버퍼에 한 번 올린 뒤 draw call 한 번으로 GPU에 넘깁니다. 나머지는 GPU가 병렬로 처리합니다. 라인 하나는 정점 스트림 하나라, 아무리 큰 라인이어도 그리기는 한 번이면 됩니다.

물론 WebGL은 SVG의 편함을 잃습니다. 셰이더를 다뤄야 하고, 좌표를 clip space로 직접 변환해야 하고, 디버깅도 까다롭습니다. 얻는 게 있으면 잃는 게 있습니다.

그래서 — 셋을 다 쓰기로 했다

세 번 겪고 나서 내린 결론은, 하나만 고르는 게 아니라 필요에 따라 섞는 것이었습니다.

k-chart가 하이브리드 렌더링 엔진이 된 이유입니다. 축·scale·layout 계산은 코어가 하고, 실제 그리기는 상황에 맞는 렌더러가 맡습니다.

  • 축·범례·툴팁처럼 요소가 적고 상호작용이 중요한 것 → 여전히 SVG. DOM의 편함이 유리합니다.
  • 중간 규모 데이터Canvas. DOM 없이 가볍게.
  • 수십만 건 이상WebGL. GPU에 맡깁니다.

같은 차트 안에서 SVG 축 위에 WebGL 라인을 얹을 수 있습니다. 각 렌더러는 코어가 계산한 같은 scale 정보를 받아 자기 방식으로 그립니다.

데이터가 많을 때 추가로 쓰는 장치도 있습니다. LTTB 다운샘플링 — 화면 폭이 800픽셀인데 12만 개 점을 다 그릴 이유가 없습니다. 시각적으로 중요한 점만 골라 그리기 직전에 줄입니다(원본 데이터는 그대로 둡니다). 그리고 OffscreenCanvas + 워커 — Canvas/WebGL 그리기를 워커 스레드로 보내 메인 스레드가 안 막히게 합니다.

남은 생각

돌아보면 각 전환은 "편함"과 "규모"의 맞바꿈이었습니다.

SVG는 제일 편하지만 규모에서 무너지고, WebGL은 규모를 감당하지만 편함을 잃습니다. 어느 하나가 정답이 아니라, 데이터 규모가 어디쯤이냐에 따라 답이 달라집니다. 그래서 하나를 고르는 대신 셋을 다 두고, 코어가 그 위에서 같은 좌표계를 공유하게 만들었습니다.

차트 라이브러리를 쓰는 입장에서는 "이거 빠른가?"만 보이지만, 만드는 입장에서는 **"어느 규모까지 편함을 유지할 수 있나, 어디서부터 편함을 포기해야 하나"**의 연속이었습니다. 그 경계를 세 번 넘으면서 지금의 하이브리드 구조가 됐습니다.


직접 만든 오픈소스 차트 엔진 @keneth80/k-chart를 유지하며 겪은 이야기입니다. 관심사 커뮤니티를 만들며 이런 개발 기록을 남기고 있습니다. → galaxyconstellate.com

은하계에서 보기나도 은하계 만들기