useLine 로직 통합하기
SVG 에디터에서 점, 선, 사각형, 원의 중복된 도형 로직을 하나의 모델로 통합한 리팩터링 과정을 정리합니다.
1. 문제 상황
기존에는 점(Dot), 선(Line), 사각형(Rect), 원(Circle)을 각각 별도의 Class로 만들어 관리했습니다.
내부 동작은 useDot.ts 와 useLine.ts 로 나누어 작성했습니다.
이 방식은 동작에는 문제가 없지만, 비슷한 동작을 각기 다른 모듈에서 관리하다 보니
문제가 발생하면 4개의 파일을 모두 수정해야 하고, 유지보수가 어려워집니다.
이에 따라 동일한 구조를 가진 도형은 하나의 Class로 통합하여 관리하기로 했습니다.
특히 문제가 된 부분은 렌더링이 아니라 상태 관리였습니다. 도형마다 시작점과 끝점을 저장하고, 드래그 중 좌표를 갱신하고, 선택 핸들을 다시 계산하는 흐름이 거의 같았습니다. 그런데 파일이 분리되어 있다 보니 작은 계산 규칙 하나를 바꿔도 Line, Rect, Circle, Dot 쪽을 모두 확인해야 했습니다.
예를 들어 최소 크기 제한이나 핸들 위치 계산은 모든 도형에서 비슷한 목적을 갖습니다. 하지만 각 도형이 자체 구현을 갖고 있으면 동일한 버그가 반복되거나, 한 도형만 수정되고 다른 도형은 이전 동작을 유지하는 문제가 생깁니다. SVG 에디터처럼 사용자의 드래그 입력에 즉시 반응해야 하는 화면에서는 이런 차이가 곧 선택 영역 오차, 저장 데이터 불일치, undo/redo 재현 오류로 이어질 수 있습니다.
이번 리팩터링의 목표는 도형의 종류를 없애는 것이 아니었습니다. 외부 API는 기존처럼 "line", "rect", "circle", "dot"을 구분하되, 내부에서는 공통 좌표 모델과 계산 함수를 사용하게 만드는 것이었습니다. 이렇게 하면 UI 컴포넌트는 도형 타입만 전달하고, 실제 SVG 속성 변환은 한곳에서 처리할 수 있습니다.
2. 설계 방향
도형을 통합할 때 가장 먼저 정한 기준은 "저장 데이터는 단순하게, 렌더링 변환은 명시적으로"였습니다.

