개발

Flutter Riverpod 완전 정복: 기초부터 실전 응용까지

Provider의 진화된 형태, 상태 관리의 새로운 표준

2025년 9월 18일
30분 읽기
Flutter Riverpod 완전 정복: 기초부터 실전 응용까지

Riverpod이란 무엇인가

Riverpod은 Flutter의 상태 관리 라이브러리입니다. Provider의 개선된 버전으로, 컴파일 타임 안전성과 더 나은 테스트 가능성을 제공합니다.

왜 Riverpod을 써야 할까요?

컴파일 타임 에러 감지: 런타임이 아닌 코딩 중에 문제 발견
Provider 충돌 없음: 같은 타입의 여러 Provider 사용 가능
테스트 용이: Provider 오버라이드로 쉬운 목 처리
자동 dispose: 메모리 관리 자동화

Provider의 기본 개념

Provider는 "전역 변수의 더 나은 버전"입니다. 전역으로 접근 가능하지만, 의존성 주입과 생명주기 관리가 자동으로 됩니다.

Provider의 종류와 용도

Dart
// 1. Provider - 기본, 변하지 않는 값
final apiClientProvider = Provider<ApiClient>((ref) {
  return ApiClient(baseUrl: 'https://api.example.com');
});

// 2. StateProvider - 간단한 상태 (카운터, 토글 등)
final counterProvider = StateProvider<int>((ref) => 0);

// 3. FutureProvider - 비동기 데이터
final userProvider = FutureProvider<User>((ref) async {
  final client = ref.read(apiClientProvider);
  return client.getUser();
});

// 4. StreamProvider - 실시간 데이터
final messagesProvider = StreamProvider<List<Message>>((ref) {
  final client = ref.read(apiClientProvider);
  return client.messageStream();
});

// 5. StateNotifierProvider - 복잡한 상태
final todoListProvider = StateNotifierProvider<TodoNotifier, List<Todo>>((ref) {
  return TodoNotifier();
});

// 6. NotifierProvider - Riverpod 2.0의 새로운 방식
final asyncTodosProvider = AsyncNotifierProvider<AsyncTodosNotifier, List<Todo>>(() {
  return AsyncTodosNotifier();
});

ref의 세 가지 메서드 이해하기

ref는 Provider 간 상호작용의 핵심입니다.

1. ref.read - 한 번 읽기

Dart
final submitButtonProvider = Provider<VoidCallback>((ref) {
  return () {
    // 버튼 클릭 시 한 번만 읽기
    final user = ref.read(userProvider);
    final api = ref.read(apiClientProvider);

    api.updateUser(user);
  };
});
언제 사용?

• 이벤트 핸들러 (onPressed, onTap)
• 초기화 로직
• 일회성 작업

2. ref.watch - 구독하고 리빌드

Dart
final greetingProvider = Provider<String>((ref) {
  // userProvider가 변경되면 greetingProvider도 재계산
  final user = ref.watch(userProvider);
  return 'Hello, ${user.name}!';
});

// Widget에서
class GreetingWidget extends ConsumerWidget {
  @override
  Widget build(BuildContext context, WidgetRef ref) {
    // userProvider 변경 시 위젯 리빌드
    final greeting = ref.watch(greetingProvider);
    return Text(greeting);
  }
}
언제 사용?

• UI 빌드 메서드
• 다른 Provider 계산
• 반응형 로직

3. ref.listen - 부수 효과 처리

Dart
class HomeScreen extends ConsumerStatefulWidget {
  @override
  ConsumerState<HomeScreen> createState() => _HomeScreenState();
}

class _HomeScreenState extends ConsumerState<HomeScreen> {
  @override
  Widget build(BuildContext context) {
    // 에러 발생 시 스낵바 표시
    ref.listen(authProvider, (previous, next) {
      if (next.hasError) {
        ScaffoldMessenger.of(context).showSnackBar(
          SnackBar(content: Text('로그인 실패: ${next.error}')),
        );
      }

      // 로그인 성공 시 화면 이동
      if (previous?.value == null && next.value != null) {
        Navigator.pushReplacementNamed(context, '/dashboard');
      }
    });

    return Scaffold(
      // UI 구성
    );
  }
}
언제 사용?

