라이브러리가 구조적 한계에 부딪히면, 패치할 것인가 처음부터 만들 것인가. 그 판단이 3주짜리 삽질과 3주짜리 설계를 가른다.
캔버스 크기가 제품의 한계가 되는 순간
Kumo Factory는 AWS 인프라를 시각적으로 설계하는 노코드 SaaS였다. 사용자가 EC2, VPC, 서브넷을 드래그 앤 드롭으로 배치하면 실제 클라우드가 프로비저닝된다. 제품의 핵심 경험은 단 하나 — 아키텍처가 아무리 커져도 끊김 없이 그릴 수 있는가.
처음에는 react-zoom-pan-pinch를 검토했다. 줌/팬 인터랙션을 빠르게 붙일 수 있어서 매력적이었지만, 곧 문제를 발견했다. 이 라이브러리는 내부적으로 CSS transform 컨테이너를 사용하는데, 컨테이너 크기에 물리적 한계가 있다. 노드 수십 개를 넘기면 가장자리에서 잘리거나 좌표가 어긋나기 시작했다.
패치할 수 있는 문제인지 먼저 따져봤다. 결론은 구조적 한계라는 것이었다. transform 컨테이너 방식 자체가 유한한 픽셀 공간을 전제하기 때문에, 아무리 패치해도 "무한"은 불가능하다. 직접 만들기로 결정했다.
SVG viewBox라는 카메라
CSS transform 대신 SVG의 viewBox를 선택했다. 핵심 아이디어는 이렇다 — SVG 좌표 공간은 원래 무한하다. viewBox는 그 무한한 공간 위를 떠다니는 카메라 창이다. 카메라를 움직이면 팬, 카메라 시야를 넓히거나 좁히면 줌이 된다.
<!-- viewBox="x y width height" -->
<!-- 카메라 위치(x,y)와 시야 크기(width,height)만 조절하면 무한 캔버스 -->
<svg viewBox="0 0 1200 800">
<!-- 이 안에 놓이는 모든 요소는 SVG 좌표 공간에 존재 -->
<!-- viewBox를 옮기면 다른 영역이 보인다 -->
</svg>이 모델에서 줌과 팬은 viewBox의 네 값 — x, y, width, height — 만 바꾸면 된다. DOM 요소의 transform을 건드리지 않으니 브라우저 렌더링 파이프라인에서 layout을 다시 계산할 필요가 없다. 이게 성능의 출발점이었다.
3x3 아핀 행렬로 좌표 변환 통합하기
줌과 팬을 따로따로 계산하면 코드가 빠르게 꼬인다. 줌 레벨이 바뀌면 팬 오프셋도 보정해야 하고, 마우스 좌표를 월드 좌표로 변환하는 것도 매번 스케일을 고려해야 한다. 이걸 깔끔하게 풀기 위해 3x3 아핀 변환 행렬을 도입했다.
아핀 행렬 하나에 이동(translate)과 확대(scale)를 모두 인코딩하면, 어떤 좌표든 행렬 곱 한 번으로 변환할 수 있다. 줌 레벨에 따라 달라지는 픽셀 단위 계산이 이 프로젝트에서 가장 까다로운 부분이었는데, 행렬로 통합하니 모든 줌 레벨에서 정확한 좌표 변환이 가능해졌다.
포인터 기준 줌 — 순서가 틀리면 좌표가 점프한다
줌의 사용성은 하나로 결정된다 — 마우스 포인터 아래의 점이 줌 전후로 같은 월드 좌표를 가리키는가. 이걸 못 맞추면 줌할 때마다 캔버스가 "미끄러진다". 구현 자체는 비례(proportion) 기반 좌표 재계산인데, 여기서 한 가지 치명적인 실수를 했다.
처음에는 스케일을 먼저 바꾸고 나서 월드 좌표를 계산했다. 결과는 줌할 때마다 좌표가 점프하는 버그. 월드 좌표를 스케일 변경 전에 계산해야 한다는 걸 깨닫는 데 하루가 걸렸다. 순서가 중요하다.
onMouseWheel: (e) => {
set((state) => {
// 1. 먼저 현재 스케일로 월드 좌표 계산 (순서 중요!)
const pt = getGridPoint(e, state.scale, state.viewBox, state.svgRect);
// 2. 스케일 업데이트
let scale = e.deltaY / 1000;
state.scale = Math.max(0.5, Math.min(5, state.scale + scale));
// 3. 포인터 기준으로 viewBox 원점 재계산
const { x, y, width, height } = state.viewBox;
const [xRatio, yRatio] = [(pt.x - x) / width, (pt.y - y) / height];
const [newW, newH] = [
state.viewBoxOriginSize.width * state.scale,
state.viewBoxOriginSize.height * state.scale,
];
state.viewBox = {
x: pt.x - xRatio * newW,
y: pt.y - yRatio * newH,
width: newW,
height: newH,
};
});
}핵심은 getGridPoint를 스케일 변경 전에 호출한다는 것이다. 비례값 xRatio, yRatio는 현재 viewBox에서 포인터가 어디에 있는지를 나타내고, 새로운 viewBox 크기에 같은 비율을 적용하면 포인터 아래의 월드 좌표가 고정된다.
getBoundingClientRect가 필요한 이유
SVG 요소를 width="100%"로 배치하면, 마우스 이벤트의 clientX/clientY를 SVG 좌표로 변환할 때 함정이 있다. 단순히 * scale로 곱하면 SVG의 실제 렌더링 크기가 뷰포트에 따라 달라지기 때문에 어긋난다.
해결은 getBoundingClientRect()로 SVG 요소의 실제 화면 크기를 구하고, 그 비율로 변환하는 것이다. 이게 반응형 레이아웃에서 유일하게 정확한 방법이다.
function toWorld(clientX: number, clientY: number, vb: ViewBox, svgEl: SVGSVGElement) {
const rect = svgEl.getBoundingClientRect();
return {
x: ((clientX - rect.left) / rect.width) * vb.width + vb.x,
y: ((clientY - rect.top) / rect.height) * vb.height + vb.y,
};
}5-slice Zustand으로 60fps 사수하기
캔버스 엔진의 상태는 복잡하다. 마우스 위치, viewBox, 줌 레벨 같은 공통 상태와 서비스 노드, 영역, 연결선, 옵션 같은 도메인 상태가 뒤섞인다. 전부 하나의 스토어에 넣으면 마우스가 움직일 때마다 노드 수백 개가 리렌더된다.
Zustand의 slice 패턴으로 상태를 5개 조각으로 분리했다 — Common, Service, Area, Line, Option. Immer를 결합해서 깊은 중첩 객체도 직관적으로 업데이트하고, 선택적 구독(selective subscribing)으로 해당 slice가 바뀔 때만 관련 컴포넌트가 리렌더되게 했다.
type AllStates = AreaState & CommonState & ServiceState & LineState & OptionState;
const useBlueprintStore = create<AllStates>()(
devtools(
immer((...a) => ({
...useCommonSlice(...a),
...useServiceSlice(...a),
...useAreaSlice(...a),
...useLineSlice(...a),
...useOptionSlice(...a),
})),
),
);
// 컴포넌트에서 — 필요한 상태만 구독
const scale = useBlueprintStore((s) => s.scale);
const nodes = useBlueprintStore((s) => s.services);결과: 대규모 캔버스에서도 60fps 유지, 메모리 사용량 약 30% 절감, 불필요한 API 호출 50% 이상 감소. 데이터 정규화로 Single Source of Truth를 확보한 것도 API 호출 감소에 크게 기여했다.
SSE로 배포 상태를 100ms 안에 브라우저로
캔버스에서 설계한 아키텍처는 실제로 클라우드에 배포된다. 배포가 시작되면 각 노드의 상태(pending, creating, running, failed)를 실시간으로 시각화해야 했다. WebSocket 대신 SSE(Server-Sent Events)를 선택했다. 서버→클라이언트 단방향 스트림이면 충분했고, 별도의 프로토콜 업그레이드 없이 HTTP 위에서 동작한다.
SSE 이벤트를 받으면 해당 노드의 상태를 Zustand 스토어에 즉시 반영한다. 선택적 구독 덕분에 상태가 바뀐 노드만 리렌더되고, 캔버스 전체가 다시 그려지지 않는다. 배포 상태 반영 지연은 100ms 이내.
3주의 투자가 남긴 것
라이브러리를 버리고 직접 만드는 결정은 위험했다. 구현에 약 3주가 걸렸고, 그 시간 동안 다른 기능은 손댈 수 없었다. 하지만 결과적으로 제품의 핵심 경험 — "끊김 없는 무한 캔버스" — 을 확보했고, 어떤 라이브러리에도 의존하지 않는 인터랙션 엔진을 가지게 되었다.
- 라이브러리가 "구조적 한계"인지 "패치 가능한 버그"인지 먼저 판단하라
- viewBox는 무한한 SVG 좌표 공간 위의 카메라다
- 포인터 기준 줌: 월드 좌표를 스케일 변경 전에 계산해야 한다
- width="100%" SVG에서는 getBoundingClientRect() 비율로 좌표를 변환하라
- Zustand slice + 선택적 구독으로 대규모 캔버스 60fps를 사수할 수 있다