Figma와 Konva의 캔버스 렌더링 방식 비교
디자인 툴이나 캔버스 기반 에디터를 만들 때 가장 먼저 마주하는 질문이 있다. "어떻게 렌더링할 것인가?" Figma는 WebGL을, Konva.js는 Canvas 2D API를 선택했다. 둘 다 HTML의 <canvas> 요소를 사용하지만 내부 동작 방식은 완전히 다르다.
Figma의 접근: WebGL + WebAssembly
Figma가 2015년에 출시될 때 대부분의 디자인 툴은 네이티브 데스크톱 앱이었다. 웹에서 Sketch 수준의 성능을 내려면 브라우저의 기본 렌더링 방식으로는 부족했다.
왜 HTML/SVG를 쓰지 않았나
브라우저가 제공하는 렌더링 옵션은 HTML, SVG, Canvas 2D 세 가지다. Figma 팀은 셋 다 불만족스러웠다고 한다.
HTML과 SVG는 DOM 접근 비용이 크다. 매번 요소를 조작할 때마다 브라우저의 레이아웃 계산이 다시 돌아간다. 스크롤에는 최적화되어 있지만 줌(zoom) 동작에서는 geometry가 매번 재계산된다. 무엇보다 GPU 가속이 보장되지 않는다.
Canvas 2D는 낫지만 여전히 CPU 기반이다. 복잡한 벡터 그래픽을 수천 개 그리면 버벅인다.
WebGL 선택
그래서 Figma는 WebGL을 선택했다. 원래 3D 게임용으로 설계된 API인데, 2D 디자인 툴에 적용한 거다. GPU와 직접 통신하니까 브라우저의 HTML 렌더링 파이프라인을 완전히 우회한다.
렌더러 자체는 C++로 작성하고 WebAssembly로 컴파일해서 브라우저에서 돌린다. 로드 시간이 3배 이상 빨라졌다고 한다.
[C++ 렌더러] → [Emscripten] → [WebAssembly] → [WebGL API] → [GPU]
재밌는 건 Figma가 내부적으로 "브라우저 안의 브라우저" 같은 구조를 갖고 있다는 점이다. 자체 DOM, 자체 compositor, 자체 텍스트 레이아웃 엔진까지 만들었다.
좌표 시스템의 이해
WebGL로 뭔가를 그리려면 좌표 변환을 이해해야 한다. 캔버스에 사각형 하나 그리는 것도 여러 좌표 공간을 거친다.
캔버스 좌표 → 화면 픽셀
Figma 같은 무한 캔버스 앱은 두 개의 좌표계가 있다.
캔버스 좌표(Canvas Coordinates) 는 문서 내 객체의 "실제" 위치다. 사용자가 Frame을 (1500, 2000) 위치에 뒀다면 그게 캔버스 좌표다. 이론상 무한대로 확장 가능하다.
스크린 좌표(Screen Coordinates) 는 브라우저 화면의 픽셀 위치다. 1920x1080 모니터라면 (0,0)부터 (1919, 1079)까지.
이 둘을 연결하는 게 카메라(Camera) 다.
interface Camera {
x: number; // 팬 오프셋 X
y: number; // 팬 오프셋 Y
z: number; // 줌 레벨 (1 = 100%, 2 = 200%)
}
변환 공식은 간단하다.
// 화면 클릭 위치 → 문서 내 위치
function screenToCanvas(screenPoint: Point, camera: Camera): Point {
return {
x: screenPoint.x / camera.z - camera.x,
y: screenPoint.y / camera.z - camera.y,
};
}
// 문서 내 위치 → 화면에 그릴 위치
function canvasToScreen(canvasPoint: Point, camera: Camera): Point {
return {
x: (canvasPoint.x + camera.x) * camera.z,
y: (canvasPoint.y + camera.y) * camera.z,
};
}
예를 들어 카메라가 { x: -100, y: -50, z: 2 }이고(200% 줌, 우하단으로 스크롤) 문서 내 사각형이 (300, 200)에 있다면, 화면에는 (400, 300) 픽셀 위치에 그려진다.
WebGL의 Clip Space
WebGL은 조금 특이한 좌표계를 쓴다. 캔버스 크기가 몇이든 상관없이 -1에서 +1 사이의 정규화된 좌표 만 받는다. 이걸 Clip Space라고 부른다.
캔버스 좌표 (1500, 2000)
↓ Camera Transform
View 좌표
↓ Projection
Clip Space (-0.3, 0.5) ← WebGL이 이해하는 좌표
↓ Viewport Transform
Screen 픽셀 (672, 270)
Vertex Shader에서 이 변환을 처리한다. 캔버스 좌표를 받아서 Clip Space 좌표로 바꿔주는 거다.
Konva.js의 접근: Canvas 2D + Dual Canvas
Konva는 WebGL 대신 Canvas 2D API를 쓴다. WebGL보다 느리지만 훨씬 쓰기 쉽다.
Scene Graph 구조
Konva는 DOM과 비슷한 트리 구조로 객체를 관리한다.
Stage (컨테이너)
└── Layer (캔버스)
├── Group
│ ├── Rect
│ └── Text
└── Circle
이게 실제 DOM에 어떻게 반영되는지 보면 재밌다. 각 Layer마다 캔버스가 두 개 생긴다.
<div class="konvajs-content">
<!-- 보이는 캔버스 -->
<canvas width="800" height="600"></canvas>
<!-- 숨겨진 캔버스 -->
<canvas width="800" height="600" style="display:none"></canvas>
</div>
Dual Canvas 시스템
두 번째 숨겨진 캔버스가 Konva의 핵심 트릭이다. 이걸 Hit Canvas 라고 부른다.
Scene Canvas에는 사용자가 보는 그대로 그린다. 빨간 사각형, 파란 원, 검은 텍스트.
Hit Canvas에는 같은 도형을 그리되, 각 객체를 고유한 색상으로 칠한다. 빨간 사각형은 #ff0001, 파란 원은 #ff0002 식으로. 이 색상이 곧 객체의 ID다.
마우스 클릭이 들어오면 Hit Canvas에서 해당 픽셀의 색상을 읽는다. 색상이 #ff0002면 파란 원을 클릭한 거다.
function getIntersection(x: number, y: number) {
// Hit Canvas에서 픽셀 색상 읽기
const pixel = hitCanvasContext.getImageData(x, y, 1, 1).data;
const colorKey = rgbToHex(pixel[0], pixel[1], pixel[2]);
// 색상으로 Shape 찾기
return shapeRegistry[colorKey];
}
RGB 색상은 256³ = 16,777,216가지 조합이 가능하니까 천만 개 이상의 객체를 구분할 수 있다.
Layer 분리의 이점
여러 Layer를 쓰면 성능상 이점이 있다. 배경처럼 변하지 않는 요소는 한 Layer에, 애니메이션 되는 요소는 다른 Layer에 넣으면 된다.
const backgroundLayer = new Konva.Layer();
const animationLayer = new Konva.Layer();
// 애니메이션할 때 animationLayer만 다시 그림
function animate() {
movingShape.x(movingShape.x() + 1);
animationLayer.batchDraw(); // backgroundLayer는 건드리지 않음
requestAnimationFrame(animate);
}
다만 Layer를 너무 많이 만들면 안 된다. 각 Layer마다 캔버스 두 개씩 생기니까 메모리를 먹는다. 공식 문서에서는 3~5개를 권장한다.
Konva의 한계: 크로스 레이어 선택
Konva에서 Layer를 여러 개 쓰면 한 가지 문제가 생긴다. 서로 다른 Layer에 있는 객체를 동시에 선택해서 변형하기가 어렵다.
Konva의 Transformer는 선택된 객체에 리사이즈/회전 핸들을 붙여주는 컴포넌트다. 그런데 Transformer 자체도 하나의 Shape이라서 특정 Layer에 추가되어야 한다.
const layer1 = new Konva.Layer();
const layer2 = new Konva.Layer();
const rect = new Konva.Rect({ ... });
const circle = new Konva.Circle({ ... });
layer1.add(rect);
layer2.add(circle);
// Transformer는 어느 Layer에?
const transformer = new Konva.Transformer();
layer1.add(transformer); // layer1에 추가
// rect는 선택 가능, circle은?
transformer.nodes([rect, circle]); // 문제 발생
Layer가 다르면 Transformer가 제대로 동작하지 않거나 예상치 못한 결과가 나온다.
우회 방법
몇 가지 해결책이 있다.
방법 1: 선택 시 임시로 같은 Layer로 이동
function selectNodes(nodes: Konva.Node[]) {
nodes.forEach(node => {
node._originalParent = node.getParent();
node.moveTo(selectionLayer);
});
transformer.nodes(nodes);
}
function deselectNodes(nodes: Konva.Node[]) {
nodes.forEach(node => {
node.moveTo(node._originalParent);
});
}
문제는 이동하는 순간 z-index가 깨진다. 모든 선택된 객체가 최상위로 올라가버린다.
방법 2: Layer를 하나만 쓰고 Group으로 논리 분리
const layer = new Konva.Layer();
const backgroundGroup = new Konva.Group();
const shapesGroup = new Konva.Group();
const textGroup = new Konva.Group();
layer.add(backgroundGroup, shapesGroup, textGroup);
// 같은 Layer 안이니까 Transformer가 잘 동작
const transformer = new Konva.Transformer();
layer.add(transformer);
디자인 툴처럼 자유로운 선택이 필요하다면 이 방식이 낫다.
WebGL 직접 구현, 얼마나 어려울까
"Konva 같은 라이브러리 없이 WebGL로 직접 만들면 어떨까?" 생각할 수 있다. 결론부터 말하면, 도형 그리기는 할 만한데 에디터 인터랙션 에서 일이 커진다.
도형별 난이도
Box (사각형) 는 쉽다. WebGL은 삼각형만 그릴 수 있는데, 사각형은 삼각형 2개로 만들면 된다. WebGL 튜토리얼 수준이다.
Circle (원) 도 해볼 만하다. SDF(Signed Distance Field) 기법을 쓰면 Fragment Shader에서 깔끔하게 그릴 수 있다.
void main() {
float dist = distance(gl_FragCoord.xy, u_center);
float edge = fwidth(dist);
float alpha = 1.0 - smoothstep(u_radius - edge, u_radius + edge, dist);
gl_FragColor = vec4(u_color.rgb, u_color.a * alpha);
}
Text는 완전히 다른 세계다. 폰트 파일 파싱, 베지어 곡선 렌더링, 텍스트 레이아웃(자간, 행간, 줄바꿈), 힌팅까지. 혼자서 만들기엔 몇 달이 걸린다. 현실적인 방법은 Canvas 2D로 텍스트를 렌더링한 뒤 텍스처로 변환하거나, MSDF 같은 기법을 쓰는 거다.
진짜 문제: 에디터 인터랙션
WebGL은 픽셀만 그릴 뿐 이벤트 시스템이 없다. 클릭하면 "캔버스를 클릭했다"만 알지, "어떤 도형을 클릭했는지"는 직접 계산해야 한다.
직접 구현해야 하는 것들:
// 1. Hit Testing - 클릭한 게 뭔지 판별
function hitTest(mouseX: number, mouseY: number, shapes: Shape[]): Shape | null {
for (let i = shapes.length - 1; i >= 0; i--) {
const shape = shapes[i];
if (shape.type === 'rect') {
if (mouseX >= shape.x && mouseX <= shape.x + shape.width &&
mouseY >= shape.y && mouseY <= shape.y + shape.height) {
return shape;
}
}
// 원, 회전된 도형 등 케이스별로 추가...
}
return null;
}
// 2. 다중 선택
// 3. 드래그 이동
// 4. 리사이즈 핸들 8개 (nw, n, ne, e, se, s, sw, w)
// 5. 회전
// 6. Undo/Redo
// 7. 스냅핑, 가이드라인
// ...
회전된 사각형의 hit test만 해도 로컬 좌표 변환이 필요하다. 리사이즈할 때 비율 유지, 여러 요소 동시 변형, 중심점 기준 회전 같은 로직은 생각보다 복잡하다.
대략적인 코드량을 비교하면:
WebGL 직접 구현:
├── 렌더링 (Shader, Buffer) ~500줄
├── Hit Testing ~200줄
├── Selection Manager ~300줄
├── Drag/Move ~200줄
├── Resize ~400줄
├── Rotate ~200줄
└── Undo/Redo, 기타 ~800줄
총 ~2,600줄 이상
Konva 사용:
const tr = new Konva.Transformer();
tr.nodes([shape1, shape2]);
총 ~50줄
PixiJS라는 중간 지점
PixiJS는 WebGL 래퍼 라이브러리다. 렌더링과 이벤트 시스템은 제공하지만, 에디터 로직(선택, 리사이즈, Transformer)은 없다.
const rect = new PIXI.Graphics();
rect.beginFill(0xff0000);
rect.drawRect(0, 0, 100, 100);
rect.eventMode = 'static';
// 이벤트는 됨
rect.on('pointerdown', () => console.log('클릭!'));
// 이런 건 없음
rect.draggable = true; // ❌
new PIXI.Transformer([rect]); // ❌
드래그, 리사이즈 같은 건 여전히 직접 만들어야 한다. 다만 순수 WebGL보다는 시작점이 낫다.
순수 WebGL: 렌더링 + 이벤트 + 에디터 로직 전부 직접
PixiJS: 렌더링 + 이벤트는 해결, 에디터 로직은 직접
Konva: 렌더링 + 이벤트 + Transformer까지 제공
WebGL 성능이 필요하면서 인터랙션을 직접 컨트롤하고 싶을 때 PixiJS가 선택지가 된다.
정리: 언제 뭘 써야 하나
| 기준 | 순수 WebGL | PixiJS | Konva |
|---|---|---|---|
| 렌더링 성능 | 최고 (GPU) | 좋음 (GPU) | 보통 (CPU) |
| 구현 난이도 | 매우 높음 | 높음 | 낮음 |
| 이벤트 처리 | 직접 | 내장 | 내장 |
| Transformer | 직접 | 직접 | 내장 |
| 적합한 상황 | 대규모, 커스텀 필요 | 중규모, 성능+자유도 | 빠른 개발, 프로토타입 |
수천 개의 객체를 다루거나 60fps 애니메이션이 필수라면 WebGL 계열이 맞다. 하지만 프로토타입이나 중소규모 에디터라면 Konva로 충분하다.
직접 WebGL 렌더러를 만드는 건 정말 큰 작업이다. 렌더링보다 에디터 인터랙션 구현 에 시간이 더 들어간다. "WebGL 공부 목적"이 아니라면 검증된 라이브러리를 쓰는 게 현명하다.
Konva를 쓴다면 Layer 분리는 신중하게 하자. 성능 최적화를 위해 나눴다가 선택/변형 기능에서 발목 잡힐 수 있다.