JIN.PROC // v3.1
~/ / 프로젝트 / 2026-04-29-lazyclaude-deep-2-workflow-canvas

[LazyClaude 심층 2] 워크플로우 캔버스 최적화 — rAF 코얼레싱과 Map 캐시

mousemove당 100회 렌더 → rAF당 1회. O(N×E) → O(deg)

DATE 2026.04.29 UPDATED 2026.04.29 READ ~ 3 MIN WORDS 480

LazyClaude 심층 시리즈 2편. 노드 기반 캔버스가 무거워지는 두 가지 원인과 그 해법.

증상

워크플로우 캔버스에서 노드를 드래그하면 화면이 밀린다. 프로파일러가 보여 준 것:

  • mousemove마다 _wfRenderMinimap()이 동기 호출 — 초당 약 100회
  • 매 호출에서 모든 엣지 를 순회해 각 엣지마다 양쪽 노드 를 다시 lookup → O(N × E)

원인은 둘이 합쳐진 결과지만, 해법은 분리된다.

원인 1 — 입력 폭주

mousemove는 사용자에게 보이는 것보다 훨씬 자주 발화된다. 60Hz 모니터에서 16.7ms 단위면 충분한데, 브라우저는 그보다 자주 이벤트를 던질 수 있다. 렌더링은 화면 주사율에 맞춰서만 의미가 있다.

해법: requestAnimationFrame 코얼레싱

let rafId = null;
let pending = null;

function scheduleRender(state) {
  pending = state;
  if (rafId) return;
  rafId = requestAnimationFrame(() => {
    rafId = null;
    const s = pending; pending = null;
    _wfRenderMinimap(s);
  });
}

canvas.addEventListener("mousemove", (e) => {
  scheduleRender(currentState(e));
});

핵심은 두 가지:

  • 들어오는 호출은 합친다 (coalesce) — 마지막 상태만 남김
  • 렌더는 rAF 한 틱당 한 번 — 화면 주사율과 동기화

원인 2 — 잘못된 자료구조

드래그 중인 노드의 인접 엣지를 찾기 위해 전체 엣지 배열 을 매 프레임 순회하고 있었다. 그리고 엣지마다 nodes.find(n => n.id === edge.from)로 노드를 또 검색.

해법: Map 캐시 + 인접 인덱스

드래그 라이프사이클 동안만 유지되는 인덱스를 만든다:

function buildDragIndex(nodes, edges) {
  const nodeById = new Map(nodes.map(n => [n.id, n]));
  const adj = new Map();
  for (const e of edges) {
    if (!adj.has(e.from)) adj.set(e.from, []);
    if (!adj.has(e.to))   adj.set(e.to,   []);
    adj.get(e.from).push(e);
    adj.get(e.to).push(e);
  }
  return { nodeById, adj };
}

let dragIndex = null;

function onDragStart(nodes, edges) {
  dragIndex = buildDragIndex(nodes, edges);
}

function onDragMove(draggedId) {
  const incident = dragIndex.adj.get(draggedId) || [];
  for (const e of incident) {
    const a = dragIndex.nodeById.get(e.from);
    const b = dragIndex.nodeById.get(e.to);
    drawEdge(a, b);
  }
}

function onDragEnd() {
  dragIndex = null;
}

복잡도 변화:

단계 이전 이후
노드 lookup Array.find O(N) Map.get O(1)
엣지 순회 전체 O(E) 인접 O(deg)
드래그 1프레임 총합 O(N × E) O(deg)

빌드/해체 비용 — 무시할 수 없다

buildDragIndex도 비용이 있다 (O(N + E)). 그래서 드래그가 시작될 때만 만들고, 끝나면 버린다. 항상 들고 다니면 노드 추가/삭제마다 동기화 부담이 생긴다.

패턴: 짧은 라이프사이클의 인덱스 는 명시적으로 시작/종료 시점을 잡아라.

부산물 — 렌더 자체도 빨라짐

코얼레싱과 인덱스화가 합쳐지면서 렌더 함수 자체 가 가벼워진다. 프레임당 일이 적어지니 requestIdleCallback에 미니맵을 미루는 등의 추가 최적화도 쉬워진다.

검증 방법

성능 PR은 재현 가능한 측정 없이 머지하지 않는다. 두 가지 도구가 충분했다:

  1. Chrome Performance 패널: 드래그 5초 녹화 → _wfRenderMinimap 호출 빈도 비교
  2. performance.mark / performance.measure: 코드에 직접 박아서 콘솔로 평균/95p 출력
performance.mark("render-start");
_wfRenderMinimap(s);
performance.mark("render-end");
performance.measure("render", "render-start", "render-end");

체감으로 “빨라졌다”고 끝내지 않는다.

일반화

두 패턴은 LazyClaude 바깥에서도 자주 쓰인다:

  • rAF 코얼레싱: scroll, resize, 무거운 폼 입력에도 동일하게 적용 가능
  • 드래그 라이프사이클 인덱스: 트리 뷰 펼침/접힘, 정렬 가능 리스트, 그래프 에디터

“이벤트 빈도와 렌더 빈도를 분리하라”가 일반 원칙이다.

한 줄 요약

입력은 들어오는 대로 받지 마라. 화면이 그릴 수 있을 때만 그려라.


다음 편: LazyClaude 심층 3 — 첫 페인트 차단 풀기 (gzip + defer + dedupe) 이전 편: LazyClaude 심층 1 — TTL + mtime 캐시