Flutter 개발 가이드라인: 효과적인 상태 관리 패턴

woogi·2025년 5월 2일
0

Flutter 개발 가이드

목록 보기
5/5
post-thumbnail

안녕하세요, 우기입니다! Flutter 개발 가이드라인 시리즈의 네 번째 글을 시작합니다. 이번에는 Flutter 앱에서 가장 중요한 측면 중 하나인 '상태 관리'에 대해 깊이 있게 알아보겠습니다.

개요

상태 관리는 Flutter 앱 개발에서 가장 많이 논의되는 주제 중 하나입니다. 다양한 상태 관리 솔루션이 존재하며, 각각의 장단점과 적합한 사용 사례가 있습니다. 이 글에서는 Flutter에서 널리 사용되는 상태 관리 솔루션들을 비교하고, 효과적인 사용 패턴을 소개하겠습니다.

이 글에서 다룰 내용:

  • 상태 관리의 기본 개념과 중요성
  • 주요 상태 관리 솔루션 비교 (BLoC, GetX, Riverpod)
  • 각 솔루션의 사용 패턴과 모범 사례
  • 상태 관리 아키텍처 설계 원칙
  • 상태 관리 솔루션 선택 가이드

상태 관리의 이해

상태란 무엇인가?

'상태'는 특정 시점에서 앱의 데이터를 나타냅니다. 이는 사용자 정보, UI 표시 여부, 네트워크 요청 상태 등 앱의 동작에 영향을 미치는 모든 데이터를 포함합니다. Flutter 앱에서 상태는 크게 다음과 같이 분류할 수 있습니다:

  1. UI 상태 (Local State): 특정 위젯에만 관련된 상태

    • 예: 텍스트 필드 내용, 체크박스 상태, 애니메이션 상태
  2. 앱 상태 (Global State): 앱 전체에서 공유되는 상태

    • 예: 사용자 정보, 장바구니 데이터, 앱 설정
  3. 임시 상태 (Ephemeral State): 짧은 시간 동안만 유지되는 상태

    • 예: 페이지 로딩 상태, 폼 유효성 검사 결과

상태 관리의 중요성

효과적인 상태 관리는 다음과 같은 이유로 중요합니다:

  1. 예측 가능성: 상태 변화가 일관되고 예측 가능한 방식으로 발생
  2. 유지보수성: 상태 로직이 UI 로직과 분리되어 코드 유지보수가 용이
  3. 성능 최적화: 필요한 부분만 효율적으로 다시 렌더링
  4. 테스트 용이성: 상태 변화를 독립적으로 테스트 가능

주요 상태 관리 솔루션 비교

Flutter 생태계에는 많은 상태 관리 솔루션이 있습니다. 이 중 가장 널리 사용되는 세 가지 솔루션을 중점적으로 살펴보겠습니다.

BLoC 패턴 (Bloc Library)

BLoC(Business Logic Component) 패턴은 비즈니스 로직을 UI에서 분리하기 위해 설계되었습니다. 이 패턴은 이벤트가 BLoC에 전달되고, BLoC은 이벤트를 처리한 후 새로운 상태를 스트림을 통해 UI에 제공합니다.

주요 특징:

  • 이벤트 기반 아키텍처
  • 반응형 프로그래밍 (Rx) 기반
  • 명확한 상태 관리 패턴
  • 중규모-대규모 앱에 적합

예제 코드:

// 이벤트 정의
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()),
          ),
        ],
      ),
    );
  }
}

장점:

  • 상태, 이벤트, 비즈니스 로직의 명확한 분리
  • 테스트하기 쉬운 구조
  • 복잡한 상태 흐름 관리에 적합
  • 풍부한 디버깅 도구 지원