• 스낵바, 다이얼로그 표시
• 네비게이션
• 로깅, 애널리틱스

ConsumerWidget vs ConsumerStatefulWidget

ConsumerWidget - 상태 없는 위젯

Dart
class ProductCard extends ConsumerWidget {
  final String productId;

  const ProductCard({required this.productId});

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final product = ref.watch(productProvider(productId));

    return Card(
      child: Column(
        children: [
          Text(product.name),
          Text('\${product.price}'),
          ElevatedButton(
            onPressed: () {
              // read 사용 - 리빌드 불필요
              ref.read(cartProvider.notifier).add(product);
            },
            child: Text('Add to Cart'),
          ),
        ],
      ),
    );
  }
}

ConsumerStatefulWidget - 상태 있는 위젯

Dart
class SearchScreen extends ConsumerStatefulWidget {
  @override
  ConsumerState<SearchScreen> createState() => _SearchScreenState();
}

class _SearchScreenState extends ConsumerState<SearchScreen> {
  final TextEditingController _controller = TextEditingController();
  Timer? _debounceTimer;

  @override
  void initState() {
    super.initState();

    // initState에서 ref 사용 가능
    _controller.text = ref.read(searchQueryProvider);

    _controller.addListener(() {
      _debounceTimer?.cancel();
      _debounceTimer = Timer(Duration(milliseconds: 300), () {
        ref.read(searchQueryProvider.notifier).state = _controller.text;
      });
    });
  }

  @override
  void dispose() {
    _controller.dispose();
    _debounceTimer?.cancel();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    final searchResults = ref.watch(searchResultsProvider);

    return Scaffold(
      body: Column(
        children: [
          TextField(controller: _controller),
          Expanded(
            child: ListView.builder(
              itemCount: searchResults.length,
              itemBuilder: (context, index) {
                return ListTile(title: Text(searchResults[index].name));
              },
            ),
          ),
        ],
      ),
    );
  }
}

Family와 AutoDispose 수식어

Family - 매개변수가 있는 Provider

Dart
// 제품 ID별로 다른 Provider 인스턴스
final productProvider = FutureProvider.family<Product, String>((ref, productId) async {
  final api = ref.read(apiClientProvider);
  return api.getProduct(productId);
});

// 사용
Widget build(BuildContext context, WidgetRef ref) {
  final product = ref.watch(productProvider('product-123'));
  return Text(product.when(
    data: (p) => p.name,
    loading: () => 'Loading...',
    error: (e, s) => 'Error: $e',
  ));
}
Family의 용도:

• 동적 ID로 데이터 조회
• 파라미터별 캐싱
• 여러 인스턴스 관리

AutoDispose - 자동 정리

Dart
// 사용하지 않으면 자동으로 dispose
final searchResultsProvider = FutureProvider.autoDispose<List<Product>>((ref) async {
  final query = ref.watch(searchQueryProvider);
  final api = ref.read(apiClientProvider);
  return api.searchProducts(query);
});

// Family와 함께 사용
final productDetailsProvider = FutureProvider.family.autoDispose<Product, String>(
  (ref, productId) async {
    // 화면 떠나면 자동 정리
    final api = ref.read(apiClientProvider);
    return api.getProduct(productId);
  },
);

// keepAlive로 유지하기
final importantDataProvider = FutureProvider.autoDispose<ImportantData>((ref) async {
  // 특정 조건에서만 유지
  final shouldKeepAlive = await checkCondition();
  if (shouldKeepAlive) {
    ref.keepAlive();  // dispose 방지
  }

  return fetchImportantData();
});

StateNotifier 패턴 실전

복잡한 비즈니스 로직을 처리할 때 StateNotifier를 사용합니다.
Dart
// 상태 클래스 (Freezed 사용)
@freezed
class CartState with _$CartState {
  const factory CartState({
    @Default([]) List<CartItem> items,
    @Default(0.0) double total,
    @Default(false) bool isLoading,
    String? errorMessage,
    PromoCode? appliedPromo,
  }) = _CartState;
}

// StateNotifier
class CartNotifier extends StateNotifier<CartState> {
  final ApiClient _api;
  final LocalStorage _storage;

