Flutter 튜토리얼 50편: 성능 최적화 기초
왜 성능 최적화가 중요한가요?
Flutter는 기본적으로 뛰어난 성능을 제공하지만, 부주의한 코드 작성은 앱을 느리게 만들 수 있습니다. 사용자는 버벅거리는 애니메이션이나 느린 화면 전환에 민감하게 반응합니다. 성능 최적화의 기본 원칙을 이해하면 처음부터 빠른 앱을 만들 수 있습니다.
무엇을 배우나요?
이 튜토리얼에서는 다음 내용을 학습합니다.
- build() 메서드 최적화 방법
- 비용이 많이 드는 연산 최소화하기
- 리스트와 그리드의 효율적인 구현
- 애니메이션 성능 개선 방법
- 프로파일 모드에서 성능 측정하기
어떻게 최적화하나요?
1. build() 메서드 최적화
Flutter에서 가장 중요한 성능 최적화 영역은 build() 메서드입니다.
부모 위젯이 리빌드될 때마다 자식의 build() 메서드도 호출되기 때문입니다.
위젯 분리하기
큰 위젯을 작은 위젯으로 분리하면 불필요한 리빌드를 줄일 수 있습니다.
// 좋지 않은 예: 하나의 큰 위젯class MyPage extends StatefulWidget { @override State<MyPage> createState() => _MyPageState();}
class _MyPageState extends State<MyPage> { int _counter = 0;
@override Widget build(BuildContext context) { return Scaffold( appBar: AppBar(title: Text('카운터')), body: Column( children: [ // 헤더 - counter와 관계없음 Container( height: 100, child: Image.asset('assets/logo.png'), ), // 카운터 표시 Text('$_counter'), // 푸터 - counter와 관계없음 Container( height: 50, child: Text('Footer'), ), ], ), floatingActionButton: FloatingActionButton( onPressed: () => setState(() => _counter++), child: Icon(Icons.add), ), ); }}// 좋은 예: 위젯 분리class MyPage extends StatefulWidget { @override State<MyPage> createState() => _MyPageState();}
class _MyPageState extends State<MyPage> { int _counter = 0;
@override Widget build(BuildContext context) { return Scaffold( appBar: AppBar(title: Text('카운터')), body: Column( children: [ const HeaderWidget(), // const로 리빌드 방지 CounterWidget(counter: _counter), const FooterWidget(), // const로 리빌드 방지 ], ), floatingActionButton: FloatingActionButton( onPressed: () => setState(() => _counter++), child: Icon(Icons.add), ), ); }}
// 분리된 위젯들class HeaderWidget extends StatelessWidget { const HeaderWidget({super.key});
@override Widget build(BuildContext context) { return Container( height: 100, child: Image.asset('assets/logo.png'), ); }}
class CounterWidget extends StatelessWidget { final int counter; const CounterWidget({required this.counter, super.key});
@override Widget build(BuildContext context) { return Text('$counter'); }}
class FooterWidget extends StatelessWidget { const FooterWidget({super.key});
@override Widget build(BuildContext context) { return Container( height: 50, child: Text('Footer'), ); }}const 생성자 활용
const 위젯은 Flutter가 리빌드를 건너뛸 수 있게 해줍니다.
// const 활용class MyWidget extends StatelessWidget { @override Widget build(BuildContext context) { return Column( children: const [ Text('정적 텍스트'), // 리빌드되지 않음 Icon(Icons.star), // 리빌드되지 않음 SizedBox(height: 16), // 리빌드되지 않음 ], ); }}const 린트 활성화
flutter_lints패키지를 사용하면const를 사용할 수 있는 곳에서 자동으로 알려줍니다.analysis_options.yaml include: package:flutter_lints/flutter.yaml
setState() 범위 최소화
setState()는 호출된 State 객체의 모든 자손 위젯을 리빌드합니다.
변경이 필요한 부분만 리빌드되도록 범위를 최소화하세요.
// 좋지 않은 예: 전체 화면 리빌드class _MyPageState extends State<MyPage> { bool _isLoading = false;
@override Widget build(BuildContext context) { return Column( children: [ ExpensiveWidget(), // 매번 리빌드됨 if (_isLoading) CircularProgressIndicator(), AnotherExpensiveWidget(), // 매번 리빌드됨 ], ); }
void startLoading() { setState(() => _isLoading = true); }}// 좋은 예: 로딩 인디케이터만 분리class _MyPageState extends State<MyPage> { @override Widget build(BuildContext context) { return Column( children: [ const ExpensiveWidget(), // const로 리빌드 방지 LoadingIndicator(), // 별도 StatefulWidget으로 분리 const AnotherExpensiveWidget(), ], ); }}
class LoadingIndicator extends StatefulWidget { @override State<LoadingIndicator> createState() => _LoadingIndicatorState();}
class _LoadingIndicatorState extends State<LoadingIndicator> { bool _isLoading = false;
void startLoading() { setState(() => _isLoading = true); // 이 위젯만 리빌드 }
@override Widget build(BuildContext context) { return _isLoading ? const CircularProgressIndicator() : const SizedBox.shrink(); }}2. 비용이 많이 드는 연산 최소화
일부 연산은 다른 것보다 훨씬 많은 리소스를 소비합니다.
Opacity 위젯 주의
Opacity1 위젯은 내부적으로 saveLayer()를 호출하여 비용이 많이 듭니다.
// 비용이 많이 드는 방법Opacity( opacity: 0.5, child: Container( color: Colors.blue, width: 100, height: 100, ),)
// 더 효율적인 방법: 직접 색상에 투명도 적용Container( color: Colors.blue.withOpacity(0.5), // 색상에 직접 적용 width: 100, height: 100,)애니메이션에서는 AnimatedOpacity나 FadeInImage를 사용하세요.
// 페이드 애니메이션AnimatedOpacity( opacity: _visible ? 1.0 : 0.0, duration: const Duration(milliseconds: 300), child: MyWidget(),)
// 이미지 페이드인FadeInImage.assetNetwork( placeholder: 'assets/placeholder.png', image: 'https://example.com/image.jpg',)클리핑(Clipping) 최소화
클리핑은 비용이 많이 드는 연산입니다.
특히 Clip.antiAliasWithSaveLayer는 피해야 합니다.
// 비용이 많이 드는 클리핑ClipRRect( clipBehavior: Clip.antiAliasWithSaveLayer, // 피해야 함 borderRadius: BorderRadius.circular(8), child: Image.network('https://example.com/image.jpg'),)
// 더 효율적인 방법: borderRadius 사용Container( decoration: BoxDecoration( borderRadius: BorderRadius.circular(8), image: DecorationImage( image: NetworkImage('https://example.com/image.jpg'), fit: BoxFit.cover, ), ),)saveLayer() 호출 확인
다음 위젯들은 내부적으로 saveLayer()를 호출할 수 있습니다.
| 위젯 | 조건 |
|---|---|
ShaderMask | 항상 |
ColorFilter | 항상 |
Chip | disabledColorAlpha != 0xff일 때 |
Text | overflowShader가 있을 때 |
DevTools의 Performance View에서 checkerboardOffscreenLayers를 활성화하면 saveLayer() 호출을 확인할 수 있습니다.
3. 리스트와 그리드 효율적으로 구현하기
Lazy 빌더 사용
큰 리스트나 그리드는 반드시 lazy 빌더를 사용해야 합니다. lazy 빌더는 화면에 보이는 항목만 빌드합니다.
// 좋지 않은 예: 모든 항목을 한 번에 빌드ListView( children: items.map((item) => ItemWidget(item)).toList(),)
// 좋은 예: 보이는 항목만 빌드ListView.builder( itemCount: items.length, itemBuilder: (context, index) => ItemWidget(items[index]),)// GridView도 마찬가지GridView.builder( gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( crossAxisCount: 2, ), itemCount: products.length, itemBuilder: (context, index) => ProductCard(products[index]),)기본 생성자 피하기
ListView(children: [...])또는Column(children: [...])같은 기본 생성자는 모든 자식을 한 번에 빌드합니다. 화면에 보이지 않는 항목이 많다면 빌드 비용이 낭비됩니다.
Intrinsic 연산 피하기
그리드에서 모든 셀의 크기를 계산하기 위해 intrinsic pass가 필요할 수 있습니다. 이는 모든 셀을 한 번 순회해야 하므로 비용이 많이 듭니다.
// intrinsic pass가 필요할 수 있음DataTable( columns: [...], rows: largeDataset.map((data) => DataRow(cells: [...])).toList(),)
// 고정 크기로 intrinsic pass 방지ListView.builder( itemCount: largeDataset.length, itemExtent: 56, // 항목 높이 고정 itemBuilder: (context, index) => ListTile( title: Text(largeDataset[index].name), ),)4. 애니메이션 성능 최적화
AnimatedBuilder 올바르게 사용하기
AnimatedBuilder를 사용할 때 변경되지 않는 부분은 child 파라미터로 전달하세요.
// 좋지 않은 예: 매 프레임마다 전체 리빌드AnimatedBuilder( animation: _controller, builder: (context, child) { return Transform.rotate( angle: _controller.value * 2 * pi, child: Column( // 매 프레임마다 리빌드됨 children: [ Icon(Icons.star, size: 50), Text('회전 중'), ], ), ); },)
// 좋은 예: child를 활용하여 리빌드 방지AnimatedBuilder( animation: _controller, child: Column( // 한 번만 빌드됨 children: [ Icon(Icons.star, size: 50), Text('회전 중'), ], ), builder: (context, child) { return Transform.rotate( angle: _controller.value * 2 * pi, child: child, // 캐시된 child 재사용 ); },)애니메이션 중 클리핑 피하기
애니메이션 도중 클리핑이 적용되면 성능이 크게 저하될 수 있습니다. 가능하면 미리 클리핑된 이미지를 사용하세요.
// 피해야 함: 애니메이션 중 클리핑AnimatedBuilder( animation: _controller, builder: (context, child) { return ClipPath( clipper: MyClipper(_controller.value), child: Image.asset('large_image.png'), ); },)
// 권장: 미리 처리된 이미지 사용 또는 클리핑 없는 디자인5. 문자열 빌딩 최적화
반복문에서 문자열을 연결할 때는 StringBuffer를 사용하세요.
// 비효율적: + 연산자 사용String buildString(List<String> items) { String result = ''; for (final item in items) { result += item + ', '; // 매번 새 String 객체 생성 } return result;}
// 효율적: StringBuffer 사용String buildString(List<String> items) { final buffer = StringBuffer(); for (final item in items) { buffer.write(item); buffer.write(', '); } return buffer.toString(); // 마지막에 한 번만 String 생성}6. 16ms 규칙
60Hz 디스플레이에서 부드러운 애니메이션을 위해서는 각 프레임을 16ms 이내에 빌드하고 렌더링해야 합니다.
- 빌드(Build): 8ms 이내
- 렌더링(Render): 8ms 이내
- 총합: 16ms 이내
120Hz 디스플레이에서는 8ms 이내에 완료해야 합니다.
배터리와 발열프레임 시간이 16ms보다 훨씬 짧더라도, 최대한 빠르게 처리하는 것이 좋습니다. 그래야 배터리 소모와 발열을 줄일 수 있습니다.
7. 프로파일 모드에서 테스트
성능 테스트는 반드시 프로파일 모드에서 해야 합니다. 디버그 모드는 핫 리로드 등의 기능으로 인해 실제보다 느립니다.
# 프로파일 모드로 실행flutter run --profile
# 프로파일 모드로 빌드flutter build apk --profile # Androidflutter build ios --profile # iOS디버그 모드 성능 측정 금지디버그 모드에서의 성능은 실제 릴리즈 성능과 크게 다릅니다. 항상 프로파일 모드에서 성능을 측정하세요.
8. DevTools로 성능 분석
Flutter DevTools의 Performance View를 사용하여 성능 문제를 진단할 수 있습니다.
# DevTools 실행flutter pub global activate devtoolsflutter pub global run devtoolsPerformance View 활용
- Frame Chart: 각 프레임의 빌드/렌더 시간 확인
- Timeline Events: 상세한 이벤트 타임라인
- Track Widget Builds: 위젯 리빌드 추적
- Track Layouts: 레이아웃 패스 추적
// 위젯 리빌드 추적을 위한 디버그 플래그import 'package:flutter/rendering.dart';
void main() { // 레이아웃 추적 활성화 debugPrintRebuildDirtyWidgets = true; debugPrintLayouts = true;
runApp(MyApp());}9. 성능 최적화 체크리스트
성능 최적화를 위한 체크리스트입니다.
| 항목 | 확인 |
|---|---|
const 생성자 최대한 활용 | [ ] |
| 큰 위젯을 작은 위젯으로 분리 | [ ] |
setState() 범위 최소화 | [ ] |
| 리스트/그리드에 lazy 빌더 사용 | [ ] |
Opacity 대신 투명 색상 사용 | [ ] |
애니메이션에서 child 파라미터 활용 | [ ] |
| 불필요한 클리핑 제거 | [ ] |
| 프로파일 모드에서 테스트 | [ ] |
주의하세요!
operator == 오버라이드 금지Widget 클래스에서
operator ==를 오버라이드하지 마세요. 이론적으로 불필요한 리빌드를 방지할 것 같지만, 실제로는 O(N^2) 동작을 유발하여 성능을 저하시킵니다.// 하지 마세요!class MyWidget extends StatelessWidget {final String title;@overridebool operator ==(Object other) {if (identical(this, other)) return true;return other is MyWidget && other.title == title;}@overrideint get hashCode => title.hashCode;}대신
const생성자를 활용하세요.
함수 대신 위젯 클래스 사용UI 조각을 함수로 만들지 말고 StatelessWidget으로 만드세요. 위젯 클래스는 Flutter의 최적화 이점을 활용할 수 있습니다.
// 피해야 함Widget _buildHeader() {return Container(...);}// 권장class HeaderWidget extends StatelessWidget {const HeaderWidget({super.key});@overrideWidget build(BuildContext context) {return Container(...);}}
마무리
Flutter 성능 최적화의 핵심은 불필요한 작업을 최소화하는 것입니다.
const 위젯 활용, 적절한 위젯 분리, lazy 빌더 사용, 비용이 많이 드는 연산 피하기 등의 원칙을 따르면 처음부터 빠른 앱을 만들 수 있습니다.
항상 프로파일 모드에서 성능을 측정하고, DevTools를 활용하여 병목 지점을 찾아 최적화하세요!
참고 자료
Footnotes
-
Opacity: 자식 위젯의 투명도를 조절하는 위젯입니다. ↩
공유
이 글이 도움이 되었다면 다른 사람과 공유해주세요!