단점:

  • 간단한 기능 구현에도 이벤트, 상태, BLoC 클래스 등 많은 코드 작성 필요
  • 반응형 프로그래밍과 스트림 개념에 익숙해져야 하는 러닝 커브
  • 작은 변경사항에도 여러 파일을 수정해야 하는 번거로움
  • 새로운 개발자가 프로젝트에 합류했을 때 패턴 이해에 시간 소요

GetX

GetX는 상태 관리, 의존성 관리, 라우팅을 포함한 올인원 솔루션입니다. 다양한 기능을 제공하면서도 최소한의 코드로 간결한 API를 제공하는 것이 특징입니다.

주요 특징:

  • 간결한 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,
          ),
        ],
      ),
    );
  }
}

장점:

  • 매우 간결한 코드 작성 가능
  • 빠른 개발 속도
  • 낮은 러닝 커브
  • 통합된 라우팅 및 의존성 관리

단점:

  • 구조화된 패턴 부족으로 대규모 앱에서 일관성 유지 어려움
  • Flutter의 기본 원칙(BuildContext 기반 접근)을 우회하는 방식으로 Flutter 업데이트 시 호환성 문제 가능성
  • 너무 많은 기능을 제공하여 앱 크기 증가 및 불필요한 오버헤드 발생 가능
  • 선언적이지 않은 접근 방식으로 코드 흐름 추적이 어려울 수 있음
  • 공식 Flutter 팀의 권장 패턴과 다른 방식으로 인한 생태계 통합 문제

Riverpod

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(),
          ),
        ],
      ),
    );
  }
}

장점:

  • 타입 안전성 우수
  • 컴파일 시점 의존성 확인
  • 상태 파편화로 인한 효율적인 리빌드
  • 코드 생성을 통한 보일러플레이트 감소
  • 테스트 용이성

단점:

  • 초기 설정 복잡성과 높은 러닝 커브
  • 코드 생성에 의존할 경우 프로젝트 규모가 커질수록 build_runner 성능 저하 문제
  • 아직 정형화된 표준 패턴이 확립되지 않아 팀마다 다른 구현 방식 발생
  • Provider보다 약간 더 많은 보일러플레이트 코드 필요

효과적인 상태 관리 패턴

각 상태 관리 솔루션에 관계없이, 효과적인 상태 관리를 위한 몇 가지 공통 패턴이 있습니다.

단일 책임 원칙 적용

상태 관리 컴포넌트는 단일 책임을 가져야 합니다. 너무 많은 책임을 하나의 상태 관리자에 집중시키면 복잡성이 증가하고 유지보수가 어려워집니다.

// ❌ 잘못된 방법: 모든 것을 처리하는 하나의 거대한 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 (새 상태 반영)

이 패턴은 모든 상태 관리 솔루션에 적용됩니다:

  • BLoC: UI → 이벤트 → BLoC → 상태 → UI
  • GetX: UI → 컨트롤러 메서드 → 상태 변경 → UI
  • Riverpod: 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('장바구니에 추가'),
          ),
        ],
      ),
    );
  }
}

고급 상태 관리 패턴

더 복잡한 앱에서는 다음과 같은 고급 패턴을 고려할 수 있습니다.

상태 선택자 (State Selectors)

큰 상태 객체에서 필요한 부분만 선택하여 불필요한 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');
  }
}

상태 조합자 (State Combiners)

여러 상태 조각을 결합하여 파생 상태를 생성합니다.

// 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)),
              ],
            ),
          ],
        ),
      ),
    );
  }
}

부작용 처리 (Side Effects)

상태 변경 외에 추가 작업이 필요한 경우(예: 알림, 로깅, 네비게이션) 각 상태 관리 솔루션은 이를 처리하는 방법을 제공합니다.

// 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();
  }
}

상태 관리 솔루션 선택 가이드

어떤 상태 관리 솔루션을 선택해야 할지는 프로젝트의 복잡성, 팀의 경험, 개발 속도, 유지보수 요구사항 등 여러 요소에 따라 달라집니다.