  CartNotifier(this._api, this._storage) : super(const CartState()) {
    _loadCart();
  }

  Future<void> _loadCart() async {
    state = state.copyWith(isLoading: true);

    try {
      final items = await _storage.getCartItems();
      final total = _calculateTotal(items);

      state = state.copyWith(
        items: items,
        total: total,
        isLoading: false,
      );
    } catch (e) {
      state = state.copyWith(
        isLoading: false,
        errorMessage: e.toString(),
      );
    }
  }

  void addItem(Product product, {int quantity = 1}) {
    final existingIndex = state.items.indexWhere((item) => item.product.id == product.id);

    List<CartItem> newItems;
    if (existingIndex != -1) {
      // 이미 있으면 수량 증가
      newItems = [...state.items];
      newItems[existingIndex] = newItems[existingIndex].copyWith(
        quantity: newItems[existingIndex].quantity + quantity,
      );
    } else {
      // 새 아이템 추가
      newItems = [
        ...state.items,
        CartItem(product: product, quantity: quantity),
      ];
    }

    state = state.copyWith(
      items: newItems,
      total: _calculateTotal(newItems),
    );

    _saveCart();
  }

  void removeItem(String productId) {
    final newItems = state.items.where((item) => item.product.id != productId).toList();

    state = state.copyWith(
      items: newItems,
      total: _calculateTotal(newItems),
    );

    _saveCart();
  }

  Future<void> checkout() async {
    state = state.copyWith(isLoading: true);

    try {
      final order = await _api.createOrder(
        items: state.items,
        promoCode: state.appliedPromo?.code,
      );

      // 결제 성공 후 카트 비우기
      await _storage.clearCart();

      state = const CartState();  // 초기 상태로

      // 주문 완료 화면으로 이동 등의 처리
    } catch (e) {
      state = state.copyWith(
        isLoading: false,
        errorMessage: '결제 처리 중 오류가 발생했습니다',
      );
    }
  }

  double _calculateTotal(List<CartItem> items) {
    return items.fold(0.0, (sum, item) => sum + (item.product.price * item.quantity));
  }

  Future<void> _saveCart() async {
    try {
      await _storage.saveCartItems(state.items);
    } catch (e) {
      print('카트 저장 실패: $e');
    }
  }
}

// Provider 정의
final cartProvider = StateNotifierProvider<CartNotifier, CartState>((ref) {
  final api = ref.read(apiClientProvider);
  final storage = ref.read(localStorageProvider);

  return CartNotifier(api, storage);
});
파생 Provider들:
Dart
final cartItemCountProvider = Provider<int>((ref) {
  final cart = ref.watch(cartProvider);
  return cart.items.fold(0, (sum, item) => sum + item.quantity);
});

final cartTotalProvider = Provider<double>((ref) {
  return ref.watch(cartProvider).total;
});

final hasPromoProvider = Provider<bool>((ref) {
  return ref.watch(cartProvider).appliedPromo != null;
});

AsyncNotifier - 비동기 상태 관리의 새로운 방식

Riverpod 2.0에서 도입된 AsyncNotifier는 비동기 작업을 더 우아하게 처리합니다.
Dart
// AsyncNotifier 구현
class ProductListNotifier extends AsyncNotifier<List<Product>> {
  @override
  Future<List<Product>> build() async {
    // 초기 데이터 로드
    return _fetchProducts();
  }

  Future<List<Product>> _fetchProducts({
    String? category,
    String? searchQuery,
    SortOrder? sortOrder,
  }) async {
    final api = ref.read(apiClientProvider);

    final products = await api.getProducts(
      category: category,
      query: searchQuery,
      sort: sortOrder,
    );

    return products;
  }

  Future<void> refresh() async {
    state = const AsyncValue.loading();
    state = await AsyncValue.guard(() => _fetchProducts());
  }

  Future<void> searchProducts(String query) async {
    state = const AsyncValue.loading();

    try {
      final products = await _fetchProducts(searchQuery: query);
      state = AsyncValue.data(products);
    } catch (error, stackTrace) {
      state = AsyncValue.error(error, stackTrace);
    }
  }

