Canvas 위에 Konva 한 겹 얹기
비트맵의 한계와 씬 그래프 도입기
개요
비디오 피드백 협업 도구를 만들면서 영상 위에 드로잉 기능이 필요했다. 펜, 사각형, 원, 화살표, 텍스트 5가지 도구를 지원해야 했고, Undo/Redo와 개별 스트로크 관리도 필요했다. Canvas API로 충분할 줄 알았는데, 막상 구현에 들어가니 Canvas만으로는 해결할 수 없는 근본적인 한계가 있었다.
비트맵과 씬 그래프
본격적으로 들어가기 전에, 이 글에서 계속 등장하는 두 개념을 먼저 짚고 가자.
비트맵(Bitmap)은 픽셀의 격자다. 가로 1920개, 세로 1080개의 점이 모여 하나의 이미지를 구성한다. 각 점은 색상 값만 가지고 있을 뿐, "이 점이 사각형의 일부인지 원의 일부인지"는 알지 못한다. 사진 파일(PNG, JPG)이 대표적인 비트맵이다. Canvas API가 바로 이 방식으로 동작한다. 화면에 무언가를 그리면 결과는 픽셀 덩어리로 남고, 그 픽셀이 어떤 도형에서 왔는지는 기록하지 않는다.
씬 그래프(Scene Graph)는 화면에 표시할 객체들을 트리 구조로 관리하는 모델이다. "빨간 원이 좌표 (100, 150)에 반지름 40으로 존재한다"는 정보를 JavaScript 객체로 들고 있다. 렌더링할 때는 이 객체들을 순회하면서 비트맵에 그리지만, 객체 자체는 메모리에 남아있다. 그래서 "세 번째 도형을 삭제하라"는 명령이 가능하다. Konva가 Canvas 위에 씬 그래프를 얹는 방식이다.
핵심은 Konva가 비트맵을 대체하는 게 아니라는 점이다. 내부적으로는 똑같이 Canvas API로 픽셀을 찍는다. 다만 그 위에 객체 모델을 얹어서, 비트맵만으로는 할 수 없는 개별 도형 관리와 이벤트 처리를 가능하게 한다.
Canvas API의 근본적인 한계
Canvas는 비트맵 기반이다. context.stroke()를 호출하는 순간 픽셀이 캔버스에 새겨지고, 그 이후로는 어떤 도형이었는지에 대한 정보가 사라진다.
실제로 구현하면서 부딪힌 문제를 보자.
도형 하나를 지우려면
SVG라면 해당 DOM 노드를 removeChild()하면 끝이다. 하지만 Canvas에는 지울 "노드"가 없다. 할 수 있는 건 전체 캔버스를 clearRect()로 지운 다음, 나머지 도형을 처음부터 다시 그리는 것뿐이다.
// Canvas API로 Undo를 구현하려면,
ctx.clearRect(0, 0, canvas.width, canvas.height);
// 마지막 스트로크를 제외한 나머지를 전부 다시 그려야 한다.
remainingStrokes.forEach((stroke) => drawStroke(ctx, stroke));
스트로크가 100개일 때 하나를 지우려고 99개를 다시 그린다. 스트로크가 늘어날수록 Undo 한 번의 비용이 선형으로 증가한다. 거기다 Redo, 중간 스트로크 삭제, 특정 도형만 색상 변경 같은 기능까지 고려하면, 매번 전체를 다시 그리는 방식은 금방 한계에 부딪힌다.
특정 도형을 선택하려면
드로잉 도구에서는 특정 스트로크를 선택하거나, 마우스 커서가 도형 위에 있을 때 하이라이트를 보여줘야 할 수 있다. SVG라면 각 요소에 addEventListener를 달면 된다. 하지만 Canvas는 하나의 <canvas> 요소가 전부다. 이벤트는 캔버스 전체에 걸리고, 클릭 좌표 (300, 200)을 받았을 때 그 위치에 어떤 도형이 있는지 알아내는 건 전적으로 개발자 몫이다. 이걸 히트 테스팅(Hit Testing)이라 한다.
직접 구현하려면 도형별로 수식이 다르다.
사각형과 원은 그나마 간단하다. 하지만 자유곡선(펜 도구)은? 사용자가 마우스를 움직이며 그린 수백 개의 점으로 이루어진 곡선 위에 마우스가 있는지 판별하려면, 모든 연속된 두 점 사이의 선분과 마우스 좌표 간 거리를 계산해야 한다. 점의 개수를 n이라 하면 O(n)이고, 스트로크가 여러 개면 모든 스트로크를 순회해야 한다.
Konva의 히트 캔버스
Konva는 이 문제를 히트 캔버스(Hit Canvas)로 해결한다. 히트 캔버스란 화면에 보이지 않는 별도의 캔버스를 두고, 그 위에 각 도형을 고유한 색상으로 칠해서 클릭 지점의 픽셀 색상만으로 어떤 도형인지 식별하는 기법이다. 원리는 단순하다.
쉽게 비유하자면, 같은 그림을 두 장 그리는 것과 비슷하다. 한 장은 사용자에게 보여주는 예쁜 그림이고, 다른 한 장은 각 도형에 번호표를 붙인 지도다. 사용자가 그림의 어느 지점을 클릭하면, 뒤에 숨겨둔 지도에서 같은 위치를 찾아 "여기는 3번 도형 영역이군" 하고 바로 알아내는 원리다.
구체적으로는 이렇다. 화면에 보이는 캔버스 뒤에 숨겨진 캔버스를 하나 더 만든다. 이 히트 캔버스에서는 각 도형을 고유한 색상으로 칠한다. 첫 번째 도형은 #000001, 두 번째는 #000002... 색상 자체가 ID 역할을 하는 셈이다. 사용자가 클릭하면,
- 클릭 좌표
(x, y)에서 히트 캔버스의 픽셀 색상을getImageData(x, y, 1, 1)로 읽는다. - 색상 값을 도형 ID로 변환한다.
- 해당 도형 객체를 반환한다.
이 방식은 도형의 복잡도와 무관하게 항상 O(1)이다. 자유곡선이든 복잡한 다각형이든, 결국 픽셀 하나만 읽으면 된다. 도형이 1000개여도 성능은 동일하다.
Konva의 씬 그래프 구조
Konva가 단순히 히트 테스팅만 해결하는 건 아니다. Canvas 위에 씬 그래프를 만든다. Stage → Layer → Shape 계층 구조로, 각 도형이 JavaScript 객체로 존재한다.
Stage (최상위 컨테이너)
├── Layer 1 (도형 레이어)
│ ├── Line (펜 스트로크)
│ ├── Rect (사각형)
│ └── Circle (원)
└── Layer 2 (텍스트 레이어)
└── Text (텍스트)
내부적으로는 결국 Canvas API의 beginPath(), stroke(), fill() 등을 호출한다. 하지만 그 위에 객체 모델을 얹었기 때문에, "세 번째 스트로크를 삭제하라"는 명령이 가능해진다. Canvas API만으로는 불가능했던 일이다.
그러면 SVG를 쓰면 되지 않나?
처음에는 SVG도 고려했다. DOM 기반이라 이벤트 처리도 쉽고, 개별 요소 삭제도 가능하다. 하지만 SVG를 선택하지 않은 이유가 있다.
SVG는 각 도형이 곧 DOM 노드다. <rect>, <circle>, <polyline> 하나하나가 HTML의 <div>와 같은 레벨의 DOM 요소로 존재한다. 도형 10개면 DOM 노드 10개, 100개면 100개다. 자유곡선 하나에 점이 200개면 그 <polyline>의 points 속성에 400개의 좌표값이 들어간다. 스트로크 50개면 DOM에 50개의 SVG 요소가 존재하고, 하나를 수정할 때마다 브라우저는 전체 레이아웃을 다시 계산한다.
Canvas(와 Konva)는 DOM 노드 하나(<canvas>)만 사용한다. 도형이 아무리 많아도 DOM 구조는 변하지 않는다. 브라우저의 레이아웃 엔진에 부하를 주지 않는다.
세 가지 선택지의 트레이드오프를 정리하면 다음과 같다.
| SVG | Canvas API | Konva (Canvas 기반) | |
|---|---|---|---|
| DOM 노드 수 | 도형 수만큼 | 1개 | 1개 |
| 이벤트 처리 | 네이티브 | 직접 구현 | 히트 캔버스 |
| 개별 도형 관리 | DOM 조작 | 불가 | 씬 그래프 |
| 성능 (도형 많을 때) | 느려짐 | 빠름 | 빠름 |
| 번들 크기 | 0 (네이티브) | 0 (네이티브) | ~150KB |
만들고 있던 드로잉 도구는 자유곡선이 핵심이고, 사용자가 많이 그릴수록 도형이 늘어난다. SVG는 이 상황에서 성능 문제가 생길 수밖에 없었다. Canvas의 성능은 유지하면서 객체 관리와 이벤트 처리가 가능한 Konva를 선택했다.
편집 모드와 읽기 모드의 분리
프로젝트에서 한 가지 더 결정해야 할 것이 있었다. 드로잉을 "편집"하는 화면과 "읽기만" 하는 화면이 따로 존재한다는 점이다. 두 화면의 요구사항이 다르기 때문에 같은 렌더링 방식을 쓸 이유가 없었다.
편집 모드에서는 Konva가 필요하다. 도형을 추가/삭제하고, Undo/Redo를 처리하고, 마우스 이벤트에 반응해야 한다. 씬 그래프 없이는 이런 상호작용이 불가능하다.
읽기 모드에서는 저장된 스트로크 데이터를 화면에 표시만 하면 된다. 상호작용이 없다. 이 경우 Konva의 씬 그래프를 메모리에 올릴 이유가 없다. Canvas API로 한번 쭉 그리면 끝이다.
// 읽기 모드: Canvas API 직접 사용
const renderStroke = (ctx: CanvasRenderingContext2D, stroke: StrokeData) => {
ctx.save();
ctx.scale(scale, scale);
ctx.strokeStyle = stroke.color;
ctx.lineWidth = stroke.strokeWidth;
if (stroke.type === "pen") {
ctx.beginPath();
for (let i = 0; i < stroke.points.length; i += 2) {
if (i === 0) ctx.moveTo(stroke.points[i], stroke.points[i + 1]);
else ctx.lineTo(stroke.points[i], stroke.points[i + 1]);
}
ctx.stroke();
}
// rect, circle, arrow, text도 각각 처리
ctx.restore();
};
이 분리가 특히 중요했던 건 읽기 모드의 드로잉이 댓글마다 하나씩 붙기 때문이다. 댓글이 20개이고 각각 드로잉이 있으면 20개의 캔버스 인스턴스가 생긴다. 전부 Konva로 렌더링하면 씬 그래프 20개가 메모리에 올라간다. Canvas API로 직접 그리면 그릴 때만 컨텍스트를 사용하고, 결과는 비트맵으로 남아서 메모리 부담이 훨씬 적다.
정리
Canvas API만으로도 드로잉 도구를 만들 수 있다. 다만 도형 관리, 이벤트 처리, Undo/Redo를 전부 직접 구현해야 한다. Konva는 그 수고를 덜어주되, 읽기 전용처럼 상호작용이 필요 없는 곳에서는 오히려 불필요한 무게가 된다. 그래서 편집 화면에는 Konva를, 읽기 화면에는 Canvas API를 각각 적용했다.