junyeokk
Blog
React Ecosystem·2025. 09. 14

Konva와 react-konva

웹에서 그래픽을 다루는 방법은 크게 두 가지다. DOM 기반(SVG)과 Canvas 기반. SVG는 각 도형이 DOM 노드로 존재해서 이벤트 바인딩이나 스타일링이 쉽지만, 노드 수가 수백 개를 넘어가면 DOM 트리가 비대해지면서 렌더링 성능이 급격히 떨어진다. Canvas는 픽셀 단위로 직접 그리기 때문에 수천 개의 도형도 빠르게 렌더링할 수 있지만, 그려진 결과물은 그냥 비트맵이라 "이 사각형을 클릭했는지" 같은 인터랙션을 직접 구현해야 한다.

Konva는 이 Canvas의 성능과 SVG의 편의성 사이에서 균형을 잡는 라이브러리다. Canvas 위에 가상의 노드 트리를 만들어서, 각 도형에 이벤트 리스너를 달고, 드래그 앤 드롭을 걸고, 레이어별로 관리할 수 있게 해준다. react-konva는 이 Konva를 React 컴포넌트로 래핑한 것이다.


Canvas API의 한계

Canvas API로 직접 그리면 이렇게 된다.

javascript
const canvas = document.getElementById('myCanvas');
const ctx = canvas.getContext('2d');

// 사각형 하나 그리기
ctx.fillStyle = 'red';
ctx.fillRect(50, 50, 100, 80);

// 원 하나 그리기
ctx.beginPath();
ctx.arc(250, 90, 40, 0, Math.PI * 2);
ctx.fillStyle = 'blue';
ctx.fill();

여기서 문제가 시작된다. "사용자가 빨간 사각형을 클릭했는지" 어떻게 알 수 있을까? Canvas는 그냥 픽셀을 찍었을 뿐이라 클릭 좌표를 받아서 (50, 50) ~ (150, 130) 범위 안인지 직접 계산해야 한다. 원이면 피타고라스 정리로 거리 계산을 해야 하고, 도형이 겹쳐 있으면 z-order까지 관리해야 한다. 드래그 앤 드롭? 매 프레임마다 전체를 지우고 다시 그려야 한다.

이걸 도형 수십 개에 대해 하려면 결국 "장면 그래프(scene graph)"를 직접 구현하게 된다. 각 도형의 위치, 크기, 회전, 투명도, 이벤트 핸들러를 관리하는 트리 구조. Konva가 바로 이 장면 그래프를 제공한다.


Konva의 노드 구조

Konva는 Stage → Layer → Group/Shape의 계층 구조를 사용한다.

text
Stage (전체 캔버스 영역)
├── Layer 1 (배경)
│   ├── Rect (배경 사각형)
│   └── Image (배경 이미지)
├── Layer 2 (드로잉)
│   ├── Line (펜 스트로크)
│   ├── Circle
│   └── Arrow
└── Layer 3 (UI 오버레이)
    └── Text (라벨)

Stage는 하나의 <canvas> 요소가 아니라 여러 개의 <canvas>를 감싸는 컨테이너다. 각 Layer가 별도의 <canvas> 엘리먼트를 가진다. 이게 핵심 최적화 포인트다. 배경 레이어는 가만히 있고 드로잉 레이어만 업데이트해야 할 때, 드로잉 레이어의 canvas만 다시 그리면 된다. 전체를 매번 다시 그릴 필요가 없다.

다만 레이어가 많을수록 DOM에 <canvas> 요소가 늘어나므로, 3~5개 이하로 유지하는 게 좋다. 같은 레이어 안에서의 그룹핑은 Group으로 한다. Group은 별도 canvas를 만들지 않고 논리적으로 묶기만 한다.

Shape은 실제로 화면에 그려지는 도형이다. Konva가 기본 제공하는 Shape은 꽤 많다.

Shape설명
Rect사각형, cornerRadius로 둥근 모서리
Circle
Ellipse타원
Line직선, 폴리라인, 폴리곤
Arrow화살표 (Line의 확장)
Text텍스트
Image이미지/비디오
PathSVG path 데이터로 커스텀 도형
Star
Ring도넛
RegularPolygon정다각형

react-konva 기본 사용법

react-konva를 쓰면 Konva의 명령형 API 대신 선언적으로 작성할 수 있다.

