자유 배치 캔버스 에디터 만들기 — 좌표계, 히트 테스트, 그리고 성능

프론트엔드

캔버스좌표계드래그앤드롭포인터 이벤트성능 최적화

이 글은 누구를 위한 것인가

  • 화이트보드·무드보드·다이어그램처럼 자유 배치 UI를 만드는 팀
  • 드래그가 어긋나거나 줌 상태에서 좌표가 틀어지는 문제를 겪는 개발자
  • 요소가 수백 개가 되면서 조작이 무거워지기 시작한 경우

들어가며

자유 배치 캔버스는 처음 만들 때 쉬워 보인다. position: absolutetransform: translate()를 걸고 mousemove로 좌표를 갱신하면 동작하기 때문이다.

무너지는 지점은 정해져 있다.

  · 줌을 넣는 순간 드래그가 커서를 못 따라온다
  · 스크롤한 상태에서 클릭 위치가 어긋난다
  · 드래그 중 커서가 요소를 벗어나면 드래그가 끊긴다
  · 요소가 200개를 넘으면 팬이 버벅인다
  · 겹친 요소 중 어느 것을 선택할지가 매번 다르다

다섯 개 전부 좌표계를 하나로 뭉뚱그린 것이 근본 원인이다. 이 글은 좌표계를 나누는 것에서 시작한다.


1. 좌표계는 세 개다

가장 먼저 할 일은 화면 좌표와 콘텐츠 좌표를 분리하는 것이다.

[세 좌표계]

  screen  — 브라우저 뷰포트 기준. PointerEvent.clientX/Y 가 이 값.
  canvas  — 캔버스 요소의 좌상단 기준. screen에서 getBoundingClientRect() 뺀 값.
  world   — 콘텐츠의 논리 좌표. 팬·줌과 무관하게 고정. 저장되는 값.

  world ──(scale, offset 적용)──> canvas ──(요소 위치 더하기)──> screen

저장은 언제나 world 좌표로 한다. 화면 좌표를 저장하면 줌 배율이 바뀌는 순간 데이터가 의미를 잃는다.

type Viewport = { scale: number; offsetX: number; offsetY: number };

function screenToWorld(
  clientX: number, clientY: number,
  rect: DOMRect, vp: Viewport,
): { x: number; y: number } {
  const cx = clientX - rect.left;          // screen → canvas
  const cy = clientY - rect.top;
  return {
    x: (cx - vp.offsetX) / vp.scale,       // canvas → world
    y: (cy - vp.offsetY) / vp.scale,
  };
}

function worldToCanvas(x: number, y: number, vp: Viewport) {
  return { x: x * vp.scale + vp.offsetX, y: y * vp.scale + vp.offsetY };
}

변환 함수를 두 개만 만들고 모든 곳에서 이것만 쓰는 것이 핵심이다. 여기저기서 직접 - rect.left를 하기 시작하면, 어느 시점에 하나를 빠뜨리고 그때부터 좌표가 어긋난다.

DOM 요소로 렌더할 때는 컨테이너에 변환을 한 번만 걸고, 개별 요소는 world 좌표를 그대로 쓴다.

.canvas-content {
  transform: translate(var(--offset-x), var(--offset-y)) scale(var(--scale));
  transform-origin: 0 0;              /* ★ 기본값(50% 50%)이면 계산이 어긋난다 */
  will-change: transform;
}

transform-origin: 0 0을 빠뜨리는 것이 가장 흔한 버그다. 기본값은 요소 중앙이라, 위의 변환 수식과 맞지 않는다.

canvas 2D로 그린다면 Canvas 변환 문서setTransform으로 같은 일을 한다.


2. 줌 — 커서 아래 지점을 고정한다

휠 줌을 그냥 scale *= 1.1로 구현하면 화면 좌상단 기준으로 확대되어, 사용자가 보려던 지점이 밖으로 밀려난다.

커서 위치의 world 좌표가 줌 전후에 같아야 한다.

function zoomAt(clientX: number, clientY: number, factor: number,
                rect: DOMRect, vp: Viewport): Viewport {
  const nextScale = clamp(vp.scale * factor, 0.1, 4);

  // 줌 전, 커서 아래의 world 좌표
  const wx = (clientX - rect.left - vp.offsetX) / vp.scale;
  const wy = (clientY - rect.top  - vp.offsetY) / vp.scale;

  // 줌 후에도 그 world 좌표가 같은 화면 위치에 오도록 offset 재계산
  return {
    scale: nextScale,
    offsetX: (clientX - rect.left) - wx * nextScale,
    offsetY: (clientY - rect.top)  - wy * nextScale,
  };
}

clamp로 배율 상하한을 두는 것도 잊지 말아야 한다. 상한이 없으면 사용자가 휠을 굴리다 배율이 1000이 되고, 하한이 없으면 콘텐츠가 점이 되어 되돌아올 방법이 없다.

