모바일 앱 성능 최적화 — 메모리·배터리·렌더링 실전 가이드

성능 최적화

성능 최적화메모리배터리FlutterReact Native

이 글은 누구를 위한 것인가

  • 앱이 느려지거나 배터리를 많이 소모한다는 사용자 피드백을 받는 팀
  • 모바일 성능 문제를 어디서부터 측정하고 개선해야 할지 모르는 개발자
  • 스크롤이 끊기거나 화면 전환이 버벅이는 앱을 개선하려는 엔지니어

성능 최적화의 순서

"추측하지 말고 측정하라." 성능 최적화는 반드시 프로파일링으로 병목을 찾은 뒤 개선하는 순서를 따른다. 직관적으로 느린 것 같은 곳이 실제 병목이 아닌 경우가 많다.

측정 → 병목 식별 → 가설 수립 → 수정 → 재측정 → 반복

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 탭 활용:

  1. 앱 실행 후 Memory 탭 열기
  2. 특정 화면으로 이동 후 뒤로 가기 반복
  3. GC 실행 후에도 메모리가 계속 증가하면 누수 의심
  4. 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 실행 횟수로 측정한다.

측정 전에 최적화하지 않는다. 프로파일러가 지목하지 않은 곳을 최적화하면 코드 복잡성만 높아지고 실제 체감 개선은 없다.