"간트 차트를 웹에서 실시간으로? 성능이 나올까요?"
2년 전 받은 질문입니다. 답은 "YES, 하지만..."
오늘은 10,000개 작업을 60fps로 렌더링하는 간트 차트를 어떻게 구현했는지 공유합니다.
기술 스택 선택: 왜 SVG인가?
Canvas, SVG, DOM 중에 뭘로 그릴까 고민했습니다.
Canvas는 빠르지만 상호작용이 어렵고 접근성이 거의 없습니다.
DOM은 네이티브 이벤트를 지원하지만 1000개만 넘어도 2초가 걸립니다.
SVG는 그 중간입니다. 200ms 정도로 빠르면서도 네이티브 이벤트를 지원합니다.
간트 차트에는 SVG가 최적입니다.
SVG 최적화 전략
하지만 SVG도 그냥 쓰면 느립니다. 최적화가 핵심이죠.
class OptimizedSVGGantt {
private taskPool: Map<string, SVGRectElement> = new Map();
// 1. Object Pooling으로 DOM 조작 최소화
getTaskBar(taskId: string): SVGRectElement {
if (this.taskPool.has(taskId)) {
return this.taskPool.get(taskId)!;
}
const rect = document.createElementNS(
"http://www.w3.org/2000/svg",
"rect"
);
this.taskPool.set(taskId, rect);
return rect;
}
// 2. CSS Transform 활용 (GPU 가속)
moveTask(element: SVGElement, x: number, y: number) {
element.style.transform = `translate(${x}px, ${y}px)`;
element.style.willChange = 'transform';
}
}
Virtual Scrolling: 10,000개 작업 처리하기
10,000개 작업을 모두 렌더링하면 메모리가 20MB를 넘어갑니다.
해결책은? 화면에 보이는 것만 렌더링하는 겁니다.
class VirtualGantt {
private itemHeight = 30;
private buffer = 5;
calculateVisibleRange(
scrollTop: number,
viewportHeight: number,
totalTasks: number
): [number, number] {
const startIndex = Math.floor(scrollTop / this.itemHeight);
const endIndex = Math.ceil(
(scrollTop + viewportHeight) / this.itemHeight
);
// 버퍼 추가 (부드러운 스크롤)
const bufferedStart = Math.max(0, startIndex - this.buffer);
const bufferedEnd = Math.min(totalTasks, endIndex + this.buffer);
return [bufferedStart, bufferedEnd];
}
}
10,000개 중 실제로는 40개만 렌더링됩니다.
메모리 사용량이 95% 줄어듭니다.
WebSocket + CRDT: 실시간 동기화
여러 명이 동시에 같은 작업을 수정하면 어떻게 될까요?
CRDT(Conflict-free Replicated Data Type)가 답입니다.
import * as Y from 'yjs';
import { WebsocketProvider } from 'y-websocket';
class RealtimeGanttSync {
private yDoc: Y.Doc;
private yTasks: Y.Map<any>;
initialize() {
this.yDoc = new Y.Doc();
this.yTasks = this.yDoc.getMap('tasks');
// WebSocket 연결
new WebsocketProvider(
'wss://api.plexo.work/gantt',
'project-123',
this.yDoc
);
// 변경사항 감지
this.yTasks.observe(this.handleRemoteChange);
}
// 충돌 없이 자동 병합!
handleRemoteChange = (event: Y.YMapEvent<any>) => {
event.changes.keys.forEach((change, key) => {
if (change.action === 'update') {
const task = this.yTasks.get(key);
this.updateGanttUI(key, task);
this.showNotification(`${task.modifiedBy}가 수정함`);
}
});
};
}
Yjs가 자동으로 충돌을 해결합니다.
A가 시작일을 바꾸고 B가 종료일을 바꿔도 문제없습니다.
성능 측정 결과
10,000개 작업 프로젝트 기준:
- 초기 렌더링: 1.2초
- 스크롤 FPS: 55-60fps
- 실시간 업데이트 지연: 50ms
- 메모리 사용: 50MB
최적화 전에는 1000개만 해도 10초가 걸렸습니다.
지금은 50,000개까지 테스트해봤습니다.
실전 구현 팁
프로파일링이 답이다
class PerformanceMonitor {
measure(name, fn) {
performance.mark(`${name}-start`);
const result = fn();
performance.mark(`${name}-end`);
performance.measure(name, `${name}-start`, `${name}-end`);
const measure = performance.getEntriesByName(name)[0];
console.log(`${name}: ${measure.duration}ms`);
return result;
}
}
Chrome DevTools Performance 탭을 열고 측정하세요.
병목이 어디인지 정확히 알 수 있습니다.
점진적 개선
처음부터 완벽할 필요 없습니다.
1단계: 기본 기능 구현 (느려도 OK)
2단계: Virtual Scrolling 적용
3단계: WebWorker로 무거운 계산 분리
4단계: 캐싱 전략 도입
5단계: WebAssembly로 핵심 알고리즘 재작성
사용자 경험 우선
느려도 반응은 즉시 해야 합니다.
async handleUserAction(action) {
// 즉시 피드백
this.showLoadingIndicator();
// 무거운 작업은 비동기로
await this.processInBackground(action);
// 완료 후 업데이트
this.hideLoadingIndicator();
}
마무리: 기술은 수단일 뿐
아무리 기술적으로 완벽해도, 사용자가 안 쓰면 의미가 없습니다.
우리가 집중한 것:
- 빠른 반응: 클릭하면 즉시 움직여야
- 부드러운 스크롤: 버벅이면 안 써요
- 실시간 동기화: 협업이 핵심이니까
- 직관적 인터페이스: 매뉴얼 없이도 OK
가장 보람 있었던 순간은 사용자가 "우와, 이거 진짜 빠르네요!"라고 했을 때입니다.
여러분도 웹 기반 간트 차트를 만들 계획이신가요?
이 글이 도움이 되길 바랍니다.
고성능 실시간 간트 차트를 체험해보세요. Plexo
댓글 없음:
댓글 쓰기