이 글은 누구를 위한 것인가
- 앱이 느려지거나 배터리를 많이 소모한다는 사용자 피드백을 받는 팀
- 모바일 성능 문제를 어디서부터 측정하고 개선해야 할지 모르는 개발자
- 스크롤이 끊기거나 화면 전환이 버벅이는 앱을 개선하려는 엔지니어
성능 최적화의 순서
"추측하지 말고 측정하라." 성능 최적화는 반드시 프로파일링으로 병목을 찾은 뒤 개선하는 순서를 따른다. 직관적으로 느린 것 같은 곳이 실제 병목이 아닌 경우가 많다.
측정 → 병목 식별 → 가설 수립 → 수정 → 재측정 → 반복
1. 렌더링 성능: 60fps 유지
Flutter 프로파일링
Flutter DevTools의 Performance 탭에서 프레임별 빌드 시간을 확인한다.
빨간 프레임 (16ms 초과): Jank(끊김) 발생
흔한 원인과 해결:
1. build() 메서드 과다 실행
// 문제: 상태 변경 시 전체 위젯 트리 재빌드
class BadWidget extends StatefulWidget {
@override
Widget build(BuildContext context) {
return Column(
children: [
ExpensiveWidget(), // 매번 재빌드
Text(counter.toString()), // 이것만 바뀌는데
],
);
}
}
// 해결: 변경되는 부분만 분리
class GoodWidget extends StatefulWidget {
@override
Widget build(BuildContext context) {
return Column(
children: [
const ExpensiveWidget(), // const로 재빌드 방지
CounterText(counter: counter), // 별도 위젯으로 분리
],
);
}
}
2. 리스트 성능: ListView.builder 필수
// 문제: 모든 항목을 한 번에 렌더링
ListView(
children: items.map((item) => ItemWidget(item)).toList(),
)
// 해결: 뷰포트에 보이는 것만 렌더링
ListView.builder(
itemCount: items.length,
itemBuilder: (context, index) => ItemWidget(items[index]),
)
3. 이미지 캐싱
// cached_network_image 패키지 사용
CachedNetworkImage(
imageUrl: url,
memCacheWidth: 300, // 메모리 캐시 크기 제한
memCacheHeight: 300,
placeholder: (_, __) => const SkeletonWidget(),
errorWidget: (_, __, ___) => const Icon(Icons.error),
)
React Native 성능
FlatList 최적화:
<FlatList
data={items}
renderItem={renderItem}
keyExtractor={(item) => item.id}
getItemLayout={(_, index) => ({ // 고정 높이 항목은 필수
length: ITEM_HEIGHT,
offset: ITEM_HEIGHT * index,
index,
})}
maxToRenderPerBatch={10} // 배치당 렌더링 수
windowSize={5} // 뷰포트 기준 렌더링 범위
removeClippedSubviews={true} // 화면 밖 항목 제거
initialNumToRender={8} // 초기 렌더링 수
/>
불필요한 리렌더 방지:
// React.memo로 Props 변경 시에만 리렌더
const ItemComponent = React.memo(({ item }: { item: Item }) => {
return <View>...</View>;
}, (prevProps, nextProps) => {
// true를 반환하면 리렌더 스킵
return prevProps.item.id === nextProps.item.id
&& prevProps.item.updatedAt === nextProps.item.updatedAt;
});
2. 메모리 누수 탐지와 해결
Flutter 메모리 누수 패턴
1. dispose() 누락
class MyWidget extends StatefulWidget {
late AnimationController _controller;
late StreamSubscription _subscription;
@override
void initState() {
super.initState();
_controller = AnimationController(vsync: this);
_subscription = stream.listen((_) {});
}
@override
void dispose() {
_controller.dispose(); // 필수
_subscription.cancel(); // 필수
super.dispose();
}
}
2. GlobalKey 과다 사용
GlobalKey는 전역 상태를 유지하므로 위젯이 제거되어도 메모리에 남는다. 꼭 필요한 경우에만 사용하고, 리스트 아이템에는 절대 사용하지 않는다.
Flutter DevTools Memory 탭 활용:
- 앱 실행 후 Memory 탭 열기
- 특정 화면으로 이동 후 뒤로 가기 반복
- GC 실행 후에도 메모리가 계속 증가하면 누수 의심
- Heap Snapshot으로 어떤 객체가 누수되는지 확인
React Native 메모리 누수 패턴
useEffect 클린업 누락:
useEffect(() => {
const subscription = eventEmitter.addListener('event', handler);
const timer = setInterval(fetchData, 5000);
return () => {
subscription.remove(); // 클린업 필수
clearInterval(timer);
};
}, []);
이미지 메모리 최적화:
import FastImage from 'react-native-fast-image';
<FastImage
source={{ uri: imageUrl, priority: FastImage.priority.normal }}
style={{ width: 200, height: 200 }}
resizeMode={FastImage.resizeMode.cover}
/>
// react-native-fast-image: 네이티브 레벨 캐싱으로 메모리 효율 높음
3. 배터리 최적화
배터리 소모 주요 원인
| 원인 | 기여도 | 대책 |
|---|---|---|
| 과도한 GPS 사용 | 높음 | 필요할 때만 위치 추적, 정확도 조절 |
| 백그라운드 네트워크 | 높음 | 배치 처리, 최소화 |
| 애니메이션 과다 | 중간 | 60fps 유지, 불필요한 애니메이션 제거 |
| 화면 밝기 | 높음 | 앱에서 직접 밝기 제어 금지 |
| 카메라/마이크 대기 | 높음 | 사용 후 즉시 해제 |
네트워크 요청 최적화
// 불필요한 폴링 대신 웹소켓 또는 SSE 사용
// 백그라운드에서는 요청 배치 처리
class NetworkOptimizer {
final Queue<ApiRequest> _pendingRequests = Queue();
Timer? _batchTimer;
void addRequest(ApiRequest request) {
_pendingRequests.add(request);
_batchTimer ??= Timer(const Duration(seconds: 2), _processBatch);
}
Future<void> _processBatch() async {
if (_pendingRequests.isEmpty) return;
final batch = _pendingRequests.toList();
_pendingRequests.clear();
_batchTimer = null;
await api.batchRequest(batch);
}
}
위치 서비스 정밀도 조절
// 정확한 위치가 필요 없는 경우 낮은 정확도 사용
final position = await Geolocator.getCurrentPosition(
desiredAccuracy: LocationAccuracy.low, // 배터리 절약
// vs LocationAccuracy.high (배터리 소모 큼)
);
4. 앱 시작 시간 최적화
콜드 스타트 시간 단축
Flutter:
// main.dart
void main() {
// 꼭 필요한 초기화만 await
// 나머지는 lazy loading
runApp(const MyApp());
// 비중요 초기화는 앱 실행 후 비동기로
WidgetsBinding.instance.addPostFrameCallback((_) {
_initializeNonCritical();
});
}
이미지 프리로딩:
// 첫 화면에 필요한 이미지만 미리 캐싱
precacheImage(NetworkImage(heroImageUrl), context);
성능 모니터링: Firebase Performance
final trace = FirebasePerformance.instance.newTrace('product_list_load');
await trace.start();
final products = await fetchProducts();
await trace.stop();
// Firebase 콘솔에서 p50, p90, p95 응답 시간 모니터링
커스텀 트레이스로 실제 사용자 환경에서의 성능을 측정한다. 개발 환경에서의 프로파일링과 프로덕션 성능은 다를 수 있다.
맺으며
모바일 성능 최적화는 "사용자가 느리다고 느끼는 것"을 수치로 바꾸는 것에서 시작한다. 렌더링 끊김은 프레임 시간으로, 메모리 문제는 힙 증가 추이로, 배터리 소모는 Background Task 실행 횟수로 측정한다.
측정 전에 최적화하지 않는다. 프로파일러가 지목하지 않은 곳을 최적화하면 코드 복잡성만 높아지고 실제 체감 개선은 없다.