ECharts 대용량 데이터 메모리 최적화 — BFF 다운샘플링 아키텍처

Stoic Park·

배경

반도체 제조 공정의 센서 데이터를 시각화하는 웹 대시보드를 운영하고 있습니다. ECharts로 프론트엔드에서 직접 차트를 렌더링하는 방식인데, 데이터 규모가 커지면서 심각한 메모리 문제가 발생했습니다.

구체적으로 이런 상황이었습니다:

  • 트레이스 분석 페이지: 20개 이상의 센서 트레이스를 동시 표시하며, AI 비교 기능을 사용하면 시리즈가 40개 이상으로 폭증. 시리즈 하이라이트 속도가 심각하게 저하.
  • 데이터 차트 페이지: 차트 카드를 무제한 추가 가능하고, 설비-챔버 x 레시피 멀티셀렉트로 시리즈 수가 예상보다 폭발적으로 증가.
  • 결과: 브라우저 JS 힙이 1GB 이상 치솟는 현상이 빈번하게 발생.

이미 적용된 프론트엔드 최적화

문제를 인식하기 전부터 이미 상당한 수준의 프론트엔드 최적화가 적용되어 있었습니다.

최적화 기법효과
Float32Array 메모리 압축센서 데이터를 Float32 타입 배열로 변환, 50-75% 메모리 절약
LTTB 다운샘플링곡선 형태를 보존하며 포인트 수 감소 (5,000~10,000pts)
Web Worker 필터링메인 스레드 블로킹 방지, 데이터 필터링을 백그라운드로 이관
WeakMap 캐시자동 GC, 차트 옵션/시리즈 데이터 중복 연산 방지
IndexedDB 3-tier 캐시API 재호출 방지, 30분 TTL
IntersectionObserver lazy mountviewport 밖 차트 컴포넌트 언마운트
ECharts progressive rendering`large: true`, 점진적 렌더링

결론: 프론트엔드에서 할 수 있는 최적화는 거의 한계에 도달한 상태였습니다. 근본적으로 브라우저가 보유해야 하는 데이터 총량 자체를 줄여야 했습니다.


리서치: 대규모 차트 서비스는 어떻게 하고 있나

해결 방향을 잡기 위해 대규모 차트 데이터를 다루는 주요 프로덕션 서비스들의 접근 방식을 조사했습니다.

Grafana — 시계열 DB에서 쿼리 시점에 다운샘플링. 차트 픽셀 너비에 맞춰 ~1,000포인트/시리즈만 전달. 프론트는 uPlot(35KB 경량 라이브러리) 사용.

Datadog — 서버 사이드 "rollup". 메트릭을 여러 해상도(1s, 10s, 60s, 5m, 1h)로 사전 저장. 대시보드 쿼리 시 적절한 해상도 반환. 차트당 300-2,000포인트.

Bloomberg Terminal — 커스텀 WASM 렌더러 + 서버 사이드 집계. 30년치 틱 데이터도 표시 해상도에 맞춰 집계 후 전달.

Note

공통 패턴이 명확했습니다: 클라이언트에 2,000포인트/시리즈 이상을 보내는 서비스는 거의 없습니다. 인간의 눈이 구분할 수 있는 해상도는 화면 픽셀 수에 제한되기 때문입니다.


검토한 접근법들

접근법장점단점판단
ECharts GL (WebGL)렌더링 속도 향상echarts-gl 유지보수 부족, 메모리 총량 동일탈락
OffscreenCanvas Worker메인 스레드 부하 해소총 메모리 감소 효과 없음탈락
ECharts SSR (서버 렌더링)클라이언트 메모리 최소tooltip/zoom 시 서버 왕복, 인터랙션 제약보류
Canvas Tiling (지도 방식)무한 데이터 대응구현 복잡도 극히 높음, 차트 인터랙션 재구현 필요탈락
BFF 다운샘플링 (채택)기존 ECharts 코드 100% 유지, 메모리 95%+ 감소BFF 서버 추가 필요채택

핵심 판단 기준은 "기존 차트 인터랙션을 유지하면서 메모리를 줄일 수 있는가" 였습니다. ECharts GL이나 OffscreenCanvas는 렌더링 속도를 개선할 뿐, 브라우저가 들고 있는 데이터 총량은 줄어들지 않습니다. SSR은 인터랙션을 포기해야 합니다. BFF 다운샘플링만이 기존 코드를 건드리지 않으면서 데이터 총량 자체를 줄이는 방법이었습니다.


왜 BFF인가

"서버에서 다운샘플링"이라는 결론은 나왔지만, 어떤 서버에서 할 것인지도 중요한 결정이었습니다.

