Riverpod 실전 가이드: 프로덕션 레벨 Flutter 앱 개발

woogi·2025년 5월 13일
0

Flutter Tips

목록 보기
1/2
post-thumbnail

안녕하세요, 우기입니다! 이번에는 Riverpod 2.0 코드 생성(Generator) 기반 상태 관리에 대해 깊이 있게 알아보겠습니다.

이 글은 중급 이상 개발자를 위한 실전 가이드로, 프로덕션 레벨의 Flutter 앱 개발에 바로 적용할 수 있는 내용을 다룹니다.

📚 개요

Riverpod는 Provider의 한계를 극복하고 더 나은 타입 안전성과 테스트 가능성을 제공하는 강력한 상태 관리 솔루션입니다. 특히 Riverpod 2.0에서 도입된 코드 생성 방식은 보일러플레이트를 크게 줄이면서도 더 안전하고 효율적인 코드 작성을 가능하게 합니다.

📌 이 글에서 다룰 내용

  • ✅ Riverpod의 핵심 설계 철학과 Provider와의 차이점
  • ✅ Provider 구조화 전략과 고급 패턴
  • ✅ ref의 다양한 활용법 (watch, read, listen, invalidate, refresh)
  • ✅ autoDispose와 keepAlive를 활용한 메모리 관리
  • ✅ 성능 최적화와 리빌드 최소화 기법
  • ✅ 실전에서 마주치는 복잡한 상태 관리 시나리오
  • ✅ 효과적인 에러 처리와 테스트 전략

🎯 Riverpod의 핵심 설계 철학

Riverpod는 Provider의 한계를 극복하고 더 안전하고 예측 가능한 상태 관리를 위해 설계되었습니다.

1. 진정한 컴파일 타임 안전성

Provider의 한계 중 하나는 잘못된 타입이나 존재하지 않는 Provider 접근 시 런타임 에러가 발생한다는 점입니다. Riverpod는 이를 코드 생성과 명시적 타이핑을 통해 해결합니다.

// ❌ Provider에서는 런타임에 에러 발생
class MyWidget extends StatelessWidget {
  
  Widget build(BuildContext context) {
    // 오타나 잘못된 타입 - 런타임 에러!
    final service = Provider.of<WrongService>(context);
    
    // Provider가 상위에 없음 - 런타임 에러!
    final data = context.watch<DataProvider>();
    
    return Container();
  }
}

// ✅ Riverpod는 컴파일 타임에 모든 오류 감지

String myData(MyDataRef ref) {
  // 존재하지 않는 Provider - 컴파일 에러!
  final wrong = ref.watch(nonExistentProvider);  
  
  // 타입 불일치 - 컴파일 에러!
  String number = ref.watch(intProvider);  // intProvider는 int 반환
  
  return 'data';
}

// Riverpod의 명시적 Provider 정의

int intProvider(IntProviderRef ref) => 42;

// family Provider도 타입 안전

User userById(UserByIdRef ref, String id) {
  // id 파라미터도 타입 체크됨
  return User(id: id);
}

// 사용 시 파라미터 타입도 체크
final user = ref.watch(userByIdProvider('123')); // OK
final error = ref.watch(userByIdProvider(123));  // 컴파일 에러!

2. 의존성 그래프 명시화

Riverpod는 Provider 간의 의존성을 명시적으로 선언하여 순환 참조나 누락된 의존성을 컴파일 타임에 감지합니다.

// Provider에서는 순환 참조가 런타임에 발견됨
final providerA = Provider<int>((ref) {
  return ref.watch(providerB) + 1;  // 런타임에 순환 참조 에러
});

final providerB = Provider<int>((ref) {
  return ref.watch(providerA) + 1;
});

// Riverpod는 더 명확한 에러 메시지와 함께 감지

int circularA(CircularARef ref) {
  return ref.watch(circularBProvider) + 1;  // 빌드 시 순환 참조 경고
}

  
int circularB(CircularBRef ref) {
  return ref.watch(circularAProvider) + 1;
}

3. Context 독립성

Provider는 BuildContext에 의존하지만, Riverpod는 이를 제거하여 더 유연한 사용이 가능합니다.

// ❌ Provider는 BuildContext 필요
class MyService {
  void doSomething(BuildContext context) {
    final auth = Provider.of<AuthService>(context);
    // BuildContext가 항상 필요함
  }
}

// ✅ Riverpod는 어디서든 사용 가능
class MyService {
  final Ref ref;
  
  MyService(this.ref);
  
  void doSomething() {
    final auth = ref.read(authServiceProvider);
    // BuildContext 불필요
  }
}

// 위젯 외부에서도 상태 접근 가능
void main() {
  final container = ProviderContainer();
  final auth = container.read(authServiceProvider);
  
  runApp(
    ProviderScope(
      container: container,
      child: MyApp(),
    ),
  );
}

