2025년 12월 7일 일요일

실시간 간트 차트 만들기: 10,000개 작업을 60fps로

"간트 차트를 웹에서 실시간으로? 성능이 나올까요?"

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

댓글 없음:

댓글 쓰기