Case Study
5천만 건 시계열 데이터 탐색을 위한 렌더링 성능 개선
DAISY 운영 데이터 자동 수집 이후 대량 시계열 탐색 UI에서 서버 LTTB·상태 분리·캔버스 렌더 경량화로 인터랙션 지연을 줄였습니다.
- React
- TypeScript
- Zustand
- React Query
- Canvas
- LTTB
- 5천만 포인트급 시계열 탐색
- 팬·줌 시 60FPS 목표 달성
Context
DAISY는 산업용 AIOps 플랫폼으로, 센서·검사 장비 등에서 들어오는 운영 데이터를 자동 수집하고 품질을 모니터링합니다. 2025년 10월 릴리즈 이후 API Key 기반 수집으로 시계열이 스토리지에 빠르게 쌓이면서, 데이터셋 탐색·구간 확인 화면에서 수천만 포인트급 곡선을 다루는 FE 영역을 담당했습니다.
현장 엔지니어·데이터 담당자는 줌/팬, 시간 구간 비교, 이상 구간 재탐색을 반복합니다. 제조 시계열의 고빈도·대규모 특성 때문에, 탐색 UI의 체감 성능이 곧 라벨링·모델 검증 업무 생산성이었습니다.
flowchart LR ingest["운영 데이터 자동 수집 API"] store["DAISY 스토리지"] quality["품질 모니터링 노드"] ui["시계열 탐색 UI"] ingest --> store store --> quality store --> ui
Problem
- 5천만 포인트 수준(다채널)에서 차트 라이브러리가 내부적으로 포인트를 생략·균등 샘플링해, 사용자가 보던 피크·이상 구간 맥락이 사라졌습니다.
- 데이터셋 메타·선택 채널·뷰포트가 한 store/컨텍스트에 묶여, 커서 이동 같은 작은 UI 변경에도 차트 트리 전체가 재렌더되었습니다.
- 팬·줌 중 프레임이 떨어지면, 탐색을 멈추고 기다리는 순간이 잦아 데이터는 있는데 UI가 못 따라온다는 인상이 생겼습니다.
Options
- 차트 라이브러리 기본 최적화만 — 설정만으로는 맥락 보존과 성능을 동시에 맞추기 어려웠고, 대량 구간에서 여전히 데이터 표현이 왜곡되었습니다.
- 서버 전처리만 — 집계는 빨라지지만, 뷰포트가 바뀔 때마다 왕복 지연이 커져 즉시 탐색 요구와 맞지 않았습니다.
- 서버 LTTB + 클라이언트 상태·렌더 분리 ← 선택
뷰포트에 맞는 표현은 서버에서 맞추고, 인터랙션은 클라이언트에서 가볍게 유지해야 했습니다.
Solution
데이터·표현·화면 레이어 분리
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와 뷰포트 연동
표시 폭·줌 레벨에 따라 목표 포인트 수를 정하고, 서버에서 LTTB로 내려받습니다. 클라이언트에서 전량을 들고 다시 샘플링하지 않아 메모리·CPU 부담을 줄였습니다.
const TARGET_FRAME_MS = 16;
const pixelsPerPoint = 2;
const targetCount = Math.max(
Math.floor(visibleWidth / pixelsPerPoint),
MIN_POINTS
);
// queryKey: [datasetId, channelIds, rangeStart, rangeEnd, targetCount]
상태 구독 범위 축소
차트는 데이터 store만, 툴팁·커서·레이아웃은 뷰 store만 구독하도록 나눴습니다.
줌/팬 같은 고빈도 이벤트는 requestAnimationFrame으로 묶고, 데이터 refetch는 debounce 후에만 트리거했습니다.
다만 debounce가 끝나기 전에는 차트 갱신이 따라오지 않아, 줌/팬 UI가 잠깐 끊기는 체감이 남아 있습니다.
// 뷰포트 변경 → rAF로 range 정규화 → debounced fetch
const scheduleRangeUpdate = (next: ViewportRange) => {
viewStore.getState().setViewport(next);
cancelAnimationFrame(rafId);
rafId = requestAnimationFrame(() => {
debouncedFetchSeries(next);
});
};
렌더 파이프라인 단순화
인터랙션 중에는 Canvas 2D로 polyline만 그리고, 축·범례 등 정적 레이어는 분리했습니다. React 트리에서는 차트 셸만 유지해, 포인트 배열 변경 시에도 하위 DOM 재생성을 막았습니다.
Results
- 5천만 포인트급 데이터셋에서도 구간 탐색·비교 흐름을 끊기지 않게 유지
- 팬·줌 시 60FPS를 목표로 프로파일링하며 병목(과도한 구독, 전량 렌더) 제거
- 성능 이슈를 「차트 옵션」이 아니라 수집 규모·표현 계약·상태 경계 설계 문제로 정리
What I’d do next
- debounce 동안 줌/팬이 끊기는 문제
API 호출을 줄이려 refetch에 debounce를 걸었더니, 손을 멈춘 뒤 잠깐 차트·축이 따라오지 않아 UI가 순간 멈춘 것처럼 느껴졌습니다.
- 해결 방향(미적용)
뷰포트(줌/팬·축·커서)는 즉시 반영하고, 서버 데이터만 debounce·취소 가능한 요청으로 뒤에서 맞추기. 그동안 이전 구간 LTTB 결과를 임시로 그려 제스처는 끊기지 않게 한 뒤, 새 데이터가 오면 교체하는 흐름을 적용하려 했습니다. 프로젝트 담당이 이관되어 반영하지 못했습니다.