Flutter DevTools 프로파일링 실전: 메모리 누수(Memory Leak) 추적 및 CPU 프레임 드랍 잡기

Flutter DevTools 프로파일링 실전: 메모리 누수 추적 및 CPU 프레임 드랍 잡기
Flutter 애플리케이션의 복잡도가 높아지면 리스트 스크롤 시 프레임 드랍(Jank)이 발생하거나, 화면을 전환할 때 메모리 사용량이 계속 증가하여 앱이 강제 종료(OOM)되는 문제를 겪게 됩니다.
이러한 성능 병목을 직관에 의존해 해결하려 하면 막대한 시간이 소요됩니다. Flutter DevTools는 메모리 힙 덤프 분석, CPU 프로파일러, Timeline 측정 등을 통해 버벅임과 메모리 누수의 원인을 정확히 짚어낼 수 있는 강력한 무기입니다.
이번 글에서는 Flutter DevTools를 활용하여 CPU 프레임 드랍 원인 분석 및 메모리 누수(Memory Leak) 추적 기법을 실전 코드와 함께 다룹니다.
1. CPU 프레임 드랍(Jank) 추적과 Performance View
Flutter는 1초에 60회(또는 120회) 화면을 다시 그립니다. 16.6ms(또는 8.3ms) 이내에 프레임 조립 및 렌더링이 완료되지 않으면 Jank(프레임 드랍)가 발생합니다.
DevTools Timeline 분석 단계
- 앱을
profile모드로 실행합니다:flutter run --profile - DevTools를 열고 Performance 탭으로 이동합니다.
- 빨간색으로 표시된 타임라인 바(Budget 초과 프레임)를 클릭합니다.
- UI Thread와 Raster Thread 중 어느 곳에서 지연이 발생했는지 확인합니다.
| 스레드 구분 | 지연 주요 원인 | 해결 방안 |
|---|---|---|
| UI Thread | build() 메서드 내 과도한 객체 생성을 비롯한 무거운 CPU 연산 |
Isolate.run() 분리, const 생성자 활용, Widget 재렌더링 범위 축소 |
| Raster Thread | 과도한 SaveLayer, 불필요한 Clip, 쉐이더(Shader) 복잡도 | RepaintBoundary 추가, Opacity 대신 Color.withValues() 사용 |
실전 코드: UI 스레드 병목 해결
다음은 스크롤 시 UI 스레드를 점유하여 프레임 드랍을 유발하는 나쁜 예시와 개선 방식입니다.
// ❌ BAD: build() 및 스크롤 시 무거운 연산 실행 (UI Thread Blocking)
Widget build(BuildContext context) {
return ListView.builder(
itemCount: items.length,
itemBuilder: (context, index) {
// 빌드 중에 복잡한 데이터 파싱 또는 정렬 수행
final processedData = heavyComputation(items[index]);
return Text(processedData);
},
);
}
// ✅ GOOD: 연산을 Isolate로 이전 및 Compute 캐싱
Widget build(BuildContext context) {
return ListView.builder(
itemCount: items.length,
itemBuilder: (context, index) {
return FutureBuilder<String>(
future: Isolate.run(() => heavyComputation(items[index])),
builder: (context, snapshot) {
if (!snapshot.hasData) return const CircularProgressIndicator();
return Text(snapshot.data!);
},
);
},
);
}
2. Memory Leak(메모리 누수) 진단과 DevTools Memory View
메모리 누수는 더 이상 필요하지 않은 객체가 Garbage Collector(GC)에 의해 해제되지 않고 힙(Heap) 메모리에 남아있는 현상입니다.
Memory Leak 발생 패턴 3가지
- Unsubscribed Stream/AnimationController: State가
dispose되어도 구독이 살아있는 경우 - Global/Static reference: 싱글톤이나 전역 변수가 BuildContext나 State 객체를 계속 참조하는 경우
- Closure capturing: 비동기 콜백에서 해제되어야 할 State 객체를 캡처하는 경우
DevTools Memory Snapshot으로 추적하는 법
DevTools의 Memory 탭에서 Snapshot 기능을 활용하면 누수 객체를 직관적으로 찾아낼 수 있습니다.
- Snapshot 1 촬영: 특정 화면에 진입하기 전 메모리 상태 기록
- 화면 진입 및 이탈 반복: 해당 화면을 5~10회 열었다 닫음
- GC 실행: DevTools의 ‘GC’ 버튼을 클릭하여 정리되지 않는 객체 수집
- Snapshot 2 촬영: Snapshot 1과 Diff 비교 수행
- 해당 화면의
State객체가 힙에 남아있는지 Retention Path 확인
3. 실전 메모리 누수 수정 패턴
패턴 1: StreamSubscription & AnimationController 누수
// ❌ BAD: dispose에서 스티림과 애니메이션 해제 누락
class _MyWidgetState extends State<MyWidget> with SingleTickerProviderStateMixin {
late AnimationController _controller;
late StreamSubscription _subscription;
@override
void initState() {
super.initState();
_controller = AnimationController(vsync: this, duration: const Duration(seconds: 1));
_subscription = eventBus.stream.listen((event) {
setState(() {});
});
}
// dispose()가 구현되지 않아 메모리 누수 발생!
}
// ✅ GOOD: dispose() 구현으로 메모리 해제
class _MyWidgetState extends State<MyWidget> with SingleTickerProviderStateMixin {
late AnimationController _controller;
late StreamSubscription _subscription;
@override
void initState() {
super.initState();
_controller = AnimationController(vsync: this, duration: const Duration(seconds: 1));
_subscription = eventBus.stream.listen((event) {
setState(() {});
});
}
@override
void dispose() {
_controller.dispose();
_subscription.cancel();
super.dispose();
}
}
요약 및 결론
Flutter 앱의 최적화는 추측이 아닌 데이터와 측정을 바탕으로 이루어져야 합니다.
[!NOTE]
- 프레임 드랍 추적 시 반드시
flutter run --profile모드로 진단하세요.- UI 스레드 과부하는
Isolate.run()으로 분리하고, Raster 스레드는RepaintBoundary와 그래픽 연산 단순화로 해결하세요.- DevTools Memory Snapshot의 Diff 기능을 통해 화면 이탈 후에도 남아있는
State객체의 Retention Path를 추적하여 해결하세요.
Flutter DevTools를 주기적 CI 렌더링 검사 및 개발 프로세스에 통합하면 60fps의 매끄러운 사용자 경험을 보장할 수 있습니다.