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

Stoic Park·

배경

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

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

  • Trace 페이지: 20개 이상의 wafer trace를 동시 표시하며, AI 비교 기능을 사용하면 시리즈가 40개 이상으로 폭증. 시리즈 하이라이트 속도가 심각하게 저하.
  • Data Chart 페이지: 차트 카드를 무제한 추가 가능하고, equipment-chamber x recipe 멀티셀렉트로 시리즈 수가 예상보다 폭발적으로 증가.
  • 결과: 브라우저 JS 힙이 1GB 이상 치솟는 현상이 빈번하게 발생.

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

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

| 최적화 기법 | 효과 | |---|---| | Float32Array 메모리 압축 | 센서 데이터를 Float32 타입 배열로 변환, 50-75% 메모리 절약 | | LTTB 다운샘플링 | 곡선 형태를 보존하며 포인트 수 감소 (5,000~10,000pts) | | Web Worker 필터링 | 메인 스레드 블로킹 방지, 데이터 필터링을 백그라운드로 이관 | | WeakMap 캐시 | 자동 GC, 차트 옵션/시리즈 데이터 중복 연산 방지 | | IndexedDB 3-tier 캐시 | API 재호출 방지, 30분 TTL | | IntersectionObserver lazy mount | viewport 밖 차트 컴포넌트 언마운트 | | 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에서는 하나의 wafer에 여러 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를 완전히 우회할 수도 있다.


기술 스택

| 항목 | 선택 | 이유 | |---|---|---| | Runtime | Node.js 18 | 프론트 모노레포와 동일 버전, 타입 공유 | | Framework | Fastify | Express 대비 ~2x JSON throughput | | Language | TypeScript | 프론트와 타입 공유 | | Cache | lru-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;
}

예상 효과

Trace 페이지

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

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

Data Chart 페이지

[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 1 | Strategy 추상화 (프론트만) | direct 모드로 기존과 100% 동일 동작 확인. BFF 없이도 리팩토링 가치 있음 | | Phase 2 | BFF 서버 구축 | Fastify + LTTB/Min-Max + LRU 캐시 + Docker. curl로 독립 테스트 | | Phase 3 | BFF 프론트 연동 | BffStrategy 구현, 자동 fallback 검증 | | Phase 4 | Zoom 재요청 | DataZoom 이벤트 연동, AbortController 기반 요청 취소 | | Phase 5 | Viewport 데이터 해제 | 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(이상치 보존). 용도에 맞는 알고리즘이 시각적 정확도를 결정한다. 하나의 알고리즘으로 모든 차트 타입을 커버하려 하면 안 된다.