Flutter 상태 관리 완벽 가이드: 네이티브 개발자를 위한 실무 선택 전략
안드로이드/iOS 개발자가 가장 고민하는 “어떤 상태 관리를 선택해야 할까?”에 대한 결정적 답변
서론: 상태 관리, 왜 이렇게 복잡할까?
안드로이드 개발자라면 MVVM의 LiveData나 DataBinding을, iOS 개발자라면 MVC나 MVVM의 관찰자 패턴을 사용해봤을 것입니다. 웹 프론트엔드 개발자라면 Redux나 MobX, Vue의 Vuex 같은 상태 관리 라이브러리에 익숙하실 겁니다.
그런데 Flutter는 왜 이렇게 많은 상태 관리 옵션이 있을까요?
- 내장 상태 관리: setState, InheritedWidget
- Provider 생태계: Provider, Riverpod
- 반응형 라이브러리: GetX, MobX
- BLoC 패턴: flutter_bloc
- 기타: Redux, fish-redux 등등…
이 글에서는 실제 프로젝트 경험을 바탕으로 각 상태 관리의 장단점과 선택 기준을 제시하겠습니다. 더 이상 “어떤 걸 써야 할지 모르겠다”는 고민은 하지 마세요!
네이티브 vs Flutter: 상태 관리 철학의 차이
안드로이드의 상태 관리
// Android MVVM with LiveData
class UserViewModel : ViewModel() {
private val _users = MutableLiveData<List<User>>()
val users: LiveData<List<User>> = _users
private val _isLoading = MutableLiveData<Boolean>()
val isLoading: LiveData<Boolean> = _isLoading
fun loadUsers() {
_isLoading.value = true
repository.getUsers { userList ->
_users.value = userList
_isLoading.value = false
}
}
}
// Activity/Fragment에서 관찰
viewModel.users.observe(this) { users ->
adapter.submitList(users)
}
iOS의 상태 관리
// iOS MVVM with Combine (또는 RxSwift)
class UserViewModel: ObservableObject {
@Published var users: [User] = []
@Published var isLoading: Bool = false
private let repository: UserRepository
private var cancellables = Set<AnyCancellable>()
func loadUsers() {
isLoading = true
repository.getUsers()
.sink(
receiveCompletion: { _ in self.isLoading = false },
receiveValue: { users in self.users = users }
)
.store(in: &cancellables)
}
}
Flutter의 접근법
Flutter는 선언적 UI를 기반으로 하므로, 상태가 변경되면 UI가 자동으로 재빌드됩니다. 이는 React의 철학과 유사합니다.
// Flutter의 기본 접근법
class UserScreen extends StatefulWidget {
@override
_UserScreenState createState() => _UserScreenState();
}
class _UserScreenState extends State<UserScreen> {
List<User> users = [];
bool isLoading = false;
@override
Widget build(BuildContext context) {
return Scaffold(
body: isLoading
? CircularProgressIndicator()
: ListView.builder(
itemCount: users.length,
itemBuilder: (context, index) => UserTile(users[index]),
),
);
}
}
핵심 차이점:
- 네이티브: 상태 변경 → 관찰자 알림 → 수동 UI 업데이트
- Flutter: 상태 변경 → 위젯 재빌드 → 자동 UI 업데이트
프로젝트 규모별 상태 관리 선택 가이드
🏠 소규모 프로젝트 (화면 5개 이하)
추천: setState + InheritedWidget
// 간단한 카운터 앱
class CounterApp extends StatefulWidget {
@override
_CounterAppState createState() => _CounterAppState();
}
class _CounterAppState extends State<CounterApp> {
int counter = 0;
void increment() {
setState(() {
counter++;
});
}
@override
Widget build(BuildContext context) {
return MaterialApp(
home: Scaffold(
body: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text('Count: $counter'),
ElevatedButton(
onPressed: increment,
child: Text('Increment'),
),
],
),
),
);
}
}
장점:
- 학습 곡선이 낮음
- 추가 의존성 없음
- 간단하고 직관적
단점:
- 상태 공유가 어려움
- 복잡해지면 관리 어려움
🏢 중간 규모 프로젝트 (화면 5-20개)
추천: Riverpod
// 사용자 목록 관리
@riverpod
class UserNotifier extends _$UserNotifier {
@override
FutureOr<List<User>> build() async {
return await ref.read(userRepositoryProvider).getUsers();
}
Future<void> addUser(User user) async {
state = const AsyncLoading();
state = await AsyncValue.guard(() async {
await ref.read(userRepositoryProvider).addUser(user);
return await ref.read(userRepositoryProvider).getUsers();
});
}
Future<void> deleteUser(String userId) async {
state = const AsyncLoading();
state = await AsyncValue.guard(() async {
await ref.read(userRepositoryProvider).deleteUser(userId);
return await ref.read(userRepositoryProvider).getUsers();
});
}
}
// UI에서 사용
class UserListScreen extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final usersAsync = ref.watch(userNotifierProvider);
return Scaffold(
appBar: AppBar(title: Text('Users')),
body: usersAsync.when(
data: (users) => ListView.builder(
itemCount: users.length,
itemBuilder: (context, index) {
final user = users[index];
return ListTile(
title: Text(user.name),
trailing: IconButton(
icon: Icon(Icons.delete),
onPressed: () => ref
.read(userNotifierProvider.notifier)
.deleteUser(user.id),
),
);
},
),
loading: () => Center(child: CircularProgressIndicator()),
error: (error, stack) => Center(
child: Text('Error: $error'),
),
),
floatingActionButton: FloatingActionButton(
onPressed: () => _showAddUserDialog(context, ref),
child: Icon(Icons.add),
),
);
}
}
// 의존성 주입
@riverpod
UserRepository userRepository(UserRepositoryRef ref) {
return UserRepository(ref.read(httpClientProvider));
}
Riverpod 선택 이유:
- 타입 안전성: 컴파일 타임 오류 검출
- 개발자 경험: 뛰어난 디버깅 도구
- 테스트 친화적: 쉬운 모킹과 테스트
- 성능: 최적화된 재빌드 시스템
🏭 대규모 프로젝트 (화면 20개 이상)
추천: BLoC Pattern
// 이벤트 정의
abstract class UserEvent extends Equatable {
const UserEvent();
@override
List<Object> get props => [];
}
class UserLoadRequested extends UserEvent {}
class UserAdded extends UserEvent {
const UserAdded(this.user);
final User user;
@override
List<Object> get props => [user];
}
class UserDeleted extends UserEvent {
const UserDeleted(this.userId);
final String userId;
@override
List<Object> get props => [userId];
}
// 상태 정의
abstract class UserState extends Equatable {
const UserState();
@override
List<Object> get props => [];
}
class UserInitial extends UserState {}
class UserLoadInProgress extends UserState {}
class UserLoadSuccess extends UserState {
const UserLoadSuccess(this.users);
final List<User> users;
@override
List<Object> get props => [users];
}
class UserLoadFailure extends UserState {
const UserLoadFailure(this.error);
final String error;
@override
List<Object> get props => [error];
}
// BLoC 구현
class UserBloc extends Bloc<UserEvent, UserState> {
UserBloc({required UserRepository userRepository})
: _userRepository = userRepository,
super(UserInitial()) {
on<UserLoadRequested>(_onUserLoadRequested);
on<UserAdded>(_onUserAdded);
on<UserDeleted>(_onUserDeleted);
}
final UserRepository _userRepository;
Future<void> _onUserLoadRequested(
UserLoadRequested event,
Emitter<UserState> emit,
) async {
emit(UserLoadInProgress());
try {
final users = await _userRepository.getUsers();
emit(UserLoadSuccess(users));
} catch (error) {
emit(UserLoadFailure(error.toString()));
}
}
Future<void> _onUserAdded(
UserAdded event,
Emitter<UserState> emit,
) async {
try {
await _userRepository.addUser(event.user);
add(UserLoadRequested());
} catch (error) {
emit(UserLoadFailure(error.toString()));
}
}
Future<void> _onUserDeleted(
UserDeleted event,
Emitter<UserState> emit,
) async {
try {
await _userRepository.deleteUser(event.userId);
add(UserLoadRequested());
} catch (error) {
emit(UserLoadFailure(error.toString()));
}
}
}
// UI에서 사용
class UserListScreen extends StatelessWidget {
@override
Widget build(BuildContext context) {
return BlocProvider(
create: (context) => UserBloc(
userRepository: context.read<UserRepository>(),
)..add(UserLoadRequested()),
child: Scaffold(
appBar: AppBar(title: Text('Users')),
body: BlocBuilder<UserBloc, UserState>(
builder: (context, state) {
if (state is UserLoadInProgress) {
return Center(child: CircularProgressIndicator());
} else if (state is UserLoadSuccess) {
return ListView.builder(
itemCount: state.users.length,
itemBuilder: (context, index) {
final user = state.users[index];
return ListTile(
title: Text(user.name),
trailing: IconButton(
icon: Icon(Icons.delete),
onPressed: () => context
.read<UserBloc>()
.add(UserDeleted(user.id)),
),
);
},
);
} else if (state is UserLoadFailure) {
return Center(child: Text('Error: ${state.error}'));
}
return Container();
},
),
floatingActionButton: FloatingActionButton(
onPressed: () => _showAddUserDialog(context),
child: Icon(Icons.add),
),
),
);
}
}
BLoC 선택 이유:
- 명확한 아키텍처: 이벤트 기반의 예측 가능한 흐름
- 테스트 용이성: 비즈니스 로직과 UI 완전 분리
- 확장성: 대규모 팀 개발에 적합
- 디버깅: 모든 상태 변화 추적 가능
심화: 각 상태 관리 패턴의 실무 활용
Riverpod: 현대적 상태 관리의 정수
1. Provider 종류별 활용법
// StateProvider: 간단한 상태
final counterProvider = StateProvider<int>((ref) => 0);
// FutureProvider: 비동기 데이터
final userProvider = FutureProvider<User>((ref) async {
final userId = ref.watch(currentUserIdProvider);
return ref.read(userRepositoryProvider).getUser(userId);
});
// StreamProvider: 실시간 데이터
final messagesProvider = StreamProvider<List<Message>>((ref) {
final chatId = ref.watch(currentChatIdProvider);
return ref.read(messageRepositoryProvider).watchMessages(chatId);
});
// NotifierProvider: 복잡한 상태 로직
@riverpod
class ShoppingCart extends _$ShoppingCart {
@override
List<CartItem> build() => [];
void addItem(Product product) {
final existingIndex = state.indexWhere((item) => item.product.id == product.id);
if (existingIndex >= 0) {
state = [
...state.sublist(0, existingIndex),
state[existingIndex].copyWith(quantity: state[existingIndex].quantity + 1),
...state.sublist(existingIndex + 1),
];
} else {
state = [...state, CartItem(product: product, quantity: 1)];
}
}
void removeItem(String productId) {
state = state.where((item) => item.product.id != productId).toList();
}
double get totalPrice => state.fold(
0.0,
(sum, item) => sum + (item.product.price * item.quantity),
);
}
2. 의존성 주입과 테스트
// 리포지토리 프로바이더
@riverpod
UserRepository userRepository(UserRepositoryRef ref) {
return UserRepository(
httpClient: ref.read(httpClientProvider),
localDatabase: ref.read(localDatabaseProvider),
);
}
// 테스트에서 오버라이드
void main() {
testWidgets('user list displays users', (tester) async {
await tester.pumpWidget(
ProviderScope(
overrides: [
userRepositoryProvider.overrideWithValue(
MockUserRepository(), // 목 객체로 대체
),
],
child: UserListScreen(),
),
);
// 테스트 로직...
});
}
3. 상태 조합과 최적화
// 여러 상태를 조합
final searchResultsProvider = Provider<List<User>>((ref) {
final users = ref.watch(usersProvider).value ?? [];
final searchQuery = ref.watch(searchQueryProvider);
if (searchQuery.isEmpty) return users;
return users.where((user) =>
user.name.toLowerCase().contains(searchQuery.toLowerCase())
).toList();
});
// 선택적 리빌드
class UserTile extends ConsumerWidget {
const UserTile({required this.userId, Key? key}) : super(key: key);
final String userId;
@override
Widget build(BuildContext context, WidgetRef ref) {
// 특정 사용자만 watch
final user = ref.watch(userProvider(userId)).value;
if (user == null) return SizedBox.shrink();
return ListTile(
title: Text(user.name),
subtitle: Text(user.email),
);
}
}
GetX: 빠른 개발을 위한 올인원 솔루션
// GetX Controller
class UserController extends GetxController {
// 반응형 변수
final users = <User>[].obs;
final isLoading = false.obs;
final selectedUser = Rx<User?>(null);
// 의존성 주입
final UserRepository _repository = Get.find<UserRepository>();
@override
void onInit() {
super.onInit();
loadUsers();
}
Future<void> loadUsers() async {
try {
isLoading.value = true;
final userList = await _repository.getUsers();
users.assignAll(userList);
} catch (e) {
Get.snackbar('Error', 'Failed to load users');
} finally {
isLoading.value = false;
}
}
void selectUser(User user) {
selectedUser.value = user;
Get.toNamed('/user-detail', arguments: user);
}
// 계산된 값
List<User> get activeUsers => users.where((user) => user.isActive).toList();
// 워커 (부작용 처리)
@override
void onReady() {
super.onReady();
// 사용자가 선택될 때마다 분석 이벤트 전송
ever(selectedUser, (User? user) {
if (user != null) {
Analytics.track('user_selected', {'userId': user.id});
}
});
// 사용자 목록이 비어있으면 경고 표시
ever(users, (List<User> userList) {
if (userList.isEmpty) {
Get.dialog(AlertDialog(
title: Text('No Users'),
content: Text('No users found. Please add some users.'),
));
}
});
}
}
// UI에서 사용
class UserListScreen extends GetView<UserController> {
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text('Users')),
body: Obx(() {
if (controller.isLoading.value) {
return Center(child: CircularProgressIndicator());
}
return ListView.builder(
itemCount: controller.users.length,
itemBuilder: (context, index) {
final user = controller.users[index];
return ListTile(
title: Text(user.name),
onTap: () => controller.selectUser(user),
);
},
);
}),
floatingActionButton: FloatingActionButton(
onPressed: controller.loadUsers,
child: Icon(Icons.refresh),
),
);
}
}
// 라우팅과 의존성 주입
class AppPages {
static final routes = [
GetPage(
name: '/users',
page: () => UserListScreen(),
binding: UserBinding(), // 의존성 바인딩
),
GetPage(
name: '/user-detail',
page: () => UserDetailScreen(),
),
];
}
class UserBinding extends Bindings {
@override
void dependencies() {
Get.lazyPut<UserRepository>(() => UserRepositoryImpl());
Get.lazyPut<UserController>(() => UserController());
}
}
GetX 장점:
- 빠른 개발: 상태 관리 + 라우팅 + 의존성 주입 통합
- 간단한 문법: 반응형 변수와 Obx 위젯
- 성능: 스마트한 리빌드 시스템
GetX 단점:
- 강한 결합: GetX에 종속적
- 디버깅 어려움: 매직 같은 동작으로 추적 어려움
- 테스트 복잡성: 글로벌 상태로 인한 테스트 격리 어려움
BLoC: 엔터프라이즈급 아키텍처
1. 복잡한 비즈니스 로직 처리
// 복잡한 검색 로직을 가진 BLoC
class ProductSearchBloc extends Bloc<ProductSearchEvent, ProductSearchState> {
ProductSearchBloc({
required ProductRepository productRepository,
required UserPreferencesRepository preferencesRepository,
}) : _productRepository = productRepository,
_preferencesRepository = preferencesRepository,
super(ProductSearchInitial()) {
on<ProductSearchQueryChanged>(_onQueryChanged);
on<ProductSearchFiltersChanged>(_onFiltersChanged);
on<ProductSearchRequested>(_onSearchRequested);
on<ProductSearchSortChanged>(_onSortChanged);
}
final ProductRepository _productRepository;
final UserPreferencesRepository _preferencesRepository;
// 디바운싱을 통한 검색 최적화
Timer? _debounceTimer;
Future<void> _onQueryChanged(
ProductSearchQueryChanged event,
Emitter<ProductSearchState> emit,
) async {
_debounceTimer?.cancel();
final currentState = state;
if (currentState is ProductSearchSuccess) {
emit(currentState.copyWith(query: event.query));
}
_debounceTimer = Timer(Duration(milliseconds: 500), () {
add(ProductSearchRequested());
});
}
Future<void> _onFiltersChanged(
ProductSearchFiltersChanged event,
Emitter<ProductSearchState> emit,
) async {
final currentState = state;
if (currentState is ProductSearchSuccess) {
emit(currentState.copyWith(filters: event.filters));
add(ProductSearchRequested());
}
}
Future<void> _onSearchRequested(
ProductSearchRequested event,
Emitter<ProductSearchState> emit,
) async {
try {
final currentState = state;
if (currentState is ProductSearchSuccess) {
emit(currentState.copyWith(isLoading: true));
} else {
emit(ProductSearchLoading());
}
final searchParams = _buildSearchParams(currentState);
final products = await _productRepository.searchProducts(searchParams);
// 사용자 선호도에 따른 정렬
final userPreferences = await _preferencesRepository.getPreferences();
final sortedProducts = _sortProducts(products, userPreferences);
emit(ProductSearchSuccess(
products: sortedProducts,
query: searchParams.query,
filters: searchParams.filters,
sortOption: searchParams.sortOption,
hasMore: products.length >= searchParams.limit,
));
} catch (error) {
emit(ProductSearchFailure(error.toString()));
}
}
SearchParams _buildSearchParams(ProductSearchState currentState) {
// 현재 상태에서 검색 파라미터 구성
return SearchParams(
query: currentState is ProductSearchSuccess ? currentState.query : '',
filters: currentState is ProductSearchSuccess ? currentState.filters : {},
sortOption: currentState is ProductSearchSuccess ? currentState.sortOption : SortOption.relevance,
limit: 20,
);
}
List<Product> _sortProducts(List<Product> products, UserPreferences preferences) {
// 사용자 선호도 기반 정렬 로직
return products..sort((a, b) {
// 복잡한 정렬 로직...
return a.relevanceScore.compareTo(b.relevanceScore);
});
}
@override
Future<void> close() {
_debounceTimer?.cancel();
return super.close();
}
}
2. 멀티 BLoC 통신
// 주문 과정에서 여러 BLoC 간 통신
class CheckoutBloc extends Bloc<CheckoutEvent, CheckoutState> {
CheckoutBloc({
required this.cartBloc,
required this.userBloc,
required this.paymentBloc,
}) : super(CheckoutInitial()) {
// 다른 BLoC의 상태 변화 감지
_cartSubscription = cartBloc.stream.listen((cartState) {
if (cartState is CartUpdated) {
add(CheckoutCartUpdated(cartState.items));
}
});
_userSubscription = userBloc.stream.listen((userState) {
if (userState is UserAddressUpdated) {
add(CheckoutAddressUpdated(userState.address));
}
});
on<CheckoutStarted>(_onCheckoutStarted);
on<CheckoutCartUpdated>(_onCartUpdated);
on<CheckoutAddressUpdated>(_onAddressUpdated);
on<CheckoutPaymentRequested>(_onPaymentRequested);
}
final CartBloc cartBloc;
final UserBloc userBloc;
final PaymentBloc paymentBloc;
StreamSubscription? _cartSubscription;
StreamSubscription? _userSubscription;
Future<void> _onPaymentRequested(
CheckoutPaymentRequested event,
Emitter<CheckoutState> emit,
) async {
emit(CheckoutProcessing());
try {
// 결제 처리
paymentBloc.add(PaymentProcessRequested(
amount: event.amount,
paymentMethod: event.paymentMethod,
));
// 결제 결과 대기
await for (final paymentState in paymentBloc.stream) {
if (paymentState is PaymentSuccess) {
// 주문 완료 처리
emit(CheckoutSuccess(paymentState.transactionId));
break;
} else if (paymentState is PaymentFailure) {
emit(CheckoutFailure(paymentState.error));
break;
}
}
} catch (error) {
emit(CheckoutFailure(error.toString()));
}
}
@override
Future<void> close() {
_cartSubscription?.cancel();
_userSubscription?.cancel();
return super.close();
}
}
실무 프로젝트 아키텍처 패턴
클린 아키텍처 + Riverpod
// Domain Layer
abstract class UserRepository {
Future<List<User>> getUsers();
Future<User> getUser(String id);
Future<void> createUser(User user);
Future<void> updateUser(User user);
Future<void> deleteUser(String id);
}
class GetUsersUseCase {
GetUsersUseCase(this._repository);
final UserRepository _repository;
Future<List<User>> call() async {
try {
return await _repository.getUsers();
} catch (e) {
throw UserException('Failed to load users: $e');
}
}
}
// Infrastructure Layer
class UserRepositoryImpl implements UserRepository {
UserRepositoryImpl({
required this.remoteDataSource,
required this.localDataSource,
});
final UserRemoteDataSource remoteDataSource;
final UserLocalDataSource localDataSource;
@override
Future<List<User>> getUsers() async {
try {
final users = await remoteDataSource.getUsers();
await localDataSource.cacheUsers(users);
return users;
} catch (e) {
// 네트워크 오류 시 캐시된 데이터 반환
return await localDataSource.getCachedUsers();
}
}
}
// Presentation Layer (Riverpod)
@riverpod
UserRepository userRepository(UserRepositoryRef ref) {
return UserRepositoryImpl(
remoteDataSource: ref.read(userRemoteDataSourceProvider),
localDataSource: ref.read(userLocalDataSourceProvider),
);
}
@riverpod
GetUsersUseCase getUsersUseCase(GetUsersUseCaseRef ref) {
return GetUsersUseCase(ref.read(userRepositoryProvider));
}
@riverpod
class UserNotifier extends _$UserNotifier {
@override
FutureOr<List<User>> build() {
return ref.read(getUsersUseCaseProvider)();
}
Future<void> refresh() async {
state = const AsyncLoading();
state = await AsyncValue.guard(() =>
ref.read(getUsersUseCaseProvider)()
);
}
}
MVVM + GetX
// Model
class User {
final String id;
final String name;
final String email;
User({required this.id, required this.name, required this.email});
}
// Repository
abstract class UserRepository {
Future<List<User>> getUsers();
Future<void> createUser(User user);
}
class UserRepositoryImpl implements UserRepository {
final ApiService _apiService;
UserRepositoryImpl(this._apiService);
@override
Future<List<User>> getUsers() => _apiService.getUsers();
@override
Future<void> createUser(User user) => _apiService.createUser(user);
}
// ViewModel (Controller)
class UserViewModel extends GetxController {
final UserRepository _repository;
UserViewModel(this._repository);
// Observable state
final users = <User>[].obs;
final isLoading = false.obs;
final error = RxnString();
@override
void onInit() {
super.onInit();
loadUsers();
}
Future<void> loadUsers() async {
try {
isLoading.value = true;
error.value = null;
final userList = await _repository.getUsers();
users.assignAll(userList);
} catch (e) {
error.value = e.toString();
} finally {
isLoading.value = false;
}
}
Future<void> addUser(User user) async {
try {
await _repository.createUser(user);
users.add(user);
Get.snackbar('Success', 'User added successfully');
} catch (e) {
Get.snackbar('Error', 'Failed to add user: $e');
}
}
}
// View
class UserListView extends GetView<UserViewModel> {
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text('Users')),
body: Obx(() {
if (controller.isLoading.value) {
return Center(child: CircularProgressIndicator());
}
if (controller.error.value != null) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text('Error: ${controller.error.value}'),
ElevatedButton(
onPressed: controller.loadUsers,
child: Text('Retry'),
),
],
),
);
}
return ListView.builder(
itemCount: controller.users.length,
itemBuilder: (context, index) {
final user = controller.users[index];
return ListTile(
title: Text(user.name),
subtitle: Text(user.email),
);
},
);
}),
);
}
}
// Dependency Injection
class UserBinding extends Bindings {
@override
void dependencies() {
Get.lazyPut<ApiService>(() => ApiServiceImpl());
Get.lazyPut<UserRepository>(() => UserRepositoryImpl(Get.find()));
Get.lazyPut<UserViewModel>(() => UserViewModel(Get.find()));
}
}
성능 최적화와 베스트 프랙티스
1. 불필요한 재빌드 방지
// ❌ 잘못된 예: 전체 화면이 재빌드됨
class BadCounterScreen extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final counter = ref.watch(counterProvider);
return Scaffold(
appBar: AppBar(title: Text('Counter')),
body: Column(
children: [
ExpensiveWidget(), // counter 변경 시 불필요하게 재빌드됨
Text('Count: $counter'),
AnotherExpensiveWidget(), // 이것도 재빌드됨
],
),
);
}
}
// ✅ 올바른 예: 필요한 부분만 재빌드
class GoodCounterScreen extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text('Counter')),
body: Column(
children: [
ExpensiveWidget(), // 재빌드되지 않음
Consumer(
builder: (context, ref, child) {
final counter = ref.watch(counterProvider);
return Text('Count: $counter'); // 이 부분만 재빌드
},
),
AnotherExpensiveWidget(), // 재빌드되지 않음
],
),
);
}
}
2. 상태 선택적 구독
// 큰 객체에서 특정 필드만 구독
@riverpod
class UserProfile extends _$UserProfile {
@override
UserProfileState build() {
return UserProfileState(
name: '',
email: '',
avatar: '',
preferences: UserPreferences.empty(),
);
}
void updateName(String name) {
state = state.copyWith(name: name);
}
void updateEmail(String email) {
state = state.copyWith(email: email);
}
}
// 이름만 변경되어도 전체가 재빌드되는 문제 해결
class UserNameWidget extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
// 이름만 선택적으로 구독
final name = ref.watch(userProfileProvider.select((user) => user.name));
return Text(name);
}
}
class UserEmailWidget extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
// 이메일만 선택적으로 구독
final email = ref.watch(userProfileProvider.select((user) => user.email));
return Text(email);
}
}
3. 비동기 상태 처리 패턴
// 로딩, 에러, 성공 상태를 우아하게 처리
@riverpod
class ProductList extends _$ProductList {
@override
FutureOr<List<Product>> build() async {
// 캐시된 데이터가 있으면 즉시 반환
final cached = ref.read(productCacheProvider);
if (cached.isNotEmpty) {
// 백그라운드에서 새 데이터 로드
_loadInBackground();
return cached;
}
return await _loadProducts();
}
Future<void> refresh() async {
state = const AsyncLoading();
state = await AsyncValue.guard(() => _loadProducts());
}
Future<List<Product>> _loadProducts() async {
final products = await ref.read(productRepositoryProvider).getProducts();
ref.read(productCacheProvider.notifier).cache(products);
return products;
}
void _loadInBackground() async {
try {
final products = await _loadProducts();
state = AsyncData(products);
} catch (e) {
// 백그라운드 로딩 실패는 무시 (캐시된 데이터 유지)
}
}
}
// UI에서 사용
class ProductListView extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final productsAsync = ref.watch(productListProvider);
return Scaffold(
appBar: AppBar(
title: Text('Products'),
actions: [
IconButton(
icon: Icon(Icons.refresh),
onPressed: () => ref.refresh(productListProvider),
),
],
),
body: productsAsync.when(
data: (products) => RefreshIndicator(
onRefresh: () => ref.refresh(productListProvider),
child: ListView.builder(
itemCount: products.length,
itemBuilder: (context, index) => ProductTile(products[index]),
),
),
loading: () => Center(child: CircularProgressIndicator()),
error: (error, stack) => ErrorWidget(
error: error,
onRetry: () => ref.refresh(productListProvider),
),
),
);
}
}
테스트 전략
Riverpod 테스트
// 테스트용 가짜 리포지토리
class FakeUserRepository implements UserRepository {
List<User> _users = [
User(id: '1', name: 'John', email: 'john@example.com'),
User(id: '2', name: 'Jane', email: 'jane@example.com'),
];
@override
Future<List<User>> getUsers() async {
await Future.delayed(Duration(milliseconds: 100)); // 네트워크 지연 시뮬레이션
return _users;
}
@override
Future<void> addUser(User user) async {
_users.add(user);
}
@override
Future<void> deleteUser(String id) async {
_users.removeWhere((user) => user.id == id);
}
}
// 유닛 테스트
void main() {
group('UserNotifier', () {
late ProviderContainer container;
late FakeUserRepository fakeRepository;
setUp(() {
fakeRepository = FakeUserRepository();
container = ProviderContainer(
overrides: [
userRepositoryProvider.overrideWithValue(fakeRepository),
],
);
});
tearDown(() {
container.dispose();
});
test('should load users successfully', () async {
final notifier = container.read(userNotifierProvider.notifier);
final users = await container.read(userNotifierProvider.future);
expect(users.length, equals(2));
expect(users[0].name, equals('John'));
});
test('should add user successfully', () async {
final notifier = container.read(userNotifierProvider.notifier);
await notifier.addUser(User(
id: '3',
name: 'Bob',
email: 'bob@example.com',
));
final users = await container.read(userNotifierProvider.future);
expect(users.length, equals(3));
expect(users.last.name, equals('Bob'));
});
test('should handle repository errors', () async {
// 에러를 발생시키는 리포지토리로 교체
container = ProviderContainer(
overrides: [
userRepositoryProvider.overrideWithValue(
ErrorUserRepository(), // 항상 에러를 발생시키는 구현
),
],
);
final asyncValue = container.read(userNotifierProvider);
expect(asyncValue.hasError, isTrue);
});
});
}
// 위젯 테스트
void main() {
group('UserListScreen', () {
testWidgets('displays loading indicator initially', (tester) async {
await tester.pumpWidget(
ProviderScope(
overrides: [
userRepositoryProvider.overrideWithValue(
SlowUserRepository(), // 느린 응답을 시뮬레이션
),
],
child: MaterialApp(home: UserListScreen()),
),
);
expect(find.byType(CircularProgressIndicator), findsOneWidget);
});
testWidgets('displays users after loading', (tester) async {
await tester.pumpWidget(
ProviderScope(
overrides: [
userRepositoryProvider.overrideWithValue(FakeUserRepository()),
],
child: MaterialApp(home: UserListScreen()),
),
);
// 로딩 완료까지 대기
await tester.pumpAndSettle();
expect(find.text('John'), findsOneWidget);
expect(find.text('Jane'), findsOneWidget);
});
testWidgets('can add new user', (tester) async {
await tester.pumpWidget(
ProviderScope(
overrides: [
userRepositoryProvider.overrideWithValue(FakeUserRepository()),
],
child: MaterialApp(home: UserListScreen()),
),
);
await tester.pumpAndSettle();
// 추가 버튼 탭
await tester.tap(find.byIcon(Icons.add));
await tester.pumpAndSettle();
// 사용자 정보 입력
await tester.enterText(find.byKey(Key('name_field')), 'Bob');
await tester.enterText(find.byKey(Key('email_field')), 'bob@example.com');
// 저장 버튼 탭
await tester.tap(find.text('Save'));
await tester.pumpAndSettle();
// 새 사용자가 목록에 추가되었는지 확인
expect(find.text('Bob'), findsOneWidget);
});
});
}
BLoC 테스트
// BLoC 테스트
void main() {
group('UserBloc', () {
late UserBloc userBloc;
late MockUserRepository mockRepository;
setUp(() {
mockRepository = MockUserRepository();
userBloc = UserBloc(userRepository: mockRepository);
});
tearDown(() {
userBloc.close();
});
test('initial state is UserInitial', () {
expect(userBloc.state, equals(UserInitial()));
});
blocTest<UserBloc, UserState>(
'emits [UserLoadInProgress, UserLoadSuccess] when users are loaded successfully',
build: () {
when(() => mockRepository.getUsers())
.thenAnswer((_) async => [
User(id: '1', name: 'John'),
User(id: '2', name: 'Jane'),
]);
return userBloc;
},
act: (bloc) => bloc.add(UserLoadRequested()),
expect: () => [
UserLoadInProgress(),
UserLoadSuccess([
User(id: '1', name: 'John'),
User(id: '2', name: 'Jane'),
]),
],
);
blocTest<UserBloc, UserState>(
'emits [UserLoadInProgress, UserLoadFailure] when loading fails',
build: () {
when(() => mockRepository.getUsers())
.thenThrow(Exception('Network error'));
return userBloc;
},
act: (bloc) => bloc.add(UserLoadRequested()),
expect: () => [
UserLoadInProgress(),
UserLoadFailure('Exception: Network error'),
],
);
});
}
상태 관리 선택 가이드: 최종 결론
프로젝트 특성별 추천
| 프로젝트 특성 | 1순위 | 2순위 | 비고 |
|---|---|---|---|
| 프로토타입/MVP | GetX | setState | 빠른 개발이 우선 |
| 중소규모 앱 | Riverpod | Provider | 안정성과 확장성 |
| 대기업/엔터프라이즈 | BLoC | Riverpod | 명확한 아키텍처 필요 |
| 팀 개발 (5명 이상) | BLoC | Riverpod | 코드 컨벤션 통일 |
| 복잡한 비즈니스 로직 | BLoC | Clean Architecture + Riverpod | 테스트 용이성 |
| 실시간 기능 중심 | GetX | Riverpod | 반응형 프로그래밍 |
학습 순서 추천
- 1단계: setState와 InheritedWidget으로 기본 개념 이해
- 2단계: Riverpod으로 현대적 상태 관리 패턴 학습
- 3단계: 프로젝트 요구사항에 따라 GetX 또는 BLoC 선택
마이그레이션 전략
// 1단계: setState → Riverpod
// 기존 StatefulWidget을 ConsumerStatefulWidget으로 변경
class OldCounterWidget extends StatefulWidget {
@override
_OldCounterWidgetState createState() => _OldCounterWidgetState();
}
class _OldCounterWidgetState extends State<OldCounterWidget> {
int _counter = 0;
void _increment() {
setState(() {
_counter++;
});
}
@override
Widget build(BuildContext context) {
return Text('$_counter');
}
}
// 새로운 Riverpod 버전
final counterProvider = StateProvider<int>((ref) => 0);
class NewCounterWidget extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final counter = ref.watch(counterProvider);
return GestureDetector(
onTap: () => ref.read(counterProvider.notifier).state++,
child: Text('$counter'),
);
}
}
마무리: 상태 관리의 미래
Flutter의 상태 관리는 계속 진화하고 있습니다. Riverpod은 현재 가장 현대적이고 권장되는 접근법이며, GetX는 빠른 프로토타이핑에, BLoC은 대규모 프로젝트에 최적화되어 있습니다.
핵심 원칙
- 단순함에서 시작하세요: setState부터 시작해 점진적으로 발전
- 팀과 프로젝트에 맞는 선택: 은총알은 없습니다
- 테스트 가능성을 고려하세요: 비즈니스 로직과 UI의 분리
- 성능을 모니터링하세요: 불필요한 재빌드 최소화
다음 학습 단계
- Riverpod Generator: 타입 안전성을 극대화하는 코드 생성
- State Restoration: 앱 재시작 시 상태 복원
- Hydrated BLoC: 상태 지속성을 위한 자동 직렬화
기억하세요: 완벽한 상태 관리 솔루션은 없습니다. 프로젝트의 요구사항, 팀의 경험, 장기적인 유지보수성을 고려하여 현명하게 선택하세요. 가장 중요한 것은 일관성 있는 패턴을 유지하는 것입니다.
추가 학습 자료
공식 문서
커뮤니티 리소스
이제 여러분만의 Flutter 앱에서 적절한 상태 관리를 선택하고 구현해보세요!
답글 남기기