4. 완전한 테스트 격리

테스트 시 Provider를 쉽게 오버라이드하고 격리된 환경에서 테스트할 수 있습니다.

// Provider 테스트 - 전체 위젯 트리 필요
testWidgets('test with Provider', (tester) async {
  await tester.pumpWidget(
    MultiProvider(
      providers: [
        Provider<ApiService>.value(value: MockApiService()),
      ],
      child: MaterialApp(home: MyWidget()),
    ),
  );
  // 복잡한 설정 필요
});

// Riverpod 테스트 - 간단하고 격리된 테스트
test('test with Riverpod', () {
  final container = ProviderContainer(
    overrides: [
      apiServiceProvider.overrideWithValue(MockApiService()),
    ],
  );
  
  final result = container.read(myDataProvider);
  expect(result, 'expected value');
  
  container.dispose();
});

5. 자동 리소스 관리

Provider의 생명주기가 자동으로 관리되어 메모리 누수를 방지합니다. 특히 autoDispose는 Provider가 더 이상 사용되지 않을 때 자동으로 정리됩니다.

// 기본 Provider - 한 번 생성되면 앱이 종료될 때까지 유지

String persistentData(PersistentDataRef ref) {
  print('Provider 생성됨 - 앱 종료까지 유지');
  return 'data';
}

// AutoDispose Provider - 사용하지 않으면 자동 정리

String temporaryData(TemporaryDataRef ref) {
  print('Provider 생성됨');
  
  ref.onDispose(() {
    print('Provider 정리됨 - 더 이상 listen하는 위젯이 없음');
  });
  
  return 'temporary data';
}

// WebSocket 연결 관리 예시

class WebSocketNotifier extends _$WebSocketNotifier {
  WebSocketChannel? _channel;
  
  
  Stream<Message> build() {
    // 연결 생성
    _channel = WebSocketChannel.connect(Uri.parse('ws://example.com'));
    print('WebSocket 연결됨');
    
    // Provider가 dispose될 때 자동으로 정리
    ref.onDispose(() {
      print('WebSocket 연결 해제');
      _channel?.sink.close();
    });
    
    return _channel!.stream.map((data) => Message.fromJson(data));
  }
}

// Family Provider와 autoDispose 조합 - 메모리 효율적

Future<UserProfile> userProfile(UserProfileRef ref, String userId) async {
  print('사용자 프로필 로드: $userId');
  
  ref.onDispose(() {
    print('사용자 프로필 캐시 제거: $userId');
  });
  
  final api = ref.watch(apiProvider);
  return await api.getUserProfile(userId);
}

🏗️ Provider 설계 원칙과 구조화 전략

효과적인 Riverpod 기반 아키텍처를 위한 설계 원칙을 살펴보겠습니다.

📁 계층별 Provider 분리

lib/
├── core/
│   ├── providers/
│   │   ├── app_providers.dart      # 앱 전역 Provider
│   │   ├── config_providers.dart   # 설정 관련 Provider
│   │   └── service_providers.dart  # 서비스 Provider
│   ├── models/
│   └── services/
├── features/
│   ├── auth/
│   │   ├── providers/
│   │   │   ├── auth_providers.dart
│   │   │   └── auth_state.dart
│   │   ├── models/
│   │   └── screens/
│   └── products/
│       ├── providers/
│       ├── models/
│       └── screens/
└── shared/
    └── providers/

💡 이러한 구조의 장점

  • 관심사 분리: 각 기능별로 Provider가 명확히 분리됩니다
  • 재사용성: 공통 Provider는 shared에서 관리합니다
  • 유지보수성: 기능별로 독립적인 상태 관리가 가능합니다

🔧 Provider 타입별 사용 지침

1. Notifier 기반 Provider

동기적인 상태 관리에 사용합니다.


class CounterNotifier extends _$CounterNotifier {
  
  int build() => 0;
  
  void increment() => state++;
  void decrement() => state--;
  void reset() => state = 0;
}

2. AsyncNotifier 기반 Provider

비동기 작업이 필요한 상태 관리에 사용합니다.


class ProductsNotifier extends _$ProductsNotifier {
  
  FutureOr<List<Product>> build() async {
    final api = ref.watch(apiProvider);
    return await api.fetchProducts();
  }
  
  Future<void> addProduct(Product product) async {
    final api = ref.watch(apiProvider);
    state = const AsyncLoading();
    
    try {
      await api.addProduct(product);
      // 제품 목록 다시 가져오기
      state = AsyncData(await api.fetchProducts());
    } catch (e, stack) {
      state = AsyncError(e, stack);
    }
  }
  
  // 올바른 refresh 구현
  Future<void> refresh() async {
    state = const AsyncLoading();
    state = await AsyncValue.guard(() => build());
  }
}

3. 간단한 값 Provider

