본문으로 건너뛰기
김민찬 포트폴리오

5천만 건 시계열 렌더링

서버 LTTB와 상태 분리, Canvas 렌더링으로 5천만 포인트급 시계열 탐색의 병목을 줄였습니다.

배경

DAISY는 센서와 검사 장비에서 들어오는 운영 데이터를 수집하고 품질을 확인하는 산업용 AIOps 플랫폼입니다. 2025년 10월 릴리즈 이후 API Key 기반 수집으로 데이터가 빠르게 늘었고, 데이터셋 탐색 화면에서 수천만 포인트급 곡선을 다루게 됐습니다.

현장 엔지니어와 데이터 담당자는 줌, 팬, 시간 구간 비교, 이상 구간 재탐색을 반복합니다. 제조 시계열 데이터의 특성상 한 번의 탐색에도 많은 포인트를 다뤄야 했습니다.

자동 수집·품질 모니터링 이후, 탐색 UI가 병목이 된 흐름
flowchart LR
ingest["운영 데이터 자동 수집 API"]
store["DAISY 스토리지"]
quality["품질 모니터링 노드"]
ui["시계열 탐색 UI"]
ingest --> store
store --> quality
store --> ui

문제

  • 다채널 데이터가 5천만 포인트 수준에 이르면 차트 라이브러리의 생략과 균등 샘플링 때문에 피크와 이상 구간이 사라졌습니다.
  • 데이터셋 메타, 선택 채널, viewport가 한 store와 context에 묶여 커서 이동에도 차트 전체가 다시 렌더됐습니다.
  • 팬과 줌 중 프레임이 떨어져 다음 구간을 확인하기 전에 기다려야 했습니다.

선택

차트 라이브러리 설정만 조정하면 대량 구간에서 형태가 계속 왜곡됐습니다. 서버에서 모든 처리를 끝내면 viewport가 바뀔 때마다 왕복 지연이 생겼습니다. 서버는 LTTB로 표시할 포인트를 고르고, 클라이언트는 상태와 렌더 경로를 나누는 방식을 선택했습니다.

서버는 현재 화면 폭에 맞는 데이터를 보내고, 클라이언트는 인터랙션을 가볍게 처리합니다.

구현

데이터와 화면 상태 분리

책임 분리: 서버는 표현용 포인트, 클라이언트는 뷰와 인터랙션
flowchart TB
subgraph server [Server]
  raw["원본 시계열"]
  lttb["LTTB 다운샘플"]
  api["viewport 기준 API"]
end
subgraph client [Client]
  rq["React Query 캐시"]
  dataStore["데이터 store"]
  viewStore["뷰포트·UI store"]
  canvas["Canvas 렌더"]
end
raw --> lttb --> api
api --> rq --> dataStore
viewStore --> canvas
dataStore --> canvas
레이어 담당 의도
서버 LTTB, 구간·해상도별 포인트 수 픽셀 수준에서 형태를 유지하며 전송량 축소
React Query 구간·채널·해상도 키 캐시 동일 뷰 재진입 시 불필요한 재요청 방지
데이터 store 시리즈·메타·API 결과 차트 데이터만 구독
뷰 store 줌, 팬, 커서, 패널 UI 데이터 변경 없이 UI만 갱신
Canvas 경로 그리기 SVG/DOM 대비 대량 polyline 부하 완화

서버 LTTB와 뷰포트 연동

표시 폭과 zoom level로 목표 포인트 수를 정하고 서버에서 LTTB 결과를 받습니다. 클라이언트가 전체 데이터를 보관하거나 다시 샘플링하지 않도록 했습니다.

const TARGET_FRAME_MS = 16;
const pixelsPerPoint = 2;
const targetCount = Math.max(
  Math.floor(visibleWidth / pixelsPerPoint),
  MIN_POINTS
);

// queryKey: [datasetId, channelIds, rangeStart, rangeEnd, targetCount]

상태 구독 범위 축소

차트는 data store만 구독하고 tooltip, cursor, layout은 view store로 분리했습니다. 줌과 팬 이벤트는 requestAnimationFrame으로 묶고, 데이터 refetch는 debounce가 끝난 뒤 실행했습니다. 이 때문에 debounce 중에는 차트 갱신이 늦게 따라오는 문제가 남았습니다.

// 뷰포트 변경 → rAF로 range 정규화 → debounced fetch
const scheduleRangeUpdate = (next: ViewportRange) => {
  viewStore.getState().setViewport(next);
  cancelAnimationFrame(rafId);
  rafId = requestAnimationFrame(() => {
    debouncedFetchSeries(next);
  });
};

렌더 파이프라인 단순화

인터랙션 중에는 Canvas 2D로 polyline만 그리고 축과 범례는 별도 layer로 뒀습니다. React tree에는 차트 shell만 남겨 포인트 배열이 바뀔 때 하위 DOM이 다시 만들어지지 않게 했습니다.

결과

  • 5천만 포인트급 데이터셋에서 전체 렌더와 과도한 store 구독을 제거했습니다.
  • 팬과 줌에서 60FPS를 목표로 프로파일링하며 병목을 확인했습니다.
  • 수집 규모에 맞춰 서버 응답과 클라이언트 상태의 경계를 다시 정했습니다.

남은 문제

API 호출을 줄이기 위해 refetch에 debounce를 적용했지만, 손을 멈춘 뒤 차트와 축이 늦게 따라오는 구간이 남았습니다.

viewport, 축, cursor는 즉시 반영하고 서버 요청만 debounce와 취소를 적용하는 방식을 검토했습니다. 새 데이터가 오기 전에는 이전 LTTB 결과를 임시로 그린 뒤 교체하는 방식입니다. 담당 업무가 이관되어 실제 제품에는 반영하지 못했습니다.