이전에 Python Plotly Dash 서버 + iframe 방식을 사용했던 경험이 있었는데, 프론트에서 화면 문제를 추적하기 매우 어려웠습니다. 서버 렌더링된 차트가 iframe 안에 갇혀 있으니, CSS 이슈든 데이터 이슈든 디버깅 경로가 불투명했습니다.

BFF(Backend For Frontend)를 선택한 이유:

  • 프론트 팀이 직접 관리: Node.js + TypeScript로 프론트 모노레포에 포함. Plotly Dash 때의 "추적 불가" 문제를 원천 차단.
  • Stateless: 데이터를 저장하지 않고 패스스루 + 다운샘플링만 수행. 장애 포인트 최소화.
  • On-premise 환경: Docker 컨테이너로 기존 인프라에 편입 가능.
  • 타입 공유: 프론트엔드와 동일한 TypeScript 타입을 공유하므로 응답 shape 불일치 위험이 없음.

아키텍처 설계

전체 구조 변화

[변경 ]
Browser (ECharts, 모든 데이터가 JS 힙에 상주)
  ├── /api      nginx  Main Backend (:8081)
  └── /fastapi  nginx  FastAPI Server (:8000)

[변경 ]
Browser (ECharts, 다운샘플링된 데이터만 보유)
  ├── /api      nginx  Main Backend (:8081)       [변경 없음]
  ├── /fastapi  nginx  FastAPI Server (:8000)     [변경 없음]
  └── /bff      nginx  Node.js BFF (:4000)        [신규]

BFF는 기존 백엔드 API를 프록시하면서 다운샘플링을 적용합니다. 기존 백엔드는 전혀 변경하지 않습니다.

Strategy Pattern — 기존 방식과 신규 방식의 공존

가장 중요한 설계 결정은 기존 코드를 건드리지 않고 새로운 데이터 경로를 추가하는 것이었습니다. 이를 위해 Strategy Pattern을 적용했습니다.

interface ChartDataStrategy {
  readonly type: 'direct' | 'bff';
  fetchStatData(params, viewport?): Promise<IStatPlotData[]>;
  fetchRawData(params, viewport?): Promise<IRawPlotResponse>;
  fetchTraceData(params, viewport?): Promise<ITraceChartResponse>;
  fetchPointDetail(params): Promise<PointDetailData | null>;
}

환경변수 하나로 전환이 가능합니다:

VITE_CHART_DATA_STRATEGY=direct    # 기존 방식 (default)
VITE_CHART_DATA_STRATEGY=bff       # BFF 다운샘플링
  • DirectStrategy: 기존 API 호출 로직을 그대로 래핑. viewport 파라미터는 무시.
  • BffStrategy: BFF 엔드포인트를 호출. 장애 시 DirectStrategy로 자동 fallback.
Tip

응답 shape이 기존 백엔드와 완전히 동일하기 때문에, 차트 옵션 빌더, 하이라이트 로직, 데이터 트랜스포머 등 하류 코드는 단 한 줄도 변경하지 않았습니다. Strategy를 교체해도 차트 컴포넌트는 그 사실을 모릅니다.


다운샘플링 알고리즘 선택

차트 특성에 따라 두 가지 알고리즘을 사용합니다.

LTTB (Largest-Triangle-Three-Buckets) — Line chart용

곡선의 시각적 형태를 최대한 보존하는 알고리즘입니다. 데이터를 N개 bucket으로 나누고, 각 bucket에서 이전 선택점-다음 bucket 평균과 형성하는 삼각형 면적이 가장 큰 포인트를 선택합니다.

입력: N개 데이터 포인트, 목표 M개
1.  번째와 마지막 포인트는 항상 포함
2. 나머지를 (M-2) bucket으로 분할
3.  bucket에서 삼각형 면적이 가장  포인트를 선택
4. 시간 복잡도: O(N), 공간 복잡도: O(M)

Peak와 valley를 자연스럽게 유지하므로, 센서 데이터의 트렌드 라인에 적합합니다.

Min-Max Bucketing — Scatter chart용

픽셀당 bucket을 만들고, 각 bucket에서 (min, max, first, last) 4포인트를 추출합니다.

Warning

반도체 공정 데이터에서는 이상치(outlier) 가시성이 매우 중요합니다. LTTB는 곡선 형태를 보존하지만 이상치를 누락할 수 있습니다. Scatter chart에는 반드시 Min-Max Bucketing을 사용해야 이상치가 절대 사라지지 않습니다.

Raw Chart의 동기화 샘플링

