안녕하세요, 우기입니다! Flutter 개발 가이드라인 시리즈의 네 번째 글을 시작합니다. 이번에는 Flutter 앱에서 가장 중요한 측면 중 하나인 '상태 관리'에 대해 깊이 있게 알아보겠습니다.
상태 관리는 Flutter 앱 개발에서 가장 많이 논의되는 주제 중 하나입니다. 다양한 상태 관리 솔루션이 존재하며, 각각의 장단점과 적합한 사용 사례가 있습니다. 이 글에서는 Flutter에서 널리 사용되는 상태 관리 솔루션들을 비교하고, 효과적인 사용 패턴을 소개하겠습니다.
이 글에서 다룰 내용:
'상태'는 특정 시점에서 앱의 데이터를 나타냅니다. 이는 사용자 정보, UI 표시 여부, 네트워크 요청 상태 등 앱의 동작에 영향을 미치는 모든 데이터를 포함합니다. Flutter 앱에서 상태는 크게 다음과 같이 분류할 수 있습니다:
UI 상태 (Local State): 특정 위젯에만 관련된 상태
앱 상태 (Global State): 앱 전체에서 공유되는 상태
임시 상태 (Ephemeral State): 짧은 시간 동안만 유지되는 상태
효과적인 상태 관리는 다음과 같은 이유로 중요합니다:
Flutter 생태계에는 많은 상태 관리 솔루션이 있습니다. 이 중 가장 널리 사용되는 세 가지 솔루션을 중점적으로 살펴보겠습니다.
BLoC(Business Logic Component) 패턴은 비즈니스 로직을 UI에서 분리하기 위해 설계되었습니다. 이 패턴은 이벤트가 BLoC에 전달되고, BLoC은 이벤트를 처리한 후 새로운 상태를 스트림을 통해 UI에 제공합니다.
주요 특징:
예제 코드:
// 이벤트 정의
abstract class CounterEvent {}
class IncrementEvent extends CounterEvent {}
class DecrementEvent extends CounterEvent {}
// 상태 정의
class CounterState {
final int count;
const CounterState(this.count);
}
// BLoC 구현
class CounterBloc extends Bloc<CounterEvent, CounterState> {
CounterBloc() : super(const CounterState(0)) {
on<IncrementEvent>((event, emit) {
emit(CounterState(state.count + 1));
});
on<DecrementEvent>((event, emit) {
emit(CounterState(state.count - 1));
});
}
}
// UI에서 사용
class CounterPage extends StatelessWidget {
Widget build(BuildContext context) {
return BlocProvider(
create: (_) => CounterBloc(),
child: CounterView(),
);
}
}
class CounterView extends StatelessWidget {
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text('카운터')),
body: Center(
child: BlocBuilder<CounterBloc, CounterState>(
builder: (context, state) {
return Text(
'${state.count}',
style: Theme.of(context).textTheme.headlineMedium,
);
},
),
),
floatingActionButton: Column(
mainAxisAlignment: MainAxisAlignment.end,
crossAxisAlignment: CrossAxisAlignment.end,
children: [
FloatingActionButton(
child: Icon(Icons.add),
onPressed: () => context.read<CounterBloc>().add(IncrementEvent()),
),
SizedBox(height: 8),
FloatingActionButton(
child: Icon(Icons.remove),
onPressed: () => context.read<CounterBloc>().add(DecrementEvent()),
),
],
),
);
}
}
장점:
단점:
GetX는 상태 관리, 의존성 관리, 라우팅을 포함한 올인원 솔루션입니다. 다양한 기능을 제공하면서도 최소한의 코드로 간결한 API를 제공하는 것이 특징입니다.
주요 특징:
예제 코드:
// 컨트롤러 정의
class CounterController extends GetxController {
// 반응형 변수
final count = 0.obs;
void increment() => count.value++;
void decrement() => count.value--;
}
// UI에서 사용
class CounterPage extends StatelessWidget {
Widget build(BuildContext context) {
// 컨트롤러 초기화
final CounterController controller = Get.put(CounterController());
return Scaffold(
appBar: AppBar(title: Text('카운터')),
body: Center(
child: Obx(() => Text(
'${controller.count.value}',
style: Theme.of(context).textTheme.headlineMedium,
)),
),
floatingActionButton: Column(
mainAxisAlignment: MainAxisAlignment.end,
crossAxisAlignment: CrossAxisAlignment.end,
children: [
FloatingActionButton(
child: Icon(Icons.add),
onPressed: controller.increment,
),
SizedBox(height: 8),
FloatingActionButton(
child: Icon(Icons.remove),
onPressed: controller.decrement,
),
],
),
);
}
}
장점:
단점:
Riverpod는 Provider의 단점을 개선한 발전된 상태 관리 라이브러리입니다. Provider의 주요 한계를 극복하면서 향상된 타입 안전성과 의존성 관리를 제공합니다.
주요 특징:
예제 코드:
// 상태 정의 (freezed 사용)
class CounterState with _$CounterState {
const factory CounterState({(0) int count}) = _CounterState;
}
// 상태 제공자 정의
class CounterNotifier extends _$CounterNotifier {
CounterState build() {
return const CounterState();
}
void increment() {
state = state.copyWith(count: state.count + 1);
}
void decrement() {
state = state.copyWith(count: state.count - 1);
}
}
// UI에서 사용
class CounterPage extends ConsumerWidget {
Widget build(BuildContext context, WidgetRef ref) {
final counter = ref.watch(counterNotifierProvider);
return Scaffold(
appBar: AppBar(title: Text('카운터')),
body: Center(
child: Text(
'${counter.count}',
style: Theme.of(context).textTheme.headlineMedium,
),
),
floatingActionButton: Column(
mainAxisAlignment: MainAxisAlignment.end,
crossAxisAlignment: CrossAxisAlignment.end,
children: [
FloatingActionButton(
child: Icon(Icons.add),
onPressed: () => ref.read(counterNotifierProvider.notifier).increment(),
),
SizedBox(height: 8),
FloatingActionButton(
child: Icon(Icons.remove),
onPressed: () => ref.read(counterNotifierProvider.notifier).decrement(),
),
],
),
);
}
}
장점:
단점:
각 상태 관리 솔루션에 관계없이, 효과적인 상태 관리를 위한 몇 가지 공통 패턴이 있습니다.
상태 관리 컴포넌트는 단일 책임을 가져야 합니다. 너무 많은 책임을 하나의 상태 관리자에 집중시키면 복잡성이 증가하고 유지보수가 어려워집니다.
// ❌ 잘못된 방법: 모든 것을 처리하는 하나의 거대한 BLoC
class SuperAppBloc extends Bloc<AppEvent, AppState> {
// 사용자 인증 관련 이벤트 처리
// 장바구니 관련 이벤트 처리
// 주문 처리 관련 이벤트 처리
// 설정 관련 이벤트 처리
// ...
}
// ✅ 좋은 방법: 각 기능별로 분리된 BLoC
class AuthBloc extends Bloc<AuthEvent, AuthState> {
// 사용자 인증 관련 이벤트만 처리
}
class CartBloc extends Bloc<CartEvent, CartState> {
// 장바구니 관련 이벤트만 처리
}
class OrderBloc extends Bloc<OrderEvent, OrderState> {
// 주문 처리 관련 이벤트만 처리
}
이는 다른 상태 관리 솔루션에도 동일하게 적용됩니다:
// ❌ 잘못된 방법: 모든 기능을 관리하는 하나의 GetX 컨트롤러
class AppController extends GetxController {
// 사용자 데이터 관리
// 장바구니 관리
// 주문 관리
// 설정 관리
// ...
}
// ✅ 좋은 방법: 각 기능별로 분리된 컨트롤러
class UserController extends GetxController {
// 사용자 데이터 관리
}
class CartController extends GetxController {
// 장바구니 관리
}
class OrderController extends GetxController {
// 주문 관리
}
상태 객체는 불변(immutable)해야 합니다. 불변 상태는 예측 가능한 앱 동작을 보장하고 디버깅을 용이하게 합니다.
// ❌ 잘못된 방법: 가변 상태
class MutableUserState {
String name;
int age;
List<String> hobbies;
MutableUserState({
required this.name,
required this.age,
required this.hobbies,
});
// 직접 속성 변경 가능
void updateName(String newName) {
name = newName;
}
void addHobby(String hobby) {
hobbies.add(hobby); // 원본 리스트 수정
}
}
// ✅ 좋은 방법: 불변 상태 (freezed 패키지 사용)
class UserState with _$UserState {
const factory UserState({
required String name,
required int age,
required List<String> hobbies,
}) = _UserState;
// 새 상태를 생성하는 메서드
const UserState._();
UserState updateName(String newName) {
return copyWith(name: newName);
}
UserState addHobby(String hobby) {
return copyWith(hobbies: [...hobbies, hobby]); // 새 리스트 생성
}
}
상태 변화는 단방향으로 흘러야 예측 가능합니다. UI에서 이벤트가 발생하면 상태 관리자가 이를 처리하고 새 상태를 생성하며, UI는 이 새 상태에 반응합니다.
UI (이벤트 발생) → 상태 관리자 (이벤트 처리) → 새 상태 생성 → UI (새 상태 반영)
이 패턴은 모든 상태 관리 솔루션에 적용됩니다:
상태 로직과 UI 로직을 명확히 분리해야 합니다. 위젯은 상태를 표시하고 이벤트를 발생시키는 역할만 담당하고, 상태 변경 로직은 상태 관리 컴포넌트에 위임해야 합니다.
// ❌ 잘못된 방법: UI에서 상태 변경 로직 처리
class ProductCard extends StatelessWidget {
final Product product;
Widget build(BuildContext context) {
return Card(
child: Column(
children: [
Image.network(product.imageUrl),
Text(product.name),
ElevatedButton(
onPressed: () {
// UI에 상태 변경 로직이 포함됨
final cart = context.read<CartBloc>().state.items;
if (cart.any((item) => item.id == product.id)) {
// 이미 있으면 수량 증가
final newCart = cart.map((item) {
if (item.id == product.id) {
return item.copyWith(quantity: item.quantity + 1);
}
return item;
}).toList();
context.read<CartBloc>().add(UpdateCartEvent(newCart));
} else {
// 없으면 추가
context.read<CartBloc>().add(
AddToCartEvent(product.copyWith(quantity: 1)),
);
}
// 스낵바 표시
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('${product.name} 추가됨')),
);
},
child: Text('장바구니에 추가'),
),
],
),
);
}
}
// ✅ 좋은 방법: 상태 변경 로직을 BLoC에 위임
class CartBloc extends Bloc<CartEvent, CartState> {
CartBloc() : super(CartState(items: [])) {
on<AddToCartEvent>(_onAddToCart);
}
void _onAddToCart(AddToCartEvent event, Emitter<CartState> emit) {
final product = event.product;
final currentItems = state.items;
if (currentItems.any((item) => item.id == product.id)) {
// 이미 있으면 수량 증가
final newItems = currentItems.map((item) {
if (item.id == product.id) {
return item.copyWith(quantity: item.quantity + 1);
}
return item;
}).toList();
emit(CartState(items: newItems));
} else {
// 없으면 추가
emit(CartState(items: [...currentItems, product.copyWith(quantity: 1)]));
}
}
}
// UI는 이벤트만 발생시킴
class ProductCard extends StatelessWidget {
final Product product;
Widget build(BuildContext context) {
return Card(
child: Column(
children: [
Image.network(product.imageUrl),
Text(product.name),
ElevatedButton(
onPressed: () {
// 이벤트만 발생시키고 로직은 BLoC에 위임
context.read<CartBloc>().add(AddToCartEvent(product));
// UI 관련 로직만 처리
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('${product.name} 추가됨')),
);
},
child: Text('장바구니에 추가'),
),
],
),
);
}
}
더 복잡한 앱에서는 다음과 같은 고급 패턴을 고려할 수 있습니다.
큰 상태 객체에서 필요한 부분만 선택하여 불필요한 UI 리빌드를 방지합니다.
// Riverpod 예시
// 전체 사용자 상태
class UserNotifier extends _$UserNotifier {
UserState build() => const UserState();
}
// 사용자 이름만 선택하는 선택자
final userNameProvider = Provider<String>((ref) {
return ref.watch(userNotifierProvider.select((state) => state.name));
});
// 사용자 나이만 선택하는 선택자
final userAgeProvider = Provider<int>((ref) {
return ref.watch(userNotifierProvider.select((state) => state.age));
});
// UI에서 필요한 부분만 감시
class UserNameDisplay extends ConsumerWidget {
Widget build(BuildContext context, WidgetRef ref) {
// 이름이 변경될 때만 리빌드
final userName = ref.watch(userNameProvider);
return Text('이름: $userName');
}
}
class UserAgeDisplay extends ConsumerWidget {
Widget build(BuildContext context, WidgetRef ref) {
// 나이가 변경될 때만 리빌드
final userAge = ref.watch(userAgeProvider);
return Text('나이: $userAge');
}
}
여러 상태 조각을 결합하여 파생 상태를 생성합니다.
// Riverpod 예시
// 장바구니 상태
class CartNotifier extends _$CartNotifier {
CartState build() => const CartState();
}
// 할인 쿠폰 상태
class CouponNotifier extends _$CouponNotifier {
CouponState build() => const CouponState();
}
// 주문 요약 정보 - 장바구니와 쿠폰 상태 결합
final orderSummaryProvider = Provider<OrderSummary>((ref) {
final cartState = ref.watch(cartNotifierProvider);
final couponState = ref.watch(couponNotifierProvider);
// 장바구니 총액 계산
final subtotal = cartState.items.fold<double>(
0,
(sum, item) => sum + (item.price * item.quantity),
);
// 할인 계산
final discount = couponState.activeCoupon != null
? subtotal * (couponState.activeCoupon!.discountPercentage / 100)
: 0.0;
// 최종 금액 계산
final total = subtotal - discount;
return OrderSummary(
subtotal: subtotal,
discount: discount,
total: total,
appliedCoupon: couponState.activeCoupon,
);
});
// UI에서 사용
class OrderSummaryWidget extends ConsumerWidget {
Widget build(BuildContext context, WidgetRef ref) {
final summary = ref.watch(orderSummaryProvider);
return Card(
child: Padding(
padding: EdgeInsets.all(16),
child: Column(
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text('소계:'),
Text('₩${summary.subtotal.toStringAsFixed(0)}'),
],
),
if (summary.discount > 0) ...[
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text('할인:'),
Text('-₩${summary.discount.toStringAsFixed(0)}'),
],
),
],
Divider(),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text('총액:', style: TextStyle(fontWeight: FontWeight.bold)),
Text('₩${summary.total.toStringAsFixed(0)}',
style: TextStyle(fontWeight: FontWeight.bold)),
],
),
],
),
),
);
}
}
상태 변경 외에 추가 작업이 필요한 경우(예: 알림, 로깅, 네비게이션) 각 상태 관리 솔루션은 이를 처리하는 방법을 제공합니다.
// BLoC 예시 - BlocListener 사용
class PaymentPage extends StatelessWidget {
Widget build(BuildContext context) {
return BlocProvider(
create: (context) => PaymentBloc(),
child: BlocListener<PaymentBloc, PaymentState>(
listenWhen: (previous, current) =>
previous.status != current.status,
listener: (context, state) {
if (state.status == PaymentStatus.success) {
// 성공 시 네비게이션
Navigator.of(context).pushReplacementNamed('/order-confirmation');
} else if (state.status == PaymentStatus.failure) {
// 실패 시 스낵바
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('결제 실패: ${state.errorMessage}')),
);
}
},
child: PaymentForm(),
),
);
}
}
// GetX 예시 - Workers 사용
class PaymentController extends GetxController {
final status = Rx<PaymentStatus>(PaymentStatus.initial);
final errorMessage = ''.obs;
void onInit() {
super.onInit();
// 상태가 변경될 때마다 호출
ever(status, (PaymentStatus s) {
if (s == PaymentStatus.success) {
Get.offAllNamed('/order-confirmation');
} else if (s == PaymentStatus.failure) {
Get.snackbar('결제 실패', errorMessage.value);
}
});
}
}
// Riverpod 예시 - ref.listen 사용
class PaymentPage extends ConsumerWidget {
Widget build(BuildContext context, WidgetRef ref) {
ref.listen<PaymentState>(
paymentNotifierProvider,
(previous, current) {
if (previous?.status != current.status) {
if (current.status == PaymentStatus.success) {
Navigator.of(context).pushReplacementNamed('/order-confirmation');
} else if (current.status == PaymentStatus.failure) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('결제 실패: ${current.errorMessage}')),
);
}
}
},
);
return PaymentForm();
}
}
어떤 상태 관리 솔루션을 선택해야 할지는 프로젝트의 복잡성, 팀의 경험, 개발 속도, 유지보수 요구사항 등 여러 요소에 따라 달라집니다.
간단한 앱 (작은 규모, 단순한 상태)
중간 규모 앱 (여러 화면, 중간 복잡도의 상태)
대규모 앱 (복잡한 비즈니스 로직, 많은 상태 관리)
Flutter 입문팀
중간 수준 팀
숙련된 팀
장기 유지보수 필요
개발 속도 우선
팀 변경 예상
큰 프로젝트에서는 단일 상태 관리 솔루션만 고집할 필요가 없습니다. 여러 솔루션을 상황에 맞게 결합하여 사용하는 것도 가능합니다.
// 간단한 UI 상태는 StatefulWidget 사용
class ExpandableCard extends StatefulWidget {
_ExpandableCardState createState() => _ExpandableCardState();
}
class _ExpandableCardState extends State<ExpandableCard> {
bool _isExpanded = false;
Widget build(BuildContext context) {
return GestureDetector(
onTap: () {
setState(() {
_isExpanded = !_isExpanded;
});
},
child: AnimatedContainer(
duration: Duration(milliseconds: 300),
height: _isExpanded ? 200 : 100,
// ...
),
);
}
}
// 복잡한 도메인 로직은 BLoC 사용
class ProductsBloc extends Bloc<ProductsEvent, ProductsState> {
// 복잡한 제품 필터링, 정렬, 페이징 등 처리
}
// 전역적이고 반응적인 상태는 Riverpod 사용
class ThemeNotifier extends _$ThemeNotifier {
ThemeState build() {
return ThemeState(
isDarkMode: _loadThemeSetting(),
primaryColor: _loadPrimaryColor(),
);
}
void toggleTheme() {
state = state.copyWith(isDarkMode: !state.isDarkMode);
_saveThemeSetting(state.isDarkMode);
}
void setPrimaryColor(Color color) {
state = state.copyWith(primaryColor: color);
_savePrimaryColor(color);
}
// 설정 로드/저장 메서드
}
이런 혼합 접근법의 장점:
단, 혼합 접근법을 사용할 때는 명확한 가이드라인을 팀 내에서 수립하여 일관성을 유지해야 합니다.
상태 관리의 중요한 이점 중 하나는 테스트 용이성입니다. 각 상태 관리 솔루션은 테스트를 위한 접근 방식을 제공합니다.
group('CounterBloc', () {
late CounterBloc counterBloc;
setUp(() {
counterBloc = CounterBloc();
});
tearDown(() {
counterBloc.close();
});
test('초기 상태는 0입니다', () {
expect(counterBloc.state, const CounterState(0));
});
blocTest<CounterBloc, CounterState>(
'IncrementEvent가 추가되면 상태가 1로 변경됩니다',
build: () => counterBloc,
act: (bloc) => bloc.add(IncrementEvent()),
expect: () => [const CounterState(1)],
);
blocTest<CounterBloc, CounterState>(
'DecrementEvent가 추가되면 상태가 -1로 변경됩니다',
build: () => counterBloc,
act: (bloc) => bloc.add(DecrementEvent()),
expect: () => [const CounterState(-1)],
);
});
group('CounterNotifier', () {
test('초기 상태는 CounterState(0)입니다', () {
final container = ProviderContainer();
addTearDown(container.dispose);
expect(container.read(counterNotifierProvider), const CounterState(count: 0));
});
test('increment는 count를 1 증가시킵니다', () {
final container = ProviderContainer();
addTearDown(container.dispose);
container.read(counterNotifierProvider.notifier).increment();
expect(container.read(counterNotifierProvider).count, 1);
});
test('decrement는 count를 1 감소시킵니다', () {
final container = ProviderContainer();
addTearDown(container.dispose);
container.read(counterNotifierProvider.notifier).decrement();
expect(container.read(counterNotifierProvider).count, -1);
});
});
group('CounterController', () {
late CounterController controller;
setUp(() {
controller = CounterController();
});
test('초기 count는 0입니다', () {
expect(controller.count.value, 0);
});
test('increment는 count를 1 증가시켜야 합니다', () {
controller.increment();
expect(controller.count.value, 1);
});
test('decrement는 count를 1 감소시켜야 합니다', () {
controller.decrement();
expect(controller.count.value, -1);
});
test('reset은 count를 0으로 설정해야 합니다', () {
controller.increment();
controller.increment();
controller.reset();
expect(controller.count.value, 0);
});
});
상태 관리를 효율적으로, 성능 최적화와 함께 구현하기 위한 팁을 살펴보겠습니다.
큰 상태 객체를 작은 단위로 나누어 필요한 부분만 업데이트되도록 합니다.
// ❌ 잘못된 방법: 하나의 큰 상태 객체
class AppNotifier extends _$AppNotifier {
AppState build() => const AppState(
user: null,
products: [],
cart: [],
settings: Settings(),
);
// 장바구니에 상품 추가 시 전체 상태가 업데이트됨
void addToCart(Product product) {
state = state.copyWith(
cart: [...state.cart, product],
);
}
}
// ✅ 좋은 방법: 세분화된 상태
class UserNotifier extends _$UserNotifier {
User? build() => null;
}
class ProductsNotifier extends _$ProductsNotifier {
List<Product> build() => [];
}
class CartNotifier extends _$CartNotifier {
List<Product> build() => [];
// 장바구니 상태만 업데이트됨
void addToCart(Product product) {
state = [...state, product];
}
}
class SettingsNotifier extends _$SettingsNotifier {
Settings build() => const Settings();
}
필요한 위젯만 리빌드되도록 selector 패턴을 사용합니다.
// BLoC 예시 - BlocSelector 사용
BlocSelector<UserBloc, UserState, String>(
selector: (state) => state.user.name, // 이름이 변경될 때만 리빌드
builder: (context, name) {
return Text('이름: $name');
},
)
// Riverpod 예시 - select 사용
Consumer(
builder: (context, ref, child) {
// 이름이 변경될 때만 리빌드
final name = ref.watch(userNotifierProvider.select((user) => user.name));
return Text('이름: $name');
},
)
// GetX 예시 - Obx와 세분화된 상태 사용
class UserController extends GetxController {
// 개별 속성을 관찰 가능하게 만들기
final name = ''.obs;
final email = ''.obs;
final age = 0.obs;
void updateUser(User user) {
name.value = user.name;
email.value = user.email;
age.value = user.age;
}
}
// 이름 표시 위젯 - 이름이 변경될 때만 리빌드
Obx(() => Text('이름: ${controller.name.value}'))
동일한 입력에 대해 반복적인 계산을 피하기 위해 메모이제이션 기법을 사용합니다.
// Riverpod 예시 - 파생 상태 메모이제이션
List<Product> filteredProducts(FilteredProductsRef ref) {
final products = ref.watch(productsProvider);
final filter = ref.watch(productFilterProvider);
// 필터가 변경될 때만 재계산
return products.where((product) {
if (filter.category != null && product.category != filter.category) {
return false;
}
if (filter.minPrice != null && product.price < filter.minPrice!) {
return false;
}
if (filter.maxPrice != null && product.price > filter.maxPrice!) {
return false;
}
if (filter.searchQuery.isNotEmpty) {
return product.name.toLowerCase().contains(filter.searchQuery.toLowerCase());
}
return true;
}).toList();
}
// BLoC 예시 - 상태 내 계산 결과 캐싱
class CartState {
final List<CartItem> items;
final double _cachedTotal; // 계산된 값 캐싱
double get total => _cachedTotal;
CartState({required this.items}) : _cachedTotal = _calculateTotal(items);
CartState copyWith({List<CartItem>? items}) {
if (items == null || items == this.items) {
return this; // 변경 없으면 동일 인스턴스 반환
}
return CartState(items: items);
}
static double _calculateTotal(List<CartItem> items) {
return items.fold(0, (sum, item) => sum + (item.price * item.quantity));
}
}
이론적인 패턴을 넘어 실제 프로젝트에 어떻게 상태 관리를 적용할 수 있는지 몇 가지 시나리오를 살펴보겠습니다.
전자상거래 앱의 장바구니 기능 구현 예시:
// 1. 도메인 모델 정의
class Product with _$Product {
const factory Product({
required String id,
required String name,
required double price,
required String imageUrl,
String? description,
required String category,
}) = _Product;
}
class CartItem with _$CartItem {
const factory CartItem({
required Product product,
(1) int quantity,
}) = _CartItem;
const CartItem._();
double get subtotal => product.price * quantity;
}
class Cart with _$Cart {
const factory Cart({
([]) List<CartItem> items,
}) = _Cart;
const Cart._();
double get total => items.fold(0, (sum, item) => sum + item.subtotal);
int get itemCount => items.fold(0, (sum, item) => sum + item.quantity);
bool get isEmpty => items.isEmpty;
}
// 2. 저장소 레이어 (API 및 로컬 저장소 접근)
abstract class CartRepository {
Future<Cart> getCart();
Future<void> saveCart(Cart cart);
Stream<Cart> watchCart();
}
// 3. 상태 관리 (Riverpod 예시)
class CartNotifier extends _$CartNotifier {
late CartRepository _repository;
Future<Cart> build() async {
_repository = ref.watch(cartRepositoryProvider);
return _repository.getCart();
}
Future<void> addToCart(Product product) async {
final cart = state.valueOrNull ?? const Cart();
if (cart == null) return;
final items = [...cart.items];
final index = items.indexWhere((item) => item.product.id == product.id);
if (index >= 0) {
// 이미 있는 상품: 수량 증가
items[index] = items[index].copyWith(
quantity: items[index].quantity + 1,
);
} else {
// 새 상품: 장바구니에 추가
items.add(CartItem(product: product));
}
state = AsyncData(Cart(items: items));
await _repository.saveCart(state.valueOrNull ?? const Cart());
}
Future<void> removeFromCart(String productId) async {
final cart = state.valueOrNull;
if (cart == null) return;
final items = cart.items.where((item) => item.product.id != productId).toList();
state = AsyncData(Cart(items: items));
await _repository.saveCart(state.valueOrNull ?? const Cart());
}
Future<void> updateQuantity(String productId, int quantity) async {
final cart = state.valueOrNull;
if (cart == null) return;
final items = [...cart.items];
final index = items.indexWhere((item) => item.product.id == productId);
if (index >= 0) {
if (quantity > 0) {
items[index] = items[index].copyWith(quantity: quantity);
} else {
items.removeAt(index);
}
state = AsyncData(Cart(items: items));
await _repository.saveCart(state.valueOrNull ?? const Cart());
}
}
Future<void> clearCart() async {
state = const AsyncData(Cart());
await _repository.saveCart(const Cart());
}
}
// 4. UI 레이어
class CartPage extends ConsumerWidget {
Widget build(BuildContext context, WidgetRef ref) {
final cartAsync = ref.watch(cartNotifierProvider);
return Scaffold(
appBar: AppBar(title: Text('장바구니')),
body: cartAsync.when(
loading: () => Center(child: CircularProgressIndicator()),
error: (error, stack) => Center(child: Text('오류: $error')),
data: (cart) {
if (cart.isEmpty) {
return Center(child: Text('장바구니가 비어 있습니다'));
}
return Column(
children: [
Expanded(
child: ListView.builder(
itemCount: cart.items.length,
itemBuilder: (context, index) {
final item = cart.items[index];
return CartItemWidget(
item: item,
onRemove: () => ref.read(cartNotifierProvider.notifier)
.removeFromCart(item.product.id),
onUpdateQuantity: (qty) => ref.read(cartNotifierProvider.notifier)
.updateQuantity(item.product.id, qty),
);
},
),
),
CartSummary(
total: cart.total,
onCheckout: () => Navigator.pushNamed(context, '/checkout'),
),
],
);
},
),
);
}
}
효과적인 상태 관리는 Flutter 앱의 품질, 유지보수성, 확장성에 큰 영향을 미칩니다. 완벽한 상태 관리 솔루션은 존재하지 않으며, 프로젝트의 요구사항과 팀의 특성에 맞는 선택이 중요합니다.
이 가이드에서 다룬 핵심 포인트:
1. 다양한 상태 관리 솔루션의 장단점 이해
2. 효과적인 상태 관리 패턴과 원칙 적용
3. 프로젝트 특성에 맞는 솔루션 선택
4. 테스트 가능하고 성능이 최적화된 구현 방법
상태 관리는 Flutter 개발에서 가장 활발히 논의되는 주제 중 하나이며, 지속적으로 진화하고 있습니다. 새로운 도구와 패턴이 계속 등장하지만, 이 글에서 소개한 기본 원칙과 패턴을 이해한다면 어떤 새로운 솔루션도 쉽게 적응할 수 있을 것입니다.
다음 글에서는 Flutter에서의 성능 최적화 기법과 모범 사례에 대해 알아보겠습니다.
다음 포스트 미리보기: Flutter 개발 가이드라인: 성능 최적화 기법
Flutter 앱의 성능을 극대화하기 위한 렌더링 최적화, 메모리 관리, 비동기 처리 최적화 등 다양한 기법을 알아봅니다.