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,
)

애니메이션에서는 AnimatedOpacityFadeInImage를 사용하세요.

// 페이드 애니메이션
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항상
ChipdisabledColorAlpha != 0xff일 때
TextoverflowShader가 있을 때

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. 프로파일 모드에서 테스트#

성능 테스트는 반드시 프로파일 모드에서 해야 합니다. 디버그 모드는 핫 리로드 등의 기능으로 인해 실제보다 느립니다.

Terminal window
# 프로파일 모드로 실행
flutter run --profile
# 프로파일 모드로 빌드
flutter build apk --profile # Android
flutter build ios --profile # iOS
디버그 모드 성능 측정 금지

디버그 모드에서의 성능은 실제 릴리즈 성능과 크게 다릅니다. 항상 프로파일 모드에서 성능을 측정하세요.

8. DevTools로 성능 분석#

Flutter DevTools의 Performance View를 사용하여 성능 문제를 진단할 수 있습니다.

Terminal window
# DevTools 실행
flutter pub global activate devtools
flutter pub global run devtools

Performance View 활용#

  1. Frame Chart: 각 프레임의 빌드/렌더 시간 확인
  2. Timeline Events: 상세한 이벤트 타임라인
  3. Track Widget Builds: 위젯 리빌드 추적
  4. 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;
@override
bool operator ==(Object other) {
if (identical(this, other)) return true;
return other is MyWidget && other.title == title;
}
@override
int get hashCode => title.hashCode;
}

대신 const 생성자를 활용하세요.

함수 대신 위젯 클래스 사용

UI 조각을 함수로 만들지 말고 StatelessWidget으로 만드세요. 위젯 클래스는 Flutter의 최적화 이점을 활용할 수 있습니다.

// 피해야 함
Widget _buildHeader() {
return Container(...);
}
// 권장
class HeaderWidget extends StatelessWidget {
const HeaderWidget({super.key});
@override
Widget build(BuildContext context) {
return Container(...);
}
}

마무리#

Flutter 성능 최적화의 핵심은 불필요한 작업을 최소화하는 것입니다. const 위젯 활용, 적절한 위젯 분리, lazy 빌더 사용, 비용이 많이 드는 연산 피하기 등의 원칙을 따르면 처음부터 빠른 앱을 만들 수 있습니다. 항상 프로파일 모드에서 성능을 측정하고, DevTools를 활용하여 병목 지점을 찾아 최적화하세요!

참고 자료#

Footnotes#

  1. Opacity: 자식 위젯의 투명도를 조절하는 위젯입니다.

공유

이 글이 도움이 되었다면 다른 사람과 공유해주세요!

Flutter 튜토리얼 50편: 성능 최적화 기초
https://moodturnpost.net/posts/flutter/flutter-performance-basics/
작성자
Moodturn
게시일
2026-01-08
Moodturn

목차