bash
npm install konva react-konva
tsx
import { Stage, Layer, Rect, Circle, Text } from 'react-konva';

function Canvas() {
  return (
    <Stage width={800} height={600}>
      <Layer>
        <Rect
          x={50}
          y={50}
          width={100}
          height={80}
          fill="red"
          draggable
          onClick={() => console.log('사각형 클릭')}
        />
        <Circle
          x={250}
          y={90}
          radius={40}
          fill="blue"
        />
        <Text
          x={50}
          y={150}
          text="Hello Konva"
          fontSize={20}
        />
      </Layer>
    </Stage>
  );
}

Konva의 순수 JS API로 같은 걸 만들려면 new Konva.Stage(...), new Konva.Layer(), layer.add(rect) 같은 명령형 코드를 써야 한다. react-konva는 내부적으로 React의 커스텀 렌더러(react-reconciler)를 사용해서, JSX로 선언한 컴포넌트 트리를 Konva 노드 트리로 변환한다. React의 상태 관리와 라이프사이클이 그대로 적용되므로, state가 바뀌면 해당 Konva 노드만 업데이트된다.

중요한 점: react-konva 컴포넌트는 DOM 엘리먼트가 아니다. <Rect><div><rect>(SVG)가 아니라 Konva.Rect 인스턴스에 대한 React 래퍼다. 그래서 일반 HTML/CSS 속성은 안 먹고, Konva의 속성만 사용할 수 있다.


이벤트 처리

Canvas API에서 가장 고통스러운 히트 테스팅을 Konva가 대신 해준다. 각 Shape에 직접 이벤트 핸들러를 달면 된다.

tsx
<Rect
  x={50}
  y={50}
  width={100}
  height={80}
  fill="red"
  onClick={(e) => {
    const shape = e.target;
    console.log('클릭 좌표:', e.evt.clientX, e.evt.clientY);
    console.log('도형:', shape.attrs);
  }}
  onMouseEnter={(e) => {
    e.target.getStage()!.container().style.cursor = 'pointer';
  }}
  onMouseLeave={(e) => {
    e.target.getStage()!.container().style.cursor = 'default';
  }}
/>

Konva가 내부적으로 히트 테스팅을 수행하는 방식이 재미있다. 별도의 "히트 캔버스"를 유지하면서, 각 도형을 고유한 색상으로 그린다. 클릭이 발생하면 해당 좌표의 히트 캔버스 픽셀 색상을 읽어서 어떤 도형인지 식별한다. 이 방식 덕분에 복잡한 Path 도형이나 투명도가 있는 도형에서도 정확한 히트 테스팅이 가능하다.

지원하는 이벤트 목록:

text
click, dblclick, mousedown, mouseup, mousemove
mouseenter, mouseleave, mouseover, mouseout
touchstart, touchmove, touchend, tap, dbltap
dragstart, dragmove, dragend
wheel

dragstart/dragmove/dragenddraggable prop을 true로 설정하면 자동으로 활성화된다. 별도의 mousedown → mousemove → mouseup 처리 없이 드래그가 된다.


좌표 시스템과 변환

Konva에서 좌표를 다룰 때 가장 헷갈리는 부분이 좌표 공간(coordinate space)이다. 각 노드는 자신만의 로컬 좌표계를 가지고, 부모의 변환(position, scale, rotation)이 누적 적용된다.

tsx
<Stage width={800} height={600}>
  <Layer>
    <Group x={100} y={100} rotation={45}>
      {/* 이 Rect의 실제 화면 위치는 Group의 변환이 적용된 결과 */}
      <Rect x={0} y={0} width={50} height={50} fill="green" />
    </Group>
  </Layer>
</Stage>

마우스 이벤트에서 받는 getPointerPosition()은 Stage 기준 좌표다. 특정 노드의 로컬 좌표로 변환하려면 getRelativePointerPosition()을 사용한다.

tsx
// Stage 기준 좌표
const stagePos = stage.getPointerPosition();
// { x: 350, y: 280 }

// 특정 노드 기준 좌표
const localPos = node.getRelativePointerPosition();
// { x: 50, y: 30 } (노드의 변환을 역산한 결과)

Scale을 적용한 캔버스에서 드로잉할 때 이 구분이 중요하다. 예를 들어 캔버스를 2배로 확대한 상태에서 그린 선의 좌표를 원본 크기 기준으로 저장하려면, 마우스 좌표를 scale로 나눠야 한다.

