EXEM 오픈소스 라이브러리 EVUI로 그리는 exemONE 차트의 성능 이야기
안녕하세요. 통합개발본부 FE3팀 손기연입니다.
exemONE(엑셈원)에서 대시보드 파트를 맡고 있습니다.
exemONE은 인프라부터 애플리케이션까지, 곳곳에 흩어져 있던 IT 지표를 하나의 플랫폼에서 관측하는 통합 대시보드입니다. 여러 모니터링 대상을 한 화면에 모아볼 수 있다는게 핵심이죠.
exemONE 대시보드의 또 다른 특징은 자유도입니다. 사용자가 보고 싶은 지표와 타겟, 차트를 직접 골라 자기만의 화면을 구성합니다. 위젯(차트)을 몇 개 올릴지, 한 위젯에 타겟을 몇 개 물릴지를 저희가 미리 정해두지 않습니다.
자유도가 높다는 건, 한 화면에 얼마나 많은 데이터가 보일지 사용자가 결정한다는 뜻이기도 합니다. 그만큼 저희는 성능에 신경 쓸 부분이 많습니다. 사용자가 화면을 어떻게 구성하든, 마우스를 올리거나 드래그하는 짧은 순간에도 차트는 부드럽게 반응해야 하니까요.
exemONE 대시보드는 화면 하나에 수십 개의 차트, 차트 하나에 수많은 시리즈가 표현되고 초 단위로 갱신되는 환경입니다. 그 차트 상당수를 exem 오픈소스 라이브러리 EVUI를 통해서 그리는데, exemONE에서 EVUI를 가장 많이 쓰는 화면이 바로 이 대시보드입니다. 그래서 “대시보드가 버벅여요”라고 들어온 제보 대부분이 차트 성능 문제였습니다.
이 글은 저희가 차트 성능을 어떻게 확보해왔는지에 대한 이야기입니다.
1. 화면의 무게는 사용자가 정합니다
성능 이야기를 하기 전에, 저희 대시보드가 어떤 환경에서 쓰이는지부터 정리하겠습니다.
- 화면: 한 화면(viewport)에 위젯을 많게는 수십 개까지 설정할 수 있습니다.
- 데이터: 차트 하나에 수백 ~ 수천 개의 시리즈가 표현됩니다. scatter의 경우 수십만 개의 점이 찍힐 수 있습니다. 갱신은 초 단위 실시간으로 가능합니다.
- 상호작용: 사용자는 마우스를 올리거나 드래그·클릭하는 등의 상호작용을 통해 툴팁이나 모달에서 상세 정보를 볼 수 있습니다.
- 제약: 렌더링을 맡은 차트의 대부분이 Canvas 기반입니다. DOM을 쌓는 게 아니라 매 프레임 캔버스에 그리는 방식입니다.
그래서 “대시보드가 버벅여요”라는 제보를 받았을 때, 가장 먼저 의심한 건 Canvas였습니다. 화면이 무거워질수록 그리는 양도 많아지니까요. 그런데 병목이 거기에만 있지는 않았습니다. 그리는 일, 그 앞단에서 데이터를 만들어 반응성으로 넘기는 일, 캔버스 밖에서 뜨는 DOM이 모두 겹쳤습니다. 개선의 상당수는 그 둘을 함께 손봐야 했습니다.
2. Scatter - 데이터와 캔버스가 함께 만든 병목
scatter는 여러 관측값의 분포를 점으로 보여주는 차트입니다. 한 화면에 수십만 개의 점을 그릴 수 있어, 캔버스 렌더링이 분명한 병목이었습니다. 하지만 새 데이터가 들어올 때마다 그 데이터를 만들고 넘기는 비용도 만만치 않았습니다. 실시간 scatter를 부드럽게 만들려면 데이터를 갱신하는 방식과 그리는 방식을 함께 바꿔야 했습니다.
2-1. Real Time Scatter의 탄생 - 데이터를 통째로 갈아끼우고 있었다
실시간 scatter는 응답시간 분포처럼 점이 계쏙 쏟아지는 화면입니다. 처음 구현은 단순했습니다. 새 데이터가 올 때마다 차트 데이터를 통째로 새로 만들어 넘겼죠. 당시 데이터는 1차원 배열이었습니다. exemONE과 EVUI는 Vue를 사용하기 때문에, Vue가 이 배열을 전부 반응형 Proxy로 감쌌습니다. 여기에 더해 EVUI 내부에서도 이 데이터를
cloneDeep 으로 한 번 더 복제했습니다.이
cloneDeep 은 차트가 내부에서 데이터를 가공해도 넘겨받은 원본이 오염되지 않기 위한 방어 코드였습니다.점이 수만·수십만 개로 불어나자, 그리기도 전에 데이터를 만드는 단계에서 프레임이 끊겼습니다. 그런데 저희가 상정한 상한은 차트 하나에 100만 개였습니다.
그래서 실시간 대량 데이터 전용
realTimeScatter 모드를 EVUI에 처음 추가했습니다. 손댄 곳은 크게 세 군데입니다.- 얕은 반응성으로 전달: 페이지 단에서 차트 데이터를
shallowRef로 넘겨, 배열 전체가 반응형 Proxy로 감싸이지 않게 했습니다.
cloneDeep우회:realTimeScatter경로에서는 내부 깊은 복제를 건너뜁니다. 매 갱신마다 배열을 통째로 재생성하지 않습니다.
- 1차원 배열 → 초 단위 버킷 배열: 데이터를 초 단위 시간 버킷의 그룹으로 쌓습니다. 시간이 흐르면 가장 오래된 버킷이 창 밖으로 밀려 자동으로 비워지고, 새 버킷이 뒤에 붙는 링버퍼입니다. 이 버킷 구조는 사내 제품 InterMax가 실시간 대량 데이터를 다루던 방식을 EVUI로 가져온 것입니다.

