본문 바로가기
project/figma-clone

확장 가능한 도형 드로잉 구조, 어떻게 만들까?

by qjatjs123123 2025. 6. 7.

안녕하세요!

 

개인 프로젝트로 Figma 클론을 개발하면서, 다양한 도형들을 다루는 드로잉 로직을 도형별로 분리하고, 상황에 따라 쉽게 교체할 수 있도록 구조를 설계해보았습니다.

 

많은 관심 부탁드립니다.

 

 


 

 

1. Konva 라이브러리

(다양한 도형)

 

Figma 클론 프로젝트를 진행하면서 Konva 라이브러리를 사용하였습니다.
Konva에서 지원하는 도형은 수십 가지있고, 종류도 매우 다양합니다.
이러한 다양한 도형에 맞춰 드로잉 및 이벤트 로직을 유연하게 구성하는 것이 중요하다고 생각했습니다.

 

 

Konva 라이브러리 몇 가지 도형 예제를 알아봅시다.

 

 

 

도형마다 가지고 있는 공통 속성들이 있지만 고유 속성들 또한 존재합니다.

 

예를 들어 타원, 사각형, 별의 크기를 키운다고 가정해 봅시다.

 

그러면, 사각형은 with, height를 증가시키면 되지만, 타원 같은 경우, radiusX, radiusY를 조절해야 하고, 별은 innerRadius, outerRadius와 같은 고유한 속성을 수정해야 합니다.

 

특히, 수정시 별도의 계산이 필요한 경우를 고려하여 구조적인 설계가 필요했습니다.

 

 

 


 

 

 

2. 전략 패턴

도형에 맞게 필요한 전략(로직)을 유연하게 교체할 수 있다면 어떨까

 

결론부터 말씀드리면, 저는 전략 패턴을 활용하여 확장성을 고려한 도형 드로잉 로직을 설계했습니다.

 

 

 

사용자가 어떤 도형을 선택하더라도, 발생하는 이벤트는 동일합니다.

즉, 도형을 움직이거나, 변형하거나, 드래그하거나, 속성을 업데이트하거나, 마우스를 눌러 생성하고 떼어 실제로 배치하는 등의 이벤트는 공통적으로 발생합니다.

 

이러한 이벤트 콜백 함수들을 추상화하고, 도형마다 공통되는 로직은 추상 클래스에 정의하여 상속받도록 했습니다.
반면, 도형별로 고유한 동작은 각 클래스에서 개별적으로 구현하였습니다.

 

 

 

(공통 로직)

  • up →  마우스를 떼어 실제로 배치하는 이벤트
  • dragEnd → 드래그 끝났을 때 발생하는 이벤트
  • update → 속성을 변경하였을 때 발생하는 이벤트

 

(고유 로직)

  • down → 도형을 임시로 생성하는 이벤트
  • move  → 도형을 움직이는 이벤트
  • transformEnd → scale이나 rotation 등 크기 관련된 속성이 변경되는 이벤트

 

추가로, 공통 로직이 있더라도 도형마다 예외적인 처리가 필요할 수 있습니다.
이럴 때는 자식 클래스에서 해당 메서드를 오버라이드하여 동작을 변경할 수 있습니다.

 

 

 


 

 

3. 구현

(Shape - 추상 클래스)

interface Point {
  x: number;
  y: number;
}

export interface DownParams {
  selectByNameArr: any[];
  startPoint: Point;
  currentPoint: Point;
  setSelectedIds: (data: any) => void;
}

export interface moveParams<> {
  startPoint: Point;
  currentPoint: Point;
  setSelectedIds: (data: any) => void;
}

export interface ShapeProps<T> {
  setShapes: React.Dispatch<React.SetStateAction<any>>;
  shapes: any[];
  setTempShape: (data: T) => void | null;
  tempShape: T | null;
}

export interface resultParams {
  originData: any;
  newData: any;
}

export abstract class Shape<T> {
  protected setTempShape;
  protected tempShape;
  protected shapes;
  protected setShapes;

  constructor({ setShapes, shapes, setTempShape, tempShape }: ShapeProps<T>) {
    this.setShapes = setShapes;
    this.shapes = shapes;
    this.setTempShape = setTempShape;
    this.tempShape = tempShape;
  }

  abstract down(params: DownParams): void;
  abstract move(params: moveParams): void;