tsx
const handleMouseDown = (e) => {
  const pos = e.target.getStage().getPointerPosition();
  // scale이 적용된 좌표를 원본 좌표로 변환
  const normalizedPos = {
    x: pos.x / scale,
    y: pos.y / scale
  };
};

드로잉 에디터 구현 패턴

캔버스 드로잉 에디터를 만들 때의 핵심 구조를 살펴보자.

스트로크 데이터 모델

각 드로잉 동작(펜 한 획, 사각형 하나)을 "스트로크"로 모델링한다.

typescript
interface DrawingStroke {
  id: string;
  tool: 'pen' | 'rect' | 'circle' | 'arrow' | 'text';
  points: number[];    // [x1, y1, x2, y2, ...]
  color: string;
  strokeWidth: number;
  text?: string;       // text 도구용
  fontSize?: number;
}

points 배열의 해석은 도구에 따라 다르다.

  • pen: 자유 곡선의 모든 점. [x1, y1, x2, y2, x3, y3, ...]
  • rect/circle/arrow: 시작점과 끝점만. [startX, startY, endX, endY]
  • text: 텍스트 위치. [x, y]

드로잉 흐름

mousedown → mousemove → mouseup 세 단계로 동작한다.

tsx
const handleMouseDown = (e) => {
  const pos = getPosition(e);

  // 새 스트로크 생성
  const newStroke = {
    id: generateId(),
    tool: selectedTool,
    points: [pos.x, pos.y],
    color: currentColor,
    strokeWidth: currentWidth,
  };

  setStrokes(prev => [...prev, newStroke]);
  setIsDrawing(true);
};

const handleMouseMove = (e) => {
  if (!isDrawing) return;
  const pos = getPosition(e);

  setStrokes(prev => {
    const updated = [...prev];
    const last = updated[updated.length - 1];

    if (last.tool === 'pen') {
      // 펜: 점을 계속 추가
      last.points = [...last.points, pos.x, pos.y];
    } else {
      // 도형: 시작점은 유지, 끝점만 갱신
      last.points = [last.points[0], last.points[1], pos.x, pos.y];
    }

    return updated;
  });
};

const handleMouseUp = () => {
  setIsDrawing(false);
  // 히스토리에 현재 상태 저장
  addToHistory(strokes);
};

펜 도구는 mousemove마다 새 점을 추가하고, 도형 도구(사각형, 원, 화살표)는 시작점을 고정한 채 끝점만 갱신한다. 이 차이를 tool 속성으로 분기 처리한다.

도형별 렌더링

스트로크 데이터를 Konva 컴포넌트로 변환하는 부분이다.

tsx
const renderStroke = (stroke: DrawingStroke) => {
  switch (stroke.tool) {
    case 'pen':
      return (
        <Line
          key={stroke.id}
          points={stroke.points}
          stroke={stroke.color}
          strokeWidth={stroke.strokeWidth}
          tension={0.5}      // 곡선 부드럽게
          lineCap="round"    // 선 끝 둥글게
          lineJoin="round"   // 꺾이는 부분 둥글게
        />
      );

    case 'rect': {
      const [x1, y1, x2, y2] = stroke.points;
      return (
        <Rect
          key={stroke.id}
          x={Math.min(x1, x2)}
          y={Math.min(y1, y2)}
          width={Math.abs(x2 - x1)}
          height={Math.abs(y2 - y1)}
          stroke={stroke.color}
          strokeWidth={stroke.strokeWidth}
          fill="transparent"
        />
      );
    }

    case 'circle': {
      const [x1, y1, x2, y2] = stroke.points;
      const radius = Math.sqrt((x2 - x1) ** 2 + (y2 - y1) ** 2);
      return (
        <Circle
          key={stroke.id}
          x={x1}
          y={y1}
          radius={radius}
          stroke={stroke.color}
          strokeWidth={stroke.strokeWidth}
          fill="transparent"
        />
      );
    }

    case 'arrow': {
      const [x1, y1, x2, y2] = stroke.points;
      return (
        <Arrow
          key={stroke.id}
          points={[x1, y1, x2, y2]}
          stroke={stroke.color}
          strokeWidth={stroke.strokeWidth}
          fill={stroke.color}
          pointerLength={10}
          pointerWidth={10}
        />
      );
    }
  }
};

