김민찬 / Frontend Engineer

Case Study

5천만 건 시계열 데이터 탐색을 위한 렌더링 성능 개선

DAISY 운영 데이터 자동 수집 이후 대량 시계열 탐색 UI에서 서버 LTTB·상태 분리·캔버스 렌더 경량화로 인터랙션 지연을 줄였습니다.

2024.11 - 재직중 DAISY FE / 시계열 탐색·차트 성능
  • React
  • TypeScript
  • Zustand
  • React Query
  • Canvas
  • LTTB
  • 5천만 포인트급 시계열 탐색
  • 팬·줌 시 60FPS 목표 달성

Context

DAISY는 산업용 AIOps 플랫폼으로, 센서·검사 장비 등에서 들어오는 운영 데이터를 자동 수집하고 품질을 모니터링합니다. 2025년 10월 릴리즈 이후 API Key 기반 수집으로 시계열이 스토리지에 빠르게 쌓이면서, 데이터셋 탐색·구간 확인 화면에서 수천만 포인트급 곡선을 다루는 FE 영역을 담당했습니다.

현장 엔지니어·데이터 담당자는 줌/팬, 시간 구간 비교, 이상 구간 재탐색을 반복합니다. 제조 시계열의 고빈도·대규모 특성 때문에, 탐색 UI의 체감 성능이 곧 라벨링·모델 검증 업무 생산성이었습니다.

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

Problem

  • 5천만 포인트 수준(다채널)에서 차트 라이브러리가 내부적으로 포인트를 생략·균등 샘플링해, 사용자가 보던 피크·이상 구간 맥락이 사라졌습니다.
  • 데이터셋 메타·선택 채널·뷰포트가 한 store/컨텍스트에 묶여, 커서 이동 같은 작은 UI 변경에도 차트 트리 전체가 재렌더되었습니다.
  • 팬·줌 중 프레임이 떨어지면, 탐색을 멈추고 기다리는 순간이 잦아 데이터는 있는데 UI가 못 따라온다는 인상이 생겼습니다.

Options

  1. 차트 라이브러리 기본 최적화만 — 설정만으로는 맥락 보존과 성능을 동시에 맞추기 어려웠고, 대량 구간에서 여전히 데이터 표현이 왜곡되었습니다.
  2. 서버 전처리만 — 집계는 빨라지지만, 뷰포트가 바뀔 때마다 왕복 지연이 커져 즉시 탐색 요구와 맞지 않았습니다.
  3. 서버 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 결과를 임시로 그려 제스처는 끊기지 않게 한 뒤, 새 데이터가 오면 교체하는 흐름을 적용하려 했습니다. 프로젝트 담당이 이관되어 반영하지 못했습니다.