상태 변경이 없는 단순 값이나 서비스 인스턴스에 사용합니다.


ApiService apiService(ApiServiceRef ref) {
  final dio = ref.watch(dioProvider);
  return ApiService(dio);
}


String appVersion(AppVersionRef ref) => '1.0.0';

🛠️ ref의 고급 활용법

ref는 Riverpod에서 Provider 간 상호작용의 핵심입니다. 다양한 활용법을 살펴보겠습니다.

📊 ref.watch vs ref.read vs ref.listen

각 메서드의 사용 시나리오를 명확히 이해하는 것이 중요합니다:


class MyNotifier extends _$MyNotifier {
  
  MyState build() {
    // watch: 다른 Provider의 변화를 구독하고 자동으로 재빌드
    final user = ref.watch(userProvider);
    
    // listen: 값 변화를 감지하고 부수 효과 실행
    ref.listen(authStateProvider, (previous, next) {
      if (next.isLoggedOut) {
        // 로그아웃 시 캐시 클리어
        ref.invalidate(userCacheProvider);
      }
    });
    
    return MyState(userId: user.id);
  }
  
  void someAction() {
    // read: 일회성 값 읽기 (이벤트 핸들러 내부)
    final currentUser = ref.read(userProvider);
    // 작업 수행...
  }
}

🔄 ref.invalidate와 ref.refresh의 차이점과 활용

invalidaterefresh는 Provider의 상태를 갱신하는 중요한 메서드입니다. 각각의 특징과 사용 사례를 자세히 살펴보겠습니다.

🚮 ref.invalidate

Provider를 무효화하여 다음 번 접근 시 재생성되도록 합니다.

// 사용자 로그아웃 시 관련 캐시 무효화
void signOut() {
  // 사용자 정보 무효화
  ref.invalidate(userProvider);
  // 사용자별 설정 무효화
  ref.invalidate(userPreferencesProvider);
  // 장바구니 무효화
  ref.invalidate(cartProvider);
  
  // 다음에 이 Provider들을 접근할 때 새로 생성됨
}

// 특정 family Provider만 무효화
ref.invalidate(productByIdProvider(productId));

// 모든 family Provider 무효화
ref.invalidate(productByIdProvider);

언제 사용하나요?

  • ✅ 로그아웃 시 사용자 관련 데이터 초기화
  • ✅ 캐시 무효화가 필요할 때
  • ✅ 다음 접근 시점에 새 데이터를 가져와야 할 때
  • ✅ 메모리 절약을 위해 미사용 Provider 정리

🔄 ref.refresh

Provider를 즉시 재생성하고 새 값을 반환합니다.

// Pull-to-refresh 구현
class ProductListScreen extends ConsumerWidget {
  
  Widget build(BuildContext context, WidgetRef ref) {
    final productsAsync = ref.watch(productsProvider);
    
    return RefreshIndicator(
      onRefresh: () async {
        // 즉시 새로고침하고 완료를 기다림
        await ref.refresh(productsProvider.future);
      },
      child: productsAsync.when(
        data: (products) => ListView.builder(
          itemCount: products.length,
          itemBuilder: (context, index) => ProductTile(products[index]),
        ),
        loading: () => const CircularProgressIndicator(),
        error: (error, stack) => ErrorWidget(error),
      ),
    );
  }
}

// 버튼 클릭으로 데이터 새로고침
ElevatedButton(
  onPressed: () {
    // refresh는 새 값을 즉시 반환
    final newProducts = ref.refresh(productsProvider);
  },
  child: const Text('새로고침'),
)

언제 사용하나요?

  • ✅ Pull-to-refresh 기능 구현
  • ✅ 수동 새로고침 버튼
  • ✅ 데이터가 변경되었을 때 즉시 업데이트
  • ✅ 에러 발생 후 재시도

⚖️ invalidate vs refresh 비교

특성invalidaterefresh
실행 시점지연 실행 (다음 접근 시)즉시 실행
반환값void새로운 값 반환
사용 사례캐시 무효화, 메모리 정리즉시 새로고침, 재시도
성능 영향낮음 (지연 실행)높음 (즉시 실행)

🔧 ref.invalidateSelf 활용

Notifier 내부에서 자기 자신을 무효화할 때 사용합니다.


class DataNotifier extends _$DataNotifier {
  
  FutureOr<Data> build() async {
    return _fetchData();
  }
  
  Future<Data> _fetchData() async {
    final api = ref.watch(apiProvider);
    return await api.fetchData();
  }
  
  // 자기 자신을 무효화하여 재빌드
  void refreshData() {
    ref.invalidateSelf();
  }
}

⏱️ 고급 패턴: 자동 갱신