이 구조 변경으로 데이터 갱신 비용이 “전체 크기”가 아니라 “새로 들어온 구간”에 비례하게 되었습니다.
데이터를 이렇게 쌓는 김에, 같은 좌표에 겹쳐 찍히는 점은 한 번만 남깁니다. 같은 좌표(같은 시각·같은 값)에 수천 개가 뭉쳐도 draw는 한 번이죠.

2-2. 이전 프레임을 밀고, 새 점만 그린다(blit)
앞에서 데이터를 만드는 비용은 줄였습니다. 하지만 그리는 비용은 별개입니다. 캔버스는 갱신할 때마다 화면을 처음부터 다시 그리기 때문에, 쌓인 점이 수십만 개면 데이터가 대부분 그대로여도 그때마다 전부를 다시 그립니다. 초 단위로 갱신이되면, 데이터를 아무리 싸게 갱신해도 그리기 단계에서 프레임이 다시 끊기죠. 그런데 scatter에서 대부분의 점은 직전 갱신과 같은 자리에 있습니다. 시간축만 왼쪽으로 흐를 뿐이죠. 이미 그려둔 걸 매번 다시 그릴 필요가 있을까요?
그래서 렌더 경로에 blit 방식을 넣었습니다. 이미 그려진 픽셀은 다시 계산하지 않고 통째로 밀어버립니다. 그리기 비용이 “전체 점”이 아니라 “새로 들어온 점”에 비례하게 됐습니다.

