5천만 건 시계열 렌더링
서버 LTTB와 상태 분리, Canvas 렌더링으로 5천만 포인트급 시계열 탐색의 병목을 줄였습니다.
배경
DAISY는 센서와 검사 장비에서 들어오는 운영 데이터를 수집하고 품질을 확인하는 산업용 AIOps 플랫폼입니다. 2025년 10월 릴리즈 이후 API Key 기반 수집으로 데이터가 빠르게 늘었고, 데이터셋 탐색 화면에서 수천만 포인트급 곡선을 다루게 됐습니다.
현장 엔지니어와 데이터 담당자는 줌, 팬, 시간 구간 비교, 이상 구간 재탐색을 반복합니다. 제조 시계열 데이터의 특성상 한 번의 탐색에도 많은 포인트를 다뤄야 했습니다.
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 결과를 임시로 그린 뒤 교체하는 방식입니다. 담당 업무가 이관되어 실제 제품에는 반영하지 못했습니다.