  public up() {
    this.setShapes([...this.shapes, this.tempShape]);
  }

  protected shapeMaxID = (rectArr: any[]) => {
    const maxID = rectArr.reduce((max, rect) => {
      return Math.max(max, rect.id);
    }, 1);

    return maxID + 1;
  };

  dragEnd(shapeId: string, currentPoint: Point): resultParams {
    const result = { originData: null, newData: null } as resultParams;

    this.setShapes((prevShapes: any) => {
      const newShapes = [...prevShapes];
      const index = newShapes.findIndex((r) => `${r.name} ${r.id}` === shapeId);

      if (index !== -1) {
        result.originData = { ...newShapes[index] };

        const updatedShape = {
          ...newShapes[index],
          x: currentPoint.x,
          y: currentPoint.y,
        };

        newShapes[index] = updatedShape;
        result.newData = updatedShape;
      }

      return newShapes;
    });

    return result;
  }

  update(shapeId: string, updateProps: any): resultParams {
    const result = { originData: null, newData: null } as resultParams;

    this.setShapes((prevShapes: any) => {
      const newShapes = [...prevShapes];
      const index = newShapes.findIndex((r) => `${r.name} ${r.id}` === shapeId);

      if (index !== -1) {
        result.originData = { ...newShapes[index] };

        const updatedShape = {
          ...newShapes[index],
          ...updateProps,
        };

        newShapes[index] = updatedShape;
        result.newData = updatedShape;
      }

      return newShapes;
    });

    return result;
  }

  abstract transformEnd(shapeId: string, data: any): resultParams;
}

 

추상클래스 입니다.

 

  • shapeMaxID  →  도형마다, 부여할 ID를 구하는 메서드입니다. 이 메서드는 공통으로 사용되는 로직입니다.
  • dragEnd  →  기존 Shape의 속성을 새로운 x, y 값으로 업데이트하여 위치를 변경하는 함수입니다. 도형 중에서 x, y를 가지고 있으면, 해당 메서드를 사용할 수 있지만, Line처럼 x, y가 없는 도형들은 따로 오버라이드 해야 합니다.
  • update  →  기존 Shape의 속성을 새로운 속성들(예: fill, stroke, strokeWidth 등)로 변경하는 함수입니다.

 

나머지 down, move, transformEnd는 각 Shape에 맞게 구현해 줍니다.

 

 


 

 

 

(Rectangle - 구현 클래스)

/* eslint-disable @typescript-eslint/no-explicit-any */
import type { Rect } from "../../type/Shape";
import { SHAPE, SHAPE_INIT_DATA } from "../constants/constants";
import {
  Shape,
  type DownParams,
  type moveParams,
  type resultParams,
  type ShapeProps,
} from "./Shape.abstract";

export class RectangleStrategy extends Shape<Rect> {
  constructor(props: ShapeProps<Rect>) {
    super(props);
  }

  down(params: DownParams): void {
    const { selectByNameArr, startPoint, currentPoint, setSelectedIds } =
      params;

    const id = this.shapeMaxID(selectByNameArr);
    const rectData = {
      id,
      name: SHAPE.Rectangle,
      type: SHAPE.Rectangle,
      fill: SHAPE_INIT_DATA.rectangle.fill,
      stroke: SHAPE_INIT_DATA.rectangle.stroke,
      strokeWidth: SHAPE_INIT_DATA.rectangle.strokeWidth,
      rotation: SHAPE_INIT_DATA.rectangle.rotation,
      x: Math.min(startPoint.x, currentPoint.x),
      y: Math.min(startPoint.y, currentPoint.y),
      width: Math.abs(currentPoint.x - startPoint.x),
      height: Math.abs(currentPoint.y - startPoint.y),
    };

    this.setTempShape(rectData as Rect);
    setSelectedIds([`${SHAPE.Rectangle} ${id}`]);
  }
  move(params: moveParams): void {
    const { startPoint, currentPoint } = params;

    const rectData = {
      ...this.tempShape!,
      x: Math.min(startPoint.x, currentPoint.x),
      y: Math.min(startPoint.y, currentPoint.y),
      width: Math.abs(currentPoint.x - startPoint.x),
      height: Math.abs(currentPoint.y - startPoint.y),
    };

    this.setTempShape(rectData);
  }