Rect에서 Math.minMath.abs를 쓰는 이유: 사용자가 오른쪽 아래에서 왼쪽 위로 드래그할 수 있다. 이 경우 x2 < x1이 되므로, x와 width를 재계산해야 음수 크기가 되는 걸 방지한다.

Circle에서 반지름은 시작점과 끝점 사이의 거리로 계산한다. 피타고라스 정리 √((x2-x1)² + (y2-y1)²)다.

Undo/Redo

히스토리 관리는 스택 기반으로 구현한다.

typescript
interface CanvasHistory {
  strokes: DrawingStroke[];
  timestamp: number;
}

function useDrawingEditor() {
  const [strokes, setStrokes] = useState<DrawingStroke[]>([]);
  const [history, setHistory] = useState<CanvasHistory[]>([
    { strokes: [], timestamp: Date.now() }
  ]);
  const [historyIndex, setHistoryIndex] = useState(0);

  const addToHistory = (currentStrokes: DrawingStroke[]) => {
    // 현재 인덱스 이후의 히스토리는 버린다 (redo 불가)
    const newHistory = history.slice(0, historyIndex + 1);
    newHistory.push({
      strokes: [...currentStrokes],
      timestamp: Date.now(),
    });

    // 히스토리 최대 50개 제한
    if (newHistory.length > 50) {
      newHistory.shift();
    } else {
      setHistoryIndex(historyIndex + 1);
    }
    setHistory(newHistory);
  };

  const undo = () => {
    if (historyIndex > 0) {
      const newIndex = historyIndex - 1;
      setStrokes(history[newIndex].strokes);
      setHistoryIndex(newIndex);
    }
  };

  const redo = () => {
    if (historyIndex < history.length - 1) {
      const newIndex = historyIndex + 1;
      setStrokes(history[newIndex].strokes);
      setHistoryIndex(newIndex);
    }
  };
  
  // ...
}

핵심 동작: addToHistory에서 현재 인덱스 이후를 잘라내는 이유는, undo 후에 새로운 드로잉을 하면 "미래" 히스토리가 의미 없어지기 때문이다. Word나 Photoshop 같은 편집기도 동일하게 동작한다.

히스토리 크기를 50으로 제한하는 건 메모리 관리 때문이다. 각 히스토리 항목이 전체 스트로크 배열의 스냅샷을 가지므로, 무한히 쌓으면 메모리를 많이 먹는다. 더 효율적인 방법은 "변경 사항(diff)"만 저장하는 것이지만, 구현 복잡도가 올라간다.


Layer 분리 전략

실제 에디터에서는 레이어를 용도별로 분리하는 게 중요하다.

tsx
<Stage width={width} height={height}>
  {/* 레이어 1: 도형 (scale 적용) */}
  <Layer scaleX={scale} scaleY={scale}>
    {strokes.filter(s => s.tool !== 'text').map(renderStroke)}
  </Layer>

  {/* 레이어 2: 텍스트 (scale 직접 관리) */}
  <Layer>
    {strokes.filter(s => s.tool === 'text').map(renderTextStroke)}
  </Layer>
</Stage>

왜 텍스트를 별도 레이어로 분리할까? Layer에 scaleX/scaleY를 적용하면 그 안의 모든 도형이 함께 확대/축소된다. 도형은 이렇게 해도 괜찮지만, 텍스트는 fontSize를 직접 스케일링해야 폰트 렌더링 품질이 유지된다. Layer 스케일로 텍스트를 확대하면 래스터 방식으로 확대되어 흐릿해진다.

tsx
const renderTextStroke = (stroke: DrawingStroke) => {
  const [x, y] = stroke.points;
  const fontSize = stroke.fontSize || 16;
  return (
    <Text
      key={stroke.id}
      x={x * scale}         // 위치는 수동 스케일링
      y={y * scale}
      text={stroke.text}
      fontSize={fontSize * scale}  // 폰트 크기도 수동 스케일링
      fill={stroke.color}
    />
  );
};

Line의 tension 속성

자유 곡선을 그릴 때 tension 속성이 곡선의 부드러움을 결정한다.

tsx
<Line
  points={[0, 0, 50, 80, 100, 20, 150, 90]}
  tension={0}     // 직선 연결 (꺾임)
  tension={0.5}   // 부드러운 곡선
  tension={1}     // 더 부드러운 곡선
/>

