junyeokk
Blog
Media·2025. 09. 13

Canvas Drawing Editor

웹에서 사용자가 직접 그림을 그리는 기능을 만들어야 한다고 해보자. 이미지 위에 빨간 동그라미를 치거나, 화살표로 특정 부분을 가리키거나, 텍스트를 올리는 것. 포토샵 같은 풀 에디터가 아니라, 피드백이나 주석 용도로 가볍게 쓸 수 있는 드로잉 에디터다.

브라우저에서 그래픽을 다루는 방법은 크게 세 가지다.

DOM vs SVG vs Canvas

DOM 조작은 HTML 요소를 직접 움직이는 방식이다. 드래그 앤 드롭 정도는 가능하지만, 자유 곡선을 그리거나 수십 개의 도형을 실시간으로 렌더링하기엔 DOM 업데이트 비용이 너무 크다. 요소 하나하나가 레이아웃에 영향을 주기 때문에 reflow가 계속 발생한다.

SVG는 벡터 기반이라 확대해도 깨지지 않고, 각 도형이 DOM 노드라서 이벤트 처리가 편하다. 하지만 도형 수가 많아지면 DOM 노드가 폭발적으로 늘어나면서 성능이 급격히 떨어진다. 수백 개의 포인트로 이루어진 자유 곡선을 그리면 각 포인트가 path 데이터에 추가되는데, SVG는 이걸 매번 DOM으로 처리해야 한다.

Canvas API는 픽셀 기반 래스터 렌더링이다. <canvas> 요소 위에 JavaScript로 직접 픽셀을 찍는 방식이라 DOM 노드가 늘어나지 않는다. 수천 개의 포인트를 가진 자유 곡선도 단일 캔버스 위에 그려지기 때문에 성능이 일정하다. 대신 "캔버스 위의 특정 도형을 클릭했다"를 감지하려면 직접 좌표 계산을 해야 한다. Canvas는 그냥 픽셀 덩어리라서 어떤 도형이 어디 있는지 모른다.

드로잉 에디터에는 Canvas가 적합하다. 자유 곡선, 도형, 텍스트를 실시간으로 그려야 하고, 성능이 중요하며, 개별 도형 클릭보다는 전체 캔버스 위에서의 마우스 이벤트가 더 중요하기 때문이다.

Canvas API 기본 동작

Canvas API는 "즉시 모드(immediate mode)" 렌더링이다. 한 번 그리면 끝이고, 그려진 내용은 픽셀로 남을 뿐 객체로 기억되지 않는다. 이게 SVG의 "보존 모드(retained mode)"와 근본적인 차이다.

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

// 선 그리기
ctx.beginPath();
ctx.moveTo(0, 0);
ctx.lineTo(200, 100);
ctx.strokeStyle = '#FF0000';
ctx.lineWidth = 2;
ctx.stroke();

// 원 그리기
ctx.beginPath();
ctx.arc(150, 75, 50, 0, Math.PI * 2);
ctx.fillStyle = 'blue';
ctx.fill();

이게 전부다. beginPath()로 경로를 시작하고, moveTo()/lineTo()로 경로를 정의한 후, stroke()fill()로 실제로 그린다. 문제는 이 방식으로 복잡한 에디터를 만들기가 고통스럽다는 것이다. undo를 구현하려면? 모든 스트로크를 배열에 저장해두고, undo할 때마다 캔버스를 전부 지우고 마지막 스트로크를 빼고 다시 그려야 한다. 도형 선택? 마우스 좌표가 어떤 도형 영역 안에 있는지 직접 수학 계산을 해야 한다.

Konva: Canvas 위의 객체 모델

Konva는 Canvas API 위에 객체 모델을 얹은 라이브러리다. Canvas의 성능은 유지하면서, SVG처럼 각 도형을 객체로 관리할 수 있게 해준다.

핵심 구조는 Stage → Layer → Shape의 트리 구조다.

text
Stage (= <canvas> 요소)
  └── Layer (내부적으로 별도 <canvas>)
      ├── Line
      ├── Rect
      ├── Circle
      ├── Arrow
      └── Text

Stage는 컨테이너다. 실제 DOM의 <canvas> 요소를 감싸고, 마우스/터치 이벤트를 받는다. Layer는 독립적인 <canvas>를 가진다. 여러 Layer를 쌓으면 각 레이어가 개별 캔버스라서, 한 레이어만 다시 그려도 다른 레이어에 영향이 없다. 배경 레이어와 드로잉 레이어를 분리하면 드로잉할 때 배경을 매번 다시 그릴 필요가 없어진다. Shape는 Line, Rect, Circle, Arrow, Text 등 실제 그려지는 객체다.

