Flutter CustomPainter와 Canvas API로 60fps 초고속 GPU 그래픽 애니메이션 구현하기

Flutter CustomPainter & Canvas API: 60fps 초고속 GPU 그래픽 애니메이션 완전 정복
Flutter 개발 중 실시간 오디오 비주얼라이저, 차트, 복잡한 파티클 효과, 또는 동적 지도 레이어를 구현할 때 단순 Widget 수백 개를 생성하면 심각한 프레임 드랍(Jank) 현상을 겪게 됩니다. 위젯 트리 생성 및 레이아웃, 페인팅 단계에서 CPU 병목이 발생하기 때문입니다.
이러한 성능 한계를 극복하기 위한 해답이 바로 **CustomPainter**와 **Canvas API**입니다. Flutter의 최신 Impeller GPU 렌더링 엔진과 결합하면 CPU 부담을 없애고 GPU 수준에서 다이렉트로 그래픽을 60fps / 120fps 부드럽게 렌더링할 수 있습니다.
본 가이드에서는 CustomPainter의 핵심 렌더링 파이프라인, RepaintBoundary 격리 기법, 그리고 실전 60fps 파티클 애니메이션 구현 코드를 다룹니다.
1. Widget 트리 vs CustomPainter 렌더링 구조 비교
| 항목 | 표준 Widget 조합 (Container/Positioned) | CustomPainter & Canvas API |
|---|---|---|
| 렌더링 방식 | 위젯 트리 ➡️ RenderObject ➡️ Layer 분할 | 단일 RenderCustomPaint ➡️ Direct Canvas 명령 |
| 요소 1,000개 렌더링 | 1,000개 Element/RenderObject 메모리 할당 | 1개의 Painter 안에서 루프문으로 GPU 캔버스 드로잉 |
| CPU / GPU 비중 | CPU 레이아웃/위젯 빌드 비용 높음 | GPU 라스터화 래핑 (CPU 최적화) |
| 적합한 유즈케이스 | 일반 폼, 리스트, 정적 UI | 실시간 파티클, 그래프, 게이지, 게임 UI |
2. 60fps 애니메이션 구현을 위한 3대 성능 최적화 핵심 법칙
법칙 1: shouldRepaint의 엄격한 제어
shouldRepaint(covariant CustomPainter oldDelegate) 메소드는 이전 프레임의 속성과 현재 속성을 비교하여 캔버스를 다시 그릴지 결정합니다. 조건 없이 true를 반환하면 무의미한 페인트 연산이 지속되므로 반드시 변경 여부를 명확히 체크해야 합니다.
법칙 2: RepaintBoundary를 통한 렌더링 레이어 격리
애니메이션이 발생하는 CustomPaint 위젯 주변을 RepaintBoundary로 감싸면, 바깥쪽 정적 UI 레이어와 페인팅 레이어가 분리되어 다른 위젯까지 불필요하게 Re-paint되는 현상을 차단합니다.
// RepaintBoundary 적용 패턴
RepaintBoundary(
child: CustomPaint(
painter: ParticlePainter(particles: _particles),
size: Size.infinite,
),
)
법칙 3: AnimationController를 CustomPainter의 repaint 파라미터로 전달
setState()를 호출해 위젯 트리를 통째로 Re-build하는 대신, CustomPainter 생성자의 repaint: Listenable 파라미터에 AnimationController를 직접 연결하세요. super(repaint: repaint)로 전달된 Listenable이 값이 바뀔 때마다 자동으로 paint()를 다시 호출해 주기 때문에, 별도의 리빌드 없이 캔버스 레이어만 단위 프레임마다 그려집니다. (참고로 CustomPaint의 willChange는 래스터 캐시 여부를 알려주는 단순 bool 힌트일 뿐, Listenable을 받는 파라미터가 아닙니다.)
3. 실전 60fps GPU 파티클 애니메이션 코드
다음은 100개 이상의 파티클을 GPU 캔버스 위에서 60fps로 매끄럽게 구동하는 완전한 Flutter 예제 코드입니다.
import 'dart:math';
import 'package:flutter/material.dart';
class Particle {
double x, y, dx, dy, radius;
Color color;
Particle({
required this.x,
required this.y,
required this.dx,
required this.dy,
required this.radius,
required this.color,
});
void update(Size size) {
x += dx;
y += dy;
if (x < 0 || x > size.width) dx = -dx;
if (y < 0 || y > size.height) dy = -dy;
}
}
class ParticleCanvasScreen extends StatefulWidget {
const ParticleCanvasScreen({super.key});
@override
State<ParticleCanvasScreen> createState() => _ParticleCanvasScreenState();
}
class _ParticleCanvasScreenState extends State<ParticleCanvasScreen>
with SingleTickerProviderStateMixin {
late AnimationController _controller;
final List<Particle> _particles = [];
final Random _random = Random();
@override
void initState() {
super.initState();
// 60fps 애니메이션 루프 컨트롤러 설정
_controller = AnimationController(
vsync: this,
duration: const Duration(seconds: 1),
)..repeat();
// 파티클 데이터 초기화 (150개)
for (int i = 0; i < 150; i++) {
_particles.add(Particle(
x: _random.nextDouble() * 400,
y: _random.nextDouble() * 800,
dx: (_random.nextDouble() - 0.5) * 3,
dy: (_random.nextDouble() - 0.5) * 3,
radius: _random.nextDouble() * 4 + 2,
color: Colors.indigoAccent.withValues(alpha: _random.nextDouble() * 0.7 + 0.3),
));
}
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: const Color(0xFF0F172A),
body: RepaintBoundary( // 정적 서클 레이어 분리
child: CustomPaint(
painter: ParticlePainter(particles: _particles, repaint: _controller),
size: Size.infinite,
),
),
);
}
}
class ParticlePainter extends CustomPainter {
final List<Particle> particles;
ParticlePainter({required this.particles, required Listenable repaint})
: super(repaint: repaint);
@override
void paint(Canvas canvas, Size size) {
final paint = Paint()..style = PaintingStyle.fill;
for (var particle in particles) {
particle.update(size);
paint.color = particle.color;
canvas.drawCircle(Offset(particle.x, particle.y), particle.radius, paint);
}
}
@override
bool shouldRepaint(covariant ParticlePainter oldDelegate) => true; // 프레임마다 파티클 위치가 변경됨
}
4. 요약 및 DevTools 프레임 프로파일링 팁
- 수백 개의 동적 요소를 그릴 때는 위젯 생성 대신 **
CustomPainter**와 **Canvas API**를 사용하세요. - 애니메이션 컴포넌트는 반드시 **
RepaintBoundary**로 감싸 다른 UI로의 Paint 전파를 막으세요. - Flutter DevTools Performance Overlay를 열어
UI frame과Raster frame타임이 16.6ms (60fps 기준) 이내로 유지되는지 항상 모니터링하세요.