트랙패드 핀치는 wheel 이벤트에 ctrlKey: true로 들어온다. 브라우저 기본 확대를 막으려면 preventDefault()가 필요하고, 그러려면 리스너에 passive: false를 명시해야 한다.

el.addEventListener('wheel', (e) => {
  if (e.ctrlKey) {                      // 핀치 줌
    e.preventDefault();
    setViewport(v => zoomAt(e.clientX, e.clientY, 1 - e.deltaY * 0.01, rect, v));
  } else {                              // 두 손가락 스크롤 = 팬
    setViewport(v => ({ ...v, offsetX: v.offsetX - e.deltaX, offsetY: v.offsetY - e.deltaY }));
  }
}, { passive: false });

3. 드래그 — 포인터 캡처로 끊김을 없앤다

드래그 중 커서가 요소 밖으로 나가면 이벤트가 끊기는 문제는, 리스너를 document에 다는 방식으로 흔히 해결한다. 하지만 표준 API가 따로 있다.

function onPointerDown(e: React.PointerEvent, cardId: string) {
  (e.target as Element).setPointerCapture(e.pointerId);   // ★
  const start = screenToWorld(e.clientX, e.clientY, rect, vp);
  dragRef.current = { cardId, startWorld: start, origin: cards[cardId].pos };
}

function onPointerMove(e: React.PointerEvent) {
  const d = dragRef.current;
  if (!d) return;
  const now = screenToWorld(e.clientX, e.clientY, rect, vp);
  pending.current = {
    x: d.origin.x + (now.x - d.startWorld.x),
    y: d.origin.y + (now.y - d.startWorld.y),
  };
}

function onPointerUp(e: React.PointerEvent) {
  (e.target as Element).releasePointerCapture(e.pointerId);
  commitDrag();
  dragRef.current = null;
}

setPointerCapture()는 해당 포인터의 이후 이벤트를 그 요소로 강제 전달한다. document 리스너 방식보다 나은 점이 셋이다.

  · 리스너 등록/해제를 반복하지 않는다
  · 멀티 터치에서 포인터별로 독립 동작한다 (pointerId 단위)
  · 다른 요소 위를 지나가도 이벤트가 새지 않는다

드래그 중 상태 갱신은 프레임 루프로 미룬다. pointermove마다 React 상태를 바꾸면 초당 수십 번 리렌더가 일어난다.

useEffect(() => {
  let raf = 0;
  const tick = () => {
    if (pending.current) {
      setCardPos(dragRef.current!.cardId, pending.current);
      pending.current = null;
    }
    raf = requestAnimationFrame(tick);
  };
  raf = requestAnimationFrame(tick);
  return () => cancelAnimationFrame(raf);
}, []);

4. 히트 테스트 — 겹친 요소 중 무엇을 고를 것인가

요소가 겹치면 "어느 것을 클릭한 것인가"를 정해야 한다. DOM 요소로 렌더한다면 브라우저가 알아서 해주지만, z-order 관리는 여전히 직접 해야 한다.

[선택 규칙 — 위에서부터 검사]

  1. 현재 선택된 요소가 클릭 지점을 포함하면 그것을 유지
     (선택 후 미세 이동 시 다른 요소로 튀지 않게)
  2. 그렇지 않으면 z-index가 가장 높은 요소
  3. 같은 z-index면 나중에 추가된 것

1번 규칙이 없으면 겹친 영역에서 드래그를 시작할 때마다 선택이 바뀌어 조작이 불가능해진다.

canvas로 그리는 경우 히트 테스트를 직접 해야 한다. 순진하게 하면 O(n)이다.

// 뒤에서부터(위에 있는 것부터) 검사
function hitTest(wx: number, wy: number, cards: Card[]): Card | null {
  for (let i = cards.length - 1; i >= 0; i--) {
    const c = cards[i];
    if (wx >= c.x && wx <= c.x + c.w && wy >= c.y && wy <= c.y + c.h) return c;
  }
  return null;
}

요소가 수천 개가 아니면 이걸로 충분하다. 공간 인덱스(쿼드트리 등)는 5,000개를 넘어갈 때 고려하면 된다. 그 전에 도입하면 유지 비용만 늘어난다.


5. 요소가 많아질 때 — 뷰포트 컬링

성능 문제의 대부분은 화면 밖 요소까지 렌더링하는 것에서 온다.

function visibleCards(cards: Card[], vp: Viewport, rect: DOMRect): Card[] {
  const pad = 200;                                   // 여유분
  const left   = (-vp.offsetX - pad) / vp.scale;
  const top    = (-vp.offsetY - pad) / vp.scale;
  const right  = (rect.width  - vp.offsetX + pad) / vp.scale;
  const bottom = (rect.height - vp.offsetY + pad) / vp.scale;

  return cards.filter(c =>
    c.x + c.w >= left && c.x <= right &&
    c.y + c.h >= top  && c.y <= bottom
  );
}