프로젝트 복잡성에 따른 선택

  1. 간단한 앱 (작은 규모, 단순한 상태)

    • Provider / 상태가 적은 StatefulWidget
    • GetX (빠른 개발이 필요한 경우)
    • Riverpod (간단하지만 확장성을 고려하는 경우)
  2. 중간 규모 앱 (여러 화면, 중간 복잡도의 상태)

    • Riverpod (대부분의 경우 가장 균형 잡힌 선택)
    • BLoC (명확한 패턴이 필요한 팀)
    • GetX (빠른 개발과 단순성 우선 시)
  3. 대규모 앱 (복잡한 비즈니스 로직, 많은 상태 관리)

    • BLoC (명확한 아키텍처와 테스트 용이성)
    • Riverpod (타입 안전성과 코드 분할)
    • Redux (엄격한 단방향 데이터 흐름 선호 시)

팀 역량에 따른 선택

  1. Flutter 입문팀

    • Provider / StatefulWidget (기본 개념 학습)
    • GetX (간단한 API, 적은 보일러플레이트)
  2. 중간 수준 팀

    • Riverpod (Provider 경험이 있다면 자연스러운 전환)
    • GetX (빠른 학습곡선, 통합된 솔루션)
  3. 숙련된 팀

    • BLoC (엄격한 패턴, 확장성)
    • Riverpod (유연성과 타입 안전성)
    • 커스텀 솔루션 (특정 요구사항에 맞춤화)

유지보수 고려사항

  1. 장기 유지보수 필요

    • BLoC (명확한 패턴, 잘 설계된 아키텍처)
    • Riverpod (타입 안전성, 모듈성)
  2. 개발 속도 우선

    • GetX (최소한의 보일러플레이트, 통합 솔루션)
    • Riverpod + 코드 생성 (균형 잡힌 접근법)
  3. 팀 변경 예상

    • BLoC (명확한 패턴으로 인수인계 용이)
    • 표준 패턴과 문서화 중시

여러 상태 관리 솔루션 혼합 사용하기

큰 프로젝트에서는 단일 상태 관리 솔루션만 고집할 필요가 없습니다. 여러 솔루션을 상황에 맞게 결합하여 사용하는 것도 가능합니다.

// 간단한 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);
  }
  
  // 설정 로드/저장 메서드
}

이런 혼합 접근법의 장점:

  • 각 기능의 복잡성에 맞는 솔루션 선택 가능
  • 점진적인 리팩토링 용이
  • 새로운 기술 도입 시 리스크 분산

단, 혼합 접근법을 사용할 때는 명확한 가이드라인을 팀 내에서 수립하여 일관성을 유지해야 합니다.

테스트 가능한 상태 관리

상태 관리의 중요한 이점 중 하나는 테스트 용이성입니다. 각 상태 관리 솔루션은 테스트를 위한 접근 방식을 제공합니다.

BLoC 테스트

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)],
  );
});

Riverpod 테스트

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);
  });
});

GetX 테스트

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);
  });
});

성능 최적화 팁

상태 관리를 효율적으로, 성능 최적화와 함께 구현하기 위한 팁을 살펴보겠습니다.

1. 상태 세분화

큰 상태 객체를 작은 단위로 나누어 필요한 부분만 업데이트되도록 합니다.

// ❌ 잘못된 방법: 하나의 큰 상태 객체

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();
}

2. 불필요한 리빌드 방지

필요한 위젯만 리빌드되도록 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}'))

3. 계산 비용이 큰 작업 메모이제이션

동일한 입력에 대해 반복적인 계산을 피하기 위해 메모이제이션 기법을 사용합니다.

// 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 앱의 성능을 극대화하기 위한 렌더링 최적화, 메모리 관리, 비동기 처리 최적화 등 다양한 기법을 알아봅니다.

profile
안녕하세요! 👋 저는 6년차 Flutter 개발자 우기입니다.

0개의 댓글