  Future<void> filterByCategory(String category) async {
    // 기존 데이터 유지하면서 로딩 표시
    state = const AsyncValue.loading().copyWithPrevious(state);

    state = await AsyncValue.guard(() => _fetchProducts(category: category));
  }

  Future<void> addProduct(Product product) async {
    final api = ref.read(apiClientProvider);

    try {
      final newProduct = await api.createProduct(product);

      // 기존 리스트에 추가
      final currentProducts = state.value ?? [];
      state = AsyncValue.data([...currentProducts, newProduct]);

    } catch (error, stackTrace) {
      // 에러 처리하되 기존 데이터 유지
      state = AsyncValue.error(error, stackTrace).copyWithPrevious(state);
    }
  }

  Future<void> deleteProduct(String productId) async {
    final api = ref.read(apiClientProvider);

    // 낙관적 업데이트 (UI 먼저 업데이트)
    final currentProducts = state.value ?? [];
    state = AsyncValue.data(
      currentProducts.where((p) => p.id != productId).toList(),
    );

    try {
      await api.deleteProduct(productId);
    } catch (error) {
      // 실패 시 롤백
      state = AsyncValue.data(currentProducts);
      rethrow;
    }
  }
}

Provider 오버라이드와 테스트

테스트에서 Provider 모킹

Dart
void main() {
  testWidgets('상품 목록 표시 테스트', (tester) async {
    final mockProducts = [
      Product(id: '1', name: 'Product 1', price: 100),
      Product(id: '2', name: 'Product 2', price: 200),
    ];

    await tester.pumpWidget(
      ProviderScope(
        overrides: [
          // Provider 오버라이드
          productListProvider.overrideWith(() {
            return MockProductListNotifier(mockProducts);
          }),
        ],
        child: MaterialApp(
          home: ProductListScreen(),
        ),
      ),
    );

    // 로딩 표시
    expect(find.byType(CircularProgressIndicator), findsOneWidget);

    // 데이터 로드 완료 대기
    await tester.pumpAndSettle();

    // 상품 표시 확인
    expect(find.text('Product 1'), findsOneWidget);
    expect(find.text('Product 2'), findsOneWidget);
  });

  test('카트에 상품 추가 테스트', () async {
    final container = ProviderContainer(
      overrides: [
        apiClientProvider.overrideWithValue(MockApiClient()),
      ],
    );

    final cart = container.read(cartProvider.notifier);
    final product = Product(id: '1', name: 'Test', price: 100);

    cart.addItem(product);

    final state = container.read(cartProvider);
    expect(state.items.length, 1);
    expect(state.total, 100);

    container.dispose();
  });
}

개발/운영 환경별 설정

Dart
// 환경 설정 Provider
final environmentProvider = Provider<Environment>((ref) {
  return Environment.fromString(
    const String.fromEnvironment('ENV', defaultValue: 'dev'),
  );
});

// API Client Provider (환경별 다른 설정)
final apiClientProvider = Provider<ApiClient>((ref) {
  final env = ref.watch(environmentProvider);

  switch (env) {
    case Environment.dev:
      return ApiClient(
        baseUrl: 'https://dev-api.example.com',
        timeout: Duration(seconds: 30),
        enableLogging: true,
      );
    case Environment.staging:
      return ApiClient(
        baseUrl: 'https://staging-api.example.com',
        timeout: Duration(seconds: 15),
        enableLogging: true,
      );
    case Environment.production:
      return ApiClient(
        baseUrl: 'https://api.example.com',
        timeout: Duration(seconds: 10),
        enableLogging: false,
      );
  }
});

// 앱 시작
void main() {
  runApp(
    ProviderScope(
      overrides: [
        // 개발 중에는 목 데이터 사용
        if (kDebugMode) ...[
          productListProvider.overrideWith(() => MockProductListNotifier()),
        ],
      ],
      child: MyApp(),
    ),
  );
}

고급 패턴과 최적화

1. Select를 사용한 부분 리빌드

Dart
// 전체 상태가 아닌 특정 필드만 구독
class CartBadge extends ConsumerWidget {
  @override
  Widget build(BuildContext context, WidgetRef ref) {
    // items.length만 변경될 때 리빌드
    final itemCount = ref.watch(cartProvider.select((cart) => cart.items.length));

    return Badge(
      label: Text('$itemCount'),
      child: Icon(Icons.shopping_cart),
    );
  }
}

