
상황
어느날 서비스를 살펴보시던 팀장님께서 이런 말씀을 slack에 남기셨다.
진호님, 예측 차트에서 zoom을 계속하면 어느 순간 라인 차트가 아니라 단일 스캐터 차트처럼 보이는 데, 이거 수정하면 좋을 거 같아요. 어느정도 zoom을 하면 그 이후로는 zoom이 안 되도록 할 수 없나요?
코드 한 줄이면 해결할 수 있다고 나는 답변을 달았다. echarts에는 이미 이런 요구를 위한 옵션이 있다는 사실을 알고 있었기 때문이다. dataZoom에는 minValueSpan이라는 속성이 있는데, 이걸 사용하면 열심히 zoom을 해도 특정 값 범위까지만 작아지고, 그 이상으로는 더 줄어들지 않는다.
내가 만든 mocharts는 react에서 echarts를 쓰기 쉽게 만든 것일 뿐, 근본적으로는 echarts이다. 때문에 나는 아무 고민 없이 dataZoom컴포넌트에 minValueSpan={7}을 전달했다. 여기까지는 아주 평범한 차트 옵션 작업이었다. 평범한 옵션 작업은 보통 평범하게 끝나야 하는데, 이상하게 이런 일은 늘 글감이 된다.
<Mocharts.DataZoom
type='inside'
minValueSpan={7}
zoomOnMouseWheel={!isMobile}
moveOnMouseMove={!isMobile}
moveOnMouseWheel={false}
/>문제
문제는 최소 span에 도달한 뒤에 나타났다. 더 이상 확대되면 안 되는 상태에서 휠을 계속 위로 굴리면, 확대 자체는 막히는 것처럼 보였다. 그런데 차트 윈도우가 조금씩 옆으로 밀렸다. 사용자는 줌 인을 하고 있는데 실제로는 팬처럼 움직이는 상태가 된 것이다.
처음에는 내가 옵션을 잘못 이해한 줄 알았다. moveOnMouseWheel: false를 줬으니 휠로 이동하면 안 된다고 생각했는데, 어쩌면 zoomOnMouseWheel과 조합될 때는 다른 의미일 수도 있었다. 하지만 찾아보니 같은 문제가 이미 echarts 쪽에 오래전에 보고되어 있었다. apache/echarts#16144는 2021년에 열린 이슈이고, minSpan과 moveOnMouseWheel: false를 함께 썼을 때 최소 확대 이후에도 휠로 차트가 이동한다고 설명한다. 2024년에는 이 문제를 고치는 apache/echarts#19475도 올라왔다.
최소한 원인은 명확했다. 내가 mocharts를 잘못 작성한 것이 아니라 echarts 자체에 존재하는 버그였다. 하지만 원인을 아는 것과 문제를 해결하는 것은 전혀 다른 이야기다. 이슈는 몇 년 전에 등록되었고 수정 PR도 존재했지만, 아직 반영되지 않은 상태였다. 언제가 될 지도 모르는데 무작정 머지해주기만을 기다리고 있을 수도 없는 노릇이다. 나는 당장 서비스에 적용할 수 있는 우회 방법을 찾아야 했다.
해결
PR에서 제시한 방향은 생각보다 단순했다. echarts가 휠 이벤트를 처리하기 전에, 현재 dataZoom이 이미 최소 span에 도달했는지 확인한다. 아직 더 확대할 여지가 있으면 그대로 통과시킨다. 하지만 이미 최소 span 이하라면 그 휠 이벤트를 막는다. 즉 echarts 내부 동작을 고치는 대신, 문제가 되는 입력만 입구에서 차단하는 방식이다.
핵심은 네 단계다.
- chart DOM에 직접
wheellistener를 붙인다. event.deltaY < 0인 zoom in 방향만 검사한다.- 현재 dataZoom의
start와end퍼센트를 읽어 실제 value span으로 환산한다. - 이미 최소 span 이하라면
preventDefault와stopImmediatePropagation으로 이벤트를 막는다.
이때 listener를 React 컴포넌트에 붙이는 것으로는 부족하다. echarts 내부 wheel handler보다 먼저 잡아야 하므로 chart DOM에 직접, 그리고 capture 단계에서 붙여야 한다. 또 preventDefault()가 실제로 동작하려면 passive: false가 필요하다. 이 두 조건 중 하나라도 빠지면 코드는 그럴듯한데 차트는 여전히 자기 길을 간다. 역시 이벤트는 대화로 해결되지 않는다.
export function useLockDataZoomAtMinSpan({
chartRef,
totalDataCount,
minValueSpan = MIN_DATA_ZOOM_VALUE_SPAN,
}: UseLockDataZoomAtMinSpanParams) {
useEffect(() => {
// 데이터가 없으면 현재 dataZoom span을 계산할 수 없으므로 listener를 등록하지 않는다.
if (totalDataCount <= 0) return;
// Mocharts 내부 echarts 인스턴스가 ref에 늦게 붙을 수 있어 다음 frame 재시도 ID를 저장한다.
let animationFrameId: number | null = null;
// cleanup 시 wheel listener를 제거하기 위해 실제 listener가 붙은 DOM을 저장한다.
let wheelListenerTarget: HTMLElement | null = null;
// unmount 이후 예약된 requestAnimationFrame이 listener를 다시 붙이지 못하게 막는다.
let isDisposed = false;
const handleWheel = (event: WheelEvent) => {
const chart = chartRef.current?.getEChartsInstance();
// chart가 없거나 zoom-in 방향 wheel이 아니면 echarts 기본 동작을 그대로 둔다.
if (!chart || !isWheelZoomingIn(event)) return;
const valueSpan = readDataZoomValueSpan(chart, totalDataCount);
// 아직 최소 span보다 넓으면 정상 zoom-in을 허용한다.
if (valueSpan === null || valueSpan > minValueSpan + SPAN_EPSILON) return;
// 이미 최소 span에 도달한 상태의 추가 zoom-in wheel은 echarts로 전달하지 않는다.
event.preventDefault();
event.stopImmediatePropagation();
};
const attachListeners = () => {
// cleanup 이후에는 재시도 중이던 listener 등록도 중단한다.
if (isDisposed) return;
const chartHandle = chartRef.current;
const chart = chartHandle?.getEChartsInstance();
// ref 또는 echarts 인스턴스가 아직 준비되지 않았으면 다음 frame에 다시 시도한다.
if (!chartHandle || !chart) {
animationFrameId = requestAnimationFrame(attachListeners);
return;
}
wheelListenerTarget = chart.getDom();
// capture 단계에서 먼저 잡아야 echarts 내부 wheel handler보다 앞에서 차단할 수 있다.
// passive: false여야 preventDefault()가 실제로 동작한다.
wheelListenerTarget.addEventListener('wheel', handleWheel, {
capture: true,
passive: false,
});
};
attachListeners();
return () => {
// cleanup 이후 requestAnimationFrame 콜백이 실행되더라도 listener를 붙이지 않게 한다.
isDisposed = true;
if (animationFrameId !== null) {
cancelAnimationFrame(animationFrameId);
}
// chart DOM에 직접 붙인 wheel listener를 제거한다.
wheelListenerTarget?.removeEventListener('wheel', handleWheel, { capture: true });
};
}, [chartRef, minValueSpan, totalDataCount]);
}조금 귀찮은 부분은 echarts 인스턴스가 ref에 늦게 붙을 수 있다는 점이다. React 렌더링이 끝났다고 해서 chart 객체가 바로 준비되어 있다는 보장은 없다. 그래서 requestAnimationFrame으로 다음 프레임에 다시 시도하고, cleanup 이후에는 예약된 콜백이 다시 listener를 붙이지 못하도록 isDisposed를 둔다. 이벤트 하나 막겠다고 플래그까지 들고 있는 모습이 조금 우습지만, unmount 이후에 listener가 살아남는 것보다는 훨씬 덜 우습다.
readDataZoomValueSpan은 현재 option에서 dataZoom의 start와 end를 읽고, 전체 데이터 개수에 맞춰 값 기준 span으로 환산하는 함수다. echarts의 start/end가 퍼센트라는 점 때문에, 우리가 막고 싶은 minValueSpan과 바로 비교할 수는 없다. 예를 들어 전체 데이터가 1,000개이고 현재 윈도우가 10%라면 대략 100개 구간을 보고 있는 셈이다. 결국 퍼센트 윈도우를 값 윈도우로 바꿔야 minValueSpan과 같은 단위에서 비교할 수 있다.
function isWheelZoomingIn(event: WheelEvent) {
return event.deltaY < 0;
}
function readDataZoomValueSpan(chart: ECharts, totalDataCount: number) {
const option = chart.getOption();
const dataZoom = Array.isArray(option.dataZoom) ? option.dataZoom[0] : option.dataZoom;
const start = Number(dataZoom?.start);
const end = Number(dataZoom?.end);
if (!Number.isFinite(start) || !Number.isFinite(end)) {
return null;
}
return ((end - start) / 100) * totalDataCount;
}실제 프로젝트 코드에서는 dataZoom이 여러 개일 수 있고, x축·y축을 동시에 제어할 수도 있다. 그런 경우에는 대상 dataZoom을 더 명확하게 찾도록 보강해야 한다. 다만 이번 문제의 본질은 거기에 있지 않았다. 중요한 것은 echarts가 최소 span 경계에서 휠 줌을 이동으로 해석하기 전에, 우리 쪽에서 "여기서부터는 더 들어오지 마세요"라고 문을 닫는 것이다.
결론
이번 문제는 옵션을 잘못 줘서 생긴 버그라기보다는, 라이브러리의 경계 동작이 기대와 다르게 열린 사례에 가까웠다. minSpan이나 minValueSpan은 줌 범위가 더 작아지는 것을 막아주지만, 그 경계에 도달한 뒤 휠 이벤트가 어떤 식으로 소비되는지까지 항상 기대대로 보장해주지는 않았다. 특히 moveOnMouseWheel: false를 줬는데도 결과적으로 윈도우가 움직인다는 점이 사람을 꽤 피곤하게 만든다.
해결책은 거창하지 않았다. 내부를 포크하지 않고, chart DOM의 wheel 이벤트를 capture 단계에서 먼저 잡고, 이미 최소 span에 도달한 zoom in만 차단했다. 라이브러리 바깥에서 라이브러리의 빈틈을 막는 방식이라 아주 아름답다고 말하긴 어렵다. 하지만 문제의 범위를 좁게 가두고, cleanup까지 명확히 처리하면 충분히 견딜 만한 해결책이 된다.
오픈소스를 쓰다 보면 가끔 이런 결론에 도달한다. 공식 옵션은 맞고, 내 코드도 대체로 맞고, 그런데 사용자는 여전히 이상한 화면을 본다. 그럴 때 필요한 건 더 많은 확신이 아니라, 이벤트가 실제로 어디까지 흘러가는지 끝까지 따라가 보는 일이다. 차트는 대체로 숫자를 그리지만, 버그는 종종 이벤트 전파 순서에서 나온다.
추가 대응
사내 서비스 중 mocharts를 쓰는 곳은 다섯 곳이나 된다. 이 모든 곳에서 매번 useLockDataZoomAtMinSpan를 가져다 쓰기도 그렇고, 한 곳에서 훅을 고칠 때마다 나머지 서비스에 옮겨심는 것도 이상하다. 처음에는 mocharts가 이 훅을 export하는 방식을 검토했다. 그런데 생각해보니 mocharts를 쓰는 쪽이 이 버그의 존재를 알아야 한다는 것 자체가 이미 조금 실패한 API였다.
그래서 이번 대응은 서비스 코드에 훅을 배포하는 대신, mocharts 내부로 넣는 방향으로 정리했다. MochartsChart가 최종 echarts option을 만든 뒤 useLockDataZoomAtMinSpan을 내부에서 호출하고, 기본값은 켜진 상태로 두었다. 사용처 입장에서는 기존처럼 minSpan이나 minValueSpan을 지정하면 되고, 특별히 원하지 않는 차트에서만 dataZoomWheelMinSpanLock={false}로 끌 수 있다.
<Mocharts dataZoomWheelMinSpanLock={false}>
<Mocharts.DataZoom type='inside' minValueSpan={7} />
</Mocharts>훅 자체도 서비스에 급히 붙였던 버전보다 조금 더 mocharts다운 형태로 보강했다. 단순히 첫 번째 dataZoom의 start/end만 보는 대신, inside dataZoom만 대상으로 삼고, zoomOnMouseWheel이 shift, ctrl, alt 같은 modifier를 요구하는 경우에는 해당 wheel 이벤트만 검사한다. minValueSpan과 minSpan의 단위도 분리해서 비교하고, startValue/endValue가 들어오는 경우와 category axis의 문자열 값까지 처리하도록 나눴다.
이렇게 하니 대응의 위치가 조금 더 자연스러워졌다. echarts의 경계 동작을 감싸는 책임은 개별 서비스가 아니라, echarts를 감싸겠다고 만든 mocharts 쪽에 있어야 한다. 서비스마다 같은 이벤트 리스너를 복사하는 것은 당장은 빠르지만, 시간이 지나면 버그 수정도 같이 복사해야 한다. 이번에는 그 복사 비용을 만들기 전에 라이브러리 경계로 밀어넣은 셈이다.
물론 이것도 근본적인 해결은 아니다. echarts 쪽 PR이 머지되고 실제 배포 버전까지 올라오면, 언젠가는 이 우회 코드를 걷어낼 수 있을 것이다. 다만 그때까지 사용자는 더 이상 최소 span에서 멈춘 차트가 휠을 먹고 옆으로 도망가는 장면을 보지 않아도 된다. 라이브러리 버그를 완전히 고치지는 못했지만, 우리 제품 안에서 그 버그가 보이는 경로는 닫았다.
더 읽어보기
2026.03.01
렌더링 전략 정리
리액트와 Next.js에서 렌더링 전략은 단순한 옵션 선택이 아니다. 이는 서비스의 초기 로딩 속도, 서버 비용, 캐싱 전략, SEO 노출, 개발 복잡도까지 동시에 좌우하는 아키텍처 결정이다. 프로젝트 규모가 커질수록 “어디에서 HTML을 생성하는가”, “언제 자바스크립트를 실행하는가”…
2026.02.22
View Transition API
웹 애플리케이션에서 전환 품질은 기능 완성도와 동등한 수준으로 중요하다. 사용자가 목록에서 항목을 선택해 상세 화면으로 이동할 때, 화면이 자연스럽게 이어지면 서비스는 빠르고 안정적으로 느껴진다. 반대로 동일한 기능이라도 전환이 끊기면 체감 성능과 신뢰도는 동시에 하락한다. 전환은 부가…
2025.10.09
Activity 컴포넌트
리액트로 UI를 구성하다 보면, 특정 컴포넌트를 조건부로 렌더링해야 하는 상황이 자주 발생한다. 일반적으로는 삼항 연산자 등을 활용해서 컴포넌트를 렌더링하는데, 내 경우에는 이런 조건 렌더링을 반복적으로 작성하는 게 싫어 라는 특별한 컴포넌트를 만들어 사용하고 있다. 하지만 어떤 방식으…
2025.10.08
useEffectEvent
React를 사용하면서 useEffect 안에서 상태를 참조할 때 의외로 자주 겪는 문제가 있다. 바로 stale closure 문제다. 예를 들어 어떤 값이 변경되었는데, useEffect 내부의 콜백에서는 여전히 이전 값을 읽고 있는 현상이다. 이는 React의 클로저 구조상 자연스…
2026.06.01
React Server Components를 위한 컴포넌트 아키텍처
이 포스트는 Vercel의 Next.js 팀 소속 개발자 Aurora Scharff가 자신의 블로그에 올린 Component Architecture for React Server Components 게시글을 번역한 것이다. 번역하는 과정에서 다소 의역이 있을 수 있으며, 일부 번역에는…
2026.05.21
피자가게로 이해하는 디자인 패턴
에이든 피자는 처음부터 복잡한 시스템을 만들 생각이 없었다. 처음에는 메뉴 몇 개만 만들면 됐다. 그런데 손님은 커스텀 주문을 넣기 시작했고, 주방은 상태를 나눠야 했고, 결제와 배달앱과 알림이 하나씩 붙었다. 코드도 가게를 닮는다. 장사가 잘될수록 이상하게 더 쉽게 망가진다. 디자인…
2026.05.21
7. Decorator — 토핑 추가할 때마다 클래스를 새로 만들 수 없다
에이든 피자에서 주문서를 객체로 만들자 취소와 재주문은 한결 편해졌다. 그런데 주문이 편해지자 손님들도 한결 편해졌다. 편해진 손님은 더 많은 요구를 한다. "치즈 추가요", "올리브도 추가요", "소스 많이요", "조금 더 바삭하게 구워주세요" 같은 요청이 주문대 위로 쌓이기 시작했다…
2026.05.21
6. Command — 주문서를 객체로 만들면 취소도 재주문도 쉬워진다
에이든 피자의 시범 운영이 어느덧 일주일을 향해 가고 있다. 주방은 이제 제법 능숙하게 피자를 구워내고, 포스기(POS) 시스템도 팩토리 메서드 덕분에 다양한 지점 메뉴를 무리 없이 받아낸다. 하지만 시스템이 안정될수록 요구사항은 더 정교해지기 마련이다. 오늘은 주문 그 자체가 문제가…
댓글
댓글을 불러오는 중...