여유분(pad)을 두는 이유는 팬할 때 요소가 갑자기 나타나는 걸 막기 위해서다. 200px 정도면 대부분의 팬 속도를 흡수한다.

DOM 렌더 방식이라면 Intersection Observer로 무거운 콘텐츠(이미지·임베드)의 로딩만 지연시키는 방법도 있다. 카드 껍데기는 전부 렌더하되 내용은 보일 때 채우는 방식이다.

[요소 수별 전략]

  ~200개     전부 DOM 렌더. 최적화 불필요
  200~2,000  뷰포트 컬링 + 콘텐츠 지연 로드
  2,000~     canvas 렌더 검토 (DOM 노드 수 자체가 부담)

DOM에서 canvas로 넘어가는 결정은 신중해야 한다. 접근성, 텍스트 선택, 입력 필드가 전부 직접 구현 대상이 된다. 요소 수만 보고 결정할 문제가 아니다.


6. 자유 배치와 정렬 사이 — 스냅

완전 자유 배치는 만들기 쉽지만 결과물이 지저분해진다. 스냅은 그 사이의 절충이다.

const GRID = 8;

function snapToGrid(v: number) {
  return Math.round(v / GRID) * GRID;
}

// 다른 요소의 모서리에도 스냅
function snapToEdges(pos: Point, size: Size, others: Card[], threshold = 6) {
  let { x, y } = pos;
  for (const o of others) {
    for (const cand of [o.x, o.x + o.w, o.x + o.w / 2 - size.w / 2]) {
      if (Math.abs(x - cand) < threshold) { x = cand; break; }
    }
    for (const cand of [o.y, o.y + o.h, o.y + o.h / 2 - size.h / 2]) {
      if (Math.abs(y - cand) < threshold) { y = cand; break; }
    }
  }
  return { x, y };
}

스냅 임계값은 줌 배율로 나눠야 한다. 화면상 6px이 기준이므로, world 좌표에서는 6 / scale이다. 이걸 빼먹으면 확대 상태에서 스냅이 지나치게 강하게 걸린다.

스냅이 걸렸을 때 정렬선을 보여주는 것이 사용성에 크게 기여한다. 스냅은 됐는데 시각적 피드백이 없으면 사용자는 자기가 정확히 놓았다고 착각한다.

캔버스형 배치가 실제 제품에서 어떤 형태로 정착하는지는 Linkme의 기능 소개를 보면 참고가 된다. 링크·영상·이미지를 한 캔버스에 카드로 배치하고 그대로 공개 페이지로 내보내는 구조인데, 편집 화면의 자유도와 공개 화면의 정돈됨을 어떻게 양립시킬 것인가가 이런 제품의 핵심 설계 문제다. 편집기에서 자유 배치를 허용하되 출력 단계에서 그리드로 정규화하는 접근이 한 가지 답이다.


7. 접근성 — 자유 배치의 가장 약한 고리

시각적 위치가 의미를 갖는 UI는 키보드·스크린리더 사용자에게 접근이 어렵다. 최소한 다음은 갖춰야 한다.

  □ 키보드로 요소 간 이동 (Tab 또는 방향키)
  □ 키보드로 위치 조정 (선택 후 방향키, Shift로 큰 단위)
  □ 읽기 순서를 시각 순서와 맞추기
      DOM 순서 ≠ 시각 순서인 경우가 자유 배치의 본질적 문제
  □ 각 요소에 의미 있는 접근 가능한 이름
  □ 선택 상태를 aria-selected 로 전달

읽기 순서 문제가 가장 까다롭다. 자유 배치에서는 DOM 순서가 생성 순서이고 시각 순서는 좌표 순이라, 스크린리더가 화면과 다른 순서로 읽는다. 완전한 해법은 없지만, 공개/출력 화면에서는 좌표 기준으로 정렬한 DOM을 따로 생성하는 것이 실용적인 타협이다.

편집 화면은 자유 배치, 공개 화면은 읽기 순서가 보장된 선형 구조 — 이렇게 나누면 두 요구를 모두 만족시킬 수 있다.


8. 정리

  1. screen / canvas / world 세 좌표계를 분리하고 변환 함수만 사용
  2. transform-origin: 0 0 (기본값은 중앙 — 수식과 안 맞음)
  3. 줌은 커서 아래 world 좌표를 고정, 배율에 상하한
  4. 드래그는 setPointerCapture, 상태 갱신은 프레임 루프로
  5. 히트 테스트는 선택 유지 규칙을 먼저 (겹침에서 튀지 않게)
  6. 뷰포트 컬링에 여유분 200px
  7. 스냅 임계값은 scale로 나눈다 + 정렬선 표시
  8. 공개 화면은 좌표 순으로 정렬된 선형 DOM을 따로

가장 먼저 할 것은 1번이다. 좌표계를 분리하지 않은 채로 줌이나 팬을 추가하면, 이후 모든 기능에서 어긋남이 재발한다.