2. 캐싱과 무효화 전략

Dart
// 캐시 시간 설정
final cachedDataProvider = FutureProvider.autoDispose<Data>((ref) async {
  // 5분 동안 캐시 유지
  ref.cacheFor(const Duration(minutes: 5));

  return fetchData();
});

// 수동 무효화
class DataManager {
  final WidgetRef ref;

  DataManager(this.ref);

  void refreshData() {
    // 특정 Provider 무효화
    ref.invalidate(cachedDataProvider);
  }

  void refreshAll() {
    // 모든 Provider 무효화
    ref.invalidateAll();
  }
}

3. Provider 의존성 그래프

Dart
// 의존성 체인
final userProvider = FutureProvider<User>((ref) async {
  return fetchUser();
});

final userSettingsProvider = FutureProvider<Settings>((ref) async {
  final user = await ref.watch(userProvider.future);
  return fetchSettings(user.id);
});

final themeProvider = Provider<ThemeData>((ref) {
  final settingsAsync = ref.watch(userSettingsProvider);

  return settingsAsync.when(
    data: (settings) => settings.isDarkMode ? darkTheme : lightTheme,
    loading: () => lightTheme,
    error: (_, __) => lightTheme,
  );
});

4. Debounce와 Throttle

Dart
// Debounce 검색
class SearchNotifier extends StateNotifier<String> {
  Timer? _debounceTimer;

  SearchNotifier() : super('');

  void updateQuery(String query) {
    _debounceTimer?.cancel();
    _debounceTimer = Timer(Duration(milliseconds: 300), () {
      state = query;
    });
  }

  @override
  void dispose() {
    _debounceTimer?.cancel();
    super.dispose();
  }
}

// Throttle 스크롤
class ScrollNotifier extends StateNotifier<double> {
  DateTime? _lastUpdate;
  static const _throttleDuration = Duration(milliseconds: 100);

  ScrollNotifier() : super(0.0);

  void updatePosition(double position) {
    final now = DateTime.now();
    if (_lastUpdate == null ||
        now.difference(_lastUpdate!) > _throttleDuration) {
      _lastUpdate = now;
      state = position;
    }
  }
}

성능 최적화 팁

1. 불필요한 리빌드 방지

Dart
// Bad - 전체 상태 구독
final widget = ref.watch(complexStateProvider);

// Good - 필요한 부분만 구독
final specificValue = ref.watch(
  complexStateProvider.select((state) => state.specificField)
);

2. 비동기 작업 최적화

Dart
// 병렬 처리
final combinedProvider = FutureProvider<CombinedData>((ref) async {
  final results = await Future.wait([
    ref.watch(dataProvider1.future),
    ref.watch(dataProvider2.future),
    ref.watch(dataProvider3.future),
  ]);

  return CombinedData(
    data1: results[0],
    data2: results[1],
    data3: results[2],
  );
});

3. 메모리 관리

Dart
// 자동 dispose 활용
final heavyDataProvider = FutureProvider.autoDispose<HeavyData>((ref) async {
  // 화면 벗어나면 자동 정리
  return fetchHeavyData();
});

// 선택적 유지
final importantProvider = Provider.autoDispose<Important>((ref) {
  // 조건부로 유지
  if (shouldKeepAlive) {
    ref.keepAlive();
  }
  return Important();
});

마무리

Riverpod은 Flutter의 강력한 상태 관리 도구입니다. 핵심 개념을 정리하면:

Provider 종류: 용도에 맞는 Provider 선택
ref 메서드: read(이벤트), watch(리빌드), listen(부수효과)
수식어: family(파라미터), autoDispose(자동정리)
StateNotifier/AsyncNotifier: 복잡한 상태 관리
테스트: Provider 오버라이드로 쉬운 모킹

Riverpod의 진정한 힘은 이 모든 기능이 타입 안전하고, 테스트 가능하며, 성능 최적화가 쉽다는 점입니다. 작은 프로젝트부터 시작해서 점진적으로 고급 기능을 적용해보세요.
#Flutter
#Riverpod
#State Management
#Provider
#Dart
#Testing