개발

Flutter Riverpod 실전 패턴: 현업에서 가장 많이 쓰는 10가지

이론을 넘어서, 실무에서 바로 쓸 수 있는 검증된 패턴들

2025년 9월 18일
25분 읽기
Flutter Riverpod 실전 패턴: 현업에서 가장 많이 쓰는 10가지

1. Repository 패턴과 Provider 조합

가장 기본이 되는 패턴입니다. Repository를 Provider로 관리하여 데이터 접근을 추상화합니다.
Dart
// Repository Provider
final userRepositoryProvider = Provider<UserRepository>((ref) {
  final api = ref.read(apiClientProvider);
  return UserRepositoryImpl(api);
});

// 데이터 Provider (Repository 사용)
final currentUserProvider = FutureProvider<User?>((ref) async {
  final repository = ref.read(userRepositoryProvider);
  return repository.getCurrentUser();
});

// UI에서 사용
class ProfileScreen extends ConsumerWidget {
  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final userAsync = ref.watch(currentUserProvider);

    return userAsync.when(
      data: (user) => Text(user?.name ?? 'Guest'),
      loading: () => CircularProgressIndicator(),
      error: (e, s) => Text('Error: $e'),
    );
  }
}

2. 검색 필터링 패턴 (Debounce 포함)

사용자 입력에 반응하는 검색 기능의 표준 패턴입니다.
Dart
// 검색어 Provider
final searchQueryProvider = StateProvider<String>((ref) => '');

// Debounced 검색어 Provider
final debouncedSearchProvider = Provider<String>((ref) {
  final query = ref.watch(searchQueryProvider);

  // Debounce 구현
  final timer = Timer(Duration(milliseconds: 500), () {});
  ref.onDispose(() => timer.cancel());

  return query;
});

// 검색 결과 Provider
final searchResultsProvider = FutureProvider<List<Product>>((ref) async {
  final query = ref.watch(debouncedSearchProvider);
  if (query.isEmpty) return [];

  final repository = ref.read(productRepositoryProvider);
  return repository.search(query);
});

// 검색 UI
class SearchBar extends ConsumerWidget {
  @override
  Widget build(BuildContext context, WidgetRef ref) {
    return TextField(
      onChanged: (value) {
        ref.read(searchQueryProvider.notifier).state = value;
      },
      decoration: InputDecoration(hintText: '검색...'),
    );
  }
}

3. 인증 상태 관리 패턴

거의 모든 앱에서 사용하는 인증 관리 패턴입니다.
Dart
@freezed
class AuthState with _$AuthState {
  const factory AuthState({
    User? user,
    @Default(false) bool isLoading,
    String? error,
  }) = _AuthState;
}

class AuthNotifier extends StateNotifier<AuthState> {
  final AuthRepository _repository;

  AuthNotifier(this._repository) : super(const AuthState()) {
    _checkAuthStatus();
  }

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

    try {
      final user = await _repository.getCurrentUser();
      state = AuthState(user: user);
    } catch (e) {
      state = AuthState(error: e.toString());
    }
  }

  Future<void> login(String email, String password) async {
    state = state.copyWith(isLoading: true, error: null);

    try {
      final user = await _repository.login(email, password);
      state = AuthState(user: user);
    } catch (e) {
      state = state.copyWith(isLoading: false, error: e.toString());
    }
  }

  Future<void> logout() async {
    await _repository.logout();
    state = const AuthState();
  }
}

final authProvider = StateNotifierProvider<AuthNotifier, AuthState>((ref) {
  return AuthNotifier(ref.read(authRepositoryProvider));
});

// 인증 여부 확인 Helper Provider
final isAuthenticatedProvider = Provider<bool>((ref) {
  return ref.watch(authProvider).user != null;
});

4. 폼 검증 패턴

입력 폼 검증을 위한 표준 패턴입니다.
Dart
class FormState {
  final String email;
  final String password;
  final Map<String, String> errors;

  bool get isValid => errors.isEmpty && email.isNotEmpty && password.isNotEmpty;
}

class FormNotifier extends StateNotifier<FormState> {
  FormNotifier() : super(FormState(email: '', password: '', errors: {}));

  void setEmail(String email) {
    state = state.copyWith(email: email);
    _validateEmail();
  }

  void setPassword(String password) {
    state = state.copyWith(password: password);
    _validatePassword();
  }

  void _validateEmail() {
    final errors = Map<String, String>.from(state.errors);

    if (!state.email.contains('@')) {
      errors['email'] = '유효한 이메일을 입력하세요';
    } else {
      errors.remove('email');
    }

    state = state.copyWith(errors: errors);
  }

  void _validatePassword() {
    final errors = Map<String, String>.from(state.errors);

    if (state.password.length < 6) {
      errors['password'] = '비밀번호는 6자 이상이어야 합니다';
    } else {
      errors.remove('password');
    }

    state = state.copyWith(errors: errors);
  }
}

