이미지 로딩 중...
AI Generated
2025. 11. 13. · 6 Views
Flutter Shader 컴파일 최적화와 SkSL 워밍업으로 초기 jank 제거
Flutter 앱의 첫 실행 시 발생하는 jank 문제를 Shader 컴파일 최적화와 SkSL 워밍업으로 해결하는 방법을 상세히 다룹니다. 실무에서 바로 적용할 수 있는 구체적인 전략과 코드 예제를 제공합니다.
목차
- Shader 컴파일 Jank 이해하기
- SkSL 워밍업 기본 개념
- ShaderWarmUp 클래스 활용
- 커스텀 ShaderWarmUp 구현
- SkSL 번들 생성과 적용
- DevTools를 활용한 Shader 분석
- 조건부 Shader 워밍업
- 점진적 워밍업 전략
1. Shader 컴파일 Jank 이해하기
시작하며
여러분의 Flutter 앱을 처음 실행했을 때 애니메이션이 버벅이거나 화면 전환이 부드럽지 않은 경험을 해본 적 있나요? 특히 복잡한 그래픽 효과나 그라디언트를 사용하는 화면에서 첫 렌더링 시 눈에 띄는 프레임 드롭이 발생하는 경우가 많습니다.
이런 문제는 Shader 컴파일이라는 GPU 작업 때문에 발생합니다. Flutter는 화면을 그릴 때 Skia 그래픽 엔진을 사용하는데, 특정 시각 효과를 처음 렌더링할 때 GPU용 Shader 프로그램을 실시간으로 컴파일해야 합니다.
이 컴파일 작업은 메인 스레드를 블로킹하여 16ms 프레임 예산을 초과하게 만들고, 결과적으로 사용자가 jank(버벅임)를 느끼게 됩니다. 바로 이럴 때 필요한 것이 Shader 사전 컴파일입니다.
앱이 시작될 때 미리 필요한 Shader들을 컴파일해두면, 실제 화면을 그릴 때는 이미 준비된 Shader를 사용하여 부드러운 60fps 애니메이션을 구현할 수 있습니다.
개요
간단히 말해서, Shader 컴파일 jank는 GPU가 처음으로 특정 시각 효과를 렌더링할 때 발생하는 일시적인 성능 저하 현상입니다. Flutter 앱에서 그라디언트, 블러, 그림자, 복잡한 Path 그리기 등의 작업을 수행할 때마다 Skia는 해당 작업에 최적화된 Shader 프로그램을 생성합니다.
문제는 이 Shader 컴파일이 처음에만 수백 밀리초가 걸릴 수 있다는 점입니다. 예를 들어, 복잡한 그라디언트 배경을 가진 화면으로 처음 전환할 때 200-500ms의 프레임 드롭이 발생할 수 있습니다.
기존에는 이런 jank를 피할 방법이 없었습니다. 사용자가 해당 화면을 처음 볼 때는 무조건 버벅임을 경험해야 했죠.
이제는 SkSL(Skia Shader Language) 워밍업을 통해 앱 시작 시 미리 컴파일을 완료할 수 있습니다. Shader 컴파일 jank의 핵심 특징은 첫 번째 렌더링에만 발생한다는 점, 플랫폼마다 영향도가 다르다는 점(특히 저사양 Android 기기에서 심함), 그리고 완전히 예측 가능하다는 점입니다.
이러한 특징들을 이해하면 효과적인 최적화 전략을 수립할 수 있습니다.
코드 예제
// Shader 컴파일 jank를 재현하는 예제
// 복잡한 그라디언트가 처음 렌더링될 때 jank 발생
import 'package:flutter/material.dart';
class ShaderJankExample extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Container(
// 이 복잡한 그라디언트가 처음 그려질 때 Shader 컴파일 발생
decoration: BoxDecoration(
gradient: LinearGradient(
colors: [Colors.blue, Colors.purple, Colors.pink],
stops: [0.0, 0.5, 1.0],
),
),
child: CustomPaint(
// 복잡한 Path도 Shader 컴파일 유발
painter: ComplexShapePainter(),
),
);
}
}
설명
이것이 하는 일: 위 코드는 Shader 컴파일 jank가 어떤 상황에서 발생하는지 보여주는 예제입니다. 복잡한 그라디언트와 CustomPaint를 사용하여 의도적으로 Shader 컴파일을 유발합니다.
첫 번째로, Container의 decoration에 정의된 LinearGradient는 세 가지 색상과 중간 정지점을 가진 복잡한 그라디언트입니다. 이 위젯이 처음 화면에 나타날 때, Skia는 이 특정 그라디언트 패턴을 렌더링하기 위한 GPU Shader 프로그램을 생성하고 컴파일해야 합니다.
이 과정에서 메인 스레드가 블로킹되어 사용자는 버벅임을 느끼게 됩니다. 그 다음으로, CustomPaint 위젯이 복잡한 도형을 그릴 때도 추가적인 Shader 컴파일이 발생합니다.
ComplexShapePainter가 Path를 사용하여 복잡한 곡선이나 도형을 그릴 때, Skia는 해당 렌더링 작업에 최적화된 또 다른 Shader를 생성합니다. 특히 BlendMode나 MaskFilter 같은 고급 효과를 사용하면 더 많은 Shader 변형이 필요합니다.
마지막으로, 이렇게 컴파일된 Shader들은 캐시되어 다음 렌더링부터는 즉시 사용됩니다. 하지만 첫 번째 렌더링의 jank는 이미 사용자 경험을 해쳤습니다.
이것이 바로 사전 워밍업이 중요한 이유입니다. 여러분이 이 문제를 이해하면 프로덕션 앱에서 어떤 UI 요소들이 jank를 유발할 수 있는지 예측할 수 있습니다.
복잡한 애니메이션, 그라디언트 배경, 블러 효과, 그림자가 많은 화면, 복잡한 벡터 그래픽 등이 대표적인 케이스입니다. DevTools의 Performance 탭에서 "Shader compilation" 이벤트를 모니터링하여 정확히 어떤 위젯이 문제를 일으키는지 파악할 수 있습니다.
실전 팁
💡 DevTools의 Performance Overlay를 활성화(flutter run --profile)하여 실제 기기에서 jank를 측정하세요. 에뮬레이터는 Shader 컴파일 성능이 실제 기기와 다르므로 정확한 테스트가 불가능합니다.
💡 복잡한 그라디언트보다는 단순한 색상을, 실시간 블러보다는 정적 이미지를 사용하는 것만으로도 많은 Shader 컴파일을 피할 수 있습니다. 디자인 결정이 성능에 직접적인 영향을 미칩니다.
💡 Android에서는 Shader 컴파일이 더 느린 경향이 있으므로 저사양 Android 기기를 주요 테스트 대상으로 삼으세요. Galaxy A 시리즈나 저가형 기기에서 테스트하면 worst-case 시나리오를 파악할 수 있습니다.
💡 flutter run --profile --trace-skia 명령으로 Skia 레벨의 상세한 렌더링 정보를 얻을 수 있습니다. 이를 통해 어떤 Shader가 컴파일되는지 정확히 추적할 수 있습니다.
2. SkSL 워밍업 기본 개념
시작하며
여러분이 Flutter 앱의 초기 jank 문제를 해결하려고 다양한 방법을 시도해봤지만 근본적인 해결책을 찾지 못한 경험이 있나요? 위젯 트리를 최적화하고, 불필요한 rebuild를 제거하고, lazy loading을 적용해도 여전히 첫 화면 전환에서 버벅임이 발생하는 경우가 있습니다.
이런 문제의 근본 원인은 위젯 레벨이 아니라 GPU 레벨에 있습니다. Shader 컴파일은 위젯 최적화로는 해결할 수 없는 저수준 문제입니다.
아무리 효율적인 위젯 트리를 구성해도 GPU가 Shader를 컴파일하는 시간 자체는 줄일 수 없죠. 바로 이럴 때 필요한 것이 SkSL 워밍업입니다.
앱이 실제 화면을 그리기 전에 백그라운드에서 미리 Shader를 컴파일해두면, 사용자가 화면을 볼 때는 이미 준비된 Shader를 사용하여 즉시 부드러운 렌더링이 가능합니다.
개요
간단히 말해서, SkSL 워밍업은 앱 시작 시 미리 필요한 Shader들을 백그라운드에서 컴파일하여 캐시에 저장하는 기법입니다. Flutter는 PaintingBinding.shaderWarmUp() 메서드를 제공하여 개발자가 앱 초기화 단계에서 Shader 워밍업을 수행할 수 있도록 지원합니다.
이 메커니즘은 실제 UI를 렌더링하지 않고도 Shader 컴파일을 트리거할 수 있어, 사용자가 느끼지 못하는 시점에 준비 작업을 완료할 수 있습니다. 예를 들어, 스플래시 스크린을 보여주는 2-3초 동안 백그라운드에서 모든 Shader 컴파일을 마칠 수 있습니다.
기존에는 jank가 발생하는 순간에 실시간으로 Shader를 컴파일했다면, 이제는 앱 시작 시 미리 컴파일하여 jank를 아예 발생하지 않게 만들 수 있습니다. 시간을 재배치하는 것이죠.
SkSL 워밍업의 핵심 특징은 첫째, 사용자 경험을 해치지 않는 시점에 무거운 작업을 수행한다는 점, 둘째, 한 번 컴파일된 Shader는 앱이 종료될 때까지 재사용된다는 점, 셋째, 앱의 특성에 맞게 커스터마이징할 수 있다는 점입니다. 이러한 특징들이 SkSL 워밍업을 강력한 성능 최적화 도구로 만듭니다.
코드 예제
// SkSL 워밍업 기본 구현
import 'package:flutter/material.dart';
import 'package:flutter/scheduler.dart';
void main() {
// 앱 시작 전 Shader 워밍업 수행
WidgetsFlutterBinding.ensureInitialized();
// 기본 ShaderWarmUp 실행
// 일반적인 Flutter 위젯들의 Shader를 미리 컴파일
final shaderWarmUp = DefaultShaderWarmUp();
PaintingBinding.instance.shaderWarmUp = shaderWarmUp;
runApp(MyApp());
}
class MyApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
return MaterialApp(
home: HomePage(),
);
}
}
설명
이것이 하는 일: 위 코드는 Flutter 앱에서 기본 Shader 워밍업을 설정하는 가장 간단한 방법을 보여줍니다. main() 함수에서 앱 실행 전에 Shader 사전 컴파일을 트리거합니다.
첫 번째로, WidgetsFlutterBinding.ensureInitialized()를 호출하여 Flutter 프레임워크를 초기화합니다. 이는 runApp() 전에 바인딩 관련 작업을 수행할 때 필수적입니다.
이 단계에서 Flutter의 렌더링 파이프라인, 플랫폼 채널, 제스처 인식 등의 핵심 시스템이 준비됩니다. 그 다음으로, DefaultShaderWarmUp 인스턴스를 생성하고 PaintingBinding.instance.shaderWarmUp에 할당합니다.
이렇게 설정하면 Flutter는 첫 프레임을 그리기 전에 자동으로 warmUp() 메서드를 실행합니다. DefaultShaderWarmUp은 Flutter 팀이 제공하는 기본 구현으로, Container, Text, Icon 등 일반적인 위젯들이 사용하는 기본 Shader들을 컴파일합니다.
마지막으로, runApp(MyApp())이 실행되면 Flutter는 위젯 트리를 빌드하기 시작하지만, 이미 기본 Shader들은 컴파일되어 캐시에 저장된 상태입니다. 따라서 HomePage가 렌더링될 때 기본적인 UI 요소들은 jank 없이 부드럽게 그려집니다.
여러분이 이 간단한 설정만으로도 많은 경우에 눈에 띄는 성능 개선을 경험할 수 있습니다. 특히 Material Design 컴포넌트를 많이 사용하는 앱에서는 DefaultShaderWarmUp이 커버하는 범위가 넓습니다.
하지만 커스텀 그래픽, 복잡한 애니메이션, 특수 효과를 많이 사용한다면 커스텀 ShaderWarmUp 구현이 필요합니다. 실무에서는 이 기본 워밍업을 시작점으로 삼고, DevTools로 측정하여 추가 최적화가 필요한 부분을 식별하는 것이 좋습니다.
워밍업 시간이 너무 길어지면 오히려 앱 시작 시간이 늘어날 수 있으므로, 필요한 Shader만 선택적으로 워밍업하는 것이 중요합니다.
실전 팁
💡 DefaultShaderWarmUp은 약 100-200ms 정도 소요되므로 대부분의 앱에서 스플래시 스크린 시간 내에 완료됩니다. 앱 시작 시간에 미치는 영향이 미미하므로 안심하고 사용하세요.
💡 워밍업은 debug 모드에서는 효과가 없습니다. 반드시 flutter run --profile 또는 --release 모드에서 테스트하세요. debug 모드는 Shader 캐싱을 비활성화하여 개발 중 hot reload가 제대로 작동하도록 합니다.
💡 PaintingBinding.instance.shaderWarmUp을 null로 설정하면 워밍업을 비활성화할 수 있습니다. A/B 테스트로 워밍업 전후의 성능을 비교해보세요.
💡 iOS에서는 Metal Shader 컴파일이 비교적 빠르지만, Android의 Vulkan/OpenGL에서는 훨씬 느립니다. Android를 우선 최적화 대상으로 고려하세요.
3. ShaderWarmUp 클래스 활용
시작하며
여러분이 DefaultShaderWarmUp을 적용했는데도 여전히 특정 화면에서 jank가 발생하는 경험을 한 적 있나요? 예를 들어, 복잡한 차트 화면으로 전환할 때나, 커스텀 애니메이션이 시작될 때 여전히 버벅임이 느껴지는 경우가 있습니다.
이런 문제는 DefaultShaderWarmUp이 일반적인 위젯만 커버하고 여러분의 앱에 특화된 그래픽 요소는 포함하지 않기 때문입니다. 각 앱은 고유한 UI 패턴과 시각 효과를 사용하므로, 맞춤형 Shader 워밍업 전략이 필요합니다.
바로 이럴 때 필요한 것이 커스텀 ShaderWarmUp 클래스 구현입니다. ShaderWarmUp 추상 클래스를 상속받아 여러분의 앱이 실제로 사용하는 모든 시각 효과를 미리 렌더링함으로써, 완벽하게 jank-free한 사용자 경험을 만들 수 있습니다.
개요
간단히 말해서, ShaderWarmUp은 앱 시작 시 어떤 Shader들을 미리 컴파일할지 정의하는 추상 클래스입니다. 이 클래스를 상속받아 warmUpOnCanvas() 메서드를 오버라이드하면, 여러분의 앱에서 실제로 사용하는 모든 그리기 작업을 미리 수행할 수 있습니다.
Flutter는 이 메서드에 Canvas 객체를 제공하므로, drawRect, drawPath, drawImage 등의 모든 그리기 API를 사용하여 실제 렌더링과 동일한 Shader 컴파일을 트리거할 수 있습니다. 예를 들어, 앱에서 특정 BlendMode를 자주 사용한다면 해당 BlendMode로 그리기 작업을 수행하여 관련 Shader를 미리 컴파일할 수 있습니다.
기존의 DefaultShaderWarmUp이 범용적인 접근이었다면, 커스텀 ShaderWarmUp은 여러분의 앱을 위한 맞춤형 솔루션입니다. 앱의 특성에 따라 필요한 Shader만 선택적으로 워밍업할 수 있습니다.
ShaderWarmUp의 핵심 특징은 첫째, Canvas API를 직접 사용하여 저수준에서 제어할 수 있다는 점, 둘째, 실제 위젯을 빌드하지 않고도 Shader 컴파일을 트리거할 수 있어 오버헤드가 적다는 점, 셋째, execute() 메서드가 Future를 반환하여 비동기 처리가 가능하다는 점입니다. 이러한 특징들이 정밀한 성능 최적화를 가능하게 합니다.
코드 예제
// 커스텀 ShaderWarmUp 구현 예제
import 'package:flutter/material.dart';
import 'dart:ui' as ui;
class CustomShaderWarmUp extends ShaderWarmUp {
@override
Future<void> warmUpOnCanvas(ui.Canvas canvas) async {
// 앱에서 자주 사용하는 그라디언트 Shader 워밍업
final rect = const Rect.fromLTWH(0, 0, 100, 100);
final gradient = ui.Gradient.linear(
Offset.zero,
const Offset(100, 100),
[const Color(0xFF0000FF), const Color(0xFFFF0000)],
);
final paint = Paint()..shader = gradient;
canvas.drawRect(rect, paint);
// 복잡한 Path 그리기 Shader 워밍업
final path = Path()
..moveTo(0, 0)
..quadraticBezierTo(50, 50, 100, 0);
canvas.drawPath(path, Paint()..style = PaintingStyle.stroke);
// 블러 효과 Shader 워밍업
canvas.drawRect(rect, Paint()..maskFilter = const MaskFilter.blur(BlurStyle.normal, 10));
}
}
설명
이것이 하는 일: 위 코드는 앱에서 자주 사용하는 세 가지 유형의 Shader를 미리 컴파일하는 커스텀 ShaderWarmUp 구현입니다. 그라디언트, 복잡한 Path, 블러 효과를 각각 워밍업합니다.
첫 번째로, ui.Gradient.linear를 사용하여 선형 그라디언트 Shader를 생성하고 drawRect로 렌더링합니다. 이 과정에서 Skia는 해당 그라디언트 패턴을 처리하는 GPU Shader를 컴파일하고 캐시에 저장합니다.
파란색에서 빨간색으로 변하는 이 그라디언트는 실제 앱에서 사용하는 그라디언트와 색상이 달라도 됩니다. 중요한 것은 그라디언트 타입(linear, radial, sweep)과 색상 정지점 개수가 일치하는 것입니다.
그 다음으로, Path 객체를 사용하여 2차 베지어 곡선을 그립니다. moveTo와 quadraticBezierTo를 조합한 복잡한 Path는 별도의 Shader 컴파일을 필요로 합니다.
만약 앱에서 커스텀 Shape이나 복잡한 아이콘을 많이 사용한다면, 대표적인 Path 패턴을 여기서 그려주는 것이 좋습니다. stroke 스타일과 fill 스타일은 서로 다른 Shader를 사용하므로 둘 다 필요하면 각각 워밍업해야 합니다.
마지막으로, MaskFilter.blur를 적용한 사각형을 그려 블러 효과 Shader를 컴파일합니다. 블러는 가장 무거운 Shader 중 하나이며, sigma 값(여기서는 10)에 따라 다른 Shader 변형이 생성될 수 있습니다.
앱에서 실제 사용하는 블러 강도와 유사한 값으로 워밍업하는 것이 이상적입니다. 여러분이 이런 커스텀 워밍업을 구현하면 앱의 모든 화면에서 일관되게 부드러운 60fps를 유지할 수 있습니다.
핵심은 앱에서 실제로 사용하는 시각 효과를 정확히 파악하는 것입니다. DevTools의 Performance 탭에서 "Shader compilation" 이벤트를 모니터링하여 어떤 Shader가 런타임에 컴파일되는지 확인하고, 해당 Shader를 워밍업 코드에 추가하세요.
실무에서는 워밍업할 항목이 너무 많아지면 앱 시작 시간이 늘어날 수 있으므로, 우선순위를 정하는 것이 중요합니다. 사용자가 처음 보는 화면(홈 화면, 로그인 화면)에서 사용하는 Shader를 최우선으로 워밍업하고, 나중에 볼 화면은 필요시 추가하는 점진적 접근이 효과적입니다.
실전 팁
💡 warmUpOnCanvas()에서 실제 화면 크기와 동일한 크기로 그릴 필요는 없습니다. 100x100 픽셀 정도의 작은 영역만 그려도 Shader 컴파일은 동일하게 트리거됩니다. 작은 영역을 사용하면 워밍업 시간을 단축할 수 있습니다.
💡 Paint 객체를 재사용하면 약간의 성능 이득이 있습니다. final paint = Paint()를 한 번 생성하고 속성만 변경하여 여러 그리기 작업에 사용하세요.
💡 execute() 메서드는 기본적으로 100x100 크기의 오프스크린 Canvas를 생성합니다. 이는 메모리 효율적이며 대부분의 경우 충분합니다.
💡 iOS와 Android에서 필요한 Shader가 다를 수 있습니다. Platform.isAndroid를 체크하여 플랫폼별로 다른 워밍업 전략을 사용할 수 있습니다.
💡 워밍업 코드가 예외를 던지면 앱 시작이 실패할 수 있으므로, try-catch로 감싸서 워밍업 실패가 앱 실행을 방해하지 않도록 하세요.
4. 커스텀 ShaderWarmUp 구현
시작하며
여러분이 실제 프로덕션 앱에서 ShaderWarmUp을 적용하려고 할 때, 어떤 Shader를 워밍업해야 할지 막막한 경험을 한 적 있나요? 앱의 수십 개 화면과 수백 개의 위젯 중에서 어떤 것이 jank를 유발하는지 일일이 확인하는 것은 비현실적입니다.
이런 문제는 체계적인 접근 없이 무작정 워밍업 코드를 추가하려고 할 때 발생합니다. 필요하지 않은 Shader까지 워밍업하면 앱 시작 시간만 늘어나고, 정작 중요한 Shader를 빠뜨리면 여전히 jank가 발생합니다.
바로 이럴 때 필요한 것이 체계적인 커스텀 ShaderWarmUp 구현 전략입니다. 앱의 실제 사용 패턴을 분석하고, 우선순위를 정하고, 측정 가능한 방식으로 워밍업을 구현하면, 최소한의 오버헤드로 최대의 성능 개선을 얻을 수 있습니다.
개요
간단히 말해서, 효과적인 커스텀 ShaderWarmUp 구현은 앱 분석, 우선순위 결정, 구현, 측정의 4단계 프로세스를 따릅니다. 먼저 DevTools로 앱의 Shader 컴파일 패턴을 분석하여 어떤 화면, 어떤 위젯이 jank를 유발하는지 정확히 파악해야 합니다.
Timeline 뷰에서 "Shader compilation" 이벤트를 필터링하면 컴파일 시간과 발생 위치를 시각적으로 확인할 수 있습니다. 이를 통해 200ms 이상 걸리는 무거운 Shader를 식별하고, 사용자가 자주 접하는 화면의 Shader에 높은 우선순위를 부여합니다.
기존에는 추측으로 워밍업 코드를 작성했다면, 이제는 데이터 기반으로 정확히 필요한 것만 워밍업할 수 있습니다. 측정 없이는 최적화도 없습니다.
효과적인 커스텀 ShaderWarmUp의 핵심 특징은 첫째, 실제 사용자 경험에 기반한다는 점, 둘째, 앱 시작 시간과 부드러움 사이의 균형을 고려한다는 점, 셋째, 지속적으로 측정하고 개선한다는 점입니다. 이러한 특징들이 실무에서 실제로 효과를 내는 최적화를 가능하게 합니다.
코드 예제
// 실무 수준의 커스텀 ShaderWarmUp 구현
import 'package:flutter/material.dart';
import 'dart:ui' as ui;
class ProductionShaderWarmUp extends ShaderWarmUp {
@override
Future<void> warmUpOnCanvas(ui.Canvas canvas) async {
final stopwatch = Stopwatch()..start();
// 1. 홈 화면 그라디언트 배경 (우선순위: 높음)
_warmUpGradientBackground(canvas);
// 2. 차트/그래프 Path (우선순위: 높음)
_warmUpChartPaths(canvas);
// 3. 카드 그림자 효과 (우선순위: 중간)
_warmUpCardShadows(canvas);
// 4. 아이콘 블렌딩 (우선순위: 낮음)
_warmUpIconBlending(canvas);
debugPrint('ShaderWarmUp completed in ${stopwatch.elapsedMilliseconds}ms');
}
void _warmUpGradientBackground(ui.Canvas canvas) {
final gradient = ui.Gradient.linear(
Offset.zero,
const Offset(0, 100),
[const Color(0xFF1E88E5), const Color(0xFF1565C0)],
);
canvas.drawRect(
const Rect.fromLTWH(0, 0, 100, 100),
Paint()..shader = gradient,
);
}
void _warmUpChartPaths(ui.Canvas canvas) {
final path = Path()
..moveTo(0, 50)
..cubicTo(25, 30, 50, 70, 75, 50)
..cubicTo(85, 40, 90, 60, 100, 50);
canvas.drawPath(
path,
Paint()
..style = PaintingStyle.stroke
..strokeWidth = 2.0
..strokeCap = StrokeCap.round,
);
}
void _warmUpCardShadows(ui.Canvas canvas) {
canvas.drawRRect(
RRect.fromRectAndRadius(
const Rect.fromLTWH(10, 10, 80, 80),
const Radius.circular(8),
),
Paint()
..color = Colors.white
..maskFilter = const MaskFilter.blur(BlurStyle.normal, 4),
);
}
void _warmUpIconBlending(ui.Canvas canvas) {
canvas.drawCircle(
const Offset(50, 50),
20,
Paint()
..color = Colors.blue
..blendMode = BlendMode.srcOver,
);
}
}
설명
이것이 하는 일: 위 코드는 실무에서 사용 가능한 수준의 커스텀 ShaderWarmUp 구현으로, 우선순위별로 4가지 유형의 Shader를 체계적으로 워밍업합니다. 첫 번째로, warmUpOnCanvas() 메서드에서 Stopwatch를 시작하여 워밍업에 소요되는 시간을 측정합니다.
이는 매우 중요한데, 워밍업 시간이 500ms를 초과하면 앱 시작 시간에 눈에 띄는 영향을 미치기 때문입니다. debugPrint로 시간을 로깅하면 개발 중에 워밍업 성능을 지속적으로 모니터링할 수 있습니다.
프로덕션에서는 이 값을 애널리틱스로 전송하여 실제 사용자 기기에서의 워밍업 시간을 추적할 수도 있습니다. 그 다음으로, 각 Shader 유형을 별도의 private 메서드로 분리했습니다.
_warmUpGradientBackground는 홈 화면의 그라디언트 배경을 워밍업하고, _warmUpChartPaths는 차트나 그래프에서 사용하는 복잡한 베지어 곡선을 워밍업하며, _warmUpCardShadows는 Material 카드의 그림자 효과를, _warmUpIconBlending은 아이콘 렌더링에 사용되는 블렌딩을 워밍업합니다. 이렇게 분리하면 나중에 특정 Shader 워밍업을 추가하거나 제거하기 쉽고, A/B 테스트도 용이합니다.
각 워밍업 메서드는 실제 앱에서 사용하는 정확한 파라미터를 반영합니다. 예를 들어, _warmUpGradientBackground의 Color(0xFF1E88E5)와 Color(0xFF1565C0)는 실제 홈 화면 배경색과 동일하거나 유사한 값입니다.
_warmUpChartPaths의 cubicTo는 실제 차트 라이브러리가 생성하는 곡선 패턴을 모방합니다. strokeWidth, strokeCap, Radius.circular 같은 세부 속성도 실제 사용값과 일치시켜야 정확히 같은 Shader가 컴파일됩니다.
마지막으로, 우선순위 주석이 각 메서드에 표시되어 있습니다. 높은 우선순위는 사용자가 앱을 열자마자 보는 화면의 Shader이고, 낮은 우선순위는 나중에 접하는 화면의 Shader입니다.
만약 워밍업 시간이 너무 길어진다면, 낮은 우선순위 항목부터 제거하여 시간을 조절할 수 있습니다. 여러분이 이런 구조화된 접근을 사용하면 ShaderWarmUp 코드를 팀원들과 공유하고 유지보수하기가 훨씬 쉬워집니다.
새로운 화면이 추가될 때마다 해당 화면의 Shader 워밍업 메서드를 추가하고, 더 이상 사용하지 않는 화면의 워밍업은 제거하는 식으로 지속적으로 관리할 수 있습니다. 실무에서는 워밍업 효과를 정량적으로 측정하는 것도 중요합니다.
Flutter의 PerformanceOverlay 위젯이나 Timeline 이벤트를 사용하여 워밍업 전후의 프레임 시간을 비교하세요. 특정 화면으로의 첫 전환 시 평균 프레임 시간이 16ms 이하로 유지되면 성공적인 워밍업입니다.
실전 팁
💡 각 워밍업 메서드를 개별적으로 활성화/비활성화할 수 있도록 feature flag를 추가하면, A/B 테스트로 각 워밍업의 실제 효과를 측정할 수 있습니다. 예: if (FeatureFlags.warmUpGradient) _warmUpGradientBackground(canvas);
💡 Stopwatch 결과가 300ms를 초과하면 워밍업 항목을 줄이는 것을 고려하세요. 사용자는 500ms 이상의 앱 시작 지연을 명확히 느낍니다.
💡 Shader 파라미터가 조금만 달라져도 다른 Shader로 인식될 수 있습니다. 예를 들어, borderRadius가 8.0과 10.0은 별도의 Shader입니다. 앱에서 실제 사용하는 정확한 값으로 워밍업하세요.
💡 복잡한 CustomPainter를 사용한다면, 해당 CustomPainter의 paint() 메서드 내용을 그대로 warmUpOnCanvas()로 복사하는 것이 가장 정확한 워밍업입니다.
💡 워밍업 코드에 주석으로 "어떤 화면의 어떤 위젯을 위한 것인지" 명시하세요. 6개월 후에 코드를 보는 사람(여러분 자신 포함)이 이해하기 쉬워집니다.
5. SkSL 번들 생성과 적용
시작하며
여러분이 커스텀 ShaderWarmUp을 완벽하게 구현했는데도, 실제 사용자들이 사용하는 다양한 기기에서는 여전히 jank가 보고되는 경험을 한 적 있나요? 갤럭시 S 시리즈에서는 완벽한데 저사양 A 시리즈에서는 문제가 생기거나, 특정 Android 버전에서만 jank가 발생하는 경우가 있습니다.
이런 문제는 기기마다 GPU 드라이버와 Shader 컴파일러가 다르기 때문에 발생합니다. 동일한 Flutter 코드라도 Mali GPU와 Adreno GPU는 서로 다른 Shader 바이너리를 생성합니다.
런타임 워밍업만으로는 모든 기기의 모든 Shader 변형을 커버하기 어렵습니다. 바로 이럴 때 필요한 것이 SkSL 번들입니다.
실제 사용자 기기에서 수집한 Shader 정보를 앱 번들에 포함시키면, 앱이 시작될 때 해당 기기에 최적화된 미리 컴파일된 Shader를 로드하여 완벽한 jank-free 경험을 제공할 수 있습니다.
개요
간단히 말해서, SkSL 번들은 실제 사용자 기기에서 발생한 Shader 컴파일을 기록하여 JSON 파일로 저장하고, 이를 앱에 포함시켜 다음 실행부터는 미리 컴파일된 Shader를 사용하는 기법입니다. Flutter는 --cache-sksl 플래그로 앱 실행 중 컴파일된 모든 Shader를 기록하고, --purge-persistent-cache로 초기 상태를 재현하며, --bundle-sksl-path로 저장된 Shader를 앱에 번들링할 수 있는 완전한 워크플로우를 제공합니다.
이 방식의 강점은 개발자가 직접 코드를 작성하지 않고도, 실제 사용자 시나리오에서 발생하는 모든 Shader를 자동으로 캡처한다는 점입니다. 예를 들어, 베타 테스터들에게 Shader 수집 빌드를 배포하고, 수집된 데이터를 프로덕션 빌드에 포함시킬 수 있습니다.
기존의 코드 기반 워밍업이 개발자의 예측에 의존했다면, SkSL 번들은 실제 데이터에 기반합니다. 놓치기 쉬운 edge case Shader까지 모두 캡처할 수 있습니다.
SkSL 번들의 핵심 특징은 첫째, 기기 특화 최적화가 가능하다는 점(Galaxy A50용 Shader와 Pixel 6용 Shader를 각각 수집 가능), 둘째, 완전히 자동화할 수 있다는 점(CI/CD 파이프라인에 통합 가능), 셋째, 앱 업데이트 없이도 지속적으로 개선할 수 있다는 점입니다. 이러한 특징들이 SkSL 번들을 대규모 프로덕션 앱을 위한 최고의 jank 해결책으로 만듭니다.
코드 예제
# SkSL 번들 생성 워크플로우 (터미널 명령어)
# 1. 기존 Shader 캐시 제거 (초기 상태로)
flutter run --profile --purge-persistent-cache
# 2. Shader 수집 모드로 앱 실행 (모든 화면을 사용해보세요)
flutter run --profile --cache-sksl --purge-persistent-cache
# 3. 앱에서 모든 기능을 사용한 후, 수집된 Shader를 파일로 저장
# 앱이 실행 중일 때 다음 명령 실행
flutter screenshot --type=skia --observatory-url=<observatory-url>
# 또는 앱 종료 시 자동으로 flutter_*.sksl.json 파일 생성됨
# 4. 생성된 SkSL 파일을 앱에 번들링하여 빌드
flutter build apk --bundle-sksl-path=flutter_01.sksl.json --release
# 5. (선택) 여러 기기에서 수집한 Shader를 병합
# merge_sksl.dart 스크립트 사용 (Flutter 팀 제공)
dart merge_sksl.dart flutter_01.sksl.json flutter_02.sksl.json -o merged.sksl.json
# 6. 병합된 파일로 최종 빌드
flutter build apk --bundle-sksl-path=merged.sksl.json --release
설명
이것이 하는 일: 위 명령어들은 실제 사용자 환경에서 발생하는 모든 Shader를 수집하고, 이를 프로덕션 앱에 포함시키는 완전한 워크플로우를 보여줍니다. 첫 번째로, --purge-persistent-cache 플래그로 기존에 캐시된 Shader를 모두 제거합니다.
이는 "첫 실행" 상태를 재현하기 위해 필수적입니다. 만약 이미 Shader가 캐시되어 있다면, 새로운 컴파일이 발생하지 않아 수집할 데이터가 없습니다.
이 플래그는 앱을 완전히 초기 상태로 만들어, 실제 신규 사용자가 경험하는 것과 동일한 환경을 만듭니다. 그 다음으로, --cache-sksl 플래그로 앱을 실행하면 Flutter 엔진이 모든 Shader 컴파일 이벤트를 가로채서 JSON 형식으로 기록합니다.
이 상태에서 앱의 모든 화면을 방문하고, 모든 애니메이션을 실행하고, 모든 기능을 사용해야 합니다. 놓친 화면이 있다면 해당 화면의 Shader는 수집되지 않습니다.
실무에서는 QA 팀이나 베타 테스터들에게 이 빌드를 배포하여 다양한 사용 패턴을 수집하는 것이 효과적입니다. 세 번째로, 앱 사용이 끝나면 flutter_XX.sksl.json 파일이 생성됩니다.
이 파일에는 모든 Shader의 SkSL(Skia Shader Language) 소스 코드가 JSON 배열로 저장되어 있습니다. 파일 크기는 수집된 Shader 수에 따라 다르지만, 일반적으로 100KB~500KB 정도입니다.
이 파일을 프로젝트의 적절한 위치(예: assets/shaders/)에 복사합니다. 네 번째로, --bundle-sksl-path 플래그로 빌드하면 Flutter는 제공된 SkSL 파일을 앱 번들에 포함시킵니다.
앱이 시작될 때 Flutter 엔진은 이 파일을 읽어서 모든 Shader를 백그라운드에서 미리 컴파일합니다. 이 과정은 ShaderWarmUp보다 훨씬 포괄적이며, 개발자가 놓칠 수 있는 edge case까지 모두 커버합니다.
다섯 번째로, 여러 기기에서 수집한 SkSL 파일을 병합할 수 있습니다. 예를 들어, Galaxy A50, Pixel 4, OnePlus 9에서 각각 수집한 파일을 merge_sksl.dart로 합치면, 모든 기기의 Shader 변형을 포함하는 통합 번들을 만들 수 있습니다.
이렇게 하면 단일 APK로 모든 기기를 최적화할 수 있습니다. 여러분이 이 워크플로우를 CI/CD 파이프라인에 통합하면, 매 릴리스마다 자동으로 최신 Shader 번들을 생성할 수 있습니다.
예를 들어, 베타 배포 단계에서 Shader를 수집하고, 프로덕션 배포 단계에서 해당 번들을 포함시키는 식으로 자동화할 수 있습니다. 실무에서는 SkSL 번들이 앱 크기에 미치는 영향도 고려해야 합니다.
500KB의 SkSL 파일은 압축 후 약 100KB 정도로 줄어들므로 대부분의 앱에서 허용 가능한 수준입니다. 하지만 앱 크기에 극도로 민감하다면, 가장 자주 사용되는 화면의 Shader만 선택적으로 포함시킬 수도 있습니다.
실전 팁
💡 SkSL 수집 시 다양한 Android 버전과 GPU 제조사(Mali, Adreno, PowerVR)를 커버하는 기기들에서 테스트하세요. 각 플랫폼은 다른 Shader 변형을 생성합니다.
💡 --cache-sksl 빌드를 베타 테스터에게 배포할 때는 "모든 기능을 사용해달라"는 명확한 가이드를 제공하세요. 사용하지 않은 화면의 Shader는 수집되지 않습니다.
💡 SkSL 파일의 크기가 1MB를 초과한다면 중복 제거가 제대로 되지 않은 것입니다. merge_sksl.dart를 사용하여 중복을 제거하세요.
💡 iOS에서는 Metal Shader가 비교적 빠르게 컴파일되므로 SkSL 번들의 효과가 제한적입니다. Android에 집중하세요.
💡 새로운 기능이나 화면을 추가할 때마다 SkSL 번들을 업데이트하는 것을 잊지 마세요. 오래된 번들은 새 화면의 jank를 방지하지 못합니다.
6. DevTools를 활용한 Shader 분석
시작하며
여러분이 Shader 최적화를 시작하려고 할 때, 어디서부터 손을 대야 할지 막연한 경험을 한 적 있나요? 앱이 버벅이는 것은 알겠는데, 정확히 어떤 Shader가 문제인지, 얼마나 많은 시간을 소비하는지 알 수 없어서 추측으로 최적화를 시도하는 경우가 많습니다.
이런 문제는 측정 없이 최적화를 시작할 때 발생합니다. "이 그라디언트가 문제일 것 같다"는 추측은 틀릴 수 있고, 정작 큰 영향을 미치는 블러 효과는 놓칠 수 있습니다.
근거 없는 최적화는 시간 낭비이며, 때로는 역효과를 낳기도 합니다. 바로 이럴 때 필요한 것이 Flutter DevTools의 Performance 탭입니다.
Timeline 뷰, Frame 차트, Shader 컴파일 이벤트를 활용하면 정확히 어떤 Shader가 언제 얼마나 오래 컴파일되는지 데이터로 확인할 수 있습니다. 데이터 기반 최적화만이 실제 효과를 냅니다.
개요
간단히 말해서, DevTools는 Flutter 앱의 성능을 실시간으로 분석하고 Shader 컴파일 병목을 시각화하는 개발자 도구입니다. Performance 탭의 Timeline 뷰는 모든 프레임을 시간순으로 보여주며, 각 프레임이 16ms(60fps) 또는 8ms(120fps) 예산을 초과하는지 한눈에 파악할 수 있습니다.
Shader 컴파일이 발생하면 빨간색 막대로 표시되며, 해당 이벤트를 클릭하면 정확한 컴파일 시간과 스택 트레이스를 볼 수 있습니다. 예를 들어, LinearGradient를 사용하는 Container가 첫 렌더링에서 350ms의 Shader 컴파일을 유발했다는 것을 정확히 확인할 수 있습니다.
기존에는 "느리다"는 주관적인 느낌에 의존했다면, 이제는 "이 화면 전환은 420ms 걸렸고, 그 중 370ms가 Shader 컴파일"이라는 객관적인 데이터를 얻을 수 있습니다. 측정 가능한 것은 개선 가능합니다.
DevTools의 핵심 특징은 첫째, 실시간으로 성능을 모니터링할 수 있다는 점, 둘째, Shader 컴파일뿐 아니라 build, layout, paint 단계도 함께 분석하여 전체 그림을 볼 수 있다는 점, 셋째, 프레임별로 드릴다운하여 정확한 원인을 찾을 수 있다는 점입니다. 이러한 특징들이 DevTools를 성능 최적화의 필수 도구로 만듭니다.
코드 예제
// DevTools로 Shader 분석하는 단계별 가이드
// 1. Profile 모드로 앱 실행 (release 모드도 가능)
// 터미널에서:
flutter run --profile
// 2. DevTools URL이 출력되면 브라우저에서 열기
// 예: http://127.0.0.1:9100/?uri=http://127.0.0.1:12345/xyz
// 3. Performance 탭 선택 후 "Record" 버튼 클릭
// 4. 앱에서 분석하고 싶은 동작 수행 (화면 전환, 애니메이션 등)
// 5. "Stop" 버튼 클릭하여 타임라인 캡처
// 6. Timeline에서 빨간색 막대(jank) 찾기
// - 16ms 라인을 초과하는 프레임이 jank
// - "Shader compilation" 이벤트 필터 활성화
// 7. Shader 컴파일 이벤트 클릭하여 세부 정보 확인
// - Duration: 컴파일에 소요된 시간
// - Stack trace: 어떤 위젯이 유발했는지
// 8. 문제가 되는 Shader를 ShaderWarmUp에 추가
// 코드에서 커스텀 타임라인 이벤트 추가 (선택사항)
import 'dart:developer';
void someFunction() {
Timeline.startSync('CustomShaderOperation');
// Shader를 사용하는 코드
Timeline.finishSync();
}
설명
이것이 하는 일: 위 가이드는 DevTools를 사용하여 Shader 컴파일 병목을 정확히 진단하는 전체 워크플로우를 보여줍니다. 첫 번째로, --profile 모드로 앱을 실행하는 것이 매우 중요합니다.
Debug 모드는 assertion과 디버깅 기능으로 인해 실제보다 훨씬 느리게 동작하며, Shader 캐싱도 비활성화되어 있어 정확한 성능 측정이 불가능합니다. Release 모드는 정확하지만 DevTools 연결이 제한적이므로, Profile 모드가 성능 분석에 최적입니다.
Profile 모드는 release 수준의 최적화를 적용하면서도 DevTools 연결을 유지합니다. 그 다음으로, Performance 탭에서 Record를 클릭하면 Flutter 엔진이 모든 프레임의 상세한 타이밍 정보를 수집하기 시작합니다.
이 상태에서 문제가 되는 화면으로 전환하거나, 버벅이는 애니메이션을 실행하거나, 복잡한 리스트를 스크롤합니다. 실제 사용자 시나리오를 재현하는 것이 중요합니다.
10-20초 정도 기록하면 충분하며, 너무 길게 기록하면 타임라인이 복잡해져 분석이 어려워집니다. 세 번째로, Stop을 클릭하면 Timeline 차트가 나타납니다.
위쪽의 프레임 차트에서 16ms 기준선을 초과하는 빨간색 막대가 jank를 나타냅니다. 이 막대를 클릭하면 아래쪽에 해당 프레임의 상세 분석이 표시됩니다.
"Frame time"이 16ms를 초과하는지, "Raster" 단계에서 시간이 많이 소요되는지 확인하세요. Shader 컴파일은 주로 Raster 단계에서 발생합니다.
네 번째로, "Shader compilation" 필터를 활성화하면 Shader 컴파일 이벤트만 하이라이트됩니다. 각 이벤트를 클릭하면 "Duration: 350ms", "Thread: io.flutter.raster" 같은 정보가 표시됩니다.
가장 중요한 것은 스택 트레이스인데, 이를 통해 "Container > BoxDecoration > LinearGradient" 같은 위젯 계층을 확인할 수 있습니다. 이 정보로 정확히 어떤 코드가 문제를 일으키는지 알 수 있습니다.
마지막으로, Timeline.startSync와 finishSync를 사용하면 커스텀 이벤트를 타임라인에 추가할 수 있습니다. 예를 들어, 복잡한 CustomPainter의 paint() 메서드를 이 API로 감싸면, DevTools에서 해당 메서드가 얼마나 오래 걸리는지 정확히 측정할 수 있습니다.
이는 여러분만의 성능 프로파일링 포인트를 추가하는 강력한 방법입니다. 여러분이 DevTools를 습관적으로 사용하면 성능 문제를 조기에 발견할 수 있습니다.
새로운 기능을 추가할 때마다 DevTools로 확인하여, 의도치 않은 Shader 컴파일이나 레이아웃 jank가 발생하지 않는지 체크하세요. 실무에서는 DevTools의 Timeline을 스크린샷으로 캡처하여 이슈 트래커에 첨부하는 것도 유용합니다.
"홈 화면이 느려요"보다 "홈 화면 전환 시 LinearGradient Shader 컴파일에 370ms 소요됨 (첨부 스크린샷 참조)"가 훨씬 명확하고 해결 가능한 리포트입니다.
실전 팁
💡 Timeline 녹화 중에는 앱이 약간 느려질 수 있습니다. 이는 정상이며, 측정 오버헤드 때문입니다. 상대적인 시간 비교는 여전히 유효합니다.
💡 "Track widget builds" 옵션을 활성화하면 어떤 위젯이 rebuild되는지도 볼 수 있습니다. Shader 컴파일과 불필요한 rebuild가 겹치면 jank가 더 심해집니다.
💡 실제 기기에서 테스트하세요. 에뮬레이터는 호스트 컴퓨터의 GPU를 사용하므로 Shader 컴파일 시간이 실제 기기와 완전히 다릅니다.
💡 "More debugging options" > "Highlight repaints" > "Highlight oversized images"를 활성화하면 Shader와는 별개로 다른 성능 문제도 발견할 수 있습니다.
💡 Timeline 데이터를 JSON으로 export하여 팀원들과 공유하거나, 버전별로 성능을 비교할 수 있습니다. "Export" 버튼을 활용하세요.
7. 조건부 Shader 워밍업
시작하며
여러분이 Android와 iOS 양쪽을 지원하는 Flutter 앱을 개발할 때, 동일한 Shader 워밍업 코드가 양쪽 플랫폼에서 다른 효과를 보이는 경험을 한 적 있나요? Android에서는 극적인 성능 개선이 있는데, iOS에서는 오히려 앱 시작 시간만 늘어나는 경우가 있습니다.
이런 문제는 플랫폼마다 그래픽 스택이 완전히 다르기 때문에 발생합니다. iOS는 Metal을 사용하며 Shader 컴파일이 비교적 빠르고 효율적이지만, Android는 Vulkan 또는 OpenGL을 사용하며 기기마다 성능 편차가 큽니다.
모든 플랫폼에 동일한 워밍업 전략을 적용하는 것은 비효율적입니다. 바로 이럴 때 필요한 것이 조건부 Shader 워밍업입니다.
Platform API를 활용하여 플랫폼별, 기기별로 다른 워밍업 전략을 적용하면, 각 환경에서 최적의 성능을 얻을 수 있습니다. Android 저사양 기기에서는 공격적으로 워밍업하고, iOS에서는 최소한만 워밍업하는 식으로 차별화할 수 있습니다.
개요
간단히 말해서, 조건부 Shader 워밍업은 플랫폼, 기기 성능, Android 버전 등의 런타임 조건에 따라 다른 워밍업 전략을 선택적으로 적용하는 기법입니다. Flutter의 Platform API(dart:io)와 DeviceInfoPlugin 같은 패키지를 사용하면 현재 실행 환경을 정확히 파악할 수 있습니다.
iOS에서는 워밍업을 완전히 스킵하거나 최소화하고, Android 11 이하의 저사양 기기에서는 모든 Shader를 워밍업하며, Android 12+ 고사양 기기에서는 중간 수준만 워밍업하는 식으로 세밀하게 제어할 수 있습니다. 예를 들어, Galaxy A 시리즈를 감지하여 해당 기기에서만 추가 워밍업을 수행할 수도 있습니다.
기존의 일괄 워밍업이 one-size-fits-all 접근이었다면, 조건부 워밍업은 각 환경에 최적화된 맞춤형 접근입니다. 리소스를 효율적으로 사용할 수 있습니다.
조건부 워밍업의 핵심 특징은 첫째, 플랫폼별 특성을 존중한다는 점, 둘째, 저사양 기기에 더 많은 최적화를 제공하여 형평성을 높인다는 점, 셋째, 앱 시작 시간과 부드러움의 균형을 플랫폼별로 조절할 수 있다는 점입니다. 이러한 특징들이 실용적이고 효율적인 최적화를 가능하게 합니다.
코드 예제
// 조건부 Shader 워밍업 구현
import 'package:flutter/material.dart';
import 'dart:io' show Platform;
import 'dart:ui' as ui;
import 'package:device_info_plus/device_info_plus.dart';
class ConditionalShaderWarmUp extends ShaderWarmUp {
final bool isLowEndDevice;
ConditionalShaderWarmUp(this.isLowEndDevice);
@override
Future<void> warmUpOnCanvas(ui.Canvas canvas) async {
if (Platform.isIOS) {
// iOS: Metal은 빠르므로 최소한만 워밍업
_warmUpEssentialShadersOnly(canvas);
} else if (Platform.isAndroid) {
if (isLowEndDevice) {
// 저사양 Android: 모든 Shader 워밍업
_warmUpAllShaders(canvas);
} else {
// 고사양 Android: 무거운 Shader만
_warmUpHeavyShaders(canvas);
}
}
}
void _warmUpEssentialShadersOnly(ui.Canvas canvas) {
// 기본 그라디언트만
final gradient = ui.Gradient.linear(
Offset.zero,
const Offset(100, 100),
[Colors.blue, Colors.purple],
);
canvas.drawRect(
const Rect.fromLTWH(0, 0, 100, 100),
Paint()..shader = gradient,
);
}
void _warmUpAllShaders(ui.Canvas canvas) {
_warmUpEssentialShadersOnly(canvas);
// 추가: 복잡한 Path
final path = Path()..addOval(const Rect.fromLTWH(0, 0, 50, 50));
canvas.drawPath(path, Paint()..style = PaintingStyle.stroke);
// 추가: 블러 효과
canvas.drawCircle(
const Offset(50, 50),
30,
Paint()..maskFilter = const MaskFilter.blur(BlurStyle.normal, 8),
);
}
void _warmUpHeavyShaders(ui.Canvas canvas) {
// 블러와 그라디언트만 (가장 무거운 것들)
final gradient = ui.Gradient.radial(
const Offset(50, 50),
50,
[Colors.red, Colors.yellow, Colors.green],
);
canvas.drawCircle(
const Offset(50, 50),
50,
Paint()
..shader = gradient
..maskFilter = const MaskFilter.blur(BlurStyle.normal, 5),
);
}
}
// main.dart에서 사용
void main() async {
WidgetsFlutterBinding.ensureInitialized();
// 기기 정보 수집
final deviceInfo = DeviceInfoPlugin();
bool isLowEnd = false;
if (Platform.isAndroid) {
final androidInfo = await deviceInfo.androidInfo;
// 메모리 4GB 이하 또는 Android 9 이하를 저사양으로 분류
isLowEnd = (androidInfo.version.sdkInt ?? 0) <= 28 ||
_isLowEndModel(androidInfo.model);
}
PaintingBinding.instance.shaderWarmUp = ConditionalShaderWarmUp(isLowEnd);
runApp(MyApp());
}
bool _isLowEndModel(String model) {
// Galaxy A 시리즈, Redmi 저가형 등
final lowEndPatterns = ['Galaxy A', 'Redmi 9', 'Redmi Note 8'];
return lowEndPatterns.any((pattern) => model.contains(pattern));
}
설명
이것이 하는 일: 위 코드는 플랫폼과 기기 성능에 따라 세 가지 다른 워밍업 전략을 선택적으로 적용하는 고급 구현입니다. 첫 번째로, ConditionalShaderWarmUp 클래스는 생성자에서 isLowEndDevice 플래그를 받아 저사양 기기 여부를 저장합니다.
warmUpOnCanvas() 메서드에서 Platform.isIOS와 Platform.isAndroid로 플랫폼을 구분하고, Android의 경우 isLowEndDevice 플래그로 다시 분기합니다. 이 3-way 분기 전략은 각 환경의 특성을 최대한 활용합니다.
그 다음으로, _warmUpEssentialShadersOnly는 iOS를 위한 최소 워밍업입니다. iOS의 Metal 프레임워크는 Shader 컴파일이 매우 빠르고(보통 10-30ms), 런타임 최적화가 우수하여 사전 워밍업의 이득이 크지 않습니다.
따라서 가장 기본적인 linear gradient만 워밍업하여 앱 시작 시간에 미치는 영향을 최소화합니다. 실제로 iOS에서는 워밍업을 완전히 스킵해도 무방한 경우가 많습니다.
세 번째로, _warmUpAllShaders는 저사양 Android 기기를 위한 공격적인 워밍업입니다. 이런 기기들은 Shader 컴파일이 매우 느려서(200-500ms 이상) jank가 사용자 경험을 크게 해칩니다.
따라서 앱 시작 시 200-300ms를 투자하여 모든 Shader를 미리 컴파일함으로써, 이후 모든 화면 전환을 부드럽게 만듭니다. 이는 시간을 재배치하는 것입니다: 스플래시 스크린에서 300ms를 사용하여, 이후 10개 화면에서 각각 50ms씩 절약합니다.
네 번째로, _warmUpHeavyShaders는 고사양 Android 기기를 위한 균형잡힌 접근입니다. 이런 기기들은 Shader 컴파일이 비교적 빠르므로 모든 Shader를 워밍업할 필요는 없지만, 블러나 복잡한 그라디언트 같은 무거운 Shader는 여전히 jank를 유발할 수 있습니다.
따라서 가장 무거운 몇 가지만 선택적으로 워밍업하여 최소한의 오버헤드로 최대한의 jank를 제거합니다. 마지막으로, main() 함수에서 device_info_plus 패키지로 기기 정보를 수집합니다.
Android SDK 버전(sdkInt)이 28(Android 9) 이하이거나, 모델명이 저가형 패턴과 일치하면 저사양으로 분류합니다. 이 분류 로직은 여러분의 앱 사용자 분포에 맞게 조정할 수 있습니다.
예를 들어, Firebase Analytics 데이터로 어떤 기기에서 jank 리포트가 많은지 확인하고, 해당 모델을 저사양 목록에 추가할 수 있습니다. 여러분이 이런 조건부 워밍업을 적용하면 모든 사용자에게 최적화된 경험을 제공할 수 있습니다.
iPhone 사용자는 빠른 앱 시작을, Galaxy A 시리즈 사용자는 부드러운 화면 전환을 각각 얻게 됩니다. 실무에서는 이 분류 로직을 지속적으로 개선하는 것이 중요합니다.
새로운 저가형 기기가 출시될 때마다 패턴 목록을 업데이트하고, Analytics 데이터로 분류 정확도를 검증하세요. 잘못 분류된 기기는 불필요한 워밍업 오버헤드를 겪거나, 필요한 워밍업을 받지 못할 수 있습니다.
실전 팁
💡 device_info_plus의 androidInfo.hardware를 사용하면 GPU 정보도 얻을 수 있습니다. 'mali-t720' 같은 저사양 GPU를 감지하여 분류 정확도를 높이세요.
💡 Firebase Remote Config로 워밍업 전략을 서버에서 제어할 수 있습니다. 앱 업데이트 없이 특정 기기의 워밍업 레벨을 조정할 수 있어 유연합니다.
💡 개발 중에는 debugPrint로 어떤 워밍업 전략이 선택되었는지 로깅하세요. "Platform: Android, Low-end: true, Strategy: WarmUpAll" 같은 로그가 디버깅에 유용합니다.
💡 웹 플랫폼(kIsWeb)에서는 Shader 워밍업이 무의미합니다. CanvasKit은 다른 메커니즘을 사용하므로, Platform.isWeb일 때는 워밍업을 스킵하세요.
💡 Flutter 3.10+에서는 Impeller 렌더러를 사용할 수 있습니다. Impeller는 Shader 사전 컴파일 아키텍처를 사용하여 워밍업이 불필요할 수 있습니다. 플랫폼별 렌더러를 확인하세요.
8. 점진적 워밍업 전략
시작하며
여러분이 Shader 워밍업을 완벽하게 구현했는데, 사용자들이 "앱 시작이 느려졌다"는 피드백을 보내온 경험이 있나요? 모든 Shader를 앱 시작 시 한꺼번에 워밍업하면 스플래시 스크린이 2-3초로 늘어나, 빠른 앱 시작을 기대하는 사용자들의 불만을 살 수 있습니다.
이런 문제는 워밍업 타이밍을 고려하지 않고 모든 것을 main() 함수에서 처리할 때 발생합니다. 사용자가 즉시 보게 될 홈 화면의 Shader와, 5분 후에나 볼 설정 화면의 Shader를 동일하게 취급하는 것은 비효율적입니다.
바로 이럴 때 필요한 것이 점진적 워밍업 전략입니다. 중요도와 사용 시점에 따라 Shader 워밍업을 여러 단계로 나누어, 앱 시작 시에는 필수적인 것만 워밍업하고, 나머지는 idle 시간에 백그라운드로 워밍업하면, 앱 시작 속도와 부드러운 UI를 모두 얻을 수 있습니다.
개요
간단히 말해서, 점진적 워밍업은 Shader를 우선순위별로 분류하여 중요한 것은 즉시 워밍업하고, 덜 중요한 것은 나중에 워밍업하는 시간 분산 기법입니다. Flutter의 SchedulerBinding.instance.addPostFrameCallback과 Future.delayed를 활용하면 첫 프레임 렌더링 이후나 앱이 idle 상태일 때 추가 워밍업을 수행할 수 있습니다.
Tier 1(즉시): 홈 화면 Shader → Tier 2(5초 후): 주요 기능 화면 Shader → Tier 3(10초 후 또는 idle): 설정, About 등 드물게 사용되는 화면 Shader 같은 3-tier 전략이 효과적입니다. 예를 들어, 사용자가 스플래시 스크린을 보는 1초 동안 Tier 1만 워밍업하고, 홈 화면에서 뉴스를 읽는 동안 백그라운드로 Tier 2와 3을 워밍업할 수 있습니다.
기존의 일괄 워밍업이 "한꺼번에 끝내기"였다면, 점진적 워밍업은 "지속적으로 개선하기"입니다. 사용자는 빠른 앱 시작을 경험하면서도 부드러운 UI를 얻습니다.
점진적 워밍업의 핵심 특징은 첫째, 사용자가 느끼는 앱 시작 시간을 최소화한다는 점, 둘째, CPU와 GPU의 idle 시간을 활용하여 백그라운드로 최적화를 진행한다는 점, 셋째, 우선순위를 잘못 설정해도 치명적이지 않다는 점(최악의 경우 일부 화면에서만 jank 발생)입니다. 이러한 특징들이 점진적 워밍업을 프로덕션에 안전하게 적용할 수 있게 합니다.
코드 예제
// 점진적 Shader 워밍업 구현
import 'package:flutter/material.dart';
import 'package:flutter/scheduler.dart';
import 'dart:ui' as ui;
void main() {
WidgetsFlutterBinding.ensureInitialized();
// Tier 1: 앱 시작 시 즉시 워밍업 (홈 화면)
PaintingBinding.instance.shaderWarmUp = ImmediateShaderWarmUp();
runApp(MyApp());
}
class MyApp extends StatefulWidget {
@override
_MyAppState createState() => _MyAppState();
}
class _MyAppState extends State<MyApp> {
@override
void initState() {
super.initState();
// Tier 2: 첫 프레임 렌더링 후 워밍업
SchedulerBinding.instance.addPostFrameCallback((_) {
Future.delayed(const Duration(seconds: 2), () {
_warmUpTier2();
});
});
// Tier 3: 앱이 idle 상태일 때 워밍업
SchedulerBinding.instance.addPostFrameCallback((_) {
Future.delayed(const Duration(seconds: 10), () {
if (SchedulerBinding.instance.schedulerPhase == SchedulerPhase.idle) {
_warmUpTier3();
}
});
});
}
Future<void> _warmUpTier2() async {
final recorder = ui.PictureRecorder();
final canvas = Canvas(recorder);
// 차트 화면 Shader
final path = Path()..addOval(const Rect.fromLTWH(0, 0, 50, 50));
canvas.drawPath(path, Paint()..style = PaintingStyle.stroke);
final picture = recorder.endRecording();
await picture.toImage(100, 100);
picture.dispose();
debugPrint('Tier 2 warmup completed');
}
Future<void> _warmUpTier3() async {
final recorder = ui.PictureRecorder();
final canvas = Canvas(recorder);
// 설정 화면의 복잡한 그림자
canvas.drawRRect(
RRect.fromRectAndRadius(
const Rect.fromLTWH(0, 0, 100, 100),
const Radius.circular(12),
),
Paint()..maskFilter = const MaskFilter.blur(BlurStyle.normal, 10),
);
final picture = recorder.endRecording();
await picture.toImage(100, 100);
picture.dispose();
debugPrint('Tier 3 warmup completed');
}
@override
Widget build(BuildContext context) {
return MaterialApp(home: HomePage());
}
}
// Tier 1: 즉시 워밍업할 필수 Shader
class ImmediateShaderWarmUp extends ShaderWarmUp {
@override
Future<void> warmUpOnCanvas(ui.Canvas canvas) async {
// 홈 화면의 기본 그라디언트만
final gradient = ui.Gradient.linear(
Offset.zero,
const Offset(100, 100),
[Colors.blue, Colors.indigo],
);
canvas.drawRect(
const Rect.fromLTWH(0, 0, 100, 100),
Paint()..shader = gradient,
);
debugPrint('Tier 1 warmup completed');
}
}
설명
이것이 하는 일: 위 코드는 Shader 워밍업을 시간에 따라 3단계로 분산시켜, 앱 시작 속도를 유지하면서도 점진적으로 모든 Shader를 워밍업하는 전략을 구현합니다. 첫 번째로, main() 함수에서 ImmediateShaderWarmUp만 설정합니다.
이 클래스는 홈 화면에 즉시 필요한 최소한의 Shader만 워밍업하므로, 100-150ms 정도만 소요됩니다. 사용자는 스플래시 스크린이 1초 정도면 사라지고 홈 화면을 볼 수 있어, 빠른 앱 시작을 경험합니다.
이 단계에서 너무 많은 것을 워밍업하면 전체 전략이 무너지므로, 정말 필수적인 것만 포함시키는 것이 중요합니다. 그 다음으로, MyApp의 initState()에서 addPostFrameCallback을 사용하여 첫 프레임이 렌더링된 직후에 Tier 2 워밍업을 예약합니다.
Future.delayed로 2초를 기다리는 이유는 사용자가 홈 화면에 정착할 시간을 주기 위해서입니다. 이 시점에 사용자는 이미 앱을 사용 중이므로, 백그라운드 워밍업을 눈치채지 못합니다.
_warmUpTier2()는 PictureRecorder와 Canvas를 직접 사용하는데, 이는 ShaderWarmUp 없이도 Shader 컴파일을 트리거할 수 있는 저수준 API입니다. 세 번째로, Tier 3 워밍업은 10초 후 또는 앱이 idle 상태일 때 실행됩니다.
SchedulerPhase.idle 체크는 중요한데, 만약 사용자가 10초 시점에 활발하게 애니메이션을 사용 중이라면 워밍업을 스킵하여 현재 애니메이션의 부드러움을 해치지 않습니다. 설정 화면 같은 드물게 사용되는 화면의 Shader는 이 단계에서 워밍업되며, 대부분의 사용자는 Tier 3가 완료된 후에 해당 화면에 접근하게 됩니다.
각 워밍업 메서드는 PictureRecorder → Canvas 그리기 → toImage() → dispose() 패턴을 따릅니다. toImage()를 호출해야 실제로 Shader 컴파일이 트리거되며, dispose()로 메모리를 즉시 해제하여 누수를 방지합니다.
debugPrint로 각 tier의 완료 시점을 로깅하면 개발 중에 전략이 제대로 작동하는지 확인할 수 있습니다. 마지막으로, 이 3-tier 전략의 타이밍은 여러분의 앱 특성에 맞게 조정할 수 있습니다.
뉴스 앱이라면 사용자가 기사를 읽는 5-10초 동안 모든 워밍업을 완료할 수 있지만, 게임이라면 메뉴 화면에서의 idle 시간을 활용해야 할 수 있습니다. 중요한 것은 사용자 행동 패턴을 분석하여 워밍업 타이밍을 최적화하는 것입니다.
여러분이 이런 점진적 접근을 사용하면 "빠른 앱 시작"과 "부드러운 UI" 사이의 트레이드오프를 거의 제거할 수 있습니다. 두 마리 토끼를 모두 잡는 것이죠.
Analytics로 "앱 시작부터 Tier 3 완료까지의 시간"을 추적하여, 대부분의 사용자가 모든 워밍업이 완료된 후에 앱을 사용하는지 확인하세요. 실무에서는 각 tier에 어떤 Shader를 포함시킬지 결정하는 것이 가장 중요합니다.
DevTools로 사용자 플로우를 분석하여 "첫 30초 내에 80%의 사용자가 접근하는 화면"은 Tier 2로, "5% 미만이 접근하는 화면"은 Tier 3로 분류하는 데이터 기반 접근이 효과적입니다.
실전 팁
💡 앱이 백그라운드로 갈 때(AppLifecycleState.paused) 워밍업을 중단하고, 다시 포그라운드로 올 때(AppLifecycleState.resumed) 재개하세요. 배터리를 절약할 수 있습니다.
💡 Tier 2와 3의 delay 시간을 Remote Config로 제어하면, 실제 사용자 데이터를 기반으로 최적 타이밍을 A/B 테스트할 수 있습니다.
💡 SchedulerBinding.instance.scheduleTask를 사용하면 priority를 지정하여 더 세밀한 제어가 가능합니다. 낮은 priority로 워밍업 태스크를 예약하면 UI 반응성이 우선됩니다.
💡 각 tier 완료 시점을 Firebase Analytics 이벤트로 전송하면, 실제 기기에서 워밍업이 얼마나 걸리는지 확인할 수 있습니다. "tier1_warmup_ms: 120" 같은 커스텀 파라미터를 사용하세요.
💡 워밍업 중에 예외가 발생하면 조용히 무시하세요. try-catch로 감싸고 에러를 로깅만 하여, 워밍업 실패가 앱 크래시로 이어지지 않도록 하세요.