  transformEnd(shapeId: string, data: any): resultParams {
    const result = { originData: null, newData: null } as resultParams;

    this.setShapes((prevShapes: any) => {
      const newShapes = [...prevShapes];
      const index = newShapes.findIndex((r) => `${r.name} ${r.id}` === shapeId);

      if (index !== -1) {
        result.originData = { ...newShapes[index] };

        const updatedShape = {
          ...newShapes[index],
          x: data.x(),
          y: data.y(),
          width: Math.max(5, data.width() * data.scaleX()),
          height: Math.max(5, data.height() * data.scaleY()),
          rotation: data.rotation(),
        };

        data.scaleX(1);
        data.scaleY(1);
        
        newShapes[index] = updatedShape;
        result.newData = updatedShape;
      }

      return newShapes;
    });
    return result;
  }
}

 

Shape 추상 클래스에서 정의한 추상 메서드를 자식 클래스에서 직접 구현합니다.

 

 

  • down  →  도형마다, 고유한 속성을 정의하고, 임시로 생성하는 이벤트입니다. 각 도형이 서로 다른 속성을 생성하기 때문에, 이를 처리하는 로직도 도형별로 별도로 구현해야 합니다.
  • move  →  도형마다, 고유한 속성을 가지고 있고, 변경할 때마다 고유한 계산이 필요한 경우가 있습니다. 그렇기에 이를 처리하는 로직도 도형별로 별도로 구현합니다.
  • transform  →  이것도 마찬가지로 고유한 계산 로직이 필요한 경우가 있으므로 따로 분리하여 구현합니다.

 

 


 

 

 

(Factory - 팩토리 클래스)

import type { Mode } from "../../type/Shape";
import { SHAPE } from "../constants/constants";
import { EllipseStrategy } from "./ElllipseStrategy";
import { RectangleStrategy } from "./RectangleStrategy";
import { SelectStrategy } from "./SelectStrategy";
import type { ShapeProps } from "./Shape.abstract";

interface ShapeFactoryProps extends ShapeProps<any> {
  mode?: Mode;
}

export class ShapeStrategyFactory {
  static createShape({
    mode,
    setTempShape,
    tempShape,
    shapes,
    setShapes,
  }: ShapeFactoryProps) {
    const props = { setTempShape, tempShape, shapes, setShapes };

    switch (mode) {
      case SHAPE.Rectangle:
        return new RectangleStrategy(props);
      case SHAPE.Ellipse:
        return new EllipseStrategy(props);
      default:
        return new SelectStrategy(props);
    }
  }
}

 

mode에 맞게 전략들을 리턴해줍니다.

 

 

 

 


 

 

 

(사용법)

export default function useModeHandlers() {

  const [mode, setMode] = useAtom(modeAtom);
  const [shapes, setShapes] = useAtom(shapeAtom);
  const selectedShapeAtom = useMemo(() => selectAtomByName(mode), [mode]);
  ...
  
  // mode에 맞는 전략 꺼내기
  const shapeStrategy = ShapeStrategyFactory.createShape({
    mode,
    setTempShape: tempShapeDispatch,
    tempShape,
    shapes,
    setShapes,
  });
  
  
  // 전략에 맞는 이벤트 실행
  const handleMouseDown = (e: KonvaEventObject<MouseEvent>) => {
    if (e.target !== e.target.getStage()) return;
    const pos = e.target.getStage().getPointerPosition();
    if (!pos) return;

    setIsCreating(true);
    startPoint.current = pos;
    shapeStrategy.down({
      selectByNameArr,
      startPoint: startPoint.current,
      currentPoint: pos,
      setSelectedIds,
    });
  };
  
  // 전략에 맞는 이벤트 실행
  const handleMouseMove = (e: KonvaEventObject<MouseEvent>) => {
    if (!isCreating) return;

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

    if (startPoint.current) isDragging.current = true;

    shapeStrategy.move({
      startPoint: startPoint.current,
      currentPoint: pos,
      setSelectedIds,
    });
  };

...

 

mode에 맞게 Factory에서 전략을 꺼내와,
추상화된 메서드를 실행하면 됩니다.

 

 

 

이렇게 하여, 확장성 있는 코드를 작성할 수 있었습니다.

 

감사합니다.

 

 

 

'project > figma-clone' 카테고리의 다른 글

Figma처럼 Undo/Redo 기능 구현하기  (0) 2025.06.07