javascript
import Konva from 'konva';

const stage = new Konva.Stage({
  container: 'container',
  width: 800,
  height: 600,
});

const layer = new Konva.Layer();
stage.add(layer);

const line = new Konva.Line({
  points: [0, 0, 100, 100, 200, 50],
  stroke: 'red',
  strokeWidth: 3,
  lineCap: 'round',
  lineJoin: 'round',
});

layer.add(line);
layer.draw();

Konva에서 각 Shape는 객체로 존재하기 때문에 .points()로 포인트를 수정하거나, .stroke()로 색을 바꾸거나, .destroy()로 제거할 수 있다. 이벤트 핸들링도 Shape 단위로 된다.

javascript
line.on('click', () => {
  console.log('이 선이 클릭됨');
});

Canvas API였으면 마우스 좌표를 받아서 모든 도형과 교차 판정을 해야 했을 일을 Konva가 내부적으로 처리해준다. Konva는 히트 테스트용 별도 캔버스를 만들어서 각 Shape에 고유 색상을 할당하고, 클릭 좌표의 색상으로 어떤 Shape인지 판별하는 방식이다.

react-konva: React에서 Konva 사용하기

react-konva는 Konva를 React 컴포넌트로 감싼 래퍼다. JSX로 선언적으로 캔버스를 구성할 수 있다.

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

function DrawingCanvas({ width, height }: { width: number; height: number }) {
  return (
    <Stage width={width} height={height}>
      <Layer>
        <Line
          points={[0, 0, 100, 100, 200, 50]}
          stroke="#FF0000"
          strokeWidth={3}
          lineCap="round"
          lineJoin="round"
        />
        <Circle x={150} y={75} radius={50} fill="blue" />
      </Layer>
    </Stage>
  );
}

React의 상태 관리와 자연스럽게 통합된다. strokes 배열을 state로 관리하고, 각 stroke를 JSX로 매핑하면 React가 알아서 변경된 부분만 업데이트한다.

tsx
function DrawingEditor() {
  const [strokes, setStrokes] = useState<DrawingStroke[]>([]);

  return (
    <Stage width={800} height={600}>
      <Layer>
        {strokes.map((stroke) => {
          switch (stroke.tool) {
            case 'pen':
              return (
                <Line
                  key={stroke.id}
                  points={stroke.points}
                  stroke={stroke.color}
                  strokeWidth={stroke.strokeWidth}
                  lineCap="round"
                  lineJoin="round"
                  tension={0.5}
                />
              );
            case 'circle':
              return (
                <Circle
                  key={stroke.id}
                  x={stroke.points[0]}
                  y={stroke.points[1]}
                  radius={Math.hypot(
                    stroke.points[2] - stroke.points[0],
                    stroke.points[3] - stroke.points[1]
                  )}
                  stroke={stroke.color}
                  strokeWidth={stroke.strokeWidth}
                />
              );
            case 'arrow':
              return (
                <Arrow
                  key={stroke.id}
                  points={stroke.points}
                  stroke={stroke.color}
                  strokeWidth={stroke.strokeWidth}
                  pointerLength={10}
                  pointerWidth={10}
                  fill={stroke.color}
                />
              );
            case 'text':
              return (
                <Text
                  key={stroke.id}
                  x={stroke.points[0]}
                  y={stroke.points[1]}
                  text={stroke.text ?? ''}
                  fontSize={stroke.fontSize ?? 16}
                  fill={stroke.color}
                />
              );
            default:
              return null;
          }
        })}
      </Layer>
    </Stage>
  );
}

스트로크 데이터 모델

드로잉 에디터에서 가장 중요한 건 데이터 모델이다. 모든 그림은 스트로크(stroke) 단위로 저장된다.

typescript
interface DrawingStroke {
  tool: 'pen' | 'circle' | 'rect' | 'arrow' | 'text';
  points: number[];
  color: string;
  strokeWidth: number;
  id: string;
  text?: string;
  fontSize?: number;
}

tool은 어떤 도구로 그렸는지, points는 좌표 배열이다. pen의 경우 [x1, y1, x2, y2, x3, y3, ...] 형태로 마우스가 움직인 모든 좌표가 순서대로 들어간다. circle이나 rect는 [startX, startY, endX, endY]로 시작점과 끝점만 저장한다. arrow도 마찬가지다.

id는 각 스트로크를 고유하게 식별하기 위한 값이다. undo/redo에서 스트로크를 추가하거나 제거할 때 필요하다.

