[LazyClaude 심층 2] 워크플로우 캔버스 최적화 — rAF 코얼레싱과 Map 캐시
mousemove당 100회 렌더 → rAF당 1회. O(N×E) → O(deg)
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은 재현 가능한 측정 없이 머지하지 않는다. 두 가지 도구가 충분했다:
- Chrome Performance 패널: 드래그 5초 녹화 →
_wfRenderMinimap호출 빈도 비교 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 캐시