한 가지 조건은 있습니다. 이 blit 방식은 y축이 고정되어야만 가능합니다. y축 변동이 없으면 잘 맞지만, 만능은 아닙니다.
새로 들어온 구간만 만들고, 지난 프레임에서 바뀌지 않은 건 다시 그리지 않는 게 핵심입니다.
3. Line(Time series) - 이번 병목은 캔버스보다 앞단에 있었다
Line은 시간에 따라 변하는 지표를 선으로 보여주는 차트입니다. 새 데이터를 주기적으로 받아 갱신하는데, 이번엔 캔버스보다 그 앞단의 비용이 더 컸습니다.
3-1. 그릴 게 scatter만큼 많지 않았는데 왜 느렸을까
scatter에서 효과를 확인했으니, Line 차트도 캔버스를 먼저 의심했습니다.
Line 차트는 한 시리즈가 그리는 점과 선이 보통 수십, 많아야 수백 개 수준입니다. 바로 앞 scatter가 한 화면에 수십만 점을 쏟아냈던 걸 떠올리면 새 발의 피죠. 물론 시리즈가 늘면 전체 렌더량도 함께 커지니, 시리즈 하나가 가볍다고 캔버스 비용까지 가볍다 단정할 수는 없습니다. 다만 이번 갱신에서 더 크게 잡힌 건 캔버스가 아니라 그 앞단이었습니다. 데이터를 받아오는 일(매 갱신마다 서버에서)과 받아온 데이터를 처리하는 일(매 갱신마다 되풀이되던)이죠. 받아오는 쪽부터 보겠습니다.
3-2. 이미 받은 건 다시 받지 않는다(증분 append)
대시보드 위젯은 몇 초마다 새 데이터를 요청해 차트를 갱신합니다. 처음 구현은 갱신할 때마다 전체 시간을 통째로 다시 요청했습니다. 위젯 하나에 시리즈·타겟이 수백 ~ 수천인데, 매 갱신마다 그 범위를 전부 다시 받았습니다. 새로 들어온 건 맨 끝 데이터 몇 개뿐인데, 이미 갖고 있는 구간까지 통째로 서버에서 되받아오는 셈이었죠.
그래서 마지막으로 받은 시각(
lastTime)을 기준으로 그 이후 구간만 요청하도록 바꿨습니다. 서버는 lastTime, toTime 구간만 돌려주고, 워커가 이 새 구간 데이터를 누적 캐시에 이어 붙입니다. 매 갱신 때 오가는 데이터는 전체가 아니라 새로 들어온 구간에 비례하게 됐습니다.이 개선으로 서버 조회·전송·워커 파싱 같은 갱신 앞단의 비용을 줄여, 수십 개 위젯이 몇 초 마다 쌓던 부담을 서버·클라이언트 양쪽에서 덜었습니다.
그런데 증분은 조건부입니다. 시간이 앞으로 흐르는 평소 갱신은 이어가지만, 사용자가 대상이나 지표·조회 구간을 바꿔 요청 자체가 달라지면 전체를 다시 받습니다.
사실 이 ‘새 구간만 받는다’는 방식은 Line 차트만의 것이 아닙니다. 앞서 본 실시간 scatter도 같은
lastTime 을 기준으로 새로 들어온 데이터만 받아옵니다. scatter는 받은 데이터를 링버퍼에 쌓는 쪽만 다뤘지만, 데이터를 받아오는 쪽도 증분입니다. 차이는 기준점을 두는 방식입니다. scatter는 점이 타겟마다 불규칙하게 들어와 요청마다 기준점을 따로 기억하고, Line은 지표가 규칙적인 시간 격자로 떨어져 기준점 하나에 겹침 버퍼면 충분합니다. 같은 원칙을 데이터 모양에 맞춰 다르게 구현한 셈입니다.
3-3. 그 외 - 매 갱신마다 반복하던 일들
받아온 데이터를 준비하는 쪽에서도 갱신마다 되풀이되던 일을 하나씩 걷어냈습니다.
- 대용량 데이터 깊은 추적 끄기: Vue가 데이터를 갱신마다 트리 전체로 추적하던 걸 얕은 추적(
shwllowRef)으로 바꿨습니다.
- 데이터 상위 k개 제한: 시리즈가 수백 ~ 수천이어도 화면에 의미 있는 상위 k개만 남깁니다.
- 수식·누적 계산 워커로 통합: 무거운 수식·누적 연산을 워커 스레드로 모아, 메인 스레드의 부하를 줄였습니다.
데이터를 덜 받는 것만으로는 부족했습니다. 갱신마다 다루던 불필요한 데이터를 걷어내고, 무거운 계산은 워커로 옮겨 메인 스레드 부담을 더는 게 핵심입니다.
4. Tooltip - 수많은 DOM의 병목
차트 시리즈에 마우스를 올리면 툴팁이 시리즈별 값을 보여줍니다. 그런데 시리즈가 수백 개인 위젯에서는 툴팁이 뜨는 순간 프레임이 버벅였습니다. 툴팁이 모든 시리즈 행을 그대로 DOM 만들어 붙였기 때문입니다.
4-1. 전체 중 일부만 DOM에 올린다(가상 스크롤)
그래서 EVUI 툴팁에 가상 스크롤 기능을 넣었습니다.
시리즈가 특정 개수를 넘어가면 툴팁이 일부 행만 남기고 나머지는 가상 처리합니다.
가상 스크롤에도 트레이드오프는 있습니다. 일부만 렌더되니 툴팁 너비가 들쭉날쭉해졌습니다. 이건 시리즈 이름 길이로 최소 너비를 추정해 고정하는 로직으로 잡았습니다.