final formProvider = StateNotifierProvider<FormNotifier, FormState>((ref) {
  return FormNotifier();
});

5. 무한 스크롤 (Pagination) 패턴

리스트 화면에서 자주 사용하는 무한 스크롤 패턴입니다.
Dart
class PaginationState<T> {
  final List<T> items;
  final bool isLoading;
  final bool hasMore;
  final int page;
  final String? error;

  const PaginationState({
    this.items = const [],
    this.isLoading = false,
    this.hasMore = true,
    this.page = 1,
    this.error,
  });
}

class PaginatedListNotifier<T> extends StateNotifier<PaginationState<T>> {
  final Future<List<T>> Function(int page) fetcher;

  PaginatedListNotifier(this.fetcher) : super(PaginationState<T>()) {
    loadMore();
  }

  Future<void> loadMore() async {
    if (state.isLoading || !state.hasMore) return;

    state = state.copyWith(isLoading: true);

    try {
      final newItems = await fetcher(state.page);

      state = state.copyWith(
        items: [...state.items, ...newItems],
        page: state.page + 1,
        hasMore: newItems.isNotEmpty,
        isLoading: false,
      );
    } catch (e) {
      state = state.copyWith(
        error: e.toString(),
        isLoading: false,
      );
    }
  }

  void refresh() {
    state = PaginationState<T>();
    loadMore();
  }
}

// 사용 예시
final productListProvider = StateNotifierProvider<
  PaginatedListNotifier<Product>,
  PaginationState<Product>
>((ref) {
  final repository = ref.read(productRepositoryProvider);
  return PaginatedListNotifier(
    (page) => repository.getProducts(page: page),
  );
});

6. 선택 상태 관리 패턴

다중 선택이 필요한 UI에서 사용하는 패턴입니다.
Dart
final selectedItemsProvider = StateProvider<Set<String>>((ref) => {});

// 선택 토글 헬퍼
final itemSelectionProvider = Provider.family<bool, String>((ref, itemId) {
  final selectedItems = ref.watch(selectedItemsProvider);
  return selectedItems.contains(itemId);
});

// 선택 액션
class SelectionActions {
  final WidgetRef ref;

  SelectionActions(this.ref);

  void toggleItem(String itemId) {
    final selected = Set<String>.from(ref.read(selectedItemsProvider));

    if (selected.contains(itemId)) {
      selected.remove(itemId);
    } else {
      selected.add(itemId);
    }

    ref.read(selectedItemsProvider.notifier).state = selected;
  }

  void selectAll(List<String> itemIds) {
    ref.read(selectedItemsProvider.notifier).state = itemIds.toSet();
  }

  void clearSelection() {
    ref.read(selectedItemsProvider.notifier).state = {};
  }
}

7. 캐싱과 리프레시 패턴

데이터 캐싱과 강제 새로고침을 관리하는 패턴입니다.
Dart
final productsProvider = FutureProvider.autoDispose<List<Product>>((ref) async {
  // 5분간 캐시 유지
  ref.cacheFor(Duration(minutes: 5));

  final repository = ref.read(productRepositoryProvider);
  return repository.getAllProducts();
});

// Pull-to-refresh 구현
class ProductListScreen extends ConsumerWidget {
  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final productsAsync = ref.watch(productsProvider);

    return RefreshIndicator(
      onRefresh: () async {
        // 캐시 무효화 후 재조회
        ref.invalidate(productsProvider);
        await ref.read(productsProvider.future);
      },
      child: productsAsync.when(
        data: (products) => ListView.builder(...),
        loading: () => Center(child: CircularProgressIndicator()),
        error: (e, s) => ErrorWidget(e.toString()),
      ),
    );
  }
}

8. 글로벌 로딩/에러 처리 패턴

앱 전체의 로딩과 에러를 중앙에서 관리하는 패턴입니다.
Dart
// 글로벌 로딩 상태
final globalLoadingProvider = StateProvider<bool>((ref) => false);

// 글로벌 에러 상태
final globalErrorProvider = StateProvider<String?>((ref) => null);

// 로딩/에러 처리 믹스인
mixin LoadingErrorMixin {
  Future<T?> handleAsyncAction<T>(
    WidgetRef ref,
    Future<T> Function() action,
  ) async {
    ref.read(globalLoadingProvider.notifier).state = true;
    ref.read(globalErrorProvider.notifier).state = null;

    try {
      final result = await action();
      return result;
    } catch (e) {
      ref.read(globalErrorProvider.notifier).state = e.toString();
      return null;
    } finally {
      ref.read(globalLoadingProvider.notifier).state = false;
    }
  }
}

// 앱 전체 래퍼
class AppWrapper extends ConsumerWidget {
  final Widget child;

  const AppWrapper({required this.child});

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final isLoading = ref.watch(globalLoadingProvider);
    final error = ref.watch(globalErrorProvider);

    return Stack(
      children: [
        child,
        if (isLoading)
          Container(
            color: Colors.black26,
            child: Center(child: CircularProgressIndicator()),
          ),
        if (error != null)
          Positioned(
            top: 50,
            left: 16,
            right: 16,
            child: ErrorBanner(message: error),
          ),
      ],
    );
  }
}

9. 필터와 정렬 조합 패턴

복잡한 리스트 필터링과 정렬을 관리하는 패턴입니다.
Dart
@freezed
class FilterOptions with _$FilterOptions {
  const factory FilterOptions({
    String? category,
    RangeValues? priceRange,
    @Default(SortBy.name) SortBy sortBy,
    @Default(true) bool ascending,
  }) = _FilterOptions;
}

// 필터 상태 Provider
final filterProvider = StateProvider<FilterOptions>((ref) {
  return const FilterOptions();
});

// 필터링된 결과 Provider
final filteredProductsProvider = Provider<List<Product>>((ref) {
  final products = ref.watch(allProductsProvider);
  final filter = ref.watch(filterProvider);

  var filtered = products.where((product) {
    // 카테고리 필터
    if (filter.category != null && product.category != filter.category) {
      return false;
    }

    // 가격 범위 필터
    if (filter.priceRange != null) {
      if (product.price < filter.priceRange!.start ||
          product.price > filter.priceRange!.end) {
        return false;
      }
    }

    return true;
  }).toList();

  // 정렬
  filtered.sort((a, b) {
    int comparison;

    switch (filter.sortBy) {
      case SortBy.name:
        comparison = a.name.compareTo(b.name);
        break;
      case SortBy.price:
        comparison = a.price.compareTo(b.price);
        break;
      case SortBy.date:
        comparison = a.createdAt.compareTo(b.createdAt);
        break;
    }

    return filter.ascending ? comparison : -comparison;
  });

  return filtered;
});

10. 의존성 체인 패턴

여러 Provider가 순차적으로 의존하는 패턴입니다.
Dart
// 1. 사용자 정보
final userProvider = FutureProvider<User>((ref) async {
  final auth = ref.watch(authProvider);
  if (auth.user == null) throw Exception('Not authenticated');
  return auth.user!;
});

// 2. 사용자 설정 (사용자 정보 필요)
final userSettingsProvider = FutureProvider<UserSettings>((ref) async {
  final user = await ref.watch(userProvider.future);
  final repository = ref.read(settingsRepositoryProvider);
  return repository.getSettings(user.id);
});

// 3. 테마 설정 (사용자 설정 필요)
final themeProvider = Provider<ThemeData>((ref) {
  final settingsAsync = ref.watch(userSettingsProvider);

  return settingsAsync.maybeWhen(
    data: (settings) => settings.darkMode ? ThemeData.dark() : ThemeData.light(),
    orElse: () => ThemeData.light(),
  );
});

// 4. 언어 설정 (사용자 설정 필요)
final localeProvider = Provider<Locale>((ref) {
  final settingsAsync = ref.watch(userSettingsProvider);

  return settingsAsync.maybeWhen(
    data: (settings) => Locale(settings.languageCode),
    orElse: () => const Locale('ko'),
  );
});

실전 활용 팁

Provider 네이밍 컨벤션

Dart
// State를 반환하는 Provider
final cartProvider = StateNotifierProvider<CartNotifier, CartState>(...);

// 단일 값을 반환하는 Provider
final userProvider = FutureProvider<User>(...);

// 파생된 값을 반환하는 Provider
final cartTotalProvider = Provider<double>(...);

// 액션/메서드를 반환하는 Provider
final cartActionsProvider = Provider<CartActions>(...);

폴더 구조

bash
lib/
  providers/
    auth/           # 인증 관련
    products/       # 상품 관련
    cart/           # 장바구니 관련
    shared/         # 공용 Provider들
  features/
    auth/           # 인증 화면
    products/       # 상품 화면
    cart/           # 장바구니 화면

성능 최적화 체크리스트

select로 필요한 부분만 구독
familyautoDispose 적절히 조합
• 무거운 계산은 Provider에서 캐싱
AsyncValue.guard로 에러 처리 단순화
• 불필요한 Provider 체인 피하기

마무리

이 10가지 패턴이 Flutter Riverpod 실전 개발의 80% 이상을 커버합니다. 각 패턴은 독립적으로 사용할 수도 있고, 조합해서 사용할 수도 있습니다.

핵심은 단순하게 시작하고 필요에 따라 확장하는 것입니다. 모든 패턴을 처음부터 적용하려 하지 말고, 프로젝트의 요구사항에 맞게 선택적으로 사용하세요.
#Flutter
#Riverpod
#Patterns
#Best Practices
#Real-world