textfontSize는 텍스트 도구 전용 필드다. 텍스트 도구는 좌표와 함께 실제 텍스트 내용과 글자 크기를 저장해야 한다.

이 데이터 모델의 핵심은 캔버스의 현재 상태를 완전히 재현할 수 있다는 것이다. 스트로크 배열만 있으면 언제든 캔버스를 처음부터 다시 그릴 수 있다. 이게 undo/redo와 서버 저장의 기반이 된다.

마우스 이벤트로 그리기 구현

자유 곡선(pen) 도구의 구현 흐름은 이렇다.

  1. mousedown — 새 스트로크를 시작한다. 현재 마우스 좌표를 첫 번째 포인트로 저장.
  2. mousemove — 드래그 중이면 현재 좌표를 스트로크의 points에 계속 추가. 실시간으로 선이 그려진다.
  3. mouseup — 스트로크를 완료한다. history에 현재 상태를 저장.
tsx
const handleMouseDown = (e: Konva.KonvaEventObject<MouseEvent>) => {
  const stage = e.target.getStage();
  const point = stage?.getPointerPosition();
  if (!point) return;

  setIsDrawing(true);
  const newStroke: DrawingStroke = {
    id: generateStrokeId(),
    tool: selectedTool,
    points: [point.x, point.y],
    color: toolConfig.color,
    strokeWidth: toolConfig.strokeWidth,
  };
  addStroke(newStroke);
};

const handleMouseMove = (e: Konva.KonvaEventObject<MouseEvent>) => {
  if (!isDrawing) return;

  const stage = e.target.getStage();
  const point = stage?.getPointerPosition();
  if (!point) return;

  updateLastStroke(point.x, point.y);
};

const handleMouseUp = () => {
  if (!isDrawing) return;
  setIsDrawing(false);
  finishStroke();
};

getPointerPosition()은 Konva가 제공하는 메서드로, Stage 기준 좌표를 반환한다. 브라우저의 clientX/clientY를 캔버스 좌표로 변환하는 작업을 내부적으로 처리해준다.

도형 도구(circle, rect, arrow)는 조금 다르다. mousedown에서 시작점을 기록하고, mousemove에서 끝점을 업데이트하면서 도형의 크기가 실시간으로 바뀐다. mouseup에서 최종 도형이 확정된다.

tsx
// 사각형 도구의 mousemove
const handleMouseMove = (e: Konva.KonvaEventObject<MouseEvent>) => {
  if (!isDrawing) return;
  const point = e.target.getStage()?.getPointerPosition();
  if (!point) return;

  // startPoint + currentPoint로 사각형 정의
  updateLastStroke(point.x, point.y);
  // → points: [startX, startY, currentX, currentY]
  // → Rect의 width = currentX - startX, height = currentY - startY
};

Undo/Redo: History 스택

Undo/Redo는 드로잉 에디터의 필수 기능이다. 구현 방식은 Command 패턴의 변형으로, 전체 상태를 스냅샷으로 저장하는 방식이다.

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

히스토리는 CanvasHistory 배열로 관리된다. 인덱스가 현재 위치를 가리키고, undo하면 인덱스를 뒤로, redo하면 앞으로 이동한다.

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

초기 상태는 빈 스트로크 배열이다. 스트로크가 추가될 때마다 현재 상태를 히스토리에 push한다.

typescript
const addToHistory = (currentStrokes: DrawingStroke[]) => {
  // 현재 인덱스 이후의 히스토리를 잘라낸다 (undo 후 새로 그리면 redo 불가)
  const newHistory = history.slice(0, historyIndex + 1);
  newHistory.push({
    strokes: [...currentStrokes],
    timestamp: Date.now(),
  });

  // 히스토리 크기 제한 (메모리 관리)
  if (newHistory.length > 50) {
    newHistory.shift();
  } else {
    setHistoryIndex(historyIndex + 1);
  }

  setHistory(newHistory);
};

여기서 중요한 부분이 history.slice(0, historyIndex + 1)이다. undo를 여러 번 한 상태에서 새로 그리면, 현재 인덱스 뒤에 있는 히스토리(redo 가능했던 상태들)가 전부 사라진다. 이건 대부분의 에디터가 따르는 표준 동작이다. 포토샵에서 undo 3번 하고 새로 그리면 redo가 사라지는 것과 같다.

히스토리 크기를 50으로 제한하는 것도 중요하다. 각 히스토리 엔트리가 모든 스트로크의 복사본을 가지고 있기 때문에, 제한 없이 쌓으면 메모리가 빠르게 증가한다.

typescript
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);
  }
};

