대시보드에 차트가 많을수록 느려지는 이유, 궁금하지 않으신가요? 저는 최근 대시보드 페이지에서 여러 차트의 동시 애니메이션으로 인한 심각한 성능 저하를 경험했는데요. 이 문제를 해결하면서 자바스크립트 런타임의 동작 원리를 더 깊이 이해하게 된 경험을 공유하고 싶습니다.
처음에는 단순히 차트가 많아서 느려지는 거라고만 생각했어요. "애니메이션 콜백 함수가 무거워서 그런가?" 하는 막연한 추측만 했죠. 하지만 크롬 DevTools로 확인해보니 전혀 다른 원인을 발견했습니다.
예상 밖의 문제점은 바로 requestAnimationFrame 자체의 호출 비용이었습니다. 애니메이션을 실행하기 위해 각 차트마다 개별적으로 requestAnimationFrame을 호출하고 있었고, 이게 차트 개수만큼 n배로 늘어나고 있었던 거죠.
여기서 놀라운 사실을 발견했는데요, requestAnimationFrame 호출 자체가 상당한 비용(약 30~60μs)을 필요로 한다는 점입니다. 이는 실제 실행될 콜백 함수의 내용과 상관없이, 단순히 태스크를 큐에 할당하는 오버헤드였죠. 심지어 빈 콜백을 넣어도 이 비용은 그대로 발생합니다!
이게 왜 문제가 될까요? 60Hz 디스플레이를 기준으로 브라우저는 16.7ms(1000ms/60) 안에 모든 작업을 처리해야 다음 프레임을 부드럽게 그릴 수 있습니다. 만약 10개의 차트가 각각 requestAnimationFrame를 호출한다면, 태스크 할당에만 최대 600μs(60μs × 10)가 소요되는 거죠. 여기에 실제 애니메이션 계산과 렌더링 시간까지 더해지면, 쉽게 프레임 드롭이 발생할 수 있습니다.
먼저 requestAnimationFrame(이하 rAF)에 대해 간단히 설명드리면, 이는 브라우저의 다음 리페인트 시점에 실행할 콜백을 등록하는 메서드입니다. 보통 60Hz 모니터를 기준으로 16.7ms마다 실행되죠. 애니메이션을 구현할 때 setInterval 대신 rAF를 사용하라고 흔히들 추천하는데, 이는 브라우저의 리페인트 주기에 맞춰 실행되어 더 부드러운 애니메이션을 구현할 수 있기 때문입니다.
하지만 여기서 중요한 점은, rAF가 매크로태스크(MacroTask)를 생성한다는 겁니다. 자바스크립트의 이벤트 루프는 매크로태스크와 마이크로태스크(MicroTask)를 구분해서 처리하는데요:
매크로태스크는 한 번에 하나씩만 실행되고, 실행 사이에 항상 렌더링이 일어날 수 있습니다. 반면 마이크로태스크는 현재 실행 중인 매크로태스크 완료 후 큐에 있는 모든 마이크로태스크가 순차적으로 실행됩니다.
자바스크립트 런타임이 어떻게 동작하는지 자세히 들여다보니, 문제의 본질이 보이기 시작했습니다.
[기존 방식]
Chart 1 |--rAF-->|--rAF-->|--rAF-->|
Chart 2 |--rAF-->|--rAF-->|--rAF-->|
Chart 3 |--rAF-->|--rAF-->|--rAF-->|
└─────── Event Loop ────────┘
[최적화 후]
Charts |--rAF(1,2,3)-->|--rAF(1,2,3)-->|
└────── Event Loop ─────────────┘
이런 이해를 바탕으로, 싱글톤 패턴을 활용한 VSync 클래스를 구현했습니다. 여러 차트가 각각 requestAnimationFrame을 호출하는 대신, 하나의 requestAnimationFrame으로 모든 차트의 애니메이션을 처리하는 방식이죠.
```javascript
/**
* Vsync manages requestAnimationFrame calls by queuing callbacks and executing them
* in a single animation frame to optimize performance.
*/
export class Vsync {
private callbacks: ((time: number) => void)[];
private frameRequested: boolean;
private rafId: number | null;
private constructor() {
this.callbacks = [];
this.frameRequested = false;
this.rafId = null;
}
public static getInstance(): Vsync {
if (typeof window === 'undefined') {
throw new Error('Vsync requires window object');
}
if (!(window as any).__flitter_vsync__) {
(window as any).__flitter_vsync__ = new Vsync();
}
return (window as any).__flitter_vsync__;
}
/**
* Schedules a callback to be executed in the next animation frame.
* If there's already a frame requested, the callback will be queued
* to run with other callbacks in that frame.
*/
public requestCallback(callback: (time: number) => void): void {
this.callbacks.push(callback);
if (!this.frameRequested) {
this.frameRequested = true;
this.rafId = requestAnimationFrame(this.handleFrame);
}
}
/**
* Removes a previously scheduled callback.
*/
public cancelCallback(callback: (time: number) => void): void {
const index = this.callbacks.indexOf(callback);
if (index !== -1) {
this.callbacks.splice(index, 1);
}
if (this.callbacks.length === 0 && this.rafId !== null) {
cancelAnimationFrame(this.rafId);
this.frameRequested = false;
this.rafId = null;
}
}
/**
* Handles the animation frame by executing all queued callbacks.
*/
private handleFrame = (time: number): void => {
this.frameRequested = false;
this.rafId = null;
// Get current callbacks and clear the queue
const callbacksToRun = [...this.callbacks];
this.callbacks = [];
// Execute all callbacks with the current time
for (const callback of callbacksToRun) {
callback(time);
}
// If new callbacks were added during execution, request a new frame
if (this.callbacks.length > 0 && !this.frameRequested) {
this.frameRequested = true;
this.rafId = requestAnimationFrame(this.handleFrame);
}
};
}
이 최적화의 결과는 놀라웠습니다. CPU 사용률이 눈에 띄게 줄었고, 차트가 몇 개가 있더라도 애니메이션이 부드럽게 동작하게 되었죠. 실제로 차트 10개를 동시에 애니메이션할 때, CPU 사용시간이 거의 차트 1개가 애니메이션하는 것과 비슷했습니다.
<headless-chart로 만든 예시>
이번 경험을 통해 자바스크립트 런타임의 동작 원리를 깊이 이해하는 것이 얼마나 중요한지 다시 한번 깨달았습니다. 표면적으로 보이는 현상 너머의 원인을 파악하고, 그에 맞는 해결책을 찾아가는 과정이 개발자로서의 성장에 큰 도움이 되었죠.
여러분도 비슷한 성능 이슈를 겪어보셨나요? 또는 자바스크립트 런타임의 동작 원리를 이해하고 나서야 해결할 수 있었던 경험이 있으신가요? 댓글로 여러분의 경험도 공유해주세요 :)
P.S. headless-chart에 관심이 있으시다면 GitHub 저장소에 방문해주세요. 여러분의 피드백과 기여를 환영합니다!
저도 rAF가 많은 오버헤드를 발생시킨다는걸 처음알았어요 감사합니다. vsync 만드신것 처럼 묶어서 사용하는게 좋겠네요!
그런데 제가 알고있는것과 다르게 설명되어있는 부분이 있는것 같아서 질문드립니다.
rAF는 task(macro task)를 생성하는게 아니라 Animation Frame Callbacks으로 분류되서 Animation Frame Queue로 따로 관리되고 task queue보다 우선순위를 갖고있다고 알고 있었는데 제가 잘못 알고 있던걸까요?
반면 마이크로태스크는 현재 실행 중인 매크로태스크 완료 후 큐에 있는 모든 마이크로태스크가 순차적으로 실행됩니다.
해당 부분에 대해서 제가 알기로는 이벤트 루프가 Microtask Queue의 작업들을 모두 처리하고 Macrotask Queue를 처리하는 것으로 알고 있는데, 혹시 제가 잘못 알고 있을까요?
이렇게 되면 기존 방식은 6개의 차트중 1개의 차트의 데이터가 망가져 차트를 그리는데 오류가 생겨도 5개는 정상적으로 그려지지만, 바뀐후에는 1개라도 데이터가 망가져 차트를 그리는데 오류가 생기면 6개 모두 차트가 안나오는 사이드 이펙트가 예상되는데 맞을까요?
맞다면 이부분도 해결되었나요?