안녕하세요, 프론트엔드 개발자 이효린입니다 :)
저희 팀이 개발 중인 서비스 eXemble에는 실시간으로 서버 자원을 모니터링하는 대시보드를 제공하고 있습니다. 이 과정에서 여러 라인 차트를 동시에 hover할 때 모든 차트의 툴팁이 같은 시간에 동기화되어야 했습니다.
첫 번째 API 응답에서는 툴팁 동기화가 잘 동작했지만, 데이터가 지속적으로 유입되기 시작하자 이상한 현상이 발생했습니다.
일부 차트의 툴팁만 업데이트 되거나, 서로 다른 시간의 값이 표시되거나, 심지어 툴팁이 사라지는 버그가 랜덤하게 발생했습니다.
처음엔 단순한 상태 관리 문제라고 생각했습니다. 하지만 파고들수록 TanStack Query의 캐싱 전략, ECharts 인스턴스의 업데이트 타이밍, React 렌더링 사이클이 얽힌 복합적인 문제였어요.
총 4번의 가설을 세우고 검증하는 과정을 거쳤는데, 그 삽질의 기록을 공유합니다 😅
목표
6개의 메트릭 차트(CPU, GPU, Memory 등)에서 하나의 차트를 호버하면 나머지 5개 차트도 같은 시간에 툴팁이 표시되어야 함. 10초마다 새 데이터가 들어와도 동기화가 유지되어야 함.
초기 문제 현상
- 마우스를 가만히 두고 있으면 새 데이터가 들어올 때 일부 차트의 툴팁만 업데이트됨
- 업데이트되지 않는 차트가 랜덤하게 달라짐
- 차트마다 다른 시간의 툴팁이 표시됨 (예: CPU는 13:20:50, GPU는 13:20:30)
원인 분석 및 시도 과정
1차 가설: TanStack Query의 structuralSharing 문제
가설: 차트 데이터의 y값이 변하지 않으면 TanStack Query가 데이터를 "같다"고 판단하여 참조를 유지하고, React가 리렌더링하지 않는다.
시도: useLineChartData.ts에서 structuralSharing: false 설정
const { data: rawMetricData } = queryHook<MetricData>({
...queryOptionsFactory(),
structuralSharing: false, // 항상 새 참조 생성
});
결과: 문제 해결 안 됨. 데이터는 갱신되지만 툴팁 동기화는 여전히 불안정.
2차 가설: dataIndex 기반 동기화의 한계
가설: 모든 차트 데이터는 동일한 구간의 API 응답을 기반으로 데이터가 생성되고있기 때문에 같은 dataIndex를 사용하면 같은 위치의 데이터를 표시할 것이다.
시도: Zustand store에 dataIndex를 저장하고, 타겟 차트에서 그 인덱스로 툴팁 표시
// Store
setAxisPointer(chartId, { dataIndex: 48 });
// 타겟 차트
chartInstanceRef.current.dispatchAction({
dataIndex: axisPointerData.dataIndex, // 48
type: 'showTip',
});
결과: 문제 해결 안 됨.
발견된 문제: 로그를 확인한 결과, 같은 **dataIndex 48**인데 각 차트의 실제 시간이 달랐음
- CPU 차트: xAxis[48] = "13:20:50"
- GPU 차트: xAxis[48] = "13:20:30"
원인: 각 차트의 API 응답 타이밍이 달라서, 데이터의 시간 범위가 미세하게 차이남.
3차 가설: 시간(value) 기반 동기화 필요
가설: dataIndex 대신 실제 시간 값으로 동기화하면, 각 차트에서 해당 시간을 찾아 정확한 위치에 툴팁을 표시할 수 있다.
시도: Store에 value(시간)를 저장하고, indexOf로 해당 시간의 인덱스 찾기
// Store
setAxisPointer(chartId, { dataIndex: 48, value: "13:20:50" });
// 타겟 차트
const targetDataIndex = xAxisData.indexOf(axisPointerData.value); // "13:20:50" 찾기
if (targetDataIndex >= 0) {
dispatchAction('showTip', targetDataIndex);
}
결과: 부분적 해결. 하지만 여전히 랜덤하게 일부 차트가 업데이트 안 됨.
4차 가설 (최종): ECharts 인스턴스 업데이트 타이밍 문제
가설: chartInstanceRef.current.getOption().xAxis[0].data로 데이터를 가져오는데, ReactECharts의 lazyUpdate 옵션 때문에 ECharts 인스턴스가 아직 업데이트되지 않았을 수 있다.
문제 코드:
// ECharts 인스턴스에서 데이터 가져옴 - 업데이트 타이밍 불일치!
const xAxisData = (
chartInstanceRef.current.getOption().xAxis as any
)?.[0]?.data;
시나리오:
- React: 모든 차트에 새 option prop 전달
- 소스 차트: ECharts 인스턴스 업데이트 완료 → 새 시간 설정
- 타겟 차트 A: ECharts 인스턴스 아직 업데이트 중 → getOption()이 이전 데이터 반환 → indexOf() 실패
- 타겟 차트 B: ECharts 인스턴스 업데이트 완료 → indexOf() 성공
최종 해결책
React prop에서 직접 xAxisData 가져오기
ECharts 인스턴스의 getOption() 대신 React의 **chartOption** prop에서 데이터를 가져옴.
**수정된 코드**:
// ✅ React prop에서 가져옴 - 항상 최신 상태 보장
const xAxisData = (chartOption?.xAxis as any)?.data as (string | number)[] | undefined;
if (!xAxisData) return;
// 시간 값으로 인덱스 찾기
const targetDataIndex = xAxisData.indexOf(timeValue);
if (targetDataIndex >= 0) {
requestAnimationFrame(() => {
chartInstanceRef.current?.dispatchAction({
dataIndex: targetDataIndex,
seriesIndex: 0,
type: 'showTip',
});
});
}
왜 이게 작동하는가?
- React의 상태 동기화: chartOption은 React의 useMemo로 생성되므로, 모든 컴포넌트가 동시에 최신 데이터를 가짐
- 타이밍 독립성: ECharts 인스턴스의 업데이트 타이밍과 무관하게 항상 최신 데이터에서 인덱스를 찾음
- **requestAnimationFrame**: 인덱스를 찾은 후, ECharts 렌더링이 완료될 때까지 대기한 뒤 dispatchAction 실행
최종 동작 흐름
새 데이터 도착 (10초마다)
↓
React: 모든 차트에 새 option prop 전달 (동시에!)
↓
모든 LineChart의 chartOption이 동시에 업데이트됨
↓
소스 차트: useWidgetSyncEvent에서 새 시간 값 계산
→ setAxisPointer({ value: "13:21:00" })
↓
타겟 차트들: useEffect 트리거
→ chartOption.xAxis.data에서 "13:21:00" 찾기 (React prop!)
→ indexOf("13:21:00") = 48 항상 찾음!
↓
requestAnimationFrame으로 ECharts 렌더링 완료 대기
↓
dispatchAction('showTip', 48) 실행
↓
모든 차트가 동시에 같은 시간의 툴팁 표시!
마치며
이번 이슈는 단순한 툴팁 버그라기보다, React(선언형)와 ECharts(명령형) 사이의 타이밍 문제에 가까웠습니다.
결국 해결의 핵심은 “어디를 source of truth로 둘 것인가”였고, ECharts 인스턴스가 아니라 React 상태를 기준으로 전환하면서 안정적으로 동작하게 만들 수 있었습니다.
비슷한 구조에서 작업하고 계신다면, 상태보다 타이밍과 기준을 먼저 의심해보셔도 좋을 것 같습니다 🙂
'🩵 React' 카테고리의 다른 글
| [React] React-Window를 활용해 DOM 성능 최적화 | DOM에 요소 800개가 추가된다고 ..? (0) | 2025.03.12 |
|---|---|
| [React] 컴포넌트 분리 기준에 관한 고찰 (0) | 2024.11.23 |
| [React] 리액트를 사용하는 이유 (컴포넌트 분리가 왜 중요할까 ?) (0) | 2024.11.11 |
| [트러블 슈팅] Geolocation API가 비정상적인 데이터를 받아올 때 (0) | 2024.08.26 |
| [UI개발] Web에서 사용할 수 있는 BottomSheet를 만들어보자 ! (4) | 2024.07.22 |