Undo는 이전 히스토리의 strokes를 현재 상태로 복원하고, redo는 다음 히스토리의 strokes를 복원한다. 상태 자체를 통째로 교체하기 때문에 구현이 단순하다.

이 방식의 단점은 메모리 사용량이다. 스트로크가 100개인 상태에서 히스토리 50개를 저장하면, 최악의 경우 5000개의 스트로크 객체가 메모리에 있게 된다. 대안으로 "delta 기반" 방식이 있다. 각 히스토리에 전체 상태 대신 "무엇이 변경되었는지"만 저장하는 것이다. 하지만 구현 복잡도가 훨씬 높아지고, 피드백용 가벼운 에디터에서는 50개 히스토리 제한으로 충분하다.

좌표 정규화와 스케일링

캔버스의 크기가 달라져도 그림이 똑같이 보여야 한다. 화면이 800x600일 때 그린 그림이 400x300 화면에서도 같은 비율로 표시되어야 한다.

이를 위해 스트로크를 저장할 때 원본 캔버스 크기(originalWidth, originalHeight)를 함께 기록하고, 렌더링할 때 현재 캔버스 크기에 맞게 스케일링한다.

typescript
const getDrawingData = () => {
  const width = originalWidth ?? stage.width();
  const height = originalHeight ?? stage.height();

  return {
    width,
    height,
    strokes: strokes.map((stroke) => ({
      type: stroke.tool,
      points: stroke.points,
      color: stroke.color,
      strokeWidth: stroke.strokeWidth,
    })),
  };
};

렌더링 시에는 Stage의 scaleXscaleY를 사용해서 전체 레이어를 스케일링할 수 있다.

tsx
const scaleX = currentWidth / originalWidth;
const scaleY = currentHeight / originalHeight;

<Stage width={currentWidth} height={currentHeight} scaleX={scaleX} scaleY={scaleY}>
  <Layer>
    {strokes.map(stroke => /* ... */)}
  </Layer>
</Stage>

이렇게 하면 각 스트로크의 좌표를 개별적으로 변환할 필요 없이, Stage 레벨에서 한 번에 스케일링이 적용된다.

도구 설정 관리

드로잉 에디터에는 보통 색상 선택, 브러시 크기 조절 등의 도구 설정이 필요하다. 이 설정들을 중앙에서 관리하면 도구 간 일관성을 유지할 수 있다.

typescript
const PRESET_COLORS = [
  '#FF0000', '#FF8800', '#FFFF00', '#00FF00',
  '#00FFFF', '#0088FF', '#8800FF', '#FF00FF',
  '#000000', '#666666', '#FFFFFF', '#A52A2A',
] as const;

const PRESET_SIZES = [1, 2, 4, 8, 12, 16, 24, 32] as const;

const DRAWING_CONFIG = {
  DEFAULT_STROKE_WIDTH: 2,
  DEFAULT_TEXT_FONT_SIZE: 16,
  DEFAULT_COLOR: '#000000',
  ARROW_POINTER_LENGTH: 10,
  ARROW_POINTER_WIDTH: 10,
} as const;

as const로 선언하면 TypeScript가 배열을 readonly tuple로 추론해서, 정의되지 않은 값을 실수로 사용하는 걸 타입 레벨에서 방지한다.

밝은 색상(노란색, 흰색 등)은 밝은 배경에서 안 보일 수 있으므로, 밝은 색상 목록을 별도로 관리해서 UI에서 테두리를 추가하는 등의 처리를 한다.

typescript
const LIGHT_COLORS = ['#FFFF00', '#00FFFF', '#00FF00', '#FFFFFF'] as const;

텍스트 입력 모드

텍스트 도구는 다른 도구들과 동작 방식이 다르다. 클릭하면 해당 위치에 텍스트 입력 UI가 나타나고, 입력이 완료되면 텍스트 스트로크로 변환된다.

구현 포인트는 Canvas 위에 HTML input을 오버레이하는 것이다. Canvas 자체는 텍스트 입력을 받을 수 없기 때문에, 클릭한 위치에 hidden input을 배치하고 포커스를 준다. 입력되는 텍스트는 실시간으로 Konva의 Text 노드에 반영되어 캔버스 위에 표시된다.

tsx
// 텍스트 모드에서 캔버스 클릭
const handleTextClick = (point: { x: number; y: number }) => {
  setIsTextMode(true);
  setTextPosition(point);
  setTextValue('');
  // hidden input에 포커스 → 키보드 입력 받기
  hiddenInputRef.current?.focus();
};