Raw chart에서는 하나의 샘플에 여러 sensor 데이터가 포함됩니다. 모든 센서가 동일한 timestamp를 공유하므로, 첫 번째 센서에서 LTTB 인덱스를 결정하고 동일 인덱스를 모든 센서와 시간축에 적용합니다. 이렇게 하면 센서 간 시간축 정렬이 유지됩니다.


Zoom 시 정밀도 보장

다운샘플링의 핵심 우려: "확대했을 때 원본 데이터를 볼 수 있는가?"

[전체 ]  100K points  LTTB  2,000pts (overview)
      사용자가 DataZoom으로 확대
[확대 ]  해당 구간 30K points  LTTB  2,000pts ( 정밀)
       확대
[상세 ]  해당 구간 500 points  원본 그대로 전송 (100% 정밀)

확대할수록 보이는 범위가 좁아지고, 해당 범위의 원본 데이터 수가 maxPoints 이하로 떨어지면 원본 데이터가 그대로 내려옵니다. 모든 개별 포인트의 툴팁 정보를 100% 확인할 수 있습니다.

구현 측면에서는 DataZoom 이벤트를 300ms debounce 처리하고, AbortController로 빠른 zoom 변경 시 이전 요청을 취소합니다. 이전 데이터는 새 데이터가 도착할 때까지 유지되므로 화면 깜빡임이 없습니다.


BFF 서버 캐시 전략

BFF는 백엔드 원본 응답을 단기 LRU 캐시에 보관합니다.

  • Key: endpoint + original params (zoomRange 제외)
  • TTL: 5분
  • Max Size: 1GB (LRU 교체, 환경변수로 설정 가능)

같은 데이터에 대해 zoom level만 바꾸며 반복 요청이 올 때, 캐시된 원본에서 다른 범위로 다운샘플링만 재수행합니다. 백엔드 재호출 없이 ~5ms 수준으로 응답이 가능합니다.

Info

LTTB 100K에서 2K로의 다운샘플링은 시리즈당 약 5ms, 20개 시리즈 기준 약 100ms가 소요됩니다. 캐시 히트 시 체감 지연은 거의 없습니다.


BFF 응답 구조

모든 BFF 엔드포인트는 동일한 래퍼 구조를 반환합니다:

{
  "data": [...],
  "meta": {
    "originalPointCount": 15000,
    "sampledPointCount": 2000,
    "samplingApplied": true,
    "samplingMethod": "min-max"
  }
}

data 필드는 기존 백엔드 응답과 100% 동일한 shape입니다. 프론트는 data만 사용하므로 기존 코드와 완벽 호환됩니다. meta 필드로 다운샘플링 효과를 실시간 모니터링할 수 있습니다.


자동 Fallback

운영 환경에서 가장 중요한 것은 장애 시에도 서비스가 중단되지 않는 것입니다.

// bffStrategy.ts
async fetchStatData(params, viewport) {
  try {
    return await bffClient.post('/bff/chart/stat-plot', {
      ...params, ...viewport
    });
  } catch {
    console.warn('BFF unavailable, falling back to direct strategy');
    return this.directFallback.fetchStatData(params);
  }
}

BFF가 다운되면 기존 direct 방식으로 자동 전환됩니다. 메모리는 높아지지만 기능 중단은 없습니다. 환경변수를 direct로 변경하면 BFF를 완전히 우회할 수도 있습니다.


기술 스택

항목선택이유
RuntimeNode.js 18프론트 모노레포와 동일 버전, 타입 공유
FrameworkFastifyExpress 대비 ~2x JSON throughput
LanguageTypeScript프론트와 타입 공유
Cachelru-cache바이트 크기 제한 LRU, 검증된 라이브러리
배포Docker (node:18-alpine, ~80MB)기존 on-premise Docker 인프라에 편입

nginx에는 /bff location 블록만 추가하면 됩니다:

location /bff/ {
    proxy_pass http://chart-bff:4000;
    proxy_http_version 1.1;
    proxy_set_header Connection "";
    proxy_buffer_size 128k;
    proxy_buffers 4 256k;
}

예상 효과

트레이스 분석 페이지

[direct 모드]
20 traces x 100K pts x Float32         = ~8MB raw data
+ ECharts 내부 객체                      = ~200-400MB
+ AI compare 추가                      = ~400-800MB
Total: 600MB ~ 1.2GB

[bff 모드]
20 traces x 2K pts x Float32           = ~0.16MB raw data
+ ECharts 내부 객체                      = ~10-20MB
+ AI compare 추가                      = ~20-40MB
Total: 30MB ~ 60MB

데이터 차트 페이지

[direct 모드]
차트 10개 x 5 series x 50K pts         = ~200MB+ 상시 유지

