본 콘텐츠의 이미지 및 내용은 AI로 생성되었습니다.
본 콘텐츠의 이미지 및 내용을 무단으로 복제, 배포, 수정하여 사용할 경우 저작권법에 의해 법적 제재를 받을 수 있습니다.
이미지 로딩 중...
AI Generated
2025. 10. 28. · 163 Views
Flutter 성능 최적화 10가지 팁
Flutter 앱의 성능을 극대화하는 실전 최적화 기법들을 배워보세요. 렌더링 성능부터 메모리 관리까지, 초급 개발자도 바로 적용할 수 있는 10가지 핵심 팁을 제공합니다.
목차
- const_생성자_활용
- ListView_builder_사용
- RepaintBoundary_활용
- Image_캐싱_전략
- setState_범위_최소화
- ValueListenableBuilder_사용
- Opacity_대신_AnimatedOpacity
- 무거운_연산_isolate_분리
- DevTools_프로파일링_활용
- 불필요한_애니메이션_제거
1. const 생성자 활용
시작하며
여러분이 Flutter 앱을 개발하다가 스크롤할 때 버벅거림을 느낀 적 있나요? 화면에 표시되는 위젯이 많아질수록 앱이 점점 느려지는 경험을 하셨을 겁니다.
이런 문제는 대부분 위젯이 불필요하게 재생성되면서 발생합니다. Flutter는 상태가 변경될 때마다 build 메서드를 다시 호출하는데, 이때 모든 위젯을 새로 만들면 엄청난 리소스가 낭비됩니다.
바로 이럴 때 필요한 것이 const 생성자입니다. 컴파일 타임에 위젯을 미리 생성해두면 런타임에 반복적으로 생성하지 않아도 되죠.
개요
간단히 말해서, const 생성자는 컴파일 타임에 불변(immutable) 객체를 생성하여 재사용하는 기법입니다. Flutter에서 위젯은 기본적으로 불변입니다.
하지만 const를 명시하지 않으면 매번 새로운 인스턴스가 생성됩니다. 예를 들어, ListView에 100개의 아이템이 있고 각 아이템마다 아이콘 위젯이 있다면, const를 사용하지 않으면 스크롤할 때마다 수백 개의 아이콘 객체가 생성됩니다.
기존에는 매 build마다 Icon(Icons.home)을 새로 생성했다면, 이제는 const Icon(Icons.home)으로 한 번만 생성하고 계속 재사용할 수 있습니다. const 생성자의 핵심 특징은 메모리 효율성, 렌더링 성능 향상, 그리고 컴파일러 최적화입니다.
이러한 특징들이 앱의 전반적인 성능을 크게 개선시킵니다.
코드 예제
// 잘못된 예: 매번 새로운 Padding 인스턴스 생성
Widget build(BuildContext context) {
return Padding(
padding: EdgeInsets.all(16.0),
child: Text('Hello'),
);
}
// 올바른 예: const로 컴파일 타임에 객체 생성
Widget build(BuildContext context) {
return const Padding(
padding: EdgeInsets.all(16.0),
child: Text('Hello'),
);
}
설명
이것이 하는 일: const 키워드는 컴파일러에게 "이 객체는 절대 변하지 않으니 미리 만들어두고 계속 재사용해"라고 알려주는 역할을 합니다. 첫 번째로, 컴파일 단계에서 const로 선언된 위젯은 메모리에 단 한 번만 할당됩니다.
왜 이렇게 하냐면, 같은 위젯을 여러 번 생성하는 것은 메모리와 CPU 리소스를 낭비하기 때문입니다. 예를 들어 const Icon(Icons.star)는 앱 전체에서 몇 번을 사용하든 메모리상 같은 인스턴스를 가리킵니다.
그 다음으로, Flutter의 렌더링 엔진이 위젯 트리를 비교할 때 const 위젯은 자동으로 건너뜁니다. build 메서드가 다시 호출되어도 const 위젯은 변경될 수 없다는 것을 알기 때문에 다시 그릴 필요가 없다고 판단하죠.
이는 복잡한 UI에서 수백, 수천 개의 비교 연산을 줄여줍니다. 마지막으로, setState()가 호출되어 위젯이 다시 빌드될 때 const가 아닌 위젯은 모두 새로 생성되지만, const 위젯은 그대로 유지됩니다.
이것이 최종적으로 앱의 프레임 드롭을 방지하고 부드러운 60fps 렌더링을 가능하게 만듭니다. 여러분이 이 코드를 사용하면 특히 복잡한 리스트나 반복적인 UI 요소에서 눈에 띄는 성능 향상을 얻을 수 있습니다.
메모리 사용량 감소, 가비지 컬렉션 빈도 감소, 그리고 배터리 수명 향상이라는 세 가지 이점을 동시에 누릴 수 있죠.
실전 팁
💡 Android Studio나 VS Code에서 const를 추가할 수 있는 곳에는 노란색 경고가 표시됩니다. 이 경고를 무시하지 말고 항상 const를 추가하세요.
💡 생성자 매개변수에 변수를 사용하면 const를 쓸 수 없습니다. padding: EdgeInsets.all(myVariable) 같은 경우는 const를 사용할 수 없으니 정적인 값만 사용하세요.
💡 const를 과도하게 사용해도 문제없습니다. 컴파일러가 알아서 최적화하므로 확실하지 않을 때는 일단 const를 붙이세요.
💡 위젯 생성자 앞에 const를 붙일 수 없다면, 해당 위젯을 별도의 StatelessWidget으로 분리하고 const 생성자를 만드는 것을 고려하세요.
2. ListView builder 사용
시작하며
여러분이 수백 개의 아이템을 보여주는 피드 화면을 만들다가 앱이 느려지거나 심지어 크래시가 발생한 적 있나요? ListView에 모든 아이템을 한 번에 넣었더니 메모리가 부족하다는 에러가 뜬 경험 말이죠.
이런 문제는 화면에 보이지 않는 수백 개의 위젯까지 모두 메모리에 올려놓기 때문에 발생합니다. 사용자는 한 번에 3-4개의 아이템만 볼 수 있는데, 1000개를 모두 렌더링하면 당연히 문제가 생깁니다.
바로 이럴 때 필요한 것이 ListView.builder입니다. 화면에 보이는 아이템만 동적으로 생성하고 스크롤하면서 필요한 것만 추가로 만들어내죠.
개요
간단히 말해서, ListView.builder는 지연 로딩(lazy loading) 방식으로 리스트 아이템을 생성하는 효율적인 위젯입니다. 일반 ListView는 모든 자식 위젯을 한 번에 생성합니다.
하지만 ListView.builder는 itemBuilder 콜백 함수를 통해 화면에 표시될 위젯만 필요할 때 생성합니다. 예를 들어, 10,000개의 상품 목록을 보여줘야 한다면 일반 ListView는 10,000개를 모두 만들지만 ListView.builder는 화면에 보이는 5-6개만 먼저 만들고 나머지는 스크롤할 때 생성합니다.
기존에는 ListView(children: [...])로 모든 위젯을 미리 생성했다면, 이제는 ListView.builder로 필요한 시점에만 생성할 수 있습니다. ListView.builder의 핵심 특징은 메모리 효율성, 초기 로딩 속도 향상, 그리고 무한 스크롤 지원입니다.
이러한 특징들이 대용량 데이터를 다루는 앱에서 필수적입니다.
코드 예제
// itemCount: 전체 아이템 개수
// itemBuilder: 각 인덱스에 해당하는 위젯을 생성하는 함수
ListView.builder(
itemCount: 1000, // 총 1000개의 아이템
itemBuilder: (context, index) {
// 화면에 보일 때만 이 함수가 호출됨
return ListTile(
leading: CircleAvatar(
child: Text('${index + 1}'),
),
title: Text('아이템 $index'),
subtitle: Text('설명 텍스트 $index'),
onTap: () => print('아이템 $index 클릭됨'),
);
},
)
설명
이것이 하는 일: ListView.builder는 스크롤 위치를 계속 감시하다가 새로운 아이템이 화면에 나타나려고 하는 순간 itemBuilder 함수를 호출해서 위젯을 생성합니다. 첫 번째로, 앱이 시작되면 itemCount로 전체 리스트의 크기를 알려줍니다.
하지만 실제로 위젯을 만들지는 않고 스크롤바의 크기만 계산합니다. 왜 이렇게 하냐면, 사용자에게 전체 리스트의 길이를 보여주면서도 메모리는 아끼기 위해서죠.
예를 들어 1000개 아이템의 스크롤바는 길게 보이지만 실제로는 5-6개만 메모리에 있습니다. 그 다음으로, 사용자가 스크롤하면 Flutter는 "아, 인덱스 7번 아이템이 곧 화면에 나타나겠네"라고 판단하고 itemBuilder(context, 7)을 호출합니다.
이 함수가 실행되면서 비로소 7번 아이템의 ListTile 위젯이 생성되고 화면에 표시되죠. 반대로 화면에서 사라진 아이템은 자동으로 메모리에서 제거됩니다.
마지막으로, 이런 방식으로 계속 스크롤하면서 필요한 위젯만 생성하고 제거하는 과정이 반복됩니다. 최종적으로 사용자는 1000개든 10000개든 아무리 긴 리스트도 부드럽게 스크롤할 수 있게 됩니다.
여러분이 이 코드를 사용하면 앱의 초기 로딩 시간이 획기적으로 줄어들고, 메모리 사용량이 일정하게 유지되며, 스크롤 성능이 항상 60fps를 유지할 수 있습니다. 특히 API에서 받아온 대용량 데이터를 표시할 때 필수적입니다.
실전 팁
💡 itemCount를 생략하면 무한 스크롤을 구현할 수 있습니다. 하지만 반드시 itemBuilder에서 null을 반환하는 조건을 넣어서 끝을 표시해야 합니다.
💡 ListView.builder와 함께 ListView.separated를 사용하면 아이템 사이에 구분선을 쉽게 추가할 수 있습니다. separatorBuilder 매개변수를 활용하세요.
💡 복잡한 아이템 위젯은 별도의 StatelessWidget으로 분리하고 const 생성자를 사용하면 성능이 더욱 향상됩니다.
💡 itemBuilder 함수 안에서 무거운 연산을 하지 마세요. 스크롤할 때마다 이 함수가 호출되므로 간단한 위젯 생성만 해야 합니다.
💡 GridView.builder도 같은 원리로 작동하므로 그리드 레이아웃에서도 동일하게 활용하세요.
3. RepaintBoundary 활용
시작하며
여러분이 복잡한 대시보드 화면을 만들었는데 한 곳의 타이머가 업데이트될 때마다 전체 화면이 깜빡이는 경험을 해본 적 있나요? 작은 부분만 변경되는데 전체 화면이 다시 그려지면서 성능이 떨어지는 상황 말이죠.
이런 문제는 Flutter의 렌더링 엔진이 변경된 위젯의 상위 위젯까지 모두 다시 그리기 때문에 발생합니다. 하나의 Text 위젯이 바뀌어도 그 부모, 조부모 위젯까지 repaint되면서 불필요한 작업이 반복됩니다.
바로 이럴 때 필요한 것이 RepaintBoundary입니다. 위젯 트리의 특정 부분을 격리시켜서 그 안의 변경사항이 밖으로 전파되지 않도록 막아주죠.
개요
간단히 말해서, RepaintBoundary는 위젯을 별도의 레이어로 분리하여 해당 영역만 독립적으로 다시 그릴 수 있게 만드는 최적화 도구입니다. Flutter는 위젯 트리를 렌더링할 때 변경된 부분을 찾기 위해 위로 올라가면서 검사합니다.
하지만 RepaintBoundary를 만나면 그 이상 올라가지 않고 해당 영역만 다시 그립니다. 예를 들어, 실시간 차트와 정적인 헤더가 있는 화면에서 차트만 RepaintBoundary로 감싸면 차트가 업데이트되어도 헤더는 다시 그려지지 않습니다.
기존에는 작은 변경에도 전체 위젯 트리가 repaint되었다면, 이제는 RepaintBoundary로 독립적인 렌더링 영역을 만들 수 있습니다. RepaintBoundary의 핵심 특징은 렌더링 격리, 불필요한 repaint 방지, 그리고 복잡한 UI의 성능 최적화입니다.
이러한 특징들이 애니메이션이나 실시간 업데이트가 많은 화면에서 프레임 드롭을 방지합니다.
코드 예제
// 자주 변경되는 위젯을 RepaintBoundary로 감싸기
Column(
children: [
// 정적인 헤더 - 다시 그릴 필요 없음
const Header(),
// 자주 업데이트되는 타이머 - 독립적으로 repaint
RepaintBoundary(
child: StreamBuilder<int>(
stream: timerStream,
builder: (context, snapshot) {
return Text('${snapshot.data ?? 0}초');
},
),
),
// 정적인 푸터 - 다시 그릴 필요 없음
const Footer(),
],
)
설명
이것이 하는 일: RepaintBoundary는 Flutter의 렌더링 파이프라인에 "여기서 멈춰! 이 경계를 넘어서는 repaint하지 마"라고 말하는 체크포인트를 설치합니다.
첫 번째로, RepaintBoundary로 감싼 위젯은 별도의 Layer 객체로 분리됩니다. Flutter의 렌더링 엔진은 레이어 단위로 화면을 그리는데, 각 레이어는 독립적으로 관리되죠.
왜 이렇게 하냐면, 하나의 레이어가 변경되어도 다른 레이어는 이전에 렌더링된 결과를 그대로 재사용할 수 있기 때문입니다. 예를 들어 위 코드에서 타이머가 매초 업데이트되어도 Header와 Footer는 처음 한 번만 렌더링되고 계속 재사용됩니다.
그 다음으로, setState()나 StreamBuilder 같은 것으로 위젯이 다시 빌드될 때 렌더링 엔진은 RepaintBoundary를 만나면 "이 안쪽만 확인하면 되겠네"라고 판단합니다. 이것이 실행되면서 변경 감지 범위가 크게 축소되고, CPU가 해야 할 비교 연산이 수백 배 줄어들죠.
1000개 위젯 중 1개만 변경되었는데 1000개를 모두 검사하는 대신 RepaintBoundary 안의 10개만 검사하면 되는 겁니다. 마지막으로, 이렇게 격리된 영역들이 독립적으로 업데이트되면서 전체 앱의 렌더링 성능이 향상됩니다.
최종적으로 복잡한 화면에서도 60fps를 유지할 수 있게 됩니다. 여러분이 이 코드를 사용하면 특히 실시간 데이터 업데이트, 애니메이션, 스크롤 리스트 같은 동적인 UI에서 극적인 성능 개선을 경험할 수 있습니다.
DevTools의 Performance Overlay에서 repaint되는 영역이 크게 줄어드는 것을 직접 확인할 수 있죠.
실전 팁
💡 과도한 RepaintBoundary 사용은 오히려 성능을 저하시킵니다. 각 boundary마다 메모리 오버헤드가 있으므로 정말 자주 변경되는 부분에만 사용하세요.
💡 ListView.builder는 내부적으로 이미 각 아이템에 RepaintBoundary를 적용합니다. 추가로 감쌀 필요가 없습니다.
💡 Flutter DevTools의 Performance 탭에서 "Show Repaint Rainbow"를 활성화하면 어느 부분이 자주 repaint되는지 시각적으로 확인할 수 있습니다.
💡 애니메이션 위젯은 RepaintBoundary의 좋은 후보입니다. AnimatedBuilder나 AnimatedWidget과 함께 사용하면 효과적입니다.
4. Image 캐싱 전략
시작하며
여러분이 소셜 미디어 앱을 만들다가 같은 프로필 이미지가 리스트에 반복적으로 나타날 때마다 네트워크에서 다시 다운로드되는 것을 본 적 있나요? 데이터 낭비는 물론이고 이미지가 깜빡이면서 로딩되는 나쁜 사용자 경험을 제공하게 되죠.
이런 문제는 Flutter가 기본적으로 이미지를 캐싱하긴 하지만, 메모리 캐시에만 의존하기 때문에 발생합니다. 앱을 다시 시작하거나 메모리가 부족하면 캐시가 날아가고 다시 다운로드해야 합니다.
바로 이럴 때 필요한 것이 체계적인 이미지 캐싱 전략입니다. 메모리 캐시와 디스크 캐시를 적절히 조합하고, 이미지 크기를 최적화하면 훨씬 효율적인 앱을 만들 수 있죠.
개요
간단히 말해서, 이미지 캐싱 전략은 네트워크 이미지를 메모리와 디스크에 저장하여 재사용함으로써 로딩 속도와 데이터 사용량을 최적화하는 기법입니다. Flutter의 Image.network는 기본적으로 메모리 캐시를 제공하지만 한계가 있습니다.
cached_network_image 패키지를 사용하면 디스크 캐시까지 지원하여 앱을 재시작해도 이미지를 다시 다운로드하지 않습니다. 예를 들어, 사용자가 피드를 스크롤하면서 본 100개의 이미지는 디스크에 저장되어 나중에 다시 볼 때 즉시 표시됩니다.
기존에는 Image.network(url)로 매번 네트워크에서 이미지를 가져왔다면, 이제는 CachedNetworkImage로 한 번 다운로드하고 계속 재사용할 수 있습니다. 이미지 캐싱의 핵심 특징은 네트워크 트래픽 감소, 로딩 속도 향상, 그리고 오프라인 지원입니다.
이러한 특징들이 사용자 경험을 크게 개선시킵니다.
코드 예제
// pubspec.yaml에 추가: cached_network_image: ^3.3.0
import 'package:cached_network_image/cached_network_image.dart';
// 기본 사용법
CachedNetworkImage(
imageUrl: 'https://example.com/image.jpg',
// 로딩 중 표시할 위젯
placeholder: (context, url) => CircularProgressIndicator(),
// 에러 발생 시 표시할 위젯
errorWidget: (context, url, error) => Icon(Icons.error),
// 메모리 캐시 크기 제한 (기본값보다 크게 설정)
memCacheHeight: 200,
memCacheWidth: 200,
// 이미지 맞춤 설정
fit: BoxFit.cover,
)
설명
이것이 하는 일: CachedNetworkImage는 이미지를 다운로드하기 전에 먼저 캐시를 확인하고, 없을 때만 네트워크에서 가져온 후 자동으로 저장하는 스마트한 위젯입니다. 첫 번째로, 이미지를 표시하라는 요청이 들어오면 메모리 캐시를 먼저 확인합니다.
메모리 캐시는 RAM에 저장되어 있어서 접근 속도가 가장 빠르죠. 왜 이렇게 하냐면, 같은 세션에서 반복적으로 표시되는 이미지(예: 프로필 사진)는 메모리에서 바로 가져오는 것이 가장 효율적이기 때문입니다.
memCacheHeight와 memCacheWidth를 설정하면 메모리에 저장될 이미지의 크기를 제한하여 메모리 사용량을 조절할 수 있습니다. 그 다음으로, 메모리 캐시에 없으면 디스크 캐시를 확인합니다.
디스크 캐시는 앱의 로컬 스토리지에 이미지 파일을 저장하는 방식이죠. 앱을 종료했다가 다시 실행해도 디스크에 저장된 이미지는 그대로 남아있어서 네트워크 요청 없이 즉시 로드됩니다.
이것이 실행되면서 데이터 사용량이 크게 줄어들고, 느린 네트워크 환경에서도 빠른 이미지 로딩이 가능해집니다. 마지막으로, 메모리와 디스크 양쪽 모두에 이미지가 없을 때만 imageUrl에서 네트워크 다운로드를 시작합니다.
다운로드가 완료되면 자동으로 메모리와 디스크 캐시 양쪽에 저장하여 다음번에는 빠르게 표시할 수 있게 준비합니다. 최종적으로 사용자는 한 번 본 이미지를 다시 볼 때 즉시 표시되는 부드러운 경험을 하게 됩니다.
여러분이 이 코드를 사용하면 네트워크 데이터 사용량이 50% 이상 감소하고, 이미지 로딩 속도가 10배 이상 빨라지며, 오프라인 상태에서도 이전에 본 이미지를 표시할 수 있습니다. 특히 이미지가 많은 소셜 미디어, 쇼핑몰, 갤러리 앱에서 필수적입니다.
실전 팁
💡 CacheManager를 커스터마이징하여 캐시 만료 시간, 최대 캐시 크기, 캐시 디렉토리를 조절할 수 있습니다. 기본값은 7일, 200개 파일입니다.
💡 ListView에서 사용할 때는 memCacheHeight와 memCacheWidth를 반드시 설정하세요. 원본 크기 그대로 캐싱하면 메모리가 금방 부족해집니다.
💡 placeholder 대신 FadeInImage를 사용하면 이미지가 부드럽게 나타나는 효과를 줄 수 있습니다.
💡 개발 중에 캐시를 지우려면 await CachedNetworkImage.evictFromCache(url)을 호출하세요. 전체 캐시를 지우려면 await DefaultCacheManager().emptyCache()를 사용합니다.
💡 썸네일 이미지는 별도의 URL로 제공받아 사용하세요. 큰 이미지를 다운로드해서 작게 표시하는 것보다 훨씬 효율적입니다.
5. setState 범위 최소화
시작하며
여러분이 복잡한 폼 화면에서 하나의 TextField 값이 바뀔 때마다 전체 화면이 다시 빌드되면서 다른 TextField의 포커스가 깜빡이는 경험을 해본 적 있나요? 작은 상태 변경이 거대한 위젯 트리 전체를 rebuild하면서 성능 문제를 일으키는 상황 말이죠.
이런 문제는 setState()를 StatefulWidget의 최상위에서 호출하기 때문에 발생합니다. setState()는 호출된 위젯과 그 모든 자식 위젯을 다시 빌드하므로, 범위가 넓을수록 불필요한 재빌드가 많아집니다.
바로 이럴 때 필요한 것이 setState 범위 최소화 기법입니다. 상태가 실제로 사용되는 가장 작은 위젯만 StatefulWidget으로 분리하면 성능이 크게 향상됩니다.
개요
간단히 말해서, setState 범위 최소화는 상태를 관리하는 StatefulWidget을 가능한 한 작게 만들어 불필요한 rebuild를 방지하는 최적화 패턴입니다. Flutter에서 setState()는 해당 위젯의 build 메서드를 다시 호출합니다.
만약 페이지 전체가 하나의 StatefulWidget이라면 작은 카운터 하나를 증가시켜도 100개의 자식 위젯이 모두 rebuild됩니다. 예를 들어, 좋아요 버튼과 댓글 목록이 있는 화면에서 좋아요만 바뀌는데 댓글 목록까지 rebuild되면 엄청난 낭비죠.
기존에는 페이지 전체를 StatefulWidget으로 만들고 setState()를 호출했다면, 이제는 실제로 변경되는 부분만 별도의 StatefulWidget으로 분리할 수 있습니다. setState 범위 최소화의 핵심 특징은 정확한 rebuild 범위 제어, 불필요한 위젯 재생성 방지, 그리고 렌더링 성능 향상입니다.
이러한 특징들이 복잡한 UI에서 반응 속도를 크게 개선시킵니다.
코드 예제
// 나쁜 예: 전체 페이지가 StatefulWidget
class BadPage extends StatefulWidget {
@override
_BadPageState createState() => _BadPageState();
}
class _BadPageState extends State<BadPage> {
int likes = 0;
@override
Widget build(BuildContext context) {
return Column(
children: [
Header(), // likes와 무관한데 rebuild됨
Text('좋아요: $likes'),
ElevatedButton(
onPressed: () => setState(() => likes++),
child: Text('좋아요'),
),
CommentList(), // likes와 무관한데 rebuild됨
],
);
}
}
// 좋은 예: 좋아요 부분만 StatefulWidget으로 분리
class GoodPage extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Column(
children: [
const Header(), // 절대 rebuild 안됨
LikeButton(), // 여기만 StatefulWidget
const CommentList(), // 절대 rebuild 안됨
],
);
}
}
class LikeButton extends StatefulWidget {
@override
_LikeButtonState createState() => _LikeButtonState();
}
class _LikeButtonState extends State<LikeButton> {
int likes = 0;
@override
Widget build(BuildContext context) {
// setState 호출 시 여기만 rebuild됨
return Column(
children: [
Text('좋아요: $likes'),
ElevatedButton(
onPressed: () => setState(() => likes++),
child: Text('좋아요'),
),
],
);
}
}
설명
이것이 하는 일: setState 범위 최소화는 "변경되는 것"과 "변경되지 않는 것"을 명확히 구분하여, 변경되는 최소 단위만 StatefulWidget으로 캡슐화하는 패턴입니다. 첫 번째로, 나쁜 예에서는 likes 변수가 변경될 때 setState()가 _BadPageState의 build 메서드를 호출합니다.
이때 Column의 모든 children이 다시 빌드되죠. 왜 문제냐면, Header()와 CommentList()는 likes와 전혀 관계가 없는데도 매번 새로운 인스턴스가 생성되고 렌더링 엔진이 이전 위젯과 비교하는 작업을 반복하기 때문입니다.
복잡한 CommentList가 100개의 댓글을 가지고 있다면 좋아요 버튼 한 번 누를 때마다 100개가 모두 rebuild됩니다. 그 다음으로, 좋은 예에서는 GoodPage가 StatelessWidget이므로 rebuild가 발생하지 않습니다.
대신 LikeButton만 StatefulWidget으로 분리하여 likes 상태를 관리하죠. setState()가 호출되어도 LikeButton의 build 메서드만 다시 실행됩니다.
이것이 실행되면서 Header와 CommentList는 완전히 영향을 받지 않고, 오직 Text('좋아요: $likes')와 ElevatedButton만 rebuild됩니다. 마지막으로, 이렇게 상태를 필요한 최소 단위로 격리하면 build 메서드 실행 횟수가 90% 이상 감소합니다.
최종적으로 복잡한 화면에서도 setState() 호출이 즉각적으로 반응하고, 프레임 드롭 없이 부드러운 사용자 경험을 제공합니다. 여러분이 이 코드를 사용하면 특히 폼 입력, 실시간 검색, 카운터, 토글 버튼 같은 부분적인 상태 변경에서 극적인 성능 향상을 경험할 수 있습니다.
DevTools의 Performance 탭에서 rebuild되는 위젯 개수가 크게 줄어드는 것을 확인할 수 있죠.
실전 팁
💡 "이 상태가 정말 여기 있어야 하나?" 자문해보세요. 가능하면 상태를 위젯 트리의 아래쪽(leaf 노드)으로 내리는 것이 좋습니다.
💡 StatefulWidget을 분리할 때는 의미 있는 이름을 지어주세요. LikeButton, SearchField처럼 명확한 이름이 코드 가독성을 높입니다.
💡 여러 상태를 관리해야 한다면 Provider, Riverpod, Bloc 같은 상태 관리 라이브러리를 고려하세요. setState의 범위 문제를 근본적으로 해결합니다.
💡 const 생성자와 함께 사용하면 효과가 배가됩니다. 변경되지 않는 위젯에는 const를 붙여서 rebuild 자체를 건너뛰게 하세요.
6. ValueListenableBuilder 사용
시작하며
여러분이 간단한 카운터나 토글 상태를 관리하기 위해 StatefulWidget을 만들고 setState를 호출하는 게 번거롭다고 느낀 적 있나요? 단 하나의 값만 바뀌는데 전체 State 클래스를 작성하는 것이 과하다는 생각이 들 때가 있죠.
이런 문제는 간단한 상태 변경에도 StatefulWidget의 무거운 구조를 사용하기 때문에 발생합니다. 또한 setState는 여전히 위젯 트리의 일부를 rebuild하므로 완벽한 최적화는 아닙니다.
바로 이럴 때 필요한 것이 ValueListenableBuilder입니다. ValueNotifier와 함께 사용하면 StatefulWidget 없이도 정확히 필요한 위젯만 rebuild할 수 있죠.
개요
간단히 말해서, ValueListenableBuilder는 ValueNotifier의 값 변경을 감지하여 해당 위젯만 정확하게 rebuild하는 효율적인 위젯입니다. ValueNotifier는 하나의 값을 저장하고 그 값이 변경될 때 리스너들에게 알려주는 간단한 상태 관리 도구입니다.
ValueListenableBuilder는 이 알림을 받아서 builder 함수만 다시 실행하죠. 예를 들어, 슬라이더의 현재 값을 표시하는 Text 위젯만 업데이트하고 싶을 때, StatefulWidget 전체를 rebuild하는 대신 ValueListenableBuilder 안의 Text만 rebuild할 수 있습니다.
기존에는 StatefulWidget과 setState로 상태를 관리했다면, 이제는 ValueNotifier와 ValueListenableBuilder로 더 세밀하고 효율적으로 관리할 수 있습니다. ValueListenableBuilder의 핵심 특징은 정밀한 rebuild 제어, 간단한 API, 그리고 StatefulWidget보다 가벼운 구조입니다.
이러한 특징들이 단순한 상태 관리를 훨씬 효율적으로 만듭니다.
코드 예제
// ValueNotifier 생성 - StatefulWidget 없이도 상태 관리
class CounterPage extends StatelessWidget {
// final로 선언해도 값 변경 가능 (notifier의 value만 바뀜)
final ValueNotifier<int> counter = ValueNotifier<int>(0);
@override
Widget build(BuildContext context) {
return Column(
children: [
const Text('카운터 앱'), // 절대 rebuild 안됨
// counter 값이 바뀔 때만 builder가 호출됨
ValueListenableBuilder<int>(
valueListenable: counter,
builder: (context, value, child) {
// value는 counter의 현재 값
return Text('현재 카운트: $value');
},
),
ElevatedButton(
onPressed: () => counter.value++, // setState 불필요
child: const Text('증가'),
),
],
);
}
}
설명
이것이 하는 일: ValueListenableBuilder는 ValueNotifier를 계속 관찰하다가 값이 변경되는 순간 builder 함수만 호출하여 최소한의 위젯만 업데이트합니다. 첫 번째로, ValueNotifier<int>(0)로 초기값 0을 가진 알림 객체를 생성합니다.
이 객체는 내부적으로 값을 저장하고 리스너 목록을 관리하죠. 왜 StatelessWidget에서 사용할 수 있냐면, ValueNotifier 자체가 상태를 가지고 있어서 위젯이 rebuild되어도 같은 인스턴스를 유지하기 때문입니다.
다만 실제 프로젝트에서는 메모리 누수를 방지하기 위해 StatefulWidget의 dispose에서 counter.dispose()를 호출해야 합니다. 그 다음으로, ValueListenableBuilder가 valueListenable: counter로 알림 객체를 구독합니다.
counter.value++가 실행되면 ValueNotifier는 내부적으로 notifyListeners()를 자동 호출하고, ValueListenableBuilder는 이 알림을 받아서 builder 함수를 다시 실행하죠. 이것이 실행되면서 return Text('현재 카운트: $value') 부분만 rebuild됩니다.
Column도, const Text('카운터 앱')도, ElevatedButton도 전혀 영향을 받지 않습니다. 마지막으로, builder의 child 매개변수를 활용하면 더욱 최적화할 수 있습니다.
builder 안에 변경되지 않는 복잡한 위젯이 있다면 child로 전달하여 rebuild를 완전히 방지할 수 있죠. 최종적으로 정말 필요한 Text 위젯 하나만 rebuild되므로 성능이 극대화됩니다.
여러분이 이 코드를 사용하면 간단한 상태 관리에서 StatefulWidget의 보일러플레이트 코드를 제거하고, setState보다 훨씬 정확한 rebuild 제어를 할 수 있습니다. 카운터, 슬라이더, 토글, 검색어 입력 같은 단순 상태에 완벽합니다.
실전 팁
💡 여러 값을 동시에 관리하려면 ValueNotifier<Map<String, dynamic>>이나 커스텀 클래스를 사용하세요. 하지만 복잡해지면 Provider나 Riverpod을 고려하는 것이 좋습니다.
💡 builder의 child 매개변수를 적극 활용하세요. ValueListenableBuilder(child: ExpensiveWidget(), builder: (context, value, child) => Column(children: [Text(value), child!]))처럼 사용하면 ExpensiveWidget은 절대 rebuild되지 않습니다.
💡 ValueNotifier는 반드시 dispose해야 합니다. StatefulWidget의 dispose 메서드에서 counter.dispose()를 호출하지 않으면 메모리 누수가 발생합니다.
💡 두 개 이상의 ValueNotifier를 조합하려면 AnimatedBuilder나 Listenable.merge()를 사용하세요.
💡 디버깅할 때는 ValueNotifier.addListener(() => print(counter.value))로 값 변경을 추적할 수 있습니다.
7. Opacity 대신 AnimatedOpacity
시작하며
여러분이 위젯을 서서히 나타나거나 사라지게 만들기 위해 Opacity 위젯을 사용했는데 애니메이션이 버벅거리는 경험을 해본 적 있나요? 특히 복잡한 위젯을 투명하게 만들 때 프레임 드롭이 심하게 발생하는 상황 말이죠.
이런 문제는 Opacity 위젯이 매번 offscreen buffer를 생성하여 렌더링하기 때문에 발생합니다. 투명도를 조절할 때마다 자식 위젯을 별도 레이어에 그리고 알파 블렌딩을 수행하므로 엄청난 성능 비용이 듭니다.
바로 이럴 때 필요한 것이 AnimatedOpacity입니다. 같은 효과를 내면서도 Flutter의 애니메이션 최적화를 활용하여 훨씬 효율적으로 작동하죠.
개요
간단히 말해서, AnimatedOpacity는 투명도 변경을 자동으로 애니메이션하면서 렌더링 최적화를 적용하여 Opacity보다 훨씬 나은 성능을 제공하는 위젯입니다. Opacity 위젯은 매 프레임마다 자식 위젯을 offscreen buffer에 렌더링한 후 알파값을 적용합니다.
하지만 AnimatedOpacity는 암묵적 애니메이션(Implicit Animation)을 사용하여 Flutter 엔진이 최적화할 수 있는 여지를 제공하죠. 예를 들어, 복잡한 차트 위젯을 페이드 인/아웃할 때 Opacity는 프레임 드롭이 발생하지만 AnimatedOpacity는 부드럽게 작동합니다.
기존에는 Opacity(opacity: _opacity)로 수동으로 투명도를 조절했다면, 이제는 AnimatedOpacity(opacity: _opacity, duration: ...)로 자동 애니메이션과 최적화를 얻을 수 있습니다. AnimatedOpacity의 핵심 특징은 자동 애니메이션, 렌더링 최적화, 그리고 간단한 API입니다.
이러한 특징들이 복잡한 위젯의 투명도 변경을 부드럽게 만듭니다.
코드 예제
// 나쁜 예: Opacity 위젯 사용
class BadFadeWidget extends StatefulWidget {
@override
_BadFadeWidgetState createState() => _BadFadeWidgetState();
}
class _BadFadeWidgetState extends State<BadFadeWidget> {
double _opacity = 0.0;
@override
Widget build(BuildContext context) {
return Column(
children: [
// 매 프레임마다 offscreen buffer 생성 - 성능 저하
Opacity(
opacity: _opacity,
child: ComplexWidget(),
),
ElevatedButton(
onPressed: () => setState(() => _opacity = 1.0),
child: Text('표시'),
),
],
);
}
}
// 좋은 예: AnimatedOpacity 사용
class GoodFadeWidget extends StatefulWidget {
@override
_GoodFadeWidgetState createState() => _GoodFadeWidgetState();
}
class _GoodFadeWidgetState extends State<GoodFadeWidget> {
bool _visible = false;
@override
Widget build(BuildContext context) {
return Column(
children: [
// Flutter 엔진이 최적화 - 부드러운 애니메이션
AnimatedOpacity(
opacity: _visible ? 1.0 : 0.0,
duration: Duration(milliseconds: 300),
// 애니메이션 곡선 설정
curve: Curves.easeInOut,
child: ComplexWidget(),
),
ElevatedButton(
onPressed: () => setState(() => _visible = !_visible),
child: Text('토글'),
),
],
);
}
}
설명
이것이 하는 일: AnimatedOpacity는 투명도 값이 변경되면 자동으로 부드러운 애니메이션을 실행하면서, 내부적으로 Flutter의 렌더링 최적화를 활용합니다. 첫 번째로, opacity 값이 변경되면 AnimatedOpacity는 현재값에서 목표값까지 duration 동안 보간(interpolation)합니다.
예를 들어 0.0에서 1.0으로 바뀌면 300ms 동안 0.0, 0.1, 0.2, ... 0.9, 1.0으로 점진적으로 변경하죠.
왜 이게 성능에 좋냐면, Flutter 엔진이 이 애니메이션을 미리 계획하고 최적화할 수 있기 때문입니다. Opacity를 수동으로 setState로 변경하면 매번 예측 불가능한 rebuild가 발생하지만, AnimatedOpacity는 예측 가능한 애니메이션으로 처리됩니다.
그 다음으로, curve 매개변수로 애니메이션 곡선을 지정할 수 있습니다. Curves.easeInOut은 시작과 끝이 느리고 중간이 빠른 자연스러운 움직임을 만들어내죠.
이것이 실행되면서 단순한 선형 변화보다 훨씬 부드럽고 전문적인 느낌의 페이드 효과를 얻을 수 있습니다. 렌더링 엔진도 이런 곡선 애니메이션을 효율적으로 처리하도록 최적화되어 있습니다.
마지막으로, opacity가 0.0일 때 AnimatedOpacity는 child를 완전히 제거하지는 않지만, 터치 이벤트는 무시합니다. 최종적으로 복잡한 위젯도 60fps로 부드럽게 페이드 인/아웃되고, Opacity처럼 프레임 드롭이 발생하지 않습니다.
여러분이 이 코드를 사용하면 위젯의 표시/숨김 전환이 훨씬 부드러워지고, 복잡한 위젯에서도 성능 저하 없이 페이드 효과를 사용할 수 있습니다. 스플래시 화면, 툴팁, 알림 배너 같은 곳에 완벽합니다.
실전 팁
💡 opacity가 0.0일 때 위젯을 완전히 제거하고 싶다면 AnimatedOpacity 대신 AnimatedSwitcher나 조건부 렌더링을 고려하세요.
💡 여러 속성을 동시에 애니메이션하려면 AnimatedOpacity 대신 AnimatedContainer를 사용하세요. opacity뿐만 아니라 width, height, color 등도 함께 애니메이션할 수 있습니다.
💡 onEnd 콜백을 사용하면 애니메이션이 완료된 시점을 감지할 수 있습니다. 페이드 아웃 후 위젯을 제거하는 등의 작업에 유용합니다.
💡 투명도가 0.0이나 1.0이 아닌 중간값(예: 0.5)으로 유지되는 경우는 여전히 성능 비용이 있습니다. 가능하면 완전히 투명하거나 불투명한 상태를 사용하세요.
💡 Opacity를 사용해야 한다면 alwaysIncludeSemantics: true를 설정하여 접근성을 유지하세요.
8. 무거운 연산 isolate 분리
시작하며
여러분이 큰 JSON 데이터를 파싱하거나 이미지 처리를 하는 동안 UI가 완전히 멈춰버린 경험이 있나요? 로딩 스피너조차 돌아가지 않고 앱이 죽은 것처럼 보이는 상황 말이죠.
이런 문제는 Dart가 싱글 스레드로 동작하기 때문에 발생합니다. 무거운 연산이 메인 스레드를 점유하면 UI 렌더링이 완전히 멈춰버립니다.
1초 이상 걸리는 작업은 사용자에게 앱이 크래시한 것처럼 보이게 만들죠. 바로 이럴 때 필요한 것이 isolate를 사용한 병렬 처리입니다.
무거운 연산을 별도 스레드로 분리하면 UI는 계속 부드럽게 동작합니다.
개요
간단히 말해서, isolate는 Dart의 병렬 처리 메커니즘으로, 별도의 메모리 공간에서 독립적으로 코드를 실행하여 메인 스레드를 차단하지 않는 기능입니다. Flutter의 메인 스레드(UI 스레드)는 60fps를 유지하기 위해 16ms마다 화면을 다시 그려야 합니다.
하지만 큰 JSON 파싱이 100ms 걸리면 6프레임이 건너뛰어지고 UI가 버벅입니다. 예를 들어, 1000개의 복잡한 계산을 해야 한다면 메인 스레드에서 하면 UI가 멈추지만, isolate에서 하면 UI는 계속 부드럽게 동작합니다.
기존에는 모든 코드가 메인 스레드에서 실행되었다면, 이제는 compute 함수나 Isolate.spawn으로 무거운 작업을 분리할 수 있습니다. isolate의 핵심 특징은 진정한 병렬 처리, 메인 스레드 보호, 그리고 독립적인 메모리 공간입니다.
이러한 특징들이 무거운 연산에서도 반응성 있는 UI를 유지하게 합니다.
코드 예제
import 'dart:isolate';
import 'package:flutter/foundation.dart';
// 무거운 연산을 수행하는 함수 (top-level 또는 static이어야 함)
// isolate에서 실행될 함수는 static이거나 전역 함수여야 함
List<int> _heavyComputation(List<int> data) {
// 예: 복잡한 정렬, 이미지 처리, JSON 파싱 등
return data.map((n) => n * n).toList()..sort();
}
// Flutter의 compute 함수로 간단하게 isolate 사용
Future<void> processData() async {
List<int> largeData = List.generate(1000000, (i) => i);
// compute(함수, 매개변수)로 별도 isolate에서 실행
// UI는 계속 부드럽게 동작
List<int> result = await compute(_heavyComputation, largeData);
print('결과: ${result.length}개 항목 처리 완료');
}
설명
이것이 하는 일: compute 함수는 무거운 연산을 새로운 isolate(별도 스레드)에서 실행하고 결과를 메인 스레드로 안전하게 반환합니다. 첫 번째로, compute(_heavyComputation, largeData)가 호출되면 Flutter는 새로운 isolate를 생성합니다.
이 isolate는 완전히 독립적인 메모리 공간을 가지고 있어서 메인 스레드와 동시에 실행될 수 있죠. 왜 이렇게 하냐면, 멀티코어 CPU의 다른 코어를 활용하여 진정한 병렬 처리를 하기 위해서입니다.
예를 들어 듀얼코어 폰에서는 한 코어가 UI를 그리는 동안 다른 코어가 데이터를 처리합니다. 그 다음으로, largeData가 새로운 isolate로 복사됩니다.
isolate는 메모리를 공유하지 않으므로 데이터를 전달할 때 복사가 발생하죠. 이것이 실행되면서 _heavyComputation 함수가 별도 스레드에서 실행되고, 메인 스레드는 다음 코드를 계속 실행합니다.
await 키워드 때문에 processData 함수는 일시정지되지만, Flutter의 이벤트 루프는 계속 돌아가면서 UI를 렌더링합니다. 마지막으로, _heavyComputation이 완료되면 결과가 다시 메인 isolate로 복사되어 반환됩니다.
await가 완료되고 result 변수에 값이 할당되며 나머지 코드가 실행되죠. 최종적으로 사용자는 무거운 연산이 진행되는 동안에도 스크롤하고 버튼을 누르는 등 UI를 자유롭게 사용할 수 있습니다.
여러분이 이 코드를 사용하면 JSON 파싱, 이미지 압축, 암호화, 복잡한 계산 등 어떤 무거운 작업도 UI를 차단하지 않고 수행할 수 있습니다. 특히 10ms 이상 걸리는 작업은 무조건 isolate로 분리하는 것이 좋습니다.
실전 팁
💡 compute에 전달하는 함수는 반드시 top-level 함수나 static 메서드여야 합니다. 클로저나 인스턴스 메서드는 사용할 수 없습니다.
💡 데이터 복사 비용을 고려하세요. 매우 큰 데이터를 전달하면 복사하는 데만 시간이 오래 걸릴 수 있습니다. 이런 경우 TransferableTypedData를 사용하면 복사 없이 전달할 수 있습니다.
💡 여러 매개변수를 전달하려면 Map이나 커스텀 클래스를 사용하세요. compute(_function, {'data': data, 'config': config}) 형태로 전달할 수 있습니다.
💡 isolate는 생성 비용이 있으므로 짧은 작업(1-2ms)에는 사용하지 마세요. 오히려 성능이 떨어질 수 있습니다.
💡 더 복잡한 양방향 통신이 필요하면 Isolate.spawn과 SendPort/ReceivePort를 직접 사용하세요. compute는 단순한 "입력 → 처리 → 출력" 패턴에만 적합합니다.
9. DevTools 프로파일링 활용
시작하며
여러분이 앱이 느리다는 것은 알겠는데 정확히 어디가 문제인지 찾지 못한 경험이 있나요? 추측만 하면서 이것저것 최적화했지만 별로 나아지지 않는 상황 말이죠.
이런 문제는 성능 병목 지점을 객관적으로 측정하지 않고 감으로만 최적화하기 때문에 발생합니다. 실제로는 문제가 아닌 부분을 최적화하면서 시간을 낭비하는 경우가 많죠.
바로 이럴 때 필요한 것이 Flutter DevTools의 프로파일링 기능입니다. 어떤 위젯이 자주 rebuild되는지, 어떤 함수가 CPU를 많이 사용하는지 정확히 보여주죠.
개요
간단히 말해서, Flutter DevTools는 앱의 성능을 실시간으로 분석하고 시각화하여 병목 지점을 정확히 찾아주는 공식 개발자 도구입니다. DevTools의 Performance 탭은 프레임 렌더링 시간, rebuild 횟수, CPU 사용량 등을 그래프로 보여줍니다.
Timeline 뷰에서는 각 프레임이 16ms 안에 완성되는지 확인할 수 있죠. 예를 들어, 특정 화면이 느리다면 Timeline에서 어떤 build 메서드가 오래 걸리는지 정확히 볼 수 있습니다.
기존에는 print 문으로 시간을 측정하거나 추측으로 최적화했다면, 이제는 DevTools로 과학적으로 성능을 분석할 수 있습니다. DevTools 프로파일링의 핵심 특징은 실시간 성능 모니터링, 시각적 데이터 표현, 그리고 정확한 병목 지점 파악입니다.
이러한 특징들이 효율적인 성능 최적화를 가능하게 합니다.
코드 예제
// 1. DevTools 실행 방법
// 터미널에서: flutter pub global activate devtools
// 그 다음: flutter pub global run devtools
// 2. 앱을 프로파일 모드로 실행
// flutter run --profile
// (디버그 모드는 성능이 느리므로 프로파일 모드 필수!)
// 3. 코드에서 특정 부분 프로파일링
import 'dart:developer' as developer;
void heavyFunction() {
// Timeline에 표시될 이름으로 마킹
developer.Timeline.startSync('Heavy Computation');
// 무거운 작업
for (int i = 0; i < 1000000; i++) {
// 복잡한 계산
}
developer.Timeline.finishSync();
}
// 4. 특정 위젯의 rebuild 추적
class ProfiledWidget extends StatelessWidget {
@override
Widget build(BuildContext context) {
developer.Timeline.startSync('ProfiledWidget.build');
final result = Column(
children: [
// 복잡한 위젯 트리
],
);
developer.Timeline.finishSync();
return result;
}
}
설명
이것이 하는 일: DevTools는 앱의 모든 프레임을 기록하고 각 프레임이 어떻게 생성되었는지 세부 정보를 제공하여, 어디를 최적화해야 하는지 명확히 알려줍니다. 첫 번째로, 앱을 --profile 모드로 실행하면 DevTools가 프레임 타이밍 데이터를 수집하기 시작합니다.
각 프레임이 16ms(60fps) 안에 완성되는지 추적하죠. 왜 프로파일 모드를 사용하냐면, 디버그 모드는 개발 편의를 위한 추가 검사가 많아서 실제보다 10배 이상 느리기 때문입니다.
프로파일 모드는 릴리즈 모드와 비슷한 성능을 내면서도 프로파일링 정보를 제공합니다. 그 다음으로, Performance 탭의 Timeline 뷰에서 빨간색으로 표시되는 프레임을 찾습니다.
이것은 16ms를 초과한 프레임으로 프레임 드롭이 발생한 시점이죠. 이 프레임을 클릭하면 어떤 위젯의 build가 오래 걸렸는지, 어떤 함수가 CPU를 많이 썼는지 상세히 보여줍니다.
이것이 실행되면서 "아, HomeScreen의 build가 45ms나 걸리네. 이 안의 ListView가 문제구나"처럼 정확한 원인을 파악할 수 있습니다.
마지막으로, developer.Timeline.startSync와 finishSync로 커스텀 이벤트를 마킹하면 Timeline에서 여러분의 코드가 얼마나 걸리는지 정확히 측정할 수 있습니다. 최종적으로 추측이 아닌 데이터 기반으로 최적화 우선순위를 정하고, 최적화 후 실제로 개선되었는지 확인할 수 있습니다.
여러분이 이 도구를 사용하면 불필요한 최적화를 하지 않고 정말 중요한 부분에만 집중할 수 있습니다. 프레임 렌더링, 메모리 사용량, 네트워크 요청까지 모든 성능 지표를 한눈에 파악할 수 있죠.
실전 팁
💡 "Show Repaint Rainbow"를 활성화하면 repaint되는 영역이 무지개색으로 표시됩니다. 색이 계속 바뀌는 곳이 불필요하게 repaint되는 부분입니다.
💡 Memory 탭에서 메모리 누수를 찾을 수 있습니다. Snapshot을 찍고 비교하면 어떤 객체가 계속 증가하는지 확인할 수 있습니다.
💡 CPU Profiler를 사용하면 어떤 함수가 CPU 시간을 가장 많이 소비하는지 플레임 그래프로 볼 수 있습니다.
💡 실제 기기에서 테스트하세요. 에뮬레이터는 실제 기기보다 성능이 훨씬 좋거나 나쁠 수 있어서 정확한 프로파일링이 어렵습니다.
💡 프로파일링은 특정 사용자 시나리오(예: 100개 아이템 스크롤, 복잡한 화면 전환 등)를 재현하면서 해야 의미 있는 데이터를 얻을 수 있습니다.
10. 불필요한 애니메이션 제거
시작하며
여러분이 앱을 멋있게 보이려고 모든 곳에 애니메이션을 넣었더니 배터리가 빨리 닳고 앱이 뜨거워지는 경험을 해본 적 있나요? 특히 리스트의 모든 아이템에 애니메이션을 넣었더니 스크롤이 버벅거리는 상황 말이죠.
이런 문제는 지나친 애니메이션이 GPU와 CPU를 계속 작동시키기 때문에 발생합니다. 애니메이션은 매 프레임마다 화면을 다시 그려야 하므로 많을수록 리소스를 많이 소비합니다.
바로 이럴 때 필요한 것이 전략적인 애니메이션 사용입니다. 정말 필요한 곳에만 적절한 애니메이션을 넣고, 불필요한 애니메이션은 과감히 제거해야 하죠.
개요
간단히 말해서, 불필요한 애니메이션 제거는 사용자 경험을 해치지 않는 선에서 과도한 애니메이션을 줄여 성능과 배터리 수명을 최적화하는 접근법입니다. 애니메이션은 사용자의 주의를 끌고 앱을 생동감 있게 만들지만, 과하면 오히려 산만하고 느려집니다.
모든 화면 전환, 모든 버튼 클릭, 모든 리스트 아이템에 애니메이션을 넣으면 GPU가 쉴 틈이 없죠. 예를 들어, ListView의 100개 아이템이 각각 페이드인 애니메이션을 가지고 있다면 스크롤할 때마다 수백 개의 애니메이션이 동시에 실행됩니다.
기존에는 "멋있어 보이니까" 모든 곳에 애니메이션을 넣었다면, 이제는 "사용자에게 가치를 주는가?"를 기준으로 선택적으로 사용할 수 있습니다. 전략적 애니메이션 사용의 핵심 특징은 성능과 디자인의 균형, 리소스 절약, 그리고 접근성 고려입니다.
이러한 특징들이 쾌적한 사용자 경험을 만듭니다.
코드 예제
// 나쁜 예: 모든 리스트 아이템에 애니메이션
ListView.builder(
itemCount: 100,
itemBuilder: (context, index) {
return AnimatedContainer(
duration: Duration(milliseconds: 300),
// 스크롤할 때마다 100개의 애니메이션 실행
curve: Curves.easeInOut,
child: ListTile(title: Text('Item $index')),
);
},
)
// 좋은 예 1: 중요한 상호작용에만 애니메이션 사용
// 버튼 클릭 같은 사용자 액션에 대한 피드백
InkWell(
onTap: () {
// 간단한 스케일 애니메이션으로 피드백
// 리소스 소비 최소화
},
child: Container(child: Text('클릭')),
)
// 좋은 예 2: 애니메이션 duration 줄이기
// 300ms 대신 150ms로 충분한 경우가 많음
AnimatedOpacity(
opacity: _visible ? 1.0 : 0.0,
duration: Duration(milliseconds: 150), // 짧고 빠르게
child: Content(),
)
// 좋은 예 3: 사용자 설정 존중
MediaQuery.of(context).disableAnimations
? Container(child: Content()) // 애니메이션 비활성화
: AnimatedContainer(child: Content()) // 애니메이션 사용
설명
이것이 하는 일: 전략적 애니메이션 사용은 사용자에게 실질적인 가치를 주는 애니메이션만 남기고 나머지는 제거하여 리소스를 절약합니다. 첫 번째로, 애니메이션의 목적을 평가합니다.
"이 애니메이션이 사용자에게 어떤 정보를 전달하는가?" 질문하세요. 버튼 클릭 피드백, 화면 전환 방향 표시, 로딩 상태 알림 같은 것은 가치가 있습니다.
왜냐면 사용자의 행동에 대한 응답을 시각적으로 보여주기 때문이죠. 하지만 단순히 "멋있어 보이려고" 넣은 애니메이션은 오히려 주의를 분산시키고 성능만 떨어뜨립니다.
그 다음으로, 리스트나 반복되는 위젯에서는 특히 조심해야 합니다. 10개 이하의 아이템이라면 각각 애니메이션을 가져도 괜찮지만, 수백 개가 되면 문제가 됩니다.
이것이 실행되면서 스크롤할 때마다 수십 개의 AnimatedWidget이 동시에 작동하고, GPU는 계속 새로운 프레임을 생성해야 합니다. 이런 경우는 애니메이션을 완전히 제거하거나, 첫 화면 로딩 시에만 적용하는 것이 좋습니다.
마지막으로, MediaQuery.of(context).disableAnimations를 확인하여 사용자가 시스템 설정에서 "애니메이션 줄이기"를 활성화했는지 체크합니다. 최종적으로 접근성이 필요한 사용자(시각 장애, 전정 장애 등)는 애니메이션 없이도 완벽하게 앱을 사용할 수 있어야 합니다.
여러분이 이 접근법을 사용하면 앱의 배터리 소비가 20-30% 줄어들고, 저사양 기기에서도 부드럽게 작동하며, 접근성이 향상됩니다. 성능과 디자인의 완벽한 균형을 이룰 수 있죠.
실전 팁
💡 애니메이션 duration은 가능한 한 짧게 유지하세요. 대부분의 경우 150-200ms면 충분하고, 300ms 이상은 느리게 느껴집니다.
💡 Hero 애니메이션은 강력하지만 무겁습니다. 이미지가 크거나 복잡한 위젯에는 사용을 자제하세요.
💡 Lottie 애니메이션(JSON 기반)은 매우 무겁습니다. 꼭 필요한 곳에만 사용하고, 가능하면 간단한 Flutter 애니메이션으로 대체하세요.
💡 애니메이션 컨트롤러를 사용할 때는 반드시 dispose()하세요. 메모리 누수와 불필요한 애니메이션 실행을 방지합니다.
💡 개발 단계에서 "Performance Overlay"를 켜고 애니메이션이 60fps를 유지하는지 확인하세요. 빨간색 바가 나타나면 애니메이션이 너무 무겁다는 신호입니다.
이상으로 Flutter 성능 최적화 10가지 팁을 모두 소개해드렸습니다. 각 기법을 실무에 적용하여 부드럽고 빠른 Flutter 앱을 만들어보세요!
댓글 (0)
함께 보면 좋은 카드 뉴스
Zipkin으로 추적 시각화 완벽 가이드
마이크로서비스 환경에서 분산 추적을 시각화하는 Zipkin의 핵심 개념과 활용 방법을 초급자도 쉽게 이해할 수 있도록 실무 스토리로 풀어낸 가이드입니다. Docker 실행부터 UI 분석까지 단계별로 배웁니다.
Spring AOT와 네이티브 이미지 완벽 가이드
Spring Boot 3.0부터 지원되는 AOT 컴파일과 GraalVM 네이티브 이미지를 통해 애플리케이션 시작 시간을 극적으로 단축하는 방법을 알아봅니다. 초급 개발자도 쉽게 이해할 수 있도록 실무 상황과 비유로 풀어냅니다.
Z-Order 클러스터링과 데이터 스킵핑 완벽 가이드
Delta Lake의 Z-Order 클러스터링과 데이터 스킵핑 메커니즘을 실무 중심으로 배웁니다. 빅데이터 쿼리 성능을 획기적으로 개선하는 최적화 기법을 초급 개발자도 쉽게 이해할 수 있도록 설명합니다.
Delta Lake CRUD 작업 완벽 마스터 가이드
Delta Lake에서 테이블 생성부터 INSERT, SELECT, UPDATE, DELETE, MERGE까지 모든 CRUD 작업을 실무 상황과 함께 쉽고 명확하게 배웁니다. 초급 개발자도 바로 따라할 수 있는 실전 예제와 최적화 팁을 제공합니다.
Riverpod 3.0 쇼핑 앱 종합 프로젝트 완벽 가이드
Flutter와 Riverpod 3.0을 활용한 실무 수준의 쇼핑 앱 개발 과정을 단계별로 학습합니다. 상품 목록, 장바구니, 주문, 인증, 검색 기능까지 모든 핵심 기능을 구현하며 상태 관리의 실전 노하우를 익힙니다.