class AutoRefreshNotifier extends _$AutoRefreshNotifier {
  Timer? _refreshTimer;
  
  
  FutureOr<Data> build() async {
    // 5분마다 자동 새로고침
    _refreshTimer = Timer.periodic(
      const Duration(minutes: 5),
      (_) => _refresh(),
    );
    
    ref.onDispose(() => _refreshTimer?.cancel());
    
    return _fetchData();
  }
  
  Future<Data> _fetchData() async {
    final api = ref.watch(apiProvider);
    return await api.fetchData();
  }
  
  // 자동 새로고침 메서드
  void _refresh() {
    // 방법 1: invalidateSelf 사용
    ref.invalidateSelf();
    
    // 방법 2: AsyncNotifier에서 state 직접 갱신
    // state = const AsyncLoading();
    // state = await AsyncValue.guard(() => _fetchData());
  }
}

📌 KeepAlive - 동적 생명주기 관리

keepAlive는 autoDispose Provider의 생명주기를 동적으로 제어할 수 있는 강력한 기능입니다.

// 1. 기본 keepAlive 사용

Future<UserData> userData(UserDataRef ref) async {
  // keepAlive()는 KeepAliveLink를 반환
  final link = ref.keepAlive();
  
  // 나중에 해제 가능
  Timer(Duration(minutes: 5), () {
    link.close();  // 이제 autoDispose 가능
  });
  
  final api = ref.watch(apiProvider);
  return await api.fetchUserData();
}

// 2. cacheFor 유틸리티 직접 구현
extension CacheExtension on AutoDisposeRef {
  void cacheFor(Duration duration) {
    final link = keepAlive();
    Timer(duration, () => link.close());
  }
}

// 사용 예시

Future<SearchResults> searchResults(
  SearchResultsRef ref, 
  String query,
) async {
  // 확장 메서드로 구현한 cacheFor 사용
  ref.cacheFor(const Duration(seconds: 30));
  
  final api = ref.watch(apiProvider);
  return await api.search(query);
}

// 3. 조건부 keepAlive

Future<ProductDetails> productDetails(
  ProductDetailsRef ref,
  String productId,
) async {
  final favoriteProducts = ref.watch(favoriteProductsProvider);
  
  // 즐겨찾기 상품만 영구 캐시
  if (favoriteProducts.contains(productId)) {
    ref.keepAlive();
  }
  
  final api = ref.watch(apiProvider);
  return await api.getProductDetails(productId);
}

// 4. 메모리 압박 대응

class MemoryAwareCacheNotifier extends _$MemoryAwareCacheNotifier 
    implements WidgetsBindingObserver {
  KeepAliveLink? _keepAliveLink;
  
  
  Future<ExpensiveData> build() async {
    // Observer 등록
    WidgetsBinding.instance.addObserver(this);
    ref.onDispose(() => WidgetsBinding.instance.removeObserver(this));
    
    // 초기에는 캐시 유지
    _keepAliveLink = ref.keepAlive();
    
    return await _loadExpensiveData();
  }
  
  
  void didHaveMemoryPressure() {
    // 메모리 압박 시 캐시 해제
    _keepAliveLink?.close();
    _keepAliveLink = null;
  }
  
  // 다른 WidgetsBindingObserver 메서드들...
  
  void didChangeAppLifecycleState(AppLifecycleState state) {}
  
  Future<ExpensiveData> _loadExpensiveData() async {
    // 비용이 큰 데이터 로드
    return ExpensiveData();
  }
}

KeepAlive 사용 가이드라인

언제 사용하나요?

  • ✅ 사용자 세션 동안 유지해야 하는 데이터
  • ✅ 비용이 큰 연산 결과 캐싱
  • ✅ 자주 접근하는 데이터
  • ✅ 조건부 캐싱이 필요한 경우

주의사항:

  • ⚠️ 메모리 사용량 모니터링 필요
  • ⚠️ 너무 많은 keepAlive는 메모리 부족 유발
  • ⚠️ 적절한 시점에 close() 호출 필요
  • ⚠️ invalidate는 keepAlive 상태도 강제 갱신

⚡ 리빌드 최적화와 성능 향상

Riverpod에서 성능 최적화의 핵심은 불필요한 리빌드를 방지하는 것입니다.

🎯 select를 활용한 세밀한 구독

// ❌ 전체 상태 구독 (비효율적)
final userState = ref.watch(userNotifierProvider);

// ✅ 특정 필드만 구독 (효율적)
final userName = ref.watch(userNotifierProvider.select((state) => state.name));
final userAge = ref.watch(userNotifierProvider.select((state) => state.age));

// 복합 값 선택
final userInfo = ref.watch(
  userNotifierProvider.select((state) => (state.name, state.email))
);

💾 파생 Provider를 통한 계산 결과 캐싱


