안녕하세요! 캔버스 튜토리얼의 대미를 장식할 '캔버스 최적화(Optimizing canvas)' 챕터에 오신 것을 환영합니다.
지금까지 캔버스로 그림을 그리고, 색을 칠하고, 애니메이션을 주고, 픽셀 데이터를 조작하는 멋진 기술들을 배웠습니다. 하지만 실무에서 이런 그래픽 요소들을 떡칠(...)하다 보면 어느 순간 랩톱 팬이 윙윙 돌면서 브라우저가 뚝뚝 끊기는 현상을 마주하게 됩니다. 여러분이 만들 포트폴리오 사이트나 데이터 뷰어는 빠르고 부드러워야 하잖아요?
이 문서에서는 캔버스 성능을 쥐어짜 내서 60프레임을 방어할 수 있는 아주 귀중한 실무 테크닉들을 소개합니다. 꿀팁 가득 담아 번역해 드릴게요!
<canvas> 요소는 웹에서 2D 그래픽을 렌더링하기 위해 가장 널리 사용되는 도구 중 하나입니다. 하지만 웹사이트나 앱이 Canvas API의 한계치까지 성능을 밀어붙이게 되면, 퍼포먼스(성능)가 급격히 저하되기 시작합니다. 이 아티클에서는 여러분이 만든 그래픽이 항상 빠르고 부드럽게 작동할 수 있도록, 캔버스 요소를 최적화하는 다양한 방법들을 제안합니다.
다음은 캔버스의 렌더링 성능을 끌어올릴 수 있는 팁들을 모아놓은 것입니다.
애니메이션의 매 프레임마다 똑같은 그리기 연산을 반복해서 낭비하고 있다면, 이 작업들을 보이지 않는 '화면 밖 캔버스(offscreen canvas)'로 덜어내는 것을 고려해 보세요. 오프스크린 캔버스에 미리 이미지를 한 번 짠 하고 생성해 두면, 메인 캔버스에서는 매번 점을 찍고 선을 그을 필요 없이 그 생성된 이미지만 툭툭 가져와 렌더링(drawImage)하면 됩니다. 불필요한 반복 연산을 크게 줄일 수 있죠.
myCanvas.offscreenCanvas = document.createElement("canvas");
myCanvas.offscreenCanvas.width = myCanvas.width;
myCanvas.offscreenCanvas.height = myCanvas.height;
// 오프스크린에 한 번 그려둔 걸 메인 캔버스에 '복사/붙여넣기'만 합니다!
myCanvas.getContext("2d").drawImage(myCanvas.offScreenCanvas, 0, 0);
💡 강사의 핵심 팁:
이 기법을 메모이제이션(Memoization) 패턴 혹은 캐싱(Caching)이라고 부릅니다. 복잡한 아이콘이나 캐릭터를 그릴 때 일일이moveTo,lineTo를 매 프레임 호출하면 프레임이 뚝뚝 떨어집니다. 보이지 않는 가상의 캔버스에 딱 한 번만 그림을 그려놓고, 진짜 화면에는drawImage로 도장 찍듯이 복사해오는 것이 실무 캔버스 최적화의 제1원칙입니다!
서브 픽셀 렌더링(Sub-pixel rendering)은 캔버스에 객체를 그릴 때 좌표값이 딱 떨어지는 정수(whole values)가 아닌 소수점(부동 소수점)을 가질 때 발생합니다.
// 좌표가 0.3, 0.5 와 같이 소수점으로 떨어집니다.
ctx.drawImage(myImage, 0.3, 0.5);
이렇게 하면 브라우저는 안티 앨리어싱(anti-aliasing, 계단 현상을 없애고 테두리를 부드럽게 만드는 기술) 효과를 만들어내기 위해 막대한 양의 추가적인 연산을 강제로 수행하게 됩니다. 이 문제를 피하려면, drawImage() 등을 호출할 때 사용하는 모든 좌표값에 Math.floor() 등을 사용해서 소수점을 버리고 정수로 딱 떨어지게 둥글게(round) 말아주는 것이 좋습니다.
drawImage 안에서 이미지를 확대/축소(scale)하지 마세요drawImage() 함수를 호출할 때마다 매번 이미지의 크기를 실시간으로 늘리거나 줄이는 연산을 하는 대신, 다양한 크기로 렌더링된 이미지 버전들을 로딩 단계에서 '화면 밖 캔버스(offscreen canvas)'에 미리 여러 개 그려두고 캐싱해서 사용하는 것이 훨씬 빠릅니다.
애플리케이션을 만들다 보면, 어떤 객체들은 쉴 새 없이 움직이거나 변해야 하는데 반해 어떤 객체들은 거의 정적으로 가만히 있는 경우가 있습니다. 이럴 때 적용할 수 있는 훌륭한 최적화 기법은, 하나의 캔버스에 모든 걸 다 그리지 말고 여러 개의 <canvas> 요소를 레이어처럼 겹쳐서 배치하는 것입니다.
예를 들어, 맨 위에는 UI(사용자 인터페이스)가 있고, 중간에는 게임 플레이 액션이 있으며, 맨 밑바닥에는 정적인 배경이 깔려 있는 게임을 만든다고 해봅시다. 이 경우, 게임을 세 개의 <canvas> 레이어로 쪼갤 수 있습니다. UI 레이어는 사용자가 클릭할 때만 업데이트되고, 게임 플레이 레이어는 60fps로 매 프레임마다 정신없이 변하겠지만, 배경 레이어는 처음 그려진 뒤로 거의 변하지 않은 채 가만히 쉴 수 있게 됩니다.
<div id="stage">
<canvas id="ui-layer" width="480" height="320"></canvas>
<canvas id="game-layer" width="480" height="320"></canvas>
<canvas id="background-layer" width="480" height="320"></canvas>
</div>
#stage {
width: 480px;
height: 320px;
position: relative; /* 자식 요소들의 기준점 */
border: 2px solid black;
}
canvas {
position: absolute; /* 캔버스들을 한 자리에 겹치게 만듭니다 */
}
#ui-layer {
z-index: 3; /* 가장 위에 */
}
#game-layer {
z-index: 2; /* 중간에 */
}
#background-layer {
z-index: 1; /* 가장 아래에 */
}
💡 강사의 실무 팁:
리액트(React)를 쓸 때 화면의 일부만 바뀌었는데도 전체 페이지가 리렌더링되지 않도록 컴포넌트를 잘게 쪼개는 것과 완전히 똑같은 원리입니다. 가만히 있는 배경을 매 프레임마다 지우고 다시 그리는 건 너무 억울하잖아요? 변하는 것(동적)과 변하지 않는 것(정적)을 다른 캔버스 태그로 분리하세요!
만약 정적인 배경 이미지를 써야 한다면, 굳이 캔버스 안에 그 이미지를 그리지 마세요. 대신 캔버스 아래에 평범한 <div> 요소를 하나 깔고 CSS background 속성을 사용해서 이미지를 집어넣으세요. 이렇게 하면 매 틱(프레임)마다 캔버스에 무거운 배경 이미지를 렌더링해야 하는 부담을 아예 날려버릴 수 있습니다.
CSS 변형(Transforms) 기술은 내부적으로 GPU(그래픽 카드)의 가속을 받기 때문에 연산 속도가 훨씬 빠릅니다.
가장 이상적인 것은 캔버스 픽셀 자체를 확대/축소하지 않는 것이지만, 만약 불가피하게 크기를 조절해야 한다면 캔버스 객체(ctx.scale) 내부에서 자체적으로 크기를 바꾸는 것보다 차라리 크기가 작은 캔버스를 만든 다음 CSS를 통해 화면상에서 뻥튀기(scale up)하는 것이 퍼포먼스 면에서 훨씬 유리합니다. 반대로 엄청나게 큰 캔버스를 만들고 CSS로 줄여버리는 짓은 피해야 합니다.
const scaleX = window.innerWidth / canvas.width;
const scaleY = window.innerHeight / canvas.height;
const scaleToFit = Math.min(scaleX, scaleY);
const scaleToCover = Math.max(scaleX, scaleY);
stage.style.transformOrigin = "0 0"; // 좌측 상단을 기준으로 확대
stage.style.transform = `scale(${scaleToFit})`; // CSS의 scale을 이용해 화면 크기에 맞춤!
만약 여러분의 애플리케이션이 캔버스를 사용하면서 뒷배경을 투명하게 비춰줄 필요가 전혀 없다면, 처음에 HTMLCanvasElement.getContext()로 드로잉 컨텍스트를 생성할 때 alpha 옵션을 false로 설정해 버리세요. 이 작은 힌트는 브라우저 내부에서 렌더링 프로세스를 최적화하는 데 아주 쏠쏠하게 사용됩니다. (투명도 계산을 건너뛰니까요!)
// 투명도 계산 기능을 아예 꺼버립니다!
const ctx = canvas.getContext("2d", { alpha: false });
맥북(MacBook)이나 최신 스마트폰 같은 고해상도 디스플레이에서 캔버스에 그린 그림이나 글자가 유독 픽셀이 깨진 것처럼 뿌옇게(blurry) 보이는 현상을 겪어보셨을 겁니다. 이를 해결하는 여러 가지 방법이 있지만, 가장 확실하고 첫 번째로 해야 할 조치는 캔버스의 고유 '속성(픽셀 수)'과 CSS의 '스타일(화면 크기)', 그리고 컨텍스트의 '스케일'을 기기의 픽셀 비율(Device Pixel Ratio, DPR)에 맞춰 동시에 올리고 줄이는 것입니다.
// 현재 디스플레이의 픽셀 비율(DPR)과 캔버스의 화면상 크기를 가져옵니다.
const dpr = window.devicePixelRatio;
const rect = canvas.getBoundingClientRect();
// 캔버스의 "실제" 도화지 픽셀 수를 DPR만큼 뻥튀기합니다 (해상도 증가).
canvas.width = rect.width * dpr;
canvas.height = rect.height * dpr;
// 그리기 명령들이 뻥튀기된 도화지에 맞게 정상적으로 그려지도록 컨텍스트의 스케일을 늘려줍니다.
ctx.scale(dpr, dpr);
// 하지만 화면에 "보여지는" 캔버스의 크기는 원래 의도했던 CSS 픽셀 크기로 꽉 잡아둡니다!
canvas.style.width = `${rect.width}px`;
canvas.style.height = `${rect.height}px`;
💡 강사의 핵심 팁:
이 코드는 그냥 복사/붙여넣기해서 스니펫으로 저장해 두세요! 최신 프론트엔드 환경에서 캔버스를 렌더링할 때 반드시, 무조건, 예외 없이 적용해야 하는 '레티나 대응(Retina Display Support) 공식'입니다. 이 처리를 안 하면 요즘 기기에서는 캔버스 글씨가 다 흐리멍덩하게 나옵니다.
fillStyle)이나 굵기(lineWidth)를 의미 없이 계속 바꿨다 원상복구하는 행위는 성능을 갉아먹습니다.shadowBlur 속성의 사용은 최대한 피하세요. 그림자 처리는 엄청난 컴퓨팅 파워를 잡아먹는 주범입니다.clearRect() vs. fillRect() vs. 캔버스 요소 크기 재할당하기).setInterval()은 절대 쓰지 말고 무조건 Window.requestAnimationFrame()을 사용하세요.이 페이지가 도움이 되었나요? (Was this page helpful to you?)
[ 예 (Yes) ][ 아니오 (No) ]
링크
자, 이렇게 해서 MDN의 Canvas API 튜토리얼을 끝까지 주행하셨습니다! 정말 고생 많으셨습니다.
특히 이번 최적화 챕터에서 나온 오프스크린 캔버스 캐싱, 다중 레이어 겹치기, 그리고 고해상도(DPR) 스케일링 기법은 프론트엔드 실무에서 캔버스를 다룰 때 모르면 안 되는 '기본 소양'이자 면접에서도 자주 물어보는 단골 질문이기도 합니다.
이제 이 지식들을 바탕으로 원하시던 웹 프로필 사이트나 독후감 통계 차트를 마음껏, 그리고 아주 부드럽고 선명하게 개발해 보세요! 더 나아가 컴포넌트 단위로 분리하는 과정에서 막히는 부분이 생긴다면 언제든 다시 찾아와 주세요!