- 저장 데이터는 도형 타입과 기준 좌표만 가진다.
- SVG 렌더링에 필요한
path,circle, 핸들 좌표는 렌더링 직전에 계산한다. - 도형별 예외는 분기 처리하되, 좌표 보정과 범위 제한은 공통 유틸리티로 둔다.
이 기준을 둔 이유는 저장 포맷과 화면 표현을 섞지 않기 위해서입니다. rect는 화면에서는 닫힌 path로 그릴 수 있지만, 편집 데이터에서는 시작점과 끝점만 있어도 충분합니다. circle도 중심점과 반지름을 직접 저장할 수 있지만, 드래그 기반 편집에서는 중심점과 외곽점을 저장하는 편이 입력 흐름을 다루기 쉽습니다.
반대로 dot은 예외로 남겼습니다. 점을 path로 그릴 수도 있지만, 내부가 비거나 fill/stroke 규칙이 다른 도형과 충돌하기 쉽습니다. 그래서 데이터 모델은 같은 계열로 두되 렌더링은 circle 요소를 사용하도록 분리했습니다.
3. getPathAttribute 함수
type 값에 따라 두 점의 좌표로 SVG Path를 다르게 만들어 반환합니다.
export const getPathAttribute = (
type: "line" | "rect" | "circle",
points: number[][]
): string => {
switch (type) {
case "line": {
const [[x1, y1], [x2, y2]] = points;
return `M${x1},${y1} L${x2},${y2}`;
}
case "rect": {
const [[x1, y1], [x2, y2]] = points;
const minX = Math.min(x1, x2);
const minY = Math.min(y1, y2);
const maxX = Math.max(x1, x2);
const maxY = Math.max(y1, y2);
return `M${minX},${minY} L${maxX},${minY} L${maxX},${maxY} L${minX},${maxY} Z`;
}
case "circle": {
const [center, edge] = points;
const [cx, cy] = center;
const [ex, ey] = edge;
const r = Math.hypot(ex - cx, ey - cy);
return `M${cx - r},${cy} A${r},${r} 0 1,0 ${
cx + r
},${cy} A${r},${r} 0 1,0 ${cx - r},${cy}`;
}
default:
throw new Error(`Unsupported type: ${type}`);
}
};
dot은path로 그리면 내부가 비게 되므로, 별도로circle요소를 사용하여 처리합니다.
fill속성을 공유하면 충돌이 생기기 때문입니다.
이 함수의 장점은 도형 타입별 SVG 문자열 생성 규칙이 한곳에 모인다는 점입니다. 이전 구조에서는 각 도형 클래스가 자기 렌더링 방식을 알고 있었기 때문에, 도형을 추가하거나 수정할 때 상태 관리 코드와 렌더링 코드를 함께 건드려야 했습니다. 지금은 도형 상태가 바뀌더라도 getPathAttribute의 입력과 출력 계약만 유지하면 됩니다.
또 하나의 장점은 테스트하기 쉽다는 점입니다. 이 함수는 DOM이나 React 상태에 의존하지 않고, type과 좌표 배열만 입력으로 받습니다. 그래서 특정 좌표가 들어왔을 때 어떤 path 문자열이 만들어지는지 순수 함수처럼 검증할 수 있습니다.
4. 로직 리팩토링 예시
4-1. 핸들러 위치 계산 개선
기존 코드:
const biggerX = Math.max(x1, x2);
const smallerX = Math.min(x1, x2);
const handlerFlg = (x2 - x1 > 0 && y2 - y1 > 0) || (x2 - x1 < 0 && y2 - y1 < 0);
const targetX = handlerFlg ? biggerX - radius : smallerX + radius;
리팩토링 후:
const [x1, y1] = circleCoords[0];
const [x2, y2] = circleCoords[1];
const isSameDirection = (x2 - x1) * (y2 - y1) > 0;
const targetX = isSameDirection
? Math.max(x1, x2) - radius
: Math.min(x1, x2) + radius;
기존 코드는 조건 자체가 나쁜 것은 아니지만, 좌표 방향을 읽는 사람이 직접 해석해야 했습니다. 리팩터링 후에는 isSameDirection이라는 이름으로 의도를 먼저 드러내고, 실제 좌표 선택은 Math.max, Math.min 조합으로 단순화했습니다. 이렇게 바꾸면 이후 y축 계산이나 다른 핸들 계산에도 같은 규칙을 재사용하기 쉽습니다.
4-2. 범위 제한 간소화
기존:
if (r < 0) {
this.r = 0;
} else if (r < minLength) {
this.r = r;
} else {
this.r = minLength;
}
리팩토링:
this.r = Math.max(0, Math.min(r, minLength));
여기서 중요한 점은 코드 줄 수를 줄이는 것보다 경계값 처리를 한 줄의 명확한 규칙으로 만든다는 점입니다. 반지름은 0보다 작을 수 없고, 특정 최소 길이를 넘어가면 제한되어야 합니다. 이 규칙은 if/else보다 clamp 형태로 표현하는 편이 실수 가능성이 낮았습니다.
5. 적용 결과
리팩터링 후 도형 처리 흐름은 다음처럼 단순해졌습니다.
- 사용자가 캔버스에서 드래그를 시작한다.
- 도형 타입과 기준 좌표를 공통 모델에 저장한다.
- 드래그 중에는 두 번째 좌표만 갱신한다.
- 렌더링 단계에서 타입에 맞는 SVG 속성을 계산한다.
- 선택 핸들, 크기 제한, 저장 데이터는 같은 좌표 모델을 기준으로 처리한다.
이 구조로 바꾸면서 도형별 코드가 크게 줄었고, 새 도형을 추가할 때 확인해야 하는 위치도 줄었습니다. 특히 undo/redo나 선택 상태 같은 기능을 붙일 때 도형마다 별도 분기를 늘리지 않아도 되는 점이 가장 큰 이점이었습니다.
단점도 있습니다. 모든 도형을 하나의 모델로 다루면 개별 도형만의 예외가 생길 때 공통 모델이 복잡해질 수 있습니다. 그래서 이번 작업에서는 공통화 범위를 좌표와 path 변환까지만 제한했습니다. 텍스트, 자유곡선, 그룹처럼 편집 규칙이 다른 객체까지 같은 구조에 억지로 넣지는 않는 편이 안전합니다.
6. 검증 기준
리팩터링이 끝났다고 판단하기 전에, 단순히 화면에 도형이 그려지는지만 보지는 않았습니다. SVG 에디터의 도형 로직은 생성, 선택, 이동, 저장, 복원까지 이어지기 때문에 한 단계만 맞아도 전체 기능이 깨질 수 있습니다.
먼저 확인한 것은 좌표 방향이 바뀌는 경우였습니다. 사용자는 항상 왼쪽 위에서 오른쪽 아래로 드래그하지 않습니다. 오른쪽에서 왼쪽으로 선을 긋거나, 아래에서 위로 사각형을 만들 수도 있습니다. 이때 저장 데이터가 음수 width나 음수 height에 의존하면 이후 핸들 계산, 선택 영역 계산, 저장 복원에서 예외가 생깁니다. 그래서 도형 타입과 관계없이 두 점의 순서가 바뀌어도 같은 시각 결과가 나오는지 확인했습니다.
두 번째는 선택 핸들 위치였습니다.
리팩터링 전에는 도형마다 핸들 위치 계산이 조금씩 달라서, 같은 좌표를 가진 도형이라도 선택 박스가 미세하게 어긋나는 경우가 있었습니다.
공통 좌표 모델로 바꾼 뒤에는 line, rect, circle, dot 모두 같은 기준점에서 핸들을 계산하도록 맞췄습니다.
이 덕분에 선택 상태와 렌더링 상태를 분리하는 작업도 더 안정적으로 이어갈 수 있었습니다.
관련 내용은 SVG 에디터 선택 상태와 렌더링 상태 분리하기에서 따로 정리했습니다.
세 번째는 히스토리 재현성이었습니다.
도형을 만들고 이동한 뒤 undo/redo를 반복했을 때, 이전 상태와 같은 path가 다시 만들어져야 합니다.
렌더링 결과만 저장하면 화면은 맞아 보여도 편집 가능한 데이터가 깨질 수 있습니다.
그래서 히스토리에는 SVG path 문자열이 아니라 도형 타입과 좌표 데이터를 남기고, 복원 시점에 다시 getPathAttribute를 통과시키는 방식을 유지했습니다.
이 기준은 SVG 에디터 undo/redo 스냅샷 설계와도 연결됩니다.
7. 프로젝트에서 얻은 기준
이 작업은 MathCanvas 프로젝트에서 SVG 기반 수학교구를 계속 추가하면서 필요해진 정리였습니다. 처음부터 공통 모델을 크게 설계했다기보다, 점과 선을 만들고 사각형과 원이 추가되면서 반복되는 계산이 눈에 띄기 시작했습니다. 특히 교구 수가 늘어날수록 "새 기능을 추가하는 속도"보다 "기존 도형과 같은 규칙으로 동작하는지"가 더 중요해졌습니다.
이번 리팩터링에서 가장 도움이 된 기준은 저장 데이터와 렌더링 데이터를 분리한 것입니다. 저장 데이터는 가능한 작고 예측 가능해야 합니다. 반대로 렌더링 데이터는 SVG가 요구하는 형태에 맞춰 매번 계산해도 됩니다. 이렇게 나누면 저장 포맷을 바꾸지 않고도 렌더링 방식만 교체할 수 있고, 도형 추가 시에도 기존 저장 데이터와의 호환성을 지키기 쉽습니다.
다만 모든 중복을 제거하는 것이 목표는 아니었습니다.
공통화가 과해지면 if (type === ...) 분기가 한 파일에 몰리면서 오히려 읽기 어려워집니다.
그래서 이번 범위에서는 좌표 모델, path 변환, 핸들 위치 계산까지만 묶었습니다.
도형별 상호작용이나 스타일 정책은 각 도형의 성격이 다르기 때문에 별도 레이어에 남기는 편이 더 안전했습니다.
8. 요약 정리
| 구분 | 설명 |
|---|---|
| 관리 | 점, 선, 사각형, 원을 하나의 Class로 통합 |
| 렌더링 | type 에 따라 getPathAttribute 로 Path 반환 |
| dot 처리 | path 대신 circle 요소 사용 |
| 개선 | 핸들러 위치, 값 범위 계산 로직 단순화 |
| 성과 | 약 1100줄 -> 600줄로 간소화 |
이번 리팩터링은 큰 구조를 새로 만든 작업이라기보다, 이미 반복되고 있던 도형 편집 규칙을 한곳으로 모은 작업에 가깝습니다. SVG 에디터처럼 작은 인터랙션이 많은 도구에서는 이런 정리가 이후 기능 추가 속도보다 버그 재현성과 수정 범위를 줄이는 데 더 큰 효과를 냅니다.