// 입력 완료 (Enter 또는 외부 클릭)
const handleTextComplete = () => {
  if (textValue.trim()) {
    addStroke({
      id: generateStrokeId(),
      tool: 'text',
      points: [textPosition.x, textPosition.y],
      color: toolConfig.color,
      strokeWidth: toolConfig.strokeWidth,
      text: textValue,
      fontSize: DRAWING_CONFIG.DEFAULT_TEXT_FONT_SIZE,
    });
  }
  setIsTextMode(false);
  setTextPosition(null);
};

텍스트 입력 중에는 커서 깜빡임도 구현해야 자연스럽다. setInterval로 500ms 간격으로 커서 가시성을 토글한다.

typescript
useEffect(() => {
  if (!isTextMode) return;

  const interval = setInterval(() => {
    setCursorVisible((prev) => !prev);
  }, 530);

  return () => clearInterval(interval);
}, [isTextMode]);

서버 저장과 데이터 포맷

그려진 내용을 서버에 저장하는 방식은 두 가지다.

이미지(래스터) 방식stage.toDataURL()로 캔버스를 PNG/JPEG 이미지로 변환해서 저장한다. 단순하지만 나중에 수정할 수 없고, 해상도에 의존적이다.

javascript
const dataURL = stage.toDataURL({ mimeType: 'image/png' });
// → "data:image/png;base64,iVBOR..." 형태의 문자열

스트로크 데이터 방식 — 스트로크 배열을 JSON이나 CBOR(Concise Binary Object Representation)으로 직렬화해서 저장한다. 용량이 작고, 나중에 수정이 가능하며, 해상도에 독립적이다.

typescript
// JSON 방식
const data = JSON.stringify({
  width: 800,
  height: 600,
  strokes: strokes.map(s => ({
    type: s.tool,
    points: s.points,
    color: s.color,
    strokeWidth: s.strokeWidth,
  })),
});

CBOR은 JSON과 동일한 데이터 모델이지만 바이너리 인코딩이라 크기가 더 작다. 좌표 데이터가 많은 드로잉 데이터에서는 JSON 대비 30-50% 정도 크기를 줄일 수 있다.

두 방식을 모두 지원하면 하위 호환성을 유지할 수 있다. 서버에서 v1(이미지)과 v2(스트로크 데이터)를 구분해서 처리하고, 클라이언트에서는 데이터 타입에 따라 렌더링 방식을 분기한다.

성능 고려사항

Canvas 드로잉 에디터에서 주의할 성능 포인트들:

Layer 분리 — 정적인 요소(배경 이미지, 읽기 전용 그림)와 동적인 요소(현재 그리고 있는 선)를 다른 Layer에 배치한다. Konva에서 Layer는 별도 <canvas>이므로, 드로잉 중에 배경 레이어는 다시 그리지 않는다.

포인트 간솔(thinning) — 마우스 이벤트는 매우 빈번하게 발생한다. 1px도 안 움직였는데 이벤트가 발생하면 불필요한 포인트가 쌓인다. 이전 포인트와의 거리가 일정 임계값 이하면 무시하는 로직을 추가하면 데이터 크기와 렌더링 부하를 줄일 수 있다.

typescript
const MIN_DISTANCE = 3;
const lastPoint = { x: points[points.length - 2], y: points[points.length - 1] };
const distance = Math.hypot(newX - lastPoint.x, newY - lastPoint.y);
if (distance < MIN_DISTANCE) return; // 무시

Line의 tension 속성 — Konva의 Line에 tension 값을 주면 포인트 사이를 부드러운 곡선으로 보간한다. 적은 포인트로도 부드러운 곡선을 얻을 수 있어서 포인트 간솔과 함께 사용하면 효과적이다.

tsx
<Line
  points={stroke.points}
  tension={0.5}  // 0 = 직선, 1 = 매우 부드러운 곡선
  lineCap="round"
  lineJoin="round"
/>

lineCap: "round"lineJoin: "round" — 선의 끝과 꺾이는 부분을 둥글게 처리한다. 이게 없으면 자유 곡선이 각지게 보여서 부자연스럽다. 특히 strokeWidth가 클수록 차이가 뚜렷하다.


정리

  • Canvas API의 즉시 모드 렌더링은 성능이 좋지만 객체 관리가 어렵고, Konva가 그 위에 Stage→Layer→Shape 트리 구조를 얹어서 해결한다
  • 스트로크 배열 기반 데이터 모델로 undo/redo(히스토리 스냅샷)와 서버 저장(JSON/CBOR 직렬화)을 일관되게 처리할 수 있다
  • Layer 분리, 포인트 간솔(MIN_DISTANCE), tension 보간을 조합하면 적은 데이터로 부드러운 드로잉 성능을 유지할 수 있다

관련 문서