원문: https://blog.ag-grid.com/optimising-html5-canvas-rendering-best-practices-and-techniques/
이 글은 AG Charts의 시니어 엔지니어 Mana Peirov의 발표를 토대로 작성되었습니다. YouTube 발표
들어가며
HTML5 캔버스 요소를 통해 개발자들은 브라우저에서 직접 2D 도형, 이미지, 텍스트를 그릴 수 있습니다. AG Grid에서는 자사의 자바스크립트 차트 라이브러리인 AG Charts에서 차트를 렌더링하기 위해 캔버스를 사용합니다. AG Charts는 처음에 React Table 라이브러리인 AG Grid의 통합 차트 기능을 지원하기 위해 개발되었습니다. AG Grid는 대규모(10만 건 이상) 데이터를 다루는 경우가 많아서, 성능 저하 없이 대용량 데이터를 처리할 수 있는 해결책이 필요했습니다.
대부분의 차트 라이브러리처럼, 우리도 SVG와 캔버스 중 하나를 선택해야 했습니다. 수십만 개의 SVG 요소를 렌더링하면 성능이 저하되기 때문에 도형과 선을 더 효율적으로 그릴 수 있는 캔버스를 선택했습니다. 하지만 캔버스가 만능 해결책은 아니며, 자체적인 성능 문제가 있습니다. 이 블로그에서는 AG Charts를 개발하면서 캔버스의 성능을 최대한 끌어올리기 위해 적용한 몇 가지 기법들을 살펴보겠습니다.
HTML5 캔버스 개요
구체적인 최적화 방법을 살펴보기 전에, 캔버스가 어떻게 작동하는지 알아보겠습니다. 캔버스 작동 방식을 이미 알고 계신다면 이 부분은 건너뛰셔도 좋습니다.
<canvas>
에 그림을 그리려면 렌더링 컨텍스트에 접근해야 합니다. 가장 일반적인 컨텍스트는 2D(getContext("2d")
)지만 캔버스는 3D 렌더링을 위한 WebGL도 지원합니다. AG Charts는 3D 렌더링을 사용하지 않기 때문에, 이 블로그는 2D 렌더링 컨텍스트에 초점을 맞추고 있습니다.
2D 컨텍스트에 접근한 후에는 다양한 메서드를 사용하여 도형, 선, 호, 이미지, 텍스트를 그릴 수 있습니다. 예를 들면 다음과 같습니다.
- 선을 위한
beginPath()
,moveTo()
,lineTo()
- 원을 위한
arc()
- 텍스트를 위한
fillText()
과strokeText()
위 메서드들을 이용해 캔버스에 그린 후에는 save()
와 restore()
메서드를 이용하여 상태를 관리할 수 있습니다. 이를 통해 이전 상태를 저장하고 복원할 수 있어, 이후 그리기에 영향을 주지 않고 변형과 스타일을 처리할 수 있습니다.
또한 캔버스는 drawImage()
같은 이미지 조작 메서드도 제공합니다. 이 메서드로 이미지를 렌더링 하고 조작할 수 있어 자르기, 크기 조정, 효과 적용 같은 작업에 적합합니다. 나중에 중요하게 다룰 내용이니 기억해주세요.
이러한 내용을 종합한 간단한 캔버스 예제는 다음과 같습니다.
const canvas = document.getElementById("canvas");
const ctx = canvas.getContext("2d");
// 현재 상태 저장
ctx.save();
ctx.fillStyle = "green";
ctx.fillRect(10, 10, 100, 100);
// 가장 최근에 save() 메서드로 저장한 상태로 되돌리기
ctx.restore();
ctx.fillRect(150, 40, 100, 100);
위 코드는 아래와 같은 이미지를 만듭니다.
캔버스 렌더링에서의 주요 과제
실제 캔버스 렌더링에서는 beginPath
, arc
, fill
, stroke
같은 메서드와 save
, restore
명령어를 반복적으로 호출합니다. 이 명령어들은 사용하기는 쉽지만, 프레임당 수천 번 또는 수십만 번 호출되면 성능에 큰 영향을 미칠 수 있습니다. 이러한 메서드들이 메인 스레드에서 실행되기 때문에, 성능 문제는 사용자가 바로 체감할 수 있으며 데이터가 늘어날수록 병목 현상이 빠르게 발생할 수 있습니다.
이해를 돕기 위해, 다음과 같은 차트를 만들기 위해 n
개의 데이터 포인트를 그리려 한다고 가정해 봅시다.
이를 구현하는 간단한 방법은 각 데이터 포인트를 순회하면서 위치를 계산하고 캔버스에 그리는 것입니다.
export const drawSimple = ({ ctx, data, size, fill, stroke }) => {
const r = size / 2;
data.forEach(({ x, y }) => {
// 컨텍스트 저장
ctx.save();
// 원 그리기
ctx.beginPath();
ctx.arc(x, y, r, 0, Math.PI * 2);
// 스타일 적용 - 캔버스 상태 변경
ctx.fillStyle = fill;
ctx.fill();
ctx.lineWidth = 2;
ctx.strokeStyle = stroke;
ctx.stroke();
// 컨텍스트 복원
ctx.restore();
});
};
이렇게 하면 모든 캔버스 API 메서드를 n
번 호출하게 되어 선형 시간 복잡도(O(n))가 발생합니다. 즉, 데이터 셋이 커질수록 렌더링 속도가 느려진다는 의미입니다.
위 예제를 크롬에서 벤치마킹한 결과, 10만 개의 데이터 포인트를 렌더링하는 데 287.1ms가 걸렸습니다.
다행히도 성능을 개선하는 데 도움이 될 수 있는 몇 가지 작업이 있습니다.
배치(Batch) 렌더링
배치 렌더링부터 시작해 보겠습니다. 간단히 말하자면, 배치 렌더링은 여러 그리기 호출이나 렌더링 작업을 하나의 그룹으로 묶어 한 번에 실행하는 최적화 기법입니다.
위 예제에서, 각 점을 개별적으로 그리는 대신 모든 점을 하나의 경로로 묶어서 fill
과 stroke
를 한 번씩만 호출하는 방법입니다. 예시는 다음과 같습니다.
export const drawBatched = ({ ctx, data, size, fill, stroke }) => {
ctx.save();
const r = size / 2;
ctx.beginPath(); // 한 번 호출
data.forEach(({ x, y }) => {
ctx.moveTo(x + r, y);
ctx.arc(x, y, r, 0, Math.PI * 2);
});
// 스타일 상태 변경 사항 적용
ctx.fillStyle = fill;
ctx.strokeStyle = stroke;
ctx.lineWidth = 2;
// 경로를 한 번에 일괄 처리하여 그리기
ctx.fill();
ctx.stroke();
ctx.restore();
};
이 함수의 시간 복잡도는 여전히 O(n) 입니다. 전체 데이터 배열을 순회해야 하기 때문입니다. 하지만 ctx.save()
, ctx.beginPath()
, 스타일 설정, 최종 ctx.fill()
/ ctx.stroke()
호출 등과 같은 대부분의 그리기 작업은 루프 밖에서 한 번만 수행되므로 상수 시간(O(1))이 소요됩니다.
이 접근 방식으로 렌더링 시간을 크게 줄일 수 있습니다. 이전 예시를 사용하면, 일괄 렌더링만으로도 100,000개의 데이터 포인트에 대한 렌더링 시간을 15.4ms까지 줄일 수 있습니다.
소프트웨어 개발에서 흔히 그렇듯이, 배치 렌더링도 단점이 있다는 점을 주목해야 합니다. 여러 개의 겹치는 도형을 렌더링 하면 모든 도형이 하나의 큰 경로로 처리되기 때문에 의도하지 않은 “뭉개짐” 효과가 발생할 수 있습니다.
이 문제를 해결하기 위해 오프스크린(Offscreen) 캔버스 API를 사용할 수 있습니다.
오프스크린 캔버스 API를 활용한 스프라이트 렌더링
캔버스 API와 달리 OffscreenCanvas
인터페이스는 메모리상에서만 렌더링(off-screen) 되는 캔버스를 제공합니다. 이를 통해 <canvas>
요소가 DOM에 종속되지 않고도 독립적으로 동작할 수 있습니다. 더불어 렌더링 작업을 워커에서 처리할 수 있어, 일부 작업을 별도의 스레드에서 실행하고 메인 스레드의 부하를 줄일 수 있습니다.
export const drawOffscreen = ({ canvasCtx, offscreenCanvasCtx, data }) => {
canvasCtx.save();
offscreenCanvasCtx.fillStyle = fill;
offscreenCanvasCtx.strokeStyle = stroke;
offscreenCanvasCtx.lineWidth = strokeWidth;
const center = (size + strokeWidth) / 2;
const r = size / 2;
const sSize = size + strokeWidth;
// 한 번 호출
offscreenCanvasCtx.beginPath();
offscreenCanvasCtx.arc(center, center, r, 0, Math.PI * 2);
offscreenCanvasCtx.fill();
offscreenCanvasCtx.stroke();
// 각 데이터 포인트에 대해 오프스크린 캔버스의 내용을 메인 캔버스에 그리기
data.forEach(({ x, y }) => {
canvasCtx.drawImage(
offscreenCanvasCtx,
x - center,
y - center,
sSize,
sSize
);
});
canvasCtx.restore();
};
위 코드에서는 오프스크린 캔버스를 사용해 하나의 점(원)을 만든 다음, drawImage
메서드로 이를 메인 캔버스의 여러 좌표에 그립니다. 이렇게 하면 모든 beginPath
, arc
, fill
, stroke
호출의 시간 복잡도를 O(1) 로 줄이고, 대신 더 빠른 렌더링 방식인 drawImage
를 O(n) 번 호출하게 됩니다. 또한 배치 렌더링에서처럼 save
와 restore
도 한 번만 호출하면 됩니다.
이 기법을 예시에 적용하면 100,000개의 데이터 포인트에 대한 렌더링 시간을 65.7ms로 줄일 수 있습니다.
이 기법의 중요한 점은 배치 렌더링에 비해 약간의 성능 비용이 발생하지만 결과물의 품질에는 전혀 영향을 미치지 않는다는 것입니다.
변경 감지
다른 블로그 게시물에서 우리는 AG Charts의 캔버스 렌더링 성능을 최적화하기 위해 트리 기반 장면(scene) 그래프를 어떻게 사용하는지에 대해 설명했습니다. 간단히 말해서, 우리의 장면 그래프는 서로 다른 차트 요소들을 나타내는 노드들로 구성됩니다. 기본 노드 클래스는 위치 지정, 스타일링, 렌더링을 위한 속성들을 포함하며, 선, 도형, 마커 등 각 노드 타입은 이 기본 클래스를 상속받아 자체적인 렌더링 로직을 구현합니다.
차트 요소를 업데이트할 때 가장 단순한 방법은 변경되지 않은 노드까지 포함하여 모두 다시 그리는 것입니다. 이는 루트 노드에서 시작해 장면 그래프를 위에서 아래로 순회하며 모든 그룹과 자식들을 다시 렌더링하는 것을 의미합니다. 이 과정에서 캔버스 렌더링 메서드 호출과 상태 업데이트가 필요하므로 비용이 많이 들 수 있습니다.
이를 해결하기 위해 우리는 마커의 채우기, 선, 크기, 위치 등의 속성이 변경될 때 true로 설정되는 dirty
플래그를 구현했습니다. 이 플래그는 트리를 따라 부모 노드까지 전파되어 관련된 부모 그룹을 'dirty'로 표시할 수 있습니다. 각 그룹은 zIndex
값을 가지는 그래픽 요소인 '레이어' 역할을 합니다. 각 레이어에 대해 오프스크린 캔버스를 만들어 다른 레이어와 독립적으로 렌더링 할 수 있습니다.
이렇게 하면 재렌더링 시 어떤 그룹이 변경된 상태이고 다시 그려야 하는지 알 수 있으며, 변경되지 않은 경우 이전 렌더링의 캐시 된 비트맵 이미지를 사용할 수 있습니다. 이 접근 방식은 전반적인 렌더링 성능을 크게 향상합니다.
간단히 말해서, 캔버스 작업 시 상태 변화를 추적하고 필요한 부분만 다시 그려서 불필요한 그리기 작업을 최소화하는 것이 중요합니다.
요약
아래 표는 100,000개의 데이터 포인트를 캔버스에 그릴 때 각 방식의 성능 차이를 명확히 보여줍니다.
배치 방식이 가장 빠르지만, 단순/오프스크린 렌더링과 비교할 때 품질이 떨어지는 단점이 있습니다.
항상 그렇듯이 최적화에 있어 만능 해결책은 없습니다. 성능과 품질 사이에서 절충하여 사용 사례에 맞는 적절한 방식을 선택하는 것이 중요합니다.
물론, 이러한 작업을 직접 하지 않고도 아름답고 고성능의 차트를 만들고 싶다면 AG Charts를 사용하면 됩니다.