tension: 0이면 점과 점 사이를 직선으로 연결한다. 값이 커질수록 카디널 스플라인(cardinal spline) 보간을 적용해서 부드러운 곡선이 된다. 드로잉 에디터에서는 0.5 정도가 자연스러운 손글씨 느낌을 준다. 너무 높으면 원래 경로에서 벗어나는 느낌이 든다.

lineCaplineJoin은 선의 끝과 꺾이는 부분의 형태를 결정한다.

text
lineCap: "butt"    → 선 끝이 딱 잘림 (기본값)
lineCap: "round"   → 선 끝이 둥글게
lineCap: "square"  → 선 끝에 사각형 추가

lineJoin: "miter"  → 꺾이는 부분이 뾰족하게 (기본값)
lineJoin: "round"  → 꺾이는 부분이 둥글게
lineJoin: "bevel"  → 꺾이는 부분이 잘림

펜 도구에서는 round/round 조합이 가장 자연스럽다.


성능 최적화

batchDraw vs draw

Konva에서 layer.draw()를 호출하면 즉시 렌더링된다. 여러 변경을 연속으로 하면서 매번 draw를 호출하면 불필요한 렌더링이 발생한다. layer.batchDraw()는 다음 애니메이션 프레임에서 한 번만 렌더링한다.

javascript
// 비효율적
shapes.forEach(shape => {
  shape.x(shape.x() + 1);
  layer.draw();  // 매번 렌더링
});

// 효율적
shapes.forEach(shape => {
  shape.x(shape.x() + 1);
});
layer.batchDraw();  // 한 번만 렌더링

react-konva에서는 React의 배치 업데이트가 이 역할을 대신하므로, 직접 batchDraw를 호출할 일은 거의 없다.

캐싱

복잡한 도형이나 그룹은 cache()로 래스터 이미지로 변환해서 렌더링 비용을 줄일 수 있다.

javascript
// 복잡한 그룹을 이미지로 캐싱
complexGroup.cache();

// 캐시 무효화 (내용이 변경되면)
complexGroup.clearCache();

캐싱된 노드는 하나의 이미지로 취급되어 매 프레임마다 각 자식 노드를 다시 그리지 않는다. 단, 내용이 자주 변경되는 노드에는 캐싱이 오히려 오버헤드가 된다.

toDataURL로 이미지 내보내기

캔버스 내용을 이미지로 추출할 수 있다. 서버에 저장하거나 미리보기를 만들 때 유용하다.

typescript
const getDataURL = () => {
  if (!stageRef.current) return '';

  let dataURL = stageRef.current.toDataURL();

  // 데이터가 너무 크면 JPEG로 압축
  if (dataURL.length > 18000) {
    dataURL = stageRef.current.toDataURL({
      mimeType: 'image/jpeg',
      quality: 0.8,
    });
  }

  return dataURL;
};

기본 포맷은 PNG다. 드로잉 데이터가 클 때 JPEG로 변환하면 크기를 크게 줄일 수 있다. quality는 0~1 사이 값이다.


Konva vs 다른 Canvas 라이브러리

KonvaFabric.jsPixiJSPaper.js
주 용도2D 에디터, 차트2D 에디터 (직물/디자인)게임, 고성능 2D벡터 그래픽
렌더링Canvas 2DCanvas 2DWebGL (+ Canvas 2D 폴백)Canvas 2D / SVG
React 래퍼react-konva (공식)없음 (커뮤니티)@pixi/react없음
번들 크기~150KB~300KB~200KB~220KB
드래그/드롭내장내장직접 구현직접 구현
히트 테스팅자동자동자동자동

Konva를 선택하는 이유는 보통 세 가지다. React와의 통합이 가장 자연스럽고, 드래그/이벤트 처리가 기본 내장되어 있고, 학습 곡선이 낮다. 게임처럼 고프레임이 필요하면 WebGL 기반인 PixiJS가, 디자인 도구처럼 풍부한 편집 기능이 필요하면 Fabric.js가 더 적합하다.


정리

  • Canvas의 성능과 SVG의 편의성을 결합한 라이브러리로, Stage → Layer → Shape 계층 구조와 자동 히트 테스팅을 제공한다
  • react-konva는 React 커스텀 렌더러 기반이라 JSX 선언형으로 작성하면서도 Konva 노드 트리와 자동 동기화된다
  • 드로잉 에디터 구현 시 스트로크 데이터 모델 + Layer 분리 + Undo/Redo 히스토리가 핵심 패턴이다

관련 문서