[bff 모드]
보이는 차트 3개 x 5 series x 2K pts    = ~1.2MB
+ 재진입  BFF 재요청 (~50ms, 캐시 히트)

메모리 감소율: 약 95%. 1GB 이상에서 50MB 수준으로 떨어집니다.


점진적 도입 전략

한 번에 모든 것을 교체하지 않았습니다. Strategy Pattern 덕분에 단계별로 안전하게 진행할 수 있었습니다.

Phase내용핵심 포인트
Phase 1Strategy 추상화 (프론트만)direct 모드로 기존과 100% 동일 동작 확인. BFF 없이도 리팩토링 가치 있음
Phase 2BFF 서버 구축Fastify + LTTB/Min-Max + LRU 캐시 + Docker. curl로 독립 테스트
Phase 3BFF 프론트 연동BffStrategy 구현, 자동 fallback 검증
Phase 4Zoom 재요청DataZoom 이벤트 연동, AbortController 기반 요청 취소
Phase 5Viewport 데이터 해제off-screen 차트 데이터를 메모리에서 해제, 재진입 시 BFF 재요청
Tip

Phase 1의 Strategy 추상화만으로도 코드 구조가 크게 개선됩니다. 데이터 fetching 로직이 hooks에서 분리되어 테스트와 교체가 쉬워집니다. BFF 도입 전에도 이 리팩토링은 가치가 있었습니다.


프론트엔드 변경 범위 — 최소 침습

Strategy Pattern의 가장 큰 장점은 변경 범위가 극히 작다는 것입니다.

변경된 파일:

  • lib/chartDataStrategy/ — 신규 디렉토리 (types, directStrategy, bffStrategy, bffClient, index)
  • hooks/api/useStatPlot.ts — strategy 분기 추가
  • hooks/api/useRawPlot.ts — strategy 분기 추가
  • vite.config.ts/bff 프록시 추가
  • .env 파일들 — 환경변수 2개 추가

변경되지 않은 파일들:

  • 차트 옵션 빌더 (chartOptionBuilder, rawChartOptionBuilder)
  • 데이터 트랜스포머 (chartDataTransformer, rawChartDataTransformer)
  • 차트 컴포넌트 (TraceChartView, StatisticChart, RawChart)
  • 하이라이트/레전드 로직
  • 기존 LTTB/Worker 코드 (direct 모드에서 계속 사용)
  • IndexedDB 캐시 (direct 모드에서 계속 사용)

레거시와의 비교

항목Plotly Dash (레거시)ECharts Direct (이전)BFF + ECharts (현재)
렌더링 위치Python 서버브라우저브라우저 (데이터만 서버 처리)
프론트 제어권낮음 (iframe)높음높음
문제 추적어려움쉬움쉬움
메모리서버 부담브라우저 부담 (1GB+)브라우저 최소화 (~50MB)
인터랙션서버 왕복즉각 반응즉각 반응
대용량 데이터처리 가능한계 있음처리 가능

BFF 방식은 Plotly Dash의 장점(서버에서 대용량 처리)과 ECharts Direct의 장점(프론트 제어권, 빠른 인터랙션)을 결합한 하이브리드 접근입니다.


핵심 교훈

프론트엔드 최적화에는 한계가 있습니다. Float32Array, LTTB, Web Worker, IndexedDB 캐시 등 가능한 모든 프론트 최적화를 적용해도, 데이터 총량이 임계치를 넘으면 근본적 해결이 안 됩니다. 최적화의 다음 단계는 "브라우저에 보내는 데이터 자체를 줄이는 것"입니다.

"2,000포인트/시리즈" 규칙. Grafana, Datadog, Bloomberg 등 프로덕션 서비스의 공통 패턴입니다. 화면 픽셀 수 이상의 데이터를 클라이언트에 보내는 것은 낭비입니다.

Strategy Pattern으로 안전한 전환. 기존 코드를 건드리지 않고 새로운 데이터 경로를 추가함으로써, 문제 발생 시 환경변수 하나로 즉시 원복할 수 있습니다. 대규모 리팩토링에서 이 안전망은 매우 중요합니다.

BFF는 프론트 팀의 서버. Node.js + TypeScript로 프론트 모노레포에 포함함으로써, 이전 Plotly Dash 시절의 "블랙박스 서버" 문제를 해소했습니다. 프론트 개발자가 직접 디버깅하고 배포할 수 있는 서버가 됩니다.

다운샘플링 알고리즘 선택이 중요합니다. Line chart에는 LTTB(형태 보존), Scatter chart에는 Min-Max(이상치 보존). 용도에 맞는 알고리즘이 시각적 정확도를 결정합니다. 하나의 알고리즘으로 모든 차트 타입을 커버하려 하면 안 됩니다.