툴팁의 또 다른 병목도 함께 손봤습니다. 툴팁은 마우스를 따라다니는데, 커서가 움직일 때마다 위치를 다시 잡느라 좌표 계산(
getBoundingClientRect)을 다시 하고 있었습니다. 그리는 순간이 아니라 마우스를 움직이는 내내 버벅인 겁니다. 그래서 좌표를 캐시해 실제로 바뀔 때(리사이즈·스크롤 등)만 새로 읽고, 마우스 이동은 살짝 솎아냈으며, 툴팁 위치는 left/top 대신 transform 으로 옮겨 리플로우를 피했습니다.전체 중 일부만 DOM에 올리는 게 핵심입니다. Canvas 기반 차트라도 상호작용의 병목은 DOM일 수 있으니까요.
5. 그 외 - 여기 실은 건 일부입니다
앞의 세 가지(Scatter·Line·툴팁)는 저희가 했던 개선의 일부입니다. 위에서 자세히 풀지 못한 개선도 몇 가지 더 있습니다.
- 레이어 분리: 차트 본체와 인터랙션 표시(하이라이트·툴팁)를 별도 캔버스로 나눠, 인터랙션마다 전체를 다시 그리지 않습니다.
- 버퍼 캔버스(Buffer Canvas): 오프스크린 버퍼에 그린 뒤 한 번에 합성해 깜빡임을 막습니다.
- 좌표 재계산 스킵: 축·데이터가 안 바뀐 프레임에서는 좌표 재계산을 건너뜁니다.
- 인스턴스 재사용: 시리즈 구성이 바뀌어도 살아남는 인스턴스는 재활용합니다.
- 범례 가상 스크롤: 시리즈가 수백 개인 위젯은 범례도 전체 중 일부만 DOM에 올립니다. 앞서 툴팁에서 쓴 것과 같은 계열의 방식입니다.
이 개선들은 저희 엑셈 FE팀이 지금도 채워 나가고 있습니다. 차트를 외부 라이브러리에 의존하지 않고 직접 손댈 수 있으니, 새 병목을 만나도 우회하지 않고 고쳐 나갑니다.
6. 마치며 - 묵직한 한 방은 없습니다.
성능 개선에 묵직한 한 방은 없었습니다. 어떤 케이스는 캔버스가, 어떤 케이스는 데이터 준비와 반응성이, 또 어떤 케이스는 DOM이 문제였습니다. 병목을 정확히 찾고, 사이드 이펙트를 최소화하기 위해 그 부분만 고치는 일의 반복이었습니다. exemONE 대시보드의 차트는 지금도 이런 방식으로 성능을 개선해 나가고 있습니다.
함께 보면 좋은 아티클
