크로스 플랫폼의 한 기둥을 맡으며
기존 캔버스는 태블릿에서만 가능했었는데, 웹과 앱에서 모두 동작하는 캔버스V2 버전 개발을 진행했다.
코어 렌더링 엔진은 Dart(Flutter)로 작성된 Wasm 모듈이다.
웹에서도 동일한 엔진을 wasm으로 컴파일해서 쓴다. React 앱 안에 Flutter Wasm 캔버스가 들어있는 구조인 셈이다.
Flutter Wasm → window.flutter_canvas → React
모바일에서는 핀치 줌과 드래그 패닝이 OS 레벨에서 자연스럽게 지원된다.
두 손가락으로 벌리면 확대, 한 손가락으로 밀면 이동. 사용자 입장에서 당연한 인터렉션이다.
Wasm 캔버스 코어 엔진에는 뷰포트 조작 기능이 없다.
단지 툴 상태를 변경하거나 텍스트나 이미지 같은 엘리먼트를 제어하는 API 들이 뚫려있고, 모든 (숏컷을 포함한) 핸들링과 제어권은 React에서 맡았다.
그래서 깡으로 확대, 축소, 패닝을 구현해야 했다. 자연스럽게.
사실 뷰포트 조작이 아예 없었던 건 아니다. 캔버스 개발 전, 이미지 뷰어 모드에서는 react-zoom-pan-pinch라는 라이브러리를 쓰고 있었다. 이미지를 확대해서 보거나 드래그로 이동하는 기본적인 기능은 이 라이브러리가 잘 해주고 있었다.
그런데 캔버스 모드를 구현하면서 문제가 생겼다. 캔버스 위에서는 react-zoom-pan-pinch를 쓸 수 없었다.
이 라이브러리가 마우스/터치 이벤트를 전부 가로채버리면 Wasm 캔버스가 드로잉 입력을 받지 못하기 때문이다.
그려야 할 때는 그리고, 이동해야 할 때는 이동하는 전환이 라이브러리 위에서는 불가능했다.
그래서 캔버스 드로잉 모드에서는 커스텀 구현을 할 수밖에 없었고, 그럴거면 이미지 뷰어 모드일때 라이브러리를 굳이 쓸 필요가..?
사용자 경험 측면이나, 복잡도 면에서도 걷어내고 직접 구현하는게 나아보였다.
기획 요구사항은 심플했다.
이 모든 걸 Wasm 캔버스 바깥의 React 레이어에서 처리해야 했다. 당연히 확대, 패닝은 이미지 경계 내에서만 이루어져야한다.
그래서 usePanningZoom 훅을 만들어 이미지 뷰어 모드, 캔버스 모드 동일하게 동작할 수 있게 신경썼다.
// canvas-container.tsx
const { panningHandler, transform, zoomIn, zoomOut, minScale } = usePanningZoom({
containerRef, // 바깥 컨테이너 DOM
width, // 캔버스 원본 width
height, // 캔버스 원본 height
isReady, // Wasm 로딩 완료 여부
});
// CSS transform으로 반영
<div style={{
transform: `translate(${transform.x}px, ${transform.y}px) scale(${transform.scale})`,
transformOrigin: 'top left',
willChange: 'transform',
}}>
<WasmCanvas ... />
</div>
```핵심 아이디어는 Wasm 캔버스 좌표계는 건드리지 않고, 감싼 부모 div 의 CSS transform만으로 확대/축소/이동을 구현한다.
캔버스의 원본 크기는 사진마다 다르다.
4032×3024 짜리 사진도 있고, 800×600 짜리도 있다.
이 다양한 크기의 캔버스를 모달 안의 컨테이너에 비율을 유지하면서 가장 크게 넣어야 한다.
const updateTransform = useCallback(
(containerWidth: number, containerHeight: number) => {
const scaleX = containerWidth / width;
const scaleY = containerHeight / height;
// 비율 유지를 위해 더 작은 scale을 사용
const initialScale = Math.min(scaleX, scaleY);
const initialX = (containerWidth - width * initialScale) / 2;
const initialY = (containerHeight - height * initialScale) / 2;
minScale.current = initialScale; // 이게 "최소 축소 한계"가 된다
setTransform({ x: initialX, y: initialY, scale: initialScale });
},
[width, height],
);가로가 더 넓은 사진이면 scaleY가 더 작을 것이고, 세로가 더 긴 사진이면 scaleX가 더 작다. 작은 쪽을 쓰면 캔버스 전체가 컨테이너 안에 빠짐없이 들어간다.
그리고 이 초기 스케일이 곧 minScale이 된다. 이것보다 더 축소하면 안 된다. 캔버스가 컨테이너보다 작아지면 의미가 없으니까.
ResizeObserver로 컨테이너 크기 변경도 감지한다. 사용자가 브라우저 창을 리사이즈하면 updateTransform이 다시 호출되어 스케일이 재계산되어야 하므로.
패닝을 구현할 때 가장 쉬운 방법은 경계에 도달하면 딱 멈추는 것이다.
Math.max(min, Math.min(value, max)) 으로 끝.
근데 이런 하드 클램핑은 써보면 알겠지만 답답하다. 뭔가 벽에 부딪히는 느낌..
좋은 UI는 사용자에게 경계를 느끼게 해주되, 강제하지 않는다.
iOS에서 스크롤을 끝까지 내렸을 때를 떠올려보자. 딱 멈추지 않는다. 약간 더 끌려가고, 손을 떼면 고무줄처럼 다시 돌아온다. 그걸 러버밴드(Rubber Band) 효과 라고 부르는 것 같다.
걷어낸 라이브러리지만, 참고할 건 참고해야 한다.
react-zoom-pan-pinch의 패닝 로직을 까보면 이 바운싱이 어떻게 구현되어 있는지 알 수 있다.
// react-zoom-pan-pinch/src/core/bounds/bounds.utils.ts
export function getMouseBoundedPosition(...) {
// 원래 경계 [min, max]에 padding을 더해서 [min - padding, max + padding]으로 확장
const x = boundLimiter(positionX, minPositionX - paddingX, maxPositionX + paddingX, limitToBounds);
const y = boundLimiter(positionY, minPositionY - paddingY, maxPositionY + paddingY, limitToBounds);
return { x, y };
}패딩 보정치로 일시적으로 경계를 확장한다. (디폴트 100)
즉, 진짜 경계보다 padding만큼 더 이동할 수 있게 허용한다.
// react-zoom-pan-pinch/src/core/pan/panning.logic.ts
export function handlePanningEnd(contextInstance) {
if (shouldAnimate) {
handleVelocityPanning(contextInstance); // 관성이 있으면 관성 + 복귀
} else {
handleAlignToBounds(contextInstance); // 관성 없으면 바로 복귀
}
}
export function handleAlignToBounds(contextInstance) {
const targetState = handlePanToBounds(contextInstance); // padding 없는 진짜 경계 좌표 계산
animate(contextInstance, targetState, animationTime, animationType); // easeOut으로 부드럽게 복귀
}애니메이션이 필요한 순간은 경계를 벗어나있을 때다.
이때는 다시 경계를 계산해서 원래 있어야 할 위치로 이동시킨다.
여담으로, 해당 라이브러리에는 IOS 러버밴드 물리 공식도 구현돼있다. 궁금하다면 bounds.utils.ts 를 확인해보시라. 경계에서 멀어질수록 저항이 커지는 비선형 감속 공식 이라고 한다.
그래서 나는 어떻게 구현했는가.
드래그 중에는 padding 이 적용된 넓은 경계를, 드래그가 끝나면 padding 없는 진짜 경계를 적용한다.
const getBoundedTransform = useCallback(
(targetTransform: { x: number; y: number; scale: number }, usePadding = true) => {
const container = containerRef.current;
if (!container) return targetTransform;
const { x, y, scale } = targetTransform;
const containerWidth = container.clientWidth;
const containerHeight = container.clientHeight;
const scaledWidth = width * scale;
const scaledHeight = height * scale;
// usePadding 플래그로 경계 확장 여부를 결정
const appliedPadding = usePadding ? panningPadding : 0;
let minX, maxX, minY, maxY;
if (scaledWidth > containerWidth) {
// 확대된 상태: 캔버스가 컨테이너보다 크므로 좌우로 이동 가능
minX = containerWidth - scaledWidth - appliedPadding;
maxX = appliedPadding;
} else {
// 축소/원본 상태: 캔버스가 컨테이너 안에 있으므로 중앙 정렬 기준으로 제한
const offsetX = (containerWidth - scaledWidth) / 2;
minX = offsetX - appliedPadding;
maxX = offsetX + appliedPadding;
}
// Y축도 동일 로직 ...
return {
scale,
x: Math.max(minX, Math.min(x, maxX)),
y: Math.max(minY, Math.min(y, maxY)),
};
},
[width, height, panningPadding],
);usePadding 파라미터 하나로 "드래그 중"과 "드래그 후"의 경계가 갈린다.
getBoundedTransform(transform, true) → 드래그 중, padding 100px 확장된 경계getBoundedTransform(transform, false) → 드래그 끝/줌, padding 없는 진짜 경계이 함수를 호출하는 쪽을 간략히 보여주면
const handleMouseMove = useCallback((e: React.MouseEvent) => {
if (!panState.current.isPanning) return;
const newTransform = getBoundedTransform({
x: e.clientX - panState.current.startX,
y: e.clientY - panState.current.startY,
scale: transformRef.current.scale,
}, true); // 여긴 true
requestAnimationFrame(() => setTransform(newTransform));
}, [getBoundedTransform]);
// 드래그 끝 — 패딩 없는 진짜 경계로 복귀
const handleMouseUpOrLeave = useCallback(() => {
panState.current.isPanning = false;
const finalTransform = getBoundedTransform(transformRef.current, false); // 여긴 false
// 이미 경계 안이면 아무것도 안 함
if (finalTransform.x === transformRef.current.x && finalTransform.y === transformRef.current.y) return;
// easeOut 커빙으로 부드럽게 복귀
const from = transformRef.current;
const to = finalTransform;
const duration = 250;
const animateToFinalPosition = (timestamp: number) => {
const progress = Math.min((timestamp - startTime) / duration, 1);
const easeOutProgress = 1 - Math.pow(1 - progress, 3);
setTransform({
x: from.x + (to.x - from.x) * easeOutProgress,
y: from.y + (to.y - from.y) * easeOutProgress,
scale: to.scale,
});
if (progress < 1) requestAnimationFrame(animateToFinalPosition);
};
requestAnimationFrame(animateToFinalPosition);
}, [getBoundedTransform]);결과적으로 사용자 입장에서는 경계 밖으로 약간 더 끌리고, 손을 떼면 부드럽게 돌아오는 경험을 얻게 된다.
작은 디테일이지만 있고 없고의 차이는 크다. 이런 게 없으면 "이 캔버스 뭔가 딱딱하다" 라는 막연한 불편함이 남는다. (원래 있었던 기능이기도 하고…)
1 - Math.pow(1 - progress, 3)이 ease-out cubic curve 라는 것도 이걸 구현하기 위해 찾아보다가 알게 됐다.
역시 해본적 없는 기능을 맡다보면 공부가 잘 된다.
아, 복귀 애니메이션을 CSS로 하지 않고 requestAnimationFrame 으로 직접 구현한 이유는 이벤트가 충돌하는 경우가 있었기 때문이다. 돌아오는 중에 다시 마우스를 꾹 누른다면, 얼른 애니메이션은 취소 시켜야한다.
const handleMouseDown = useCallback((e) => {
// 스냅백 애니메이션 중이면 즉시 취소
if (snapAnimationId.current) {
cancelAnimationFrame(snapAnimationId.current);
}
// ... 패닝 시작
}, []);피그마에서 스페이스바를 꾹 누르면 마우스 포인터가 grab 으로 바뀌면서 잡아 끌고 다닐 수 있다.
무릇 캔버스라면 가능해야 하는 기능이다.
문제는, 핸드 모드는 Wasm 코어 엔진에 없는 상태라는 것이다. 코어의 도구는 pen, eraser, highlighter, selection뿐이다. hand라는 도구는 없다.
그래서, 구조는 이렇게 설계했다.
[React 레이어]
- webToolbarState: 'drawing' | 'textElement' | 'grab' ← 'grab'은 웹 전용
- isDrawingDisabled: boolean ← true면 Wasm 위에 투명 div를 덮어서 입력 차단 (상태관리)
[Wasm 레이어]
- currentTool: 'pen' | 'eraser' | ... (핸드 모드를 모름)
스페이스바를 누르면
isDrawingDisabled = true → Wasm 캔버스 위에 투명 div가 덮인다.div가 마우스 이벤트를 받는다.div의 부모에 panningHandler가 걸려 있으므로 패닝은 동작한다.
// canvas-container.tsx — 투명 오버레이
{isDrawingDisabled && <div className="absolute inset-0 w-full h-full" />}이 한 줄의 div가 Wasm의 입력을 차단하고 패닝을 가능하게 하는 스위치 역할을 한다.
캔버스 엔진에서 eraser 툴 상태일때 커서에 지우개 영역을 표시해주는 부분이 있다.
지우개 도구를 쓰다가 스페이스바를 누르면 핸드 모드로 전환되는데, 이때 Wasm 코어의 상태는 그대로다 보니 도구의 상태를 잠시 웹에서 의도적으로 pen 이나 highlight 같은걸로 교체해서 ref 에 담아두고, 키 스트림이 끝나면 다시 되돌려주는 방식으로 해결했다.
const handleHandToolShortcut = useCallback(
(isHand: boolean) => {
if (isHand) {
setIsActiveHand(true);
setIsDrawingDisabled(true);
// 지우개 상태였으면 기억해뒀다가 나중에 복원
if (webTool === "eraser") {
eraserSnapshotRef.current = true;
setTool("pen"); // 코어 도구를 잠시 바꿈
}
} else {
setIsActiveHand(false);
setIsDrawingDisabled(false);
// 핸드 모드 해제 시 지우개로 복원
if (eraserSnapshotRef.current) {
setTool("eraser");
eraserSnapshotRef.current = false;
}
}
},
[setTool, webTool],
);┌──────────── 웹 레이어 (React) ────────────┐
│ usePanningZoom: scale, x, y 관리 │
│ CSS transform으로 뷰포트 제어 │
│ 스페이스바 핸드 모드 (투명 div 오버레이) │
│ 경계 보정 + 스냅백 애니메이션 │
├─────────────────────────────────────────┤
│ window.flutter_canvas (브릿지) │
├─────────────────────────────────────────┤
│ Flutter Wasm (코어 엔진) │
│ → 자기 좌표계에서 그림만 그림 │
│ → 뷰포트 존재를 모름 │
└─────────────────────────────────────────┘이렇게해서 웹에서 캔버스를 사용할때 자연스러운 확대/축소, 패닝 까지 다뤄볼 수 있었다.
react-zoom-pan-pinch 오픈소스를 뜯어보며 러버밴드 원리를 알게되고, 마우스 기준 줌 공식(newX = mouseX - (mouseX - oldX) * (newScale / oldScale))도 비슷한 류의 캔버스 라이브러리 코드들을 기웃거리다가 찾았다.
직접 유도하려고 종이에 좌표계를 그려본 적도 있는데, 솔직히 공식을 먼저 보고 역으로 이해하는 게 훨씬 빨랐다.
react-zoom-pan-pinch을 걷어내야 할 상황이 되면서, 내장돼있는 편의기능들을 최대한 살리려고 노력했고, 그게 좋은 공부가 되었던 것 같다.
개발 과정에서 세웠던 원칙이 있었는데,
getBoundedTransform이라는 단일 관문을 거친다. 여기저기서 경계를 체크하면 어디서 보정이 빠졌는지 못 찾는다.requestAnimationFrame으로 직접 제어해야 "스냅백 중 다시 드래그", “더블클릭 1.2배 확대 기능” 같은 이벤트 충돌 케이스를 처리할 수 있다.이 원칙들이 없었을 때 실제로 코드가 엉망이었고, 원칙을 세운 뒤에 리팩토링하면서 비로소 정리가 됐다.
물론 이 글에서 다루지 못한 것도 있다.
이 이야기들은 기회가 된다면 회고식으로 여러번 다뤄볼까 한다.