List<Product> filteredProducts(FilteredProductsRef ref) {
  final products = ref.watch(productsProvider);
  final filters = ref.watch(productFiltersProvider);
  
  // 필터가 변경될 때만 재계산
  return products.where((product) {
    if (filters.category != null && product.category != filters.category) {
      return false;
    }
    if (filters.minPrice != null && product.price < filters.minPrice!) {
      return false;
    }
    if (filters.maxPrice != null && product.price > filters.maxPrice!) {
      return false;
    }
    return true;
  }).toList();
}

// 추가 파생 Provider

ProductStats productStats(ProductStatsRef ref) {
  final filtered = ref.watch(filteredProductsProvider);
  
  return ProductStats(
    count: filtered.length,
    totalValue: filtered.fold(0.0, (sum, product) => sum + product.price),
    averagePrice: filtered.isEmpty ? 0 : 
      filtered.fold(0.0, (sum, product) => sum + product.price) / filtered.length,
  );
}

🔀 Provider 분리 전략

// ❌ 잘못된 방법: 하나의 거대한 상태

class AppStateNotifier extends _$AppStateNotifier {
  
  AppState build() => AppState();
  
  void updateUser(User user) { /* ... */ }
  void updateCart(Cart cart) { /* ... */ }
  void updateSettings(Settings settings) { /* ... */ }
}

// ✅ 좋은 방법: 기능별로 분리된 Provider

class UserNotifier extends _$UserNotifier {
  
  User build() => User.guest();
  
  void updateProfile(UserProfile profile) { /* ... */ }
}


class CartNotifier extends _$CartNotifier {
  
  Cart build() => Cart.empty();
  
  void addItem(Product product) { /* ... */ }
}


class SettingsNotifier extends _$SettingsNotifier {
  
  Settings build() => Settings.defaults();
  
  void updateTheme(ThemeMode theme) { /* ... */ }
}

🔄 비동기 상태 관리와 에러 처리

📦 AsyncValue의 효과적인 활용

class ProductListScreen extends ConsumerWidget {
  
  Widget build(BuildContext context, WidgetRef ref) {
    final productsAsync = ref.watch(productsNotifierProvider);
    
    return productsAsync.when(
      data: (products) => ProductGrid(products: products),
      loading: () => const LoadingIndicator(),
      error: (error, stack) => ErrorView(
        error: error,
        onRetry: () => ref.refresh(productsNotifierProvider),
      ),
    );
  }
}

// 더 세밀한 제어가 필요한 경우
class DetailedProductView extends ConsumerWidget {
  
  Widget build(BuildContext context, WidgetRef ref) {
    final productsAsync = ref.watch(productsNotifierProvider);
    
    return productsAsync.map(
      data: (data) {
        if (data.value.isEmpty) {
          return const EmptyStateView(
            message: '상품이 없습니다',
            icon: Icons.inventory_2,
          );
        }
        return ProductGrid(products: data.value);
      },
      loading: (loading) => Column(
        children: [
          if (loading.value != null) 
            ProductGrid(products: loading.value!), // 이전 데이터 표시
          const LinearProgressIndicator(),
        ],
      ),
      error: (error) => ErrorView(
        error: error.error,
        stackTrace: error.stackTrace,
        onRetry: () => ref.refresh(productsNotifierProvider),
      ),
    );
  }
}

🔁 재시도 로직과 에러 복구


class ResilientDataNotifier extends _$ResilientDataNotifier {
  static const _maxRetries = 3;
  static const _retryDelay = Duration(seconds: 2);
  
  
  FutureOr<List<Data>> build() async {
    return _fetchWithRetry();
  }
  
  Future<List<Data>> _fetchWithRetry({int attempt = 0}) async {
    try {
      final api = ref.watch(apiProvider);
      return await api.fetchData();
    } catch (e) {
      if (attempt < _maxRetries) {
        await Future.delayed(_retryDelay * (attempt + 1));
        return _fetchWithRetry(attempt: attempt + 1);
      }
      rethrow;
    }
  }
  
  Future<void> refresh() async {
    state = const AsyncLoading();
    state = await AsyncValue.guard(() => _fetchWithRetry());
  }
}

🛒 실전 예제: 전자상거래 앱의 복합 상태 관리

실제 프로덕션 레벨의 전자상거래 앱에서 사용할 수 있는 상태 관리 구조를 살펴보겠습니다.

🔐 사용자 인증 상태 관리

// 인증 상태 모델

class AuthState with _$AuthState {
  const factory AuthState({
    User? user,
    (false) bool isLoading,
    String? error,
  }) = _AuthState;
  
  const AuthState._();
  
  bool get isAuthenticated => user != null;
}

// 인증 Notifier

class AuthNotifier extends _$AuthNotifier {
  
  AuthState build() {
    // 앱 시작 시 저장된 토큰 확인
    _checkStoredAuth();
    return const AuthState();
  }
  
  Future<void> _checkStoredAuth() async {
    final storage = ref.watch(secureStorageProvider);
    final token = await storage.read(key: 'auth_token');
    
    if (token != null) {
      try {
        final api = ref.watch(apiProvider);
        final user = await api.getCurrentUser(token);
        state = AuthState(user: user);
      } catch (e) {
        // 토큰이 유효하지 않음
        await storage.delete(key: 'auth_token');
      }
    }
  }
  
  Future<void> signIn(String email, String password) async {
    state = state.copyWith(isLoading: true, error: null);
    
    try {
      final api = ref.watch(apiProvider);
      final response = await api.signIn(email, password);
      
      // 토큰 저장
      final storage = ref.watch(secureStorageProvider);
      await storage.write(key: 'auth_token', value: response.token);
      
      state = AuthState(user: response.user);
    } catch (e) {
      state = state.copyWith(
        isLoading: false,
        error: e.toString(),
      );
    }
  }
  
  Future<void> signOut() async {
    final storage = ref.watch(secureStorageProvider);
    await storage.delete(key: 'auth_token');
    
    state = const AuthState();
    
    // 관련 캐시 무효화
    ref.invalidate(cartNotifierProvider);
    ref.invalidate(orderHistoryProvider);
  }
}

🛒 장바구니 상태 관리와 새로고침


class CartItem with _$CartItem {
  const factory CartItem({
    required Product product,
    required int quantity,
  }) = _CartItem;
  
  const CartItem._();
  
  double get subtotal => product.price * quantity;
}


class CartNotifier extends _$CartNotifier {
  
  List<CartItem> build() {
    // 로컬 저장소에서 장바구니 복원
    _loadFromStorage();
    return [];
  }
  
  Future<void> _loadFromStorage() async {
    final storage = ref.watch(localStorageProvider);
    final data = await storage.getCart();
    if (data != null) {
      state = data;
    }
  }
  
  Future<void> _saveToStorage() async {
    final storage = ref.watch(localStorageProvider);
    await storage.saveCart(state);
  }
  
  void addProduct(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)];
    }
    
    _saveToStorage();
  }
  
  void updateQuantity(String productId, int quantity) {
    if (quantity <= 0) {
      removeProduct(productId);
      return;
    }
    
    state = state.map((item) {
      if (item.product.id == productId) {
        return item.copyWith(quantity: quantity);
      }
      return item;
    }).toList();
    
    _saveToStorage();
  }
  
  void removeProduct(String productId) {
    state = state.where((item) => item.product.id != productId).toList();
    _saveToStorage();
  }
  
  void clear() {
    state = [];
    _saveToStorage();
  }
  
  Future<void> syncWithServer() async {
    // 서버와 동기화
    final api = ref.watch(apiProvider);
    final serverCart = await api.getCart();
    
    // 로컬과 서버 데이터 병합
    state = _mergeCartData(state, serverCart);
    _saveToStorage();
  }
  
  List<CartItem> _mergeCartData(List<CartItem> local, List<CartItem> server) {
    // 병합 로직 구현
    final merged = <String, CartItem>{};
    
    // 서버 데이터 우선
    for (final item in server) {
      merged[item.product.id] = item;
    }
    
    // 로컬에만 있는 항목 추가
    for (final item in local) {
      if (!merged.containsKey(item.product.id)) {
        merged[item.product.id] = item;
      }
    }
    
    return merged.values.toList();
  }
}

🧪 Provider 테스트 전략

Riverpod의 큰 장점 중 하나는 뛰어난 테스트 용이성입니다.

🔬 단위 테스트

void main() {
  group('CartNotifier', () {
    test('상품 추가 시 장바구니에 포함되어야 함', () {
      final container = ProviderContainer();
      addTearDown(container.dispose);
      
      final product = Product(
        id: '1',
        name: 'Test Product',
        price: 10000,
      );
      
      // 초기 상태 확인
      expect(container.read(cartNotifierProvider), isEmpty);
      
      // 상품 추가
      container.read(cartNotifierProvider.notifier).addProduct(product);
      
      // 상태 확인
      final cart = container.read(cartNotifierProvider);
      expect(cart.length, 1);
      expect(cart.first.product.id, '1');
      expect(cart.first.quantity, 1);
    });
    
    test('invalidate 후 Provider 재생성 확인', () {
      final container = ProviderContainer();
      addTearDown(container.dispose);
      
      // 초기값 확인
      expect(container.read(counterProvider), 0);
      
      // 값 변경
      container.read(counterProvider.notifier).increment();
      expect(container.read(counterProvider), 1);
      
      // invalidate
      container.invalidate(counterProvider);
      
      // 다시 읽을 때 초기값으로 재생성
      expect(container.read(counterProvider), 0);
    });
    
    test('refresh 시 즉시 재생성 확인', () async {
      final container = ProviderContainer();
      addTearDown(container.dispose);
      
      var callCount = 0;
      final testProvider = FutureProvider<int>((ref) async {
        callCount++;
        return callCount;
      });
      
      // 첫 번째 호출
      final first = await container.read(testProvider.future);
      expect(first, 1);
      
      // refresh로 즉시 재생성
      final second = await container.refresh(testProvider.future);
      expect(second, 2);
      expect(callCount, 2);```
    });
  });
}

🔗 통합 테스트

void main() {
  group('인증 플로우 통합 테스트', () {
    test('로그인 성공 시 관련 Provider 갱신 확인', () async {
      final container = ProviderContainer(
        overrides: [
          apiProvider.overrideWithValue(MockApiService()),
        ],
      );
      addTearDown(container.dispose);
      
      final authNotifier = container.read(authNotifierProvider.notifier);
      
      // 로그인 시도
      await authNotifier.signIn('test@example.com', 'password');
      
      // 인증 상태 확인
      final authState = container.read(authNotifierProvider);
      expect(authState.isAuthenticated, isTrue);
      
      // 로그아웃 시 관련 Provider invalidate 확인
      await authNotifier.signOut();
      
      // 장바구니가 초기화되었는지 확인
      final cart = container.read(cartNotifierProvider);
      expect(cart, isEmpty);
    });
  });
}

❓ 자주 발생하는 문제와 해결 방법

1. part 파일 생성 오류

문제: part 'xxx.g.dart'; 선언 후에도 생성 파일이 없다는 오류 발생

해결:

# 클린 빌드
flutter pub run build_runner clean
flutter pub run build_runner build --delete-conflicting-outputs

# 지속적 모니터링
flutter pub run build_runner watch

2. Provider 순환 참조

문제: Provider A가 B를 참조하고, B가 다시 A를 참조하는 순환 참조 발생

해결: 의존성 구조를 재검토하고 필요시 중간 Provider를 도입

// ❌ 순환 참조

int providerA(ProviderARef ref) {
  return ref.watch(providerB) + 1;
}


int providerB(ProviderBRef ref) {
  return ref.watch(providerA) + 1;
}

// ✅ 중간 Provider 도입

int baseValue(BaseValueRef ref) => 0;


int providerA(ProviderARef ref) {
  return ref.watch(baseValue) + 1;
}


int providerB(ProviderBRef ref) {
  return ref.watch(baseValue) + 2;
}

3. 과도한 리빌드

문제: 작은 상태 변경에도 많은 위젯이 리빌드됨

해결: select를 사용한 세밀한 구독 및 Provider 분리

// ❌ 전체 상태 구독
final user = ref.watch(userProvider);
Text(user.name); // user의 어떤 필드가 변경되어도 리빌드

// ✅ 필요한 필드만 구독
final userName = ref.watch(userProvider.select((u) => u.name));
Text(userName); // name이 변경될 때만 리빌드

4. invalidate와 refresh 오용

문제: invalidate와 refresh를 잘못 사용하여 예상치 못한 동작 발생

해결: 각각의 용도를 명확히 이해하고 적절히 사용

// ❌ 즉시 갱신이 필요한데 invalidate 사용
onPressed: () {
  ref.invalidate(dataProvider);
  // 데이터가 즉시 갱신되지 않음!
}

// ✅ 즉시 갱신이 필요할 때는 refresh 사용
onPressed: () async {
  final newData = await ref.refresh(dataProvider.future);
  // 즉시 새 데이터를 받음
}

// ❌ 메모리 정리가 목적인데 refresh 사용
void cleanup() {
  ref.refresh(heavyDataProvider); // 불필요한 재생성 발생
}

// ✅ 메모리 정리가 목적일 때는 invalidate 사용
void cleanup() {
  ref.invalidate(heavyDataProvider); // 다음 접근 시까지 재생성 연기
}

📊 다른 상태 관리 솔루션과의 비교

Riverpod vs BLoC

특성RiverpodBLoC
러닝 커브중간높음
보일러플레이트적음 (코드 생성 사용 시)많음
타입 안전성매우 높음높음
테스트 용이성매우 높음높음
커뮤니티성장 중매우 큼
아키텍처 자유도높음제한적 (이벤트-상태 패턴)
캐시 관리내장 (invalidate/refresh)수동 구현 필요

Riverpod vs GetX

특성RiverpodGetX
타입 안전성매우 높음낮음
Flutter 원칙 준수높음낮음
의존성 관리명시적암시적
성능최적화 가능기본적으로 빠름
디버깅우수 (DevTools 지원)제한적
상태 갱신명시적 (invalidate/refresh)자동/수동 혼재

🎯 상태관리 선택 가이드

✅ Riverpod가 적합한 경우

  • 중대규모 프로젝트
  • 타입 안전성이 중요한 프로젝트
  • 테스트 커버리지가 중요한 프로젝트
  • 복잡한 상태 의존성이 있는 앱
  • 캐시 관리가 중요한 앱
  • 장기적 유지보수가 중요한 프로젝트

💭 다른 상태관리 솔루션을 고려해야 할 경우

  • BLoC: 엄격한 아키텍처 패턴이 필요한 경우
  • GetX: 빠른 프로토타이핑이 필요한 경우
  • Provider: 기존 Provider 코드베이스가 있는 경우

💡 약 6년간의 Flutter 개발 경험에서 얻은 통찰

Flutter 커뮤니티에서 가장 뜨거운 이슈 중 하나는 '어떤 상태 관리 솔루션이 가장 좋은가?'입니다. Provider, Riverpod, BLoC, GetX, MobX 등 각각의 장단점과 호불호에 대한 끝없는 논쟁을 봐왔습니다.

하지만 약 6년간 Flutter 개발을 하면서 깨달은 것은 이러한 논쟁이 결국 무의미하다는 것입니다. 실제 개발 환경에서는 다음과 같은 변수들이 훨씬 더 중요하기 때문입니다:

  • 프로젝트의 규모와 복잡도가 모두 다름
  • 팀원들의 기술 수준과 경험이 상이함
  • 프로젝트의 일정과 비즈니스 요구사항이 다양함
  • 유지보수 기간과 확장성 요구사항이 제각각임

따라서 "어떤 상태 관리가 가장 좋은가?"보다는 "현재 우리 상황에 가장 적합한 상태 관리는 무엇인가?"를 묻는 것이 훨씬 중요합니다.

결론적으로, 현재 진행하고 있는 프로젝트에 가장 적합한 상태 관리 솔루션을 빠르게 선택하고, 실제 동작하는 제품을 만들어내는 것이 가장 중요합니다. 완벽한 선택을 위해 고민하는 시간보다, 합리적인 선택으로 제품을 빠르게 출시하고 피드백을 받아 개선하는 것이 더 가치 있습니다.

"Perfect is the enemy of good" - 완벽한 상태 관리를 찾느라 시간을 낭비하기보다는,
현재 상황에 충분히 좋은 솔루션으로 제품을 출시하는 것이 더 중요합니다.


🎯 결론

Riverpod는 Flutter 상태 관리의 미래를 보여주는 강력한 솔루션입니다. 코드 생성을 통한 타입 안전성, 우수한 테스트 용이성, 그리고 invalidate/refresh를 통한 효율적인 캐시 관리가 가능하다는 점에서 프로덕션 레벨의 앱 개발에 매우 적합합니다.

📋 이 가이드에서 다룬 내용 정리

  • Riverpod의 핵심 설계 철학과 원칙
  • 효과적인 Provider 구조화 전략
  • ref의 다양한 활용법과 최적화 기법
  • invalidate와 refresh를 활용한 효율적인 상태 갱신
  • autoDispose와 keepAlive를 통한 메모리 관리
  • 실전에서 바로 적용 가능한 패턴과 예제
  • 테스트 전략과 일반적인 문제 해결 방법

특히 invalidate와 refresh의 적절한 사용은 Riverpod의 성능과 효율성을 극대화하는 핵심 요소입니다. 상황에 맞는 적절한 선택으로 최적의 사용자 경험을 제공할 수 있습니다.

Riverpod를 시작하시는 분들께 이 가이드가 실질적인 도움이 되기를 바랍니다. 상태 관리는 Flutter 앱의 핵심이며, Riverpod는 이를 더욱 안전하고 효율적으로 만들어주는 도구입니다.


💭 마치며

솔직히 말해서 Riverpod를 처음 접했을 때는 "아, 이거 뭐야 너무 복잡한데?"라고 생각했어요. Provider/GetX에서 넘어올 때도 "굳이 이렇게까지 해야 하나?" 싶었죠. 근데 막상 프로덕션에서 써보니까 진짜 다르더라고요. 런타임 에러로 앱이 터지는 것보다, 컴파일 에러로 미리 잡는 게 얼마나 편한지 😅

지금 이 글을 읽고 계신 분들도 처음엔 어렵게 느껴질 수 있어요. 저도 그랬으니까요. 하지만 한 번만 제대로 익혀두면, 정말 코딩하는 게 즐거워집니다. 특히 ref.invalidate()로 깔끔하게 캐시 정리할 때의 그 쾌감은... 👌

그러니까 너무 부담갖지 마시고, 작은 프로젝트부터 하나씩 적용해보세요. 실수해도 괜찮아요. 저도 처음엔 invalidaterefresh 헷갈려서 삽질 많이 했거든요.

결국 중요한 건 완벽한 코드가 아니라, 계속 성장하는 개발자가 되는 거잖아요?

Riverpod와 함께 더 나은 Flutter 개발자로 성장하시길 응원합니다! 🚀✨

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

0개의 댓글