성능 최적화 개요
99개 화면, 39개 Provider, 44개 Service를 가진 대규모 Flutter 앱에서 성능은 사용자 경험의 핵심입니다. MICEMore에서 적용한 7가지 성능 최적화 전략을 소개합니다.
| 전략 | 적용 영역 | 효과 |
|---|---|---|
| Future.wait 병렬 초기화 | 앱 시작 | 로딩 시간 40~60% 단축 |
| 비블로킹 폰트 프리로드 | 웹 렌더링 | 스플래시 500~1500ms 단축 |
| 플랫폼별 Firestore 캐시 | 데이터 접근 | 오프라인 지원 + 멀티탭 안정성 |
| Stream 구독 관리 | 실시간 데이터 | 메모리 누수 방지 |
| Batch Write | 대량 데이터 처리 | 네트워크 요청 최소화 |
| Fire-and-Forget 패턴 | 부차적 작업 | 응답 속도 향상 |
| 네트워크 상태 모니터링 | 전체 앱 | 오프라인 대응 |
1. Future.wait 병렬 초기화
앱 시작 시 독립적인 초기화 작업을 병렬로 동시 실행합니다.
void main() async {
WidgetsFlutterBinding.ensureInitialized();
FlutterNativeSplash.preserve(widgetsBinding: widgetsBinding);
usePathUrlStrategy();
_setupErrorHandlers();
// 4개 독립 작업 병렬 실행
await Future.wait([
SystemChrome.setPreferredOrientations([DeviceOrientation.portraitUp]),
initializeDateFormatting('ko', null),
_initializeFirebase(),
_initializeKakaoSdk(),
]);
runApp(MultiProvider(providers: [...], child: MyApp()));
}
순차 vs 병렬 성능 비교
| 작업 | 소요 시간 | 순차 실행 | 병렬 실행 |
|---|---|---|---|
| 화면 방향 고정 | ~50ms | 0~50ms | max(50, 200, 800, 300) = ~800ms |
| 날짜 포맷 초기화 | ~200ms | 50~250ms | |
| Firebase 초기화 | ~800ms | 250~1050ms | |
| 카카오 SDK 초기화 | ~300ms | 1050~1350ms | |
| 합계 | ~1350ms | ~800ms (41% 단축) | |
2. 비블로킹 폰트 프리로드
웹 환경에서 한글 폰트 로드를 await 없이 트리거합니다.
if (kIsWeb) {
// 비블로킹: 폰트 로드 시작만 하고 완료를 기다리지 않음
GoogleFonts.notoSansKr(fontWeight: FontWeight.w400);
GoogleFonts.notoSansKr(fontWeight: FontWeight.w500);
GoogleFonts.notoSansKr(fontWeight: FontWeight.w600);
GoogleFonts.notoSansKr(fontWeight: FontWeight.w700);
// await GoogleFonts.pendingFonts() 제거
// → 스플래시 중 500~1500ms 블로킹 방지
}
CanvasKit 렌더러가 시스템 폰트로 폴백하므로, 폰트 로드 전에도 텍스트가 정상 표시됩니다. 백그라운드에서 폰트가 준비되면 자동으로 교체됩니다.
3. 플랫폼별 Firestore 캐시 전략
웹과 모바일에서 서로 다른 캐시 전략을 적용합니다.
if (kIsWeb) {
// 웹: 영속성 비활성화 (멀티탭 충돌 방지)
FirebaseFirestore.instance.settings = const Settings(
persistenceEnabled: false,
);
} else {
// 모바일: 무제한 캐시 (오프라인 지원)
FirebaseFirestore.instance.settings = const Settings(
persistenceEnabled: true,
cacheSizeBytes: Settings.CACHE_SIZE_UNLIMITED,
);
}
플랫폼별 캐시 전략 이유
| 플랫폼 | 영속성 | 캐시 크기 | 이유 |
|---|---|---|---|
| 웹 | 비활성화 | - | 멀티탭에서 IndexedDB 동시 접근 시 충돌 방지 |
| 모바일 | 활성화 | 무제한 | 오프라인 지원, 네트워크 절약, 빠른 데이터 접근 |
4. Stream 구독 관리: 메모리 누수 방지
39개 Provider가 Firestore Stream을 구독하므로, 체계적인 구독 관리가 필수입니다.
방어적 구독 패턴
class GamificationProvider with ChangeNotifier {
StreamSubscription<List<LeaderboardEntryModel>>? _leaderboardSubscription;
StreamSubscription<LeaderboardEntryModel?>? _myRankSubscription;
StreamSubscription<List<PointRecordModel>>? _myPointHistorySubscription;
void loadLeaderboard(String eventId, {int? limit}) {
// 핵심: 새 구독 전 기존 구독 해제
_leaderboardSubscription?.cancel();
_leaderboardSubscription =
_service.getLeaderboard(eventId, limit: limit).listen(
(data) {
_leaderboard = data;
notifyListeners();
},
onError: (e) {
_error = e.toString();
notifyListeners();
},
);
}
@override
void dispose() {
// 모든 구독 해제
_leaderboardSubscription?.cancel();
_myRankSubscription?.cancel();
_myPointHistorySubscription?.cancel();
super.dispose();
}
}
구독 관리 3원칙
- Cancel-Before-Subscribe: 새 구독 시작 전 기존 구독 해제로 중복 구독 방지
- Nullable Subscription: StreamSubscription?로 선언하여 null-safe cancel 호출
- Dispose Cleanup: Provider dispose()에서 모든 구독 일괄 해제
5. Batch Write: 대량 데이터 처리
Firestore에 여러 문서를 한 번에 처리할 때 Batch Write로 네트워크 요청을 최소화합니다.
리더보드 순위 일괄 업데이트
Future<void> _updateRanks(String eventId) async {
final snapshot = await _firestore
.collection('events').doc(eventId)
.collection('leaderboard')
.orderBy('totalPoints', descending: true)
.get();
final batch = _firestore.batch();
int rank = 1;
for (var doc in snapshot.docs) {
batch.update(doc.reference, {'rank': rank});
rank++;
}
await batch.commit(); // 단일 네트워크 요청으로 모든 순위 업데이트
}
알림 전체 읽음 처리
Future<void> markAllAsRead() async {
final batch = _firestore.batch();
final unreadNotifications = _notifications.where((n) => !n.isRead);
for (final notification in unreadNotifications) {
batch.update(
_firestore.collection('notifications').doc(notification.id),
{'isRead': true},
);
}
await batch.commit();
// Optimistic Update: Firestore 응답 전 로컬 상태 즉시 반영
_notifications = _notifications.map((n) => n.copyWith(isRead: true)).toList();
notifyListeners();
}
Batch 500 제한 대응: 계단식 커밋
// 계정 삭제 시 대량 문서 삭제
int operationCount = 0;
for (var doc in participations.docs) {
batch.delete(doc.reference);
operationCount++;
if (operationCount >= 500) {
await batch.commit(); // 500개마다 커밋
operationCount = 0; // 새 batch 시작
}
}
Firestore Batch는 한 번에 최대 500개 연산만 허용합니다. 대량 삭제 시 500개 단위로 커밋하는 계단식 패턴을 사용합니다.
6. Fire-and-Forget 패턴: 응답 속도 우선
핵심 작업 완료 후 부차적인 작업을 await 없이 비동기로 실행하여 응답 속도를 우선합니다.
포인트 부여 후 비동기 처리
Future<void> awardPoints({...}) async {
// 핵심: Transaction으로 포인트 부여 + 리더보드 업데이트
await _firestore.runTransaction((transaction) async {
// ... 포인트 기록 + 리더보드 업데이트
});
// Fire-and-Forget: await 없이 비동기 실행
_updateRanks(eventId); // 순위 재계산
_checkBadgesAfterPoints(...) // 배지 조건 체크
// → 사용자는 포인트 부여 즉시 응답을 받음
// → 순위와 배지는 백그라운드에서 처리
}
배지 체크 실패 격리
Future<void> _checkBadgesAfterPoints({...}) async {
try {
// 소스별 선별적 배지 체크 (불필요한 체크 방지)
if (source.startsWith('quiz_correct')) {
await _badgeService.checkQuizMasterBadge(...);
}
// 공통 배지 병렬 체크
await Future.wait([
_badgeService.checkSocialButterflyBadge(...),
_badgeService.checkTopScorerBadge(...),
]);
} catch (e) {
// 배지 체크 실패해도 포인트 부여는 이미 성공
print('배지 체크 중 오류 발생 (무시): $e');
}
}
try-catch로 배지 체크를 격리하여 실패가 포인트 부여에 영향을 주지 않습니다. 또한 source prefix 기반으로 관련 배지만 선별 체크하여 불필요한 Firestore 쿼리를 방지합니다.
7. 네트워크 상태 모니터링
ConnectivityService가 실시간 네트워크 상태를 추적하여 오프라인 시 적절한 UI 피드백을 제공합니다.
class ConnectivityService extends ChangeNotifier {
final Connectivity _connectivity = Connectivity();
StreamSubscription<List<ConnectivityResult>>? _connectivitySubscription;
bool _isOnline = true;
bool _hasWifi = false;
bool _hasMobile = false;
ConnectivityService() {
_init();
}
Future<void> _init() async {
await _updateConnectionStatus();
_connectivitySubscription =
_connectivity.onConnectivityChanged.listen(_onConnectivityChanged);
}
void _onConnectivityChanged(List<ConnectivityResult> results) {
_hasWifi = results.contains(ConnectivityResult.wifi);
_hasMobile = results.contains(ConnectivityResult.mobile);
_isOnline = _hasWifi || _hasMobile ||
results.contains(ConnectivityResult.ethernet);
notifyListeners();
}
}
ChangeNotifier를 상속하여 Provider로 등록되므로, UI 어디서든 Consumer나 context.watch로 네트워크 상태를 감시할 수 있습니다. 모바일 환경에서 Firestore 무제한 캐시와 결합하면 오프라인에서도 캐시된 데이터로 앱을 사용할 수 있습니다.
추가 최적화: 웹 스크롤 동작 커스터마이징
/// Flutter 웹에서 마우스 스크롤 및 트랙패드 드래그 활성화
class AppScrollBehavior extends MaterialScrollBehavior {
@override
Set<PointerDeviceKind> get dragDevices => {
PointerDeviceKind.touch,
PointerDeviceKind.mouse,
PointerDeviceKind.trackpad,
PointerDeviceKind.stylus,
};
}
Flutter 웹은 기본적으로 마우스 드래그 스크롤을 지원하지 않습니다. AppScrollBehavior를 MaterialApp의 scrollBehavior에 설정하여 데스크톱 브라우저에서도 자연스러운 스크롤 경험을 제공합니다.
에러 핸들링: 전역 에러 캐처
void _setupErrorHandlers() {
// Flutter 프레임워크 에러 (위젯 빌드 에러 등)
FlutterError.onError = (details) {
if (kDebugMode) {
FlutterError.dumpErrorToConsole(details);
} else if (!kIsWeb) {
FirebaseCrashlytics.instance.recordFlutterFatalError(details);
}
};
// 비동기 에러 (Zone 밖의 에러)
PlatformDispatcher.instance.onError = (error, stack) {
if (!kDebugMode && !kIsWeb) {
FirebaseCrashlytics.instance.recordError(error, stack, fatal: true);
}
return true; // 에러 처리 완료
};
}
2단계 에러 핸들링으로 모든 종류의 에러를 포착합니다. 디버그 모드에서는 콘솔 출력, 프로덕션에서는 Crashlytics로 전송하여 무음 크래시를 방지합니다. 웹은 Crashlytics를 지원하지 않으므로 kIsWeb 체크로 분기합니다.
성능 최적화 체크리스트
| 항목 | 적용 여부 | 적용 위치 |
|---|---|---|
| 병렬 초기화 | O | main.dart Future.wait |
| 비블로킹 리소스 로드 | O | GoogleFonts 프리로드 |
| 플랫폼별 캐시 전략 | O | Firestore Settings |
| Stream 구독 관리 | O | 39개 Provider dispose() |
| Batch Write | O | 리더보드, 알림, 계정삭제 |
| Transaction 원자성 | O | 포인트, 토큰, 배지 |
| Fire-and-Forget | O | 순위 재계산, 배지 체크 |
| 에러 격리 | O | try-catch 부차적 작업 |
| 네트워크 모니터링 | O | ConnectivityService |
| 전역 에러 핸들링 | O | FlutterError + PlatformDispatcher |
마치며
MICEMore는 대규모 Flutter 프로젝트에서 실전적인 성능 최적화를 적용했습니다. Future.wait 병렬 초기화로 앱 시작 속도를 40% 이상 단축하고, 체계적인 Stream 구독 관리로 메모리 누수를 방지하며, Batch Write와 Transaction으로 Firestore 연산을 최적화했습니다. 이 시리즈를 통해 99개 화면, 42개 모델, 39개 Provider, 44개 서비스로 구성된 MICEMore 프로젝트의 전체 아키텍처와 핵심 기술을 분석했습니다.
초보자를 위한 상세 가이드: 성능 최적화 전략 완전 정복
성능 최적화란? - 왜 중요한가
앱 성능 최적화는 "앱이 빠르고 부드럽게 동작하도록 만드는 것"입니다. 사용자가 앱을 열었을 때 3초 이상 걸리면 53%의 사용자가 이탈합니다. MICEMore는 28개의 Provider와 Firebase를 사용하는 대규모 앱이므로, 최적화가 특히 중요합니다.
| 최적화 영역 | 하지 않으면? | 최적화하면? | 비유 |
|---|---|---|---|
| 병렬 초기화 | 앱 시작 5초+ | 앱 시작 1~2초 | 한 명이 순서대로 vs 여러 명이 동시에 |
| Firestore 캐시 | 매번 서버 요청 | 로컬에서 즉시 로드 | 매번 마트 vs 냉장고에서 꺼내기 |
| Stream 관리 | 메모리 누수, 앱 느려짐 | 깔끔한 메모리 관리 | 수도꼭지 안 잠그기 vs 쓰고 잠그기 |
| Batch Write | 100번 네트워크 요청 | 1번 네트워크 요청 | 편지 100번 보내기 vs 택배 1상자 |
1. 병렬 초기화 (Future.wait) 상세 분석
순차 초기화 vs 병렬 초기화
앱이 시작될 때 여러 가지를 준비해야 합니다. 이걸 하나씩 순서대로 하면 느리고, 동시에 하면 빠릅니다.
// 나쁜 예: 순차 초기화 (하나 끝나면 다음 하나)
await SystemChrome.setPreferredOrientations(...); // 100ms
await initializeDateFormatting('ko', null); // 200ms
await Firebase.initializeApp(...); // 800ms
await _initializeKakaoSdk(); // 300ms
// 총 시간: 100 + 200 + 800 + 300 = 1400ms (1.4초)
// 좋은 예: 병렬 초기화 (모두 동시에 실행!)
await Future.wait([
SystemChrome.setPreferredOrientations(...), // 100ms ┐
initializeDateFormatting('ko', null), // 200ms ├ 동시 실행
_initializeFirebase(), // 800ms ├
_initializeKakaoSdk(), // 300ms ┘
]);
// 총 시간: max(100, 200, 800, 300) = 800ms (0.8초!)
// 약 43% 시간 단축!
Future.wait의 원리:
| 비유 | 순차 실행 | 병렬 실행 (Future.wait) |
|---|---|---|
| 요리 | 밥 짓고 → 국 끓이고 → 반찬 만들기 | 밥+국+반찬 동시에! |
| 택배 | A배달 → B배달 → C배달 | 택배기사 3명이 동시에! |
| 총 시간 | 모든 시간의 합계 | 가장 오래 걸리는 것만큼 |
병렬 초기화 시 주의할 점
// 주의: 의존성이 있는 작업은 병렬로 실행하면 안 됩니다!
// 잘못된 예: Firebase가 초기화되기 전에 Firestore 사용
await Future.wait([
Firebase.initializeApp(), // Firebase 초기화
loadUserData(), // Firebase 필요! (에러 발생)
]);
// 올바른 예: 의존성 있는 작업은 순서대로
await Future.wait([
Firebase.initializeApp(), // 독립적인 작업들만
initializeDateFormatting(), // 병렬로 실행
]);
// Firebase 초기화 완료 후에
await loadUserData(); // 의존적인 작업 실행
2. Firestore 캐시 전략 상세 분석
캐시(Cache)란?
캐시는 "자주 쓰는 데이터를 가까운 곳에 미리 저장해두는 것"입니다. 마트에서 매번 장보러 가는 대신, 냉장고에 재료를 넣어두는 것과 같습니다.
// 모바일: 캐시 활성화 (오프라인에서도 동작!)
if (!kIsWeb) {
FirebaseFirestore.instance.settings = const Settings(
persistenceEnabled: true, // 캐시 켜기
cacheSizeBytes: Settings.CACHE_SIZE_UNLIMITED, // 용량 제한 없음
);
}
// 웹: 캐시 비활성화 (멀티탭 충돌 방지)
if (kIsWeb) {
FirebaseFirestore.instance.settings = const Settings(
persistenceEnabled: false, // 캐시 끄기
);
}
왜 웹에서는 캐시를 끄나요?
웹 브라우저에서는 여러 탭을 동시에 열 수 있습니다. 각 탭이 같은 캐시를 사용하면 충돌이 발생합니다. 마치 두 사람이 동시에 같은 냉장고 문을 열면 부딪히는 것과 같습니다.
| 플랫폼 | 캐시 설정 | 이유 | 효과 |
|---|---|---|---|
| iOS/Android | 활성화 (무제한) | 오프라인 지원, 빠른 로딩 | 서버 요청 50~70% 감소 |
| Web | 비활성화 | 멀티탭 충돌 방지 | 데이터 일관성 보장 |
3. MultiProvider와 28개 Provider 관리
MultiProvider란?
MultiProvider는 여러 Provider를 한 곳에서 등록하는 방법입니다. Provider는 앱의 상태(데이터)를 관리하는 매니저입니다.
// MultiProvider: 28개의 매니저를 한꺼번에 등록
runApp(
MultiProvider(
providers: [
// 인프라 서비스
ChangeNotifierProvider(create: (_) => ConnectivityService()),
ChangeNotifierProvider(create: (_) => AuthProvider()),
// 핵심 기능
ChangeNotifierProvider(create: (_) => EventProvider()),
ChangeNotifierProvider(create: (_) => NotificationProvider()),
ChangeNotifierProvider(create: (_) => FCMProvider()),
// 게이미피케이션
ChangeNotifierProvider(create: (_) => GamificationProvider()),
ChangeNotifierProvider(create: (_) => TokenProvider()),
ChangeNotifierProvider(create: (_) => BadgeProvider()),
// ... 총 28개
],
child: MyApp(),
),
);
왜 28개나 필요한가요?
MICEMore는 대규모 MICE 행사 앱이기 때문에 관리할 상태가 많습니다:
| 카테고리 | Provider 수 | 예시 |
|---|---|---|
| 인프라 | 3개 | Connectivity, Auth, Mode |
| 행사 관리 | 6개 | Event, Participant, Question, Announcement, Lottery, Quiz |
| 알림 | 2개 | Notification, FCM |
| 게이미피케이션 | 3개 | Gamification, Badge, Token |
| 부스 시스템 | 5개 | Booth, BoothQuestion, BoothVote, BoothVisit, BoothGamification |
| AI/추천 | 4개 | AIPlanning, Recommendation, ImplicitFeedback, Chatbot |
| 기타 | 5개 | Vote, Team, EventFlow, Congestion, Community 등 |
Provider 성능 최적화 팁
// 나쁜 예: context.watch (불필요한 리빌드 발생)
// 이벤트 목록이 바뀔 때마다 이 위젯 전체가 다시 그려짐
Widget build(BuildContext context) {
final events = context.watch<EventProvider>().events;
return Column(
children: [
Text('행사 수: ' + events.length.toString()),
BigExpensiveWidget(), // 이것도 다시 그려짐! (불필요)
],
);
}
// 좋은 예: Consumer + context.read 조합
Widget build(BuildContext context) {
return Column(
children: [
// Consumer: EventProvider 변경 시 이 부분만 다시 그려짐
Consumer<EventProvider>(
builder: (context, eventProvider, child) {
return Text('행사 수: ' + eventProvider.events.length.toString());
},
),
BigExpensiveWidget(), // 이건 다시 안 그려짐! (성능 UP)
],
);
}
4. Stream 관리와 메모리 누수 방지
Stream이란?
Stream은 "시간이 지나면서 계속 데이터가 흘러오는 파이프"입니다. 수도꼭지를 틀면 물이 계속 나오듯, Firestore의 snapshots()를 구독하면 데이터 변경이 계속 흘러옵니다.
| 비유 | 설명 | 코드 |
|---|---|---|
| 수도꼭지 틀기 | Stream 구독 시작 | .listen((data) { ... }) |
| 물 받기 | 데이터 수신 처리 | 콜백 함수 실행 |
| 수도꼭지 잠그기 | Stream 구독 해제 | subscription.cancel() |
| 수도꼭지 안 잠그면? | 메모리 누수! | dispose()에서 cancel 안 함 |
메모리 누수(Memory Leak)란?
메모리 누수는 "사용하지 않는 데이터가 메모리에 계속 쌓이는 것"입니다. 마치 수도꼭지를 안 잠그면 물이 계속 나와 바닥이 잠기는 것과 같습니다.
// 메모리 누수가 발생하는 코드 (나쁜 예)
class BadProvider extends ChangeNotifier {
void startListening() {
// 구독은 하지만 해제를 안 함!
_firestore.collection('events').snapshots().listen((snapshot) {
// 화면을 벗어나도 계속 실행됨...
// 메모리가 점점 쌓임...
});
}
// dispose()가 없음! 수도꼭지 안 잠금!
}
// 올바른 코드 (좋은 예)
class GoodProvider extends ChangeNotifier {
StreamSubscription? _subscription; // 구독 참조 저장
void startListening() {
_subscription?.cancel(); // Cancel-Before-Subscribe
_subscription = _firestore
.collection('events')
.snapshots()
.listen((snapshot) {
// 데이터 처리
});
}
@override
void dispose() {
_subscription?.cancel(); // 수도꼭지 잠그기!
super.dispose();
}
}
ConnectivityService의 Stream 관리 예시
ConnectivityService는 네트워크 연결 상태를 실시간으로 감시합니다. Wi-Fi가 끊기면 즉시 감지해서 사용자에게 알려줍니다.
class ConnectivityService extends ChangeNotifier {
final Connectivity _connectivity = Connectivity();
// Stream 구독 참조를 반드시 변수에 저장!
StreamSubscription<List<ConnectivityResult>>? _connectivitySubscription;
bool _isOnline = true;
bool _hasWifi = false;
bool _hasMobile = false;
ConnectivityService() {
_init(); // 생성자에서 초기화
}
Future<void> _init() async {
await _updateConnectionStatus(); // 현재 상태 확인
// 상태 변경 실시간 감지 시작
_connectivitySubscription = _connectivity
.onConnectivityChanged
.listen(_onConnectivityChanged);
}
void _onConnectivityChanged(List<ConnectivityResult> results) {
_hasWifi = results.contains(ConnectivityResult.wifi);
_hasMobile = results.contains(ConnectivityResult.mobile);
_isOnline = _hasWifi || _hasMobile ||
results.contains(ConnectivityResult.ethernet);
notifyListeners(); // UI에 상태 변경 알림
}
@override
void dispose() {
_connectivitySubscription?.cancel(); // 반드시 해제!
super.dispose();
}
}
5. Batch Write: 대량 데이터 처리의 핵심
Batch Write란?
Batch Write는 "여러 개의 데이터베이스 작업을 하나로 묶어서 한 번에 실행"하는 것입니다. 편지를 10통 보낼 때 우체국을 10번 가는 대신, 한 번에 10통을 들고 가는 것과 같습니다.
// 나쁜 예: 하나씩 보내기 (네트워크 요청 100번)
for (final userId in userIds) { // 100명
await _firestore.collection('notifications').add({
'userId': userId,
'title': '행사 시작',
'isRead': false,
});
// 매번 서버와 통신! 100번 왕복!
}
// 시간: 100 x 200ms = 20초
// 좋은 예: Batch Write (네트워크 요청 1번)
final batch = _firestore.batch();
for (final userId in userIds) { // 100명
final docRef = _firestore.collection('notifications').doc();
batch.set(docRef, {
'userId': userId,
'title': '행사 시작',
'isRead': false,
});
}
await batch.commit(); // 한 번에 전송!
// 시간: 1 x 500ms = 0.5초 (40배 빠름!)
| 비교 항목 | 개별 Write | Batch Write |
|---|---|---|
| 100개 문서 쓰기 | ~20초 | ~0.5초 |
| 네트워크 요청 수 | 100번 | 1번 |
| 원자성 (Atomicity) | 일부만 성공 가능 | 전부 성공 또는 전부 실패 |
| 최대 문서 수 | 제한 없음 | 500개 |
Batch vs Transaction 차이점
둘 다 여러 작업을 묶는 것이지만, 목적이 다릅니다:
| 구분 | Batch Write | Transaction |
|---|---|---|
| 읽기 가능? | 쓰기만 가능 | 읽기 + 쓰기 가능 |
| 사용 시나리오 | 알림 100개 생성, 전체 읽음 처리 | 포인트 증감, 토큰 차감 |
| 비유 | 택배 100개 한 번에 보내기 | ATM에서 잔액 확인 후 출금 |
| 충돌 시 | 전체 실패 | 자동 재시도 (최대 5회) |
6. 비블로킹(Non-blocking) 초기화 패턴
블로킹 vs 비블로킹
블로킹은 작업이 끝날 때까지 다음 코드가 실행되지 않는 것이고, 비블로킹은 작업을 시작만 하고 다음 코드로 넘어가는 것입니다.
// 블로킹 (느림): 폰트 로딩이 끝날 때까지 앱 표시 안됨
GoogleFonts.notoSansKr(fontWeight: FontWeight.w400);
GoogleFonts.notoSansKr(fontWeight: FontWeight.w700);
await GoogleFonts.pendingFonts(); // 500~1500ms 대기!
// 이 시간 동안 스플래시 화면만 보임...
// 비블로킹 (빠름): 폰트 로딩 시작만 하고 앱 바로 표시
GoogleFonts.notoSansKr(fontWeight: FontWeight.w400);
GoogleFonts.notoSansKr(fontWeight: FontWeight.w700);
// await 없음! 백그라운드에서 로딩
// 앱이 즉시 표시되고, 폰트는 준비되면 자동 적용
7. 에러 핸들링과 Crashlytics
// 전역 에러 핸들러: 앱 어디서든 발생하는 에러를 잡아냄
void _setupErrorHandlers() {
// Flutter 프레임워크 에러 (UI 관련)
FlutterError.onError = (details) {
if (!kIsWeb) {
FirebaseCrashlytics.instance.recordFlutterError(details);
}
};
// Dart 에러 (비동기 에러 등)
PlatformDispatcher.instance.onError = (error, stack) {
if (!kIsWeb) {
FirebaseCrashlytics.instance.recordError(error, stack);
}
return true;
};
}
// Crashlytics: 릴리즈 모드에서만 에러 수집
// 디버그 모드에서는 꺼둠 (개발 중 에러가 대시보드를 더럽히지 않게)
if (!kDebugMode) {
await FirebaseCrashlytics.instance
.setCrashlyticsCollectionEnabled(true);
}
성능 최적화 체크리스트
| 체크 항목 | 적용 여부 | 효과 |
|---|---|---|
| Future.wait로 병렬 초기화 | 적용 | 앱 시작 시간 43% 단축 |
| Firestore 캐시 (모바일) | 적용 | 서버 요청 50~70% 감소 |
| Firestore 캐시 끄기 (웹) | 적용 | 멀티탭 충돌 방지 |
| Cancel-Before-Subscribe | 적용 | Stream 중복 구독 방지 |
| dispose()에서 Stream cancel | 적용 | 메모리 누수 방지 |
| Batch Write 사용 | 적용 | 네트워크 요청 40배 감소 |
| 비블로킹 폰트 로딩 | 적용 | 스플래시 시간 0.5~1.5초 단축 |
| Consumer로 부분 리빌드 | 적용 | 불필요한 위젯 리빌드 방지 |
| Crashlytics 릴리즈만 활성화 | 적용 | 디버그 로그 오염 방지 |
핵심 정리
| 개념 | 한 줄 요약 |
|---|---|
| Future.wait | 독립적인 비동기 작업을 동시에 실행해 총 시간 단축 |
| Firestore Cache | 모바일은 캐시 ON(오프라인 지원), 웹은 OFF(멀티탭 안전) |
| Stream 관리 | 구독 시작과 해제를 쌍으로, dispose()에서 반드시 cancel |
| Batch Write | 여러 문서 쓰기를 1회 요청으로 묶어 네트워크 절약 |
| Transaction | 읽기+쓰기가 필요한 원자적 작업(포인트, 토큰) |
| 비블로킹 초기화 | await 없이 시작만 하고 앱 즉시 표시 |
| Consumer | 변경된 부분만 리빌드해서 불필요한 UI 갱신 방지 |
| Crashlytics | 프로덕션 에러만 수집, 디버그 모드에서는 비활성화 |