Flutter ์ƒํƒœ ๊ด€๋ฆฌ: Riverpod vs BLoC - ์™„์ „ ๋น„๊ต ๊ฐ€์ด๋“œ

Flutter ์ƒํƒœ ๊ด€๋ฆฌ๋ฅผ ์œ„ํ•œ Riverpod์™€ BLoC์˜ ์‹ฌ์ธต ๋น„๊ต์ž…๋‹ˆ๋‹ค. ์•„ํ‚คํ…์ฒ˜, ์„ฑ๋Šฅ, ํ…Œ์ŠคํŠธ ์šฉ์ด์„ฑ, ์‚ฌ์šฉ ์‚ฌ๋ก€๋ฅผ ํ†ตํ•ด ์ตœ์ ์˜ ์†”๋ฃจ์…˜์„ ์„ ํƒํ•ฉ๋‹ˆ๋‹ค.

Flutter ์ƒํƒœ ๊ด€๋ฆฌ๋ฅผ ์œ„ํ•œ Riverpod์™€ BLoC ๋น„๊ต

์ƒํƒœ ๊ด€๋ฆฌ๋Š” Flutter ๊ฐœ๋ฐœ์˜ ํ•ต์‹ฌ ๊ณผ์ œ์ž…๋‹ˆ๋‹ค. Riverpod์™€ BLoC๊ฐ€ ์ƒํƒœ๊ณ„๋ฅผ ์ง€๋ฐฐํ•˜๋ฉฐ, ๊ฐ๊ฐ ๊ณ ์œ ํ•œ ์ฒ ํ•™์„ ์ œ์‹œํ•ฉ๋‹ˆ๋‹ค. ๋ณธ ๊ฐ€์ด๋“œ๋Š” ํ”„๋กœ์ ํŠธ ์š”๊ตฌ์‚ฌํ•ญ์— ๋”ฐ๋ฅธ ์„ ํƒ์„ ๋•๊ธฐ ์œ„ํ•ด ๋‘ ์†”๋ฃจ์…˜์„ ๊ตฌ์ฒด์ ์ธ ๊ตฌํ˜„์œผ๋กœ ๋น„๊ตํ•ฉ๋‹ˆ๋‹ค.

์ „์ œ ์กฐ๊ฑด

๋ณธ ๊ฐ€์ด๋“œ๋Š” Flutter์™€ ์ƒํƒœ ๊ด€๋ฆฌ ๊ธฐ์ดˆ์— ๋Œ€ํ•œ ์ดํ•ด๋ฅผ ์ „์ œํ•ฉ๋‹ˆ๋‹ค. ์˜ˆ์ œ๋Š” ํ˜„์žฌ ์•ˆ์ • ๋ฒ„์ „์ธ Riverpod 2.x์™€ flutter_bloc 8.x๋ฅผ ์‚ฌ์šฉํ•ฉ๋‹ˆ๋‹ค.

๋‘ ์ ‘๊ทผ ๋ฐฉ์‹์˜ ํ•ต์‹ฌ ์ฒ ํ•™

Riverpod์™€ BLoC๋Š” ๋™์ผํ•œ ๋ฌธ์ œ๋ฅผ ์ •๋ฐ˜๋Œ€์˜ ํŒจ๋Ÿฌ๋‹ค์ž„์œผ๋กœ ํ•ด๊ฒฐํ•ฉ๋‹ˆ๋‹ค. ์ด๋Ÿฌํ•œ ๊ฐœ๋…์  ์ฐจ์ด๋ฅผ ์ดํ•ดํ•˜๋ฉด ๊ฐ ์ƒํ™ฉ์— ์ ํ•ฉํ•œ ๋„๊ตฌ๋ฅผ ์„ ํƒํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.

Riverpod๋Š” ์„ ์–ธ์ ์ด๊ณ  ๋ฐ˜์‘ํ˜• ์ ‘๊ทผ์„ ์ฑ„ํƒํ•ฉ๋‹ˆ๋‹ค. provider๊ฐ€ widget์ด ๊ด€์ฐฐํ•˜๋Š” ๋ฐ์ดํ„ฐ ์†Œ์Šค๋ฅผ ์ •์˜ํ•ฉ๋‹ˆ๋‹ค. ํ”„๋ ˆ์ž„์›Œํฌ๊ฐ€ ์ƒ๋ช…์ฃผ๊ธฐ, ์บ์‹ฑ, provider ๊ฐ„ ์˜์กด์„ฑ์„ ์ž๋™์œผ๋กœ ๊ด€๋ฆฌํ•ฉ๋‹ˆ๋‹ค.

BLoC(Business Logic Component)๋Š” ์ด๋ฒคํŠธ ๊ธฐ๋ฐ˜์˜ ์—„๊ฒฉํ•œ ์•„ํ‚คํ…์ฒ˜๋ฅผ ๊ฐ•์ œํ•ฉ๋‹ˆ๋‹ค. ์ปดํฌ๋„ŒํŠธ๊ฐ€ ์ด๋ฒคํŠธ๋ฅผ ๋ฐœํ–‰ํ•˜๊ณ , Bloc์ด ์ด๋ฅผ ์ฒ˜๋ฆฌํ•ด ์ƒˆ๋กœ์šด ์ƒํƒœ๋ฅผ ์ƒ์„ฑํ•ฉ๋‹ˆ๋‹ค. ์ด ๋ช…์‹œ์ ์ธ ๋ถ„๋ฆฌ๊ฐ€ ๋ฐ์ดํ„ฐ ํ๋ฆ„์˜ ์ถ”์ ์„ ์šฉ์ดํ•˜๊ฒŒ ํ•ฉ๋‹ˆ๋‹ค.

riverpod_philosophy.dartdart
// Riverpod: simple declaration, framework handles the rest
final counterProvider = StateProvider<int>((ref) => 0);

// Usage in a widget
class CounterWidget extends ConsumerWidget {
  
  Widget build(BuildContext context, WidgetRef ref) {
    // Reactive read: automatic rebuild if value changes
    final count = ref.watch(counterProvider);
    return Text('$count');
  }
}
bloc_philosophy.dartdart
// BLoC: explicit events/states separation
abstract class CounterEvent {}
class IncrementPressed extends CounterEvent {}

class CounterBloc extends Bloc<CounterEvent, int> {
  CounterBloc() : super(0) {
    // Each event has its dedicated handler
    on<IncrementPressed>((event, emit) => emit(state + 1));
  }
}

// Usage in a widget
class CounterWidget extends StatelessWidget {
  
  Widget build(BuildContext context) {
    return BlocBuilder<CounterBloc, int>(
      builder: (context, count) => Text('$count'),
    );
  }
}

๋‘ ์ ‘๊ทผ ๋ฐฉ์‹ ์ค‘ ์–ด๋А ์ชฝ์„ ์„ ํƒํ• ์ง€๋Š” ํŒ€์˜ ์„ ํ˜ธ์™€ ํ”„๋กœ์ ํŠธ ์ œ์•ฝ์— ๋”ฐ๋ผ ๋‹ฌ๋ผ์ง‘๋‹ˆ๋‹ค.

์ดˆ๊ธฐ ์„ค์ •

์ดˆ๊ธฐ ์„ค์ •์—์„œ ๋‘ ์†”๋ฃจ์…˜ ์‚ฌ์ด์˜ ์‚ฌ์šฉ์„ฑ ์ฐจ์ด๊ฐ€ ๋“œ๋Ÿฌ๋‚ฉ๋‹ˆ๋‹ค. Riverpod๋Š” ๋‹จ์ˆœํ•จ์„, BLoC๋Š” ๋” ๋งŽ์€ ๊ตฌ์กฐ๋ฅผ ์ œ๊ณตํ•ฉ๋‹ˆ๋‹ค.

Riverpod ์„ค์น˜

Riverpod๋Š” ๋‹จ์ผ ํŒจํ‚ค์ง€์™€ ์• ํ”Œ๋ฆฌ์ผ€์ด์…˜ ๋ฃจํŠธ์˜ wrapper๋งŒ ํ•„์š”ํ•ฉ๋‹ˆ๋‹ค. ์„ ํƒ์  ์ฝ”๋“œ ์ƒ์„ฑ์ด ์ƒ์‚ฐ์„ฑ์„ ๋†’์—ฌ ์ค๋‹ˆ๋‹ค.

main.dartdart
// Riverpod configuration: single wrapper at root
import 'package:flutter_riverpod/flutter_riverpod.dart';

void main() {
  runApp(
    // ProviderScope wraps the entire application
    const ProviderScope(
      child: MyApp(),
    ),
  );
}

class MyApp extends StatelessWidget {
  const MyApp({super.key});

  
  Widget build(BuildContext context) {
    return MaterialApp(
      home: HomeScreen(),
    );
  }
}

BLoC ์„ค์น˜

BLoC๋Š” ์—ฌ๋Ÿฌ ํŒจํ‚ค์ง€์™€ ์‚ฌ์šฉํ•˜๋Š” ๊ฐ Bloc๋งˆ๋‹ค BlocProvider๋ฅผ ๋‘๋Š” ๋” ์ •๊ตํ•œ ์„ค์ •์ด ํ•„์š”ํ•ฉ๋‹ˆ๋‹ค.

main.dartdart
// BLoC configuration: explicit providers for each Bloc
import 'package:flutter_bloc/flutter_bloc.dart';

void main() {
  runApp(const MyApp());
}

class MyApp extends StatelessWidget {
  const MyApp({super.key});

  
  Widget build(BuildContext context) {
    // MultiBlocProvider for multiple Blocs
    return MultiBlocProvider(
      providers: [
        BlocProvider(create: (_) => AuthBloc()),
        BlocProvider(create: (_) => ThemeBloc()),
      ],
      child: MaterialApp(
        home: HomeScreen(),
      ),
    );
  }
}

BLoC ์„ค์ •์€ ์ดˆ๊ธฐ ์ฝ”๋“œ๋Ÿ‰์€ ๋งŽ์ง€๋งŒ, ์˜์กด์„ฑ์„ ์ฒ˜์Œ๋ถ€ํ„ฐ ๋ช…์‹œ์ ์œผ๋กœ ๋“œ๋Ÿฌ๋ƒ…๋‹ˆ๋‹ค.

๋‹จ์ˆœํ•œ ์ƒํƒœ ๊ด€๋ฆฌ: ์นด์šดํ„ฐ์™€ ํ† ๊ธ€

๋‹จ์ˆœํ•œ ์‚ฌ๋ก€์—์„œ ๊ฐ ์†”๋ฃจ์…˜์˜ ์ผ์ƒ์ ์ธ ์‚ฌ์šฉ์„ฑ์ด ๋“œ๋Ÿฌ๋‚ฉ๋‹ˆ๋‹ค. Riverpod๋Š” ๊ฐ„๊ฒฐํ•จ์—์„œ ๋‘๋“œ๋Ÿฌ์ง€๊ณ , BLoC๋Š” ์ด๋ฒคํŠธ ๊ธฐ๋ฐ˜ ๊ตฌ์กฐ๋ฅผ ์œ ์ง€ํ•ฉ๋‹ˆ๋‹ค.

Riverpod ์นด์šดํ„ฐ

counter_riverpod.dartdart
// StateProvider: simple state without complex logic
final counterProvider = StateProvider<int>((ref) => 0);

class CounterScreen extends ConsumerWidget {
  const CounterScreen({super.key});

  
  Widget build(BuildContext context, WidgetRef ref) {
    // watch for reactive value
    final count = ref.watch(counterProvider);

    return Scaffold(
      body: Center(child: Text('Count: $count')),
      floatingActionButton: FloatingActionButton(
        // read for actions (no rebuild)
        onPressed: () => ref.read(counterProvider.notifier).state++,
        child: const Icon(Icons.add),
      ),
    );
  }
}

BLoC ์นด์šดํ„ฐ

counter_bloc.dartdart
// Typed events for each possible action
sealed class CounterEvent {}
class CounterIncremented extends CounterEvent {}
class CounterDecremented extends CounterEvent {}
class CounterReset extends CounterEvent {}

// Bloc with handlers for each event
class CounterBloc extends Bloc<CounterEvent, int> {
  CounterBloc() : super(0) {
    on<CounterIncremented>((event, emit) => emit(state + 1));
    on<CounterDecremented>((event, emit) => emit(state - 1));
    on<CounterReset>((event, emit) => emit(0));
  }
}

class CounterScreen extends StatelessWidget {
  const CounterScreen({super.key});

  
  Widget build(BuildContext context) {
    return Scaffold(
      body: Center(
        child: BlocBuilder<CounterBloc, int>(
          builder: (context, count) => Text('Count: $count'),
        ),
      ),
      floatingActionButton: FloatingActionButton(
        // Event dispatch to modify state
        onPressed: () => context.read<CounterBloc>().add(CounterIncremented()),
        child: const Icon(Icons.add),
      ),
    );
  }
}

๋‹จ์ˆœํ•œ ์‚ฌ๋ก€์—์„œ๋Š” Riverpod๊ฐ€ ๋ณด์ผ๋Ÿฌํ”Œ๋ ˆ์ดํŠธ๋ฅผ ํฌ๊ฒŒ ์ค„์—ฌ ์ค๋‹ˆ๋‹ค. ๋กœ์ง์ด ๋ณต์žกํ•ด์งˆ์ˆ˜๋ก BLoC์˜ ๊ฐ€์น˜๊ฐ€ ๋“œ๋Ÿฌ๋‚ฉ๋‹ˆ๋‹ค.

StateProvider vs StateNotifierProvider

StateProvider๋Š” ๋‹จ์ˆœํ•œ ์›์‹œ ๊ฐ’์— ์ ํ•ฉํ•ฉ๋‹ˆ๋‹ค. ๋ณต์žกํ•œ ๊ฐ์ฒด๋‚˜ ๋น„์ฆˆ๋‹ˆ์Šค ๋กœ์ง์—๋Š” StateNotifierProvider ๋˜๋Š” NotifierProvider๊ฐ€ ๋” ๋งŽ์€ ์ œ์–ด๋ฅผ ์ œ๊ณตํ•ฉ๋‹ˆ๋‹ค.

๋น„๋™๊ธฐ ์ƒํƒœ ๊ด€๋ฆฌ: API ํ˜ธ์ถœ

๋น„๋™๊ธฐ ์ž‘์—…์€ ๊ฐ ์†”๋ฃจ์…˜์˜ ๊ฐ•์ ์„ ๋“œ๋Ÿฌ๋ƒ…๋‹ˆ๋‹ค. loading, error, data ์ƒํƒœ์˜ ๊ด€๋ฆฌ๊ฐ€ ํ•ต์‹ฌ ๊ณผ์ œ์ž…๋‹ˆ๋‹ค.

Riverpod ๋น„๋™๊ธฐ ๋ฐ์ดํ„ฐ

async_riverpod.dartdart
// FutureProvider: automatic loading/error/data management
final usersProvider = FutureProvider.autoDispose<List<User>>((ref) async {
  final repository = ref.watch(userRepositoryProvider);
  // autoDispose releases resources when provider is no longer used
  return repository.fetchUsers();
});

class UsersScreen extends ConsumerWidget {
  const UsersScreen({super.key});

  
  Widget build(BuildContext context, WidgetRef ref) {
    final usersAsync = ref.watch(usersProvider);

    // when handles all 3 possible states
    return usersAsync.when(
      loading: () => const Center(child: CircularProgressIndicator()),
      error: (error, stack) => Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            Text('Error: $error'),
            ElevatedButton(
              // invalidate forces reload
              onPressed: () => ref.invalidate(usersProvider),
              child: const Text('Retry'),
            ),
          ],
        ),
      ),
      data: (users) => ListView.builder(
        itemCount: users.length,
        itemBuilder: (context, index) => UserTile(user: users[index]),
      ),
    );
  }
}

BLoC ๋น„๋™๊ธฐ ๋ฐ์ดํ„ฐ

async_bloc.dartdart
// Explicit states for each loading phase
sealed class UsersState {}
class UsersInitial extends UsersState {}
class UsersLoading extends UsersState {}
class UsersLoaded extends UsersState {
  final List<User> users;
  UsersLoaded(this.users);
}
class UsersError extends UsersState {
  final String message;
  UsersError(this.message);
}

// Events to trigger actions
sealed class UsersEvent {}
class UsersFetchRequested extends UsersEvent {}
class UsersRefreshRequested extends UsersEvent {}

class UsersBloc extends Bloc<UsersEvent, UsersState> {
  final UserRepository _repository;

  UsersBloc(this._repository) : super(UsersInitial()) {
    on<UsersFetchRequested>(_onFetchRequested);
    on<UsersRefreshRequested>(_onRefreshRequested);
  }

  Future<void> _onFetchRequested(
    UsersFetchRequested event,
    Emitter<UsersState> emit,
  ) async {
    emit(UsersLoading());
    try {
      final users = await _repository.fetchUsers();
      emit(UsersLoaded(users));
    } catch (e) {
      emit(UsersError(e.toString()));
    }
  }

  Future<void> _onRefreshRequested(
    UsersRefreshRequested event,
    Emitter<UsersState> emit,
  ) async {
    // Keep current state during refresh
    final currentState = state;
    try {
      final users = await _repository.fetchUsers();
      emit(UsersLoaded(users));
    } catch (e) {
      // Restore previous state on error
      if (currentState is UsersLoaded) {
        emit(currentState);
      } else {
        emit(UsersError(e.toString()));
      }
    }
  }
}
users_screen_bloc.dartdart
// Widget with pattern matching on states
class UsersScreen extends StatelessWidget {
  const UsersScreen({super.key});

  
  Widget build(BuildContext context) {
    return BlocBuilder<UsersBloc, UsersState>(
      builder: (context, state) {
        return switch (state) {
          UsersInitial() => const Center(
              child: ElevatedButton(
                onPressed: _fetchUsers,
                child: Text('Load'),
              ),
            ),
          UsersLoading() => const Center(child: CircularProgressIndicator()),
          UsersError(:final message) => Center(
              child: Column(
                mainAxisAlignment: MainAxisAlignment.center,
                children: [
                  Text('Error: $message'),
                  ElevatedButton(
                    onPressed: () => context
                        .read<UsersBloc>()
                        .add(UsersFetchRequested()),
                    child: const Text('Retry'),
                  ),
                ],
              ),
            ),
          UsersLoaded(:final users) => ListView.builder(
              itemCount: users.length,
              itemBuilder: (context, index) => UserTile(user: users[index]),
            ),
        };
      },
    );
  }

  void _fetchUsers(BuildContext context) {
    context.read<UsersBloc>().add(UsersFetchRequested());
  }
}

BLoC๋Š” ๊ฐ ์ƒํƒœ ์ „์ด๋ฅผ ์„ธ๋ฐ€ํ•˜๊ฒŒ ์ œ์–ดํ•ฉ๋‹ˆ๋‹ค. Riverpod๋Š” AsyncValue๋ฅผ ํ†ตํ•ด ๋” ๋งŽ์€ ๋ถ€๋ถ„์„ ์ž๋™ํ™”ํ•ฉ๋‹ˆ๋‹ค.

Flutter ๋ฉด์ ‘ ์ค€๋น„๊ฐ€ ๋˜์…จ๋‚˜์š”?

์ธํ„ฐ๋ž™ํ‹ฐ๋ธŒ ์‹œ๋ฎฌ๋ ˆ์ดํ„ฐ, flashcards, ๊ธฐ์ˆ  ํ…Œ์ŠคํŠธ๋กœ ์—ฐ์Šตํ•˜์„ธ์š”.

์ƒํƒœ ๊ฐ„ ์˜์กด์„ฑ: ์ปดํฌ์ง€์…˜๊ณผ ์ฃผ์ž…

์‹ค์ œ ์• ํ”Œ๋ฆฌ์ผ€์ด์…˜์—๋Š” ์ƒํ˜ธ ์˜์กดํ•˜๋Š” ์ƒํƒœ๊ฐ€ ์กด์žฌํ•ฉ๋‹ˆ๋‹ค. ์ด๋Ÿฌํ•œ ์˜์กด์„ฑ์˜ ๊ด€๋ฆฌ๊ฐ€ ๋‘ ์ ‘๊ทผ์„ ํฌ๊ฒŒ ๊ตฌ๋ถ„์ง“์Šต๋‹ˆ๋‹ค.

Riverpod ์ปดํฌ์ง€์…˜

composition_riverpod.dartdart
// Base provider: configuration
final apiClientProvider = Provider<ApiClient>((ref) {
  final baseUrl = ref.watch(environmentProvider).apiUrl;
  return ApiClient(baseUrl: baseUrl);
});

// Dependent provider: repository
final productRepositoryProvider = Provider<ProductRepository>((ref) {
  // Automatic client injection
  final client = ref.watch(apiClientProvider);
  return ProductRepository(client);
});

// Provider with parameter: product by ID
final productProvider = FutureProvider.autoDispose.family<Product, String>(
  (ref, productId) async {
    final repository = ref.watch(productRepositoryProvider);
    return repository.getProduct(productId);
  },
);

// Derived provider: filtered products
final filteredProductsProvider = Provider<List<Product>>((ref) {
  final products = ref.watch(productsProvider).valueOrNull ?? [];
  final filter = ref.watch(productFilterProvider);

  return products.where((p) => p.category == filter.category).toList();
});

// Usage with parameter
class ProductDetailScreen extends ConsumerWidget {
  final String productId;

  const ProductDetailScreen({super.key, required this.productId});

  
  Widget build(BuildContext context, WidgetRef ref) {
    // family allows passing parameters
    final productAsync = ref.watch(productProvider(productId));

    return productAsync.when(
      loading: () => const ProductSkeleton(),
      error: (e, _) => ErrorWidget(error: e),
      data: (product) => ProductDetails(product: product),
    );
  }
}

BLoC ์ปดํฌ์ง€์…˜

composition_bloc.dartdart
// Repository injected into the Bloc
class ProductBloc extends Bloc<ProductEvent, ProductState> {
  final ProductRepository _repository;
  final CartBloc _cartBloc;
  late final StreamSubscription _cartSubscription;

  ProductBloc({
    required ProductRepository repository,
    required CartBloc cartBloc,
  })  : _repository = repository,
        _cartBloc = cartBloc,
        super(ProductInitial()) {
    on<ProductFetchRequested>(_onFetchRequested);
    on<ProductAddedToCart>(_onAddedToCart);

    // Listen to cart changes
    _cartSubscription = _cartBloc.stream.listen((cartState) {
      // React to cart changes
      if (cartState is CartUpdated) {
        add(ProductCartSyncRequested(cartState.items));
      }
    });
  }

  Future<void> _onFetchRequested(
    ProductFetchRequested event,
    Emitter<ProductState> emit,
  ) async {
    emit(ProductLoading());
    try {
      final product = await _repository.getProduct(event.productId);
      // Check if product is in cart
      final isInCart = _cartBloc.state.contains(product.id);
      emit(ProductLoaded(product, isInCart: isInCart));
    } catch (e) {
      emit(ProductError(e.toString()));
    }
  }

  
  Future<void> close() {
    _cartSubscription.cancel();
    return super.close();
  }
}

// Configuration with dependency injection
class ProductsPage extends StatelessWidget {
  
  Widget build(BuildContext context) {
    return BlocProvider(
      create: (context) => ProductBloc(
        repository: context.read<ProductRepository>(),
        cartBloc: context.read<CartBloc>(),
      )..add(ProductFetchRequested()),
      child: const ProductsView(),
    );
  }
}

Riverpod๋Š” ์˜์กด์„ฑ์„ ์„ ์–ธ์ ์œผ๋กœ ๊ด€๋ฆฌํ•ฉ๋‹ˆ๋‹ค. BLoC๋Š” Bloc ์‚ฌ์ด์˜ ๊ตฌ๋…์„ ์ˆ˜๋™์œผ๋กœ ๊ด€๋ฆฌํ•ด์•ผ ํ•ฉ๋‹ˆ๋‹ค.

ํ…Œ์ŠคํŠธ ์šฉ์ด์„ฑ๊ณผ ๋ชจํ‚น

ํ…Œ์ŠคํŠธ๋Š” ์ „๋ฌธ ํ”„๋กœ์ ํŠธ์—์„œ ๊ฒฐ์ •์ ์ธ ํ‰๊ฐ€ ๊ธฐ์ค€์ž…๋‹ˆ๋‹ค. ๋‘ ์†”๋ฃจ์…˜ ๋ชจ๋‘ ์„œ๋กœ ๋‹ค๋ฅธ ์ ‘๊ทผ์œผ๋กœ ์ด ์˜์—ญ์—์„œ ๋›ฐ์–ด๋‚ฉ๋‹ˆ๋‹ค.

Riverpod ํ…Œ์ŠคํŠธ

test_riverpod.dartdart
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:mocktail/mocktail.dart';

class MockUserRepository extends Mock implements UserRepository {}

void main() {
  group('UserProvider Tests', () {
    late MockUserRepository mockRepository;
    late ProviderContainer container;

    setUp(() {
      mockRepository = MockUserRepository();
      // Isolated container with override
      container = ProviderContainer(
        overrides: [
          userRepositoryProvider.overrideWithValue(mockRepository),
        ],
      );
    });

    tearDown(() => container.dispose());

    test('returns users from repository', () async {
      // Arrange
      final expectedUsers = [User(id: '1', name: 'Test')];
      when(() => mockRepository.fetchUsers())
          .thenAnswer((_) async => expectedUsers);

      // Act
      final users = await container.read(usersProvider.future);

      // Assert
      expect(users, expectedUsers);
      verify(() => mockRepository.fetchUsers()).called(1);
    });

    test('handles repository errors', () async {
      when(() => mockRepository.fetchUsers())
          .thenThrow(Exception('Network error'));

      expect(
        () => container.read(usersProvider.future),
        throwsException,
      );
    });
  });
}

BLoC ํ…Œ์ŠคํŠธ

test_bloc.dartdart
import 'package:bloc_test/bloc_test.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:mocktail/mocktail.dart';

class MockUserRepository extends Mock implements UserRepository {}

void main() {
  group('UsersBloc Tests', () {
    late MockUserRepository mockRepository;

    setUp(() {
      mockRepository = MockUserRepository();
    });

    // blocTest simplifies state sequence testing
    blocTest<UsersBloc, UsersState>(
      'emits [Loading, Loaded] when fetch succeeds',
      build: () {
        when(() => mockRepository.fetchUsers())
            .thenAnswer((_) async => [User(id: '1', name: 'Test')]);
        return UsersBloc(mockRepository);
      },
      act: (bloc) => bloc.add(UsersFetchRequested()),
      expect: () => [
        isA<UsersLoading>(),
        isA<UsersLoaded>().having(
          (s) => s.users.length,
          'users count',
          1,
        ),
      ],
    );

    blocTest<UsersBloc, UsersState>(
      'emits [Loading, Error] when fetch fails',
      build: () {
        when(() => mockRepository.fetchUsers())
            .thenThrow(Exception('Network error'));
        return UsersBloc(mockRepository);
      },
      act: (bloc) => bloc.add(UsersFetchRequested()),
      expect: () => [
        isA<UsersLoading>(),
        isA<UsersError>(),
      ],
    );
  });
}

bloc_test ํŒจํ‚ค์ง€๋Š” ์ƒํƒœ ์‹œํ€€์Šค ํ…Œ์ŠคํŠธ๋ฅผ ์œ„ํ•œ ์ „์šฉ ๋ฌธ๋ฒ•์„ ์ œ๊ณตํ•ฉ๋‹ˆ๋‹ค. Riverpod๋Š” ํ‘œ์ค€ Flutter ํ…Œ์ŠคํŠธ ํŒจํ„ด์„ ์‚ฌ์šฉํ•ฉ๋‹ˆ๋‹ค.

ํ…Œ์ŠคํŠธ ์ปค๋ฒ„๋ฆฌ์ง€

์ •์ƒ ์ผ€์ด์Šค๋งŒ ํ…Œ์ŠคํŠธํ•˜๋Š” ๊ฒƒ์€ ์ถฉ๋ถ„ํ•˜์ง€ ์•Š์Šต๋‹ˆ๋‹ค. ํ…Œ์ŠคํŠธ๋Š” ๋„คํŠธ์›Œํฌ ์˜ค๋ฅ˜, ํƒ€์ž„์•„์›ƒ, ๊ฒฝ๊ณ„ ์ƒํƒœ, ์˜ˆ๊ธฐ์น˜ ์•Š์€ ์ƒํƒœ ์ „์ด๋ฅผ ํฌ๊ด„ํ•ด์•ผ ํ•ฉ๋‹ˆ๋‹ค.

์„ฑ๋Šฅ๊ณผ ๋ฆฌ๋นŒ๋“œ ์ตœ์ ํ™”

์„ฑ๋Šฅ์€ ์‚ฌ์šฉ์ž ๊ฒฝํ—˜์— ์ง์ ‘ ์˜ํ–ฅ์„ ๋ฏธ์นฉ๋‹ˆ๋‹ค. ๋‘ ์†”๋ฃจ์…˜ ๋ชจ๋‘ ์„œ๋กœ ๋‹ค๋ฅธ ์ตœ์ ํ™” ๋ฉ”์ปค๋‹ˆ์ฆ˜์„ ์ œ๊ณตํ•ฉ๋‹ˆ๋‹ค.

Riverpod ์ตœ์ ํ™”

perf_riverpod.dartdart
// select to rebuild only if targeted value changes
class UserNameWidget extends ConsumerWidget {
  
  Widget build(BuildContext context, WidgetRef ref) {
    // Rebuilds only if user.name changes
    final name = ref.watch(userProvider.select((user) => user.name));
    return Text(name);
  }
}

// Provider with automatic caching
final expensiveComputationProvider = Provider<ExpensiveResult>((ref) {
  final input = ref.watch(inputProvider);
  // Computation automatically cached
  return performExpensiveComputation(input);
});

// autoDispose to release unused resources
final searchResultsProvider = FutureProvider.autoDispose
    .family<List<Product>, String>((ref, query) async {
  // Temporary keepAlive during typing
  final link = ref.keepAlive();

  // Timer to release after inactivity
  final timer = Timer(const Duration(seconds: 30), link.close);
  ref.onDispose(timer.cancel);

  return searchProducts(query);
});

BLoC ์ตœ์ ํ™”

perf_bloc.dartdart
// buildWhen limits rebuilds conditionally
class UserNameWidget extends StatelessWidget {
  
  Widget build(BuildContext context) {
    return BlocBuilder<UserBloc, UserState>(
      // Rebuilds only if name changes
      buildWhen: (previous, current) {
        if (previous is UserLoaded && current is UserLoaded) {
          return previous.user.name != current.user.name;
        }
        return true;
      },
      builder: (context, state) {
        if (state is UserLoaded) {
          return Text(state.user.name);
        }
        return const SizedBox.shrink();
      },
    );
  }
}

// BlocSelector to extract a specific value
class UserAvatarWidget extends StatelessWidget {
  
  Widget build(BuildContext context) {
    return BlocSelector<UserBloc, UserState, String?>(
      // Select only the avatar URL
      selector: (state) => state is UserLoaded ? state.user.avatarUrl : null,
      builder: (context, avatarUrl) {
        if (avatarUrl == null) return const DefaultAvatar();
        return NetworkImage(avatarUrl);
      },
    );
  }
}

๋‘ ์†”๋ฃจ์…˜ ๋ชจ๋‘ ์„ธ๋ฐ€ํ•œ ์ตœ์ ํ™”๋ฅผ ์ œ๊ณตํ•ฉ๋‹ˆ๋‹ค. Riverpod๋Š” select๋กœ, BLoC๋Š” buildWhen๊ณผ BlocSelector๋กœ ์ฒ˜๋ฆฌํ•ฉ๋‹ˆ๋‹ค.

์‹ค์šฉ ์‚ฌ๋ก€: ์™„์ „ํ•œ ์ธ์ฆ

์ธ์ฆ ์‹œ์Šคํ…œ์€ ๊ฐ ์†”๋ฃจ์…˜์˜ ์‹ค์ œ ํŒจํ„ด์„ ๋ณด์—ฌ์ค๋‹ˆ๋‹ค. ์ด ์‚ฌ๋ก€๋Š” ์˜์† ์ƒํƒœ, API ํ˜ธ์ถœ, ๋‚ด๋น„๊ฒŒ์ด์…˜์„ ๊ฒฐํ•ฉํ•ฉ๋‹ˆ๋‹ค.

Riverpod ์ธ์ฆ

auth_riverpod.dartdart
// Authentication state with sealed class
sealed class AuthState {
  const AuthState();
}
class AuthInitial extends AuthState {
  const AuthInitial();
}
class AuthLoading extends AuthState {
  const AuthLoading();
}
class AuthAuthenticated extends AuthState {
  final User user;
  const AuthAuthenticated(this.user);
}
class AuthUnauthenticated extends AuthState {
  final String? error;
  const AuthUnauthenticated([this.error]);
}

// Notifier to manage auth state
class AuthNotifier extends StateNotifier<AuthState> {
  final AuthRepository _repository;
  final SecureStorage _storage;

  AuthNotifier(this._repository, this._storage) : super(const AuthInitial()) {
    _checkAuthStatus();
  }

  Future<void> _checkAuthStatus() async {
    final token = await _storage.getToken();
    if (token != null) {
      try {
        final user = await _repository.getCurrentUser(token);
        state = AuthAuthenticated(user);
      } catch (_) {
        await _storage.deleteToken();
        state = const AuthUnauthenticated();
      }
    } else {
      state = const AuthUnauthenticated();
    }
  }

  Future<void> login(String email, String password) async {
    state = const AuthLoading();
    try {
      final result = await _repository.login(email, password);
      await _storage.saveToken(result.token);
      state = AuthAuthenticated(result.user);
    } catch (e) {
      state = AuthUnauthenticated(e.toString());
    }
  }

  Future<void> logout() async {
    await _storage.deleteToken();
    state = const AuthUnauthenticated();
  }
}

// Provider with injected dependencies
final authProvider = StateNotifierProvider<AuthNotifier, AuthState>((ref) {
  return AuthNotifier(
    ref.watch(authRepositoryProvider),
    ref.watch(secureStorageProvider),
  );
});

// Redirect based on auth state
final routerProvider = Provider<GoRouter>((ref) {
  final authState = ref.watch(authProvider);

  return GoRouter(
    redirect: (context, state) {
      final isAuth = authState is AuthAuthenticated;
      final isAuthRoute = state.matchedLocation.startsWith('/auth');

      if (!isAuth && !isAuthRoute) return '/auth/login';
      if (isAuth && isAuthRoute) return '/home';
      return null;
    },
    routes: [...],
  );
});

BLoC ์ธ์ฆ

auth_bloc.dartdart
// Exhaustive states for authentication
sealed class AuthState {
  const AuthState();
}
class AuthInitial extends AuthState {
  const AuthInitial();
}
class AuthCheckInProgress extends AuthState {
  const AuthCheckInProgress();
}
class AuthLoginInProgress extends AuthState {
  const AuthLoginInProgress();
}
class AuthSuccess extends AuthState {
  final User user;
  const AuthSuccess(this.user);
}
class AuthFailure extends AuthState {
  final String error;
  const AuthFailure(this.error);
}
class AuthLoggedOut extends AuthState {
  const AuthLoggedOut();
}

// Authentication events
sealed class AuthEvent {
  const AuthEvent();
}
class AuthCheckRequested extends AuthEvent {
  const AuthCheckRequested();
}
class AuthLoginSubmitted extends AuthEvent {
  final String email;
  final String password;
  const AuthLoginSubmitted(this.email, this.password);
}
class AuthLogoutRequested extends AuthEvent {
  const AuthLogoutRequested();
}

class AuthBloc extends Bloc<AuthEvent, AuthState> {
  final AuthRepository _repository;
  final SecureStorage _storage;

  AuthBloc({
    required AuthRepository repository,
    required SecureStorage storage,
  })  : _repository = repository,
        _storage = storage,
        super(const AuthInitial()) {
    on<AuthCheckRequested>(_onCheckRequested);
    on<AuthLoginSubmitted>(_onLoginSubmitted);
    on<AuthLogoutRequested>(_onLogoutRequested);
  }

  Future<void> _onCheckRequested(
    AuthCheckRequested event,
    Emitter<AuthState> emit,
  ) async {
    emit(const AuthCheckInProgress());
    final token = await _storage.getToken();

    if (token == null) {
      emit(const AuthLoggedOut());
      return;
    }

    try {
      final user = await _repository.getCurrentUser(token);
      emit(AuthSuccess(user));
    } catch (_) {
      await _storage.deleteToken();
      emit(const AuthLoggedOut());
    }
  }

  Future<void> _onLoginSubmitted(
    AuthLoginSubmitted event,
    Emitter<AuthState> emit,
  ) async {
    emit(const AuthLoginInProgress());
    try {
      final result = await _repository.login(event.email, event.password);
      await _storage.saveToken(result.token);
      emit(AuthSuccess(result.user));
    } catch (e) {
      emit(AuthFailure(e.toString()));
    }
  }

  Future<void> _onLogoutRequested(
    AuthLogoutRequested event,
    Emitter<AuthState> emit,
  ) async {
    await _storage.deleteToken();
    emit(const AuthLoggedOut());
  }
}

๋‘ ๊ตฌํ˜„์€ ๋™์ผํ•œ ๊ธฐ๋Šฅ์„ ์„œ๋กœ ๋‹ค๋ฅธ ๋ฐฉ์‹์œผ๋กœ ๋‹ค๋ฃน๋‹ˆ๋‹ค. BLoC๋Š” ๊ฐ ์ „์ด๋ฅผ ๋ช…์‹œํ•˜๊ณ , Riverpod๋Š” ๋ฌธ๋ฒ•์„ ๋‹จ์ˆœํ™”ํ•ฉ๋‹ˆ๋‹ค.

๋น„๊ต ์š”์•ฝ ํ‘œ

| ๊ธฐ์ค€ | Riverpod | BLoC | |------|----------|------| | ํ•™์Šต ๊ณก์„  | ๋ณดํ†ต | ๋” ๊ฐ€ํŒŒ๋ฆ„ | | ๋ณด์ผ๋Ÿฌํ”Œ๋ ˆ์ดํŠธ | ์ตœ์†Œ | ๋งŽ์Œ | | ํƒ€์ž… ์•ˆ์ „์„ฑ | ์šฐ์ˆ˜ | ์šฐ์ˆ˜ | | ํ…Œ์ŠคํŠธ ์šฉ์ด์„ฑ | ์šฐ์ˆ˜ | ์šฐ์ˆ˜ | | ์ถ”์ ์„ฑ | DevTools ๊ฒฝ์œ  | ๋ช…์‹œ์  Events/States | | ์ปดํฌ์ง€์…˜ | ์ž๋™ | ์ˆ˜๋™ | | ์ฝ”๋“œ ์ƒ์„ฑ | ์„ ํƒ ์‚ฌํ•ญ | ๋ถˆํ•„์š” | | ํŒ€ ๊ทœ๋ชจ | ์œ ์—ฐ | ๋Œ€๊ทœ๋ชจ ํŒ€ |

์ƒํ™ฉ๋ณ„ ๊ถŒ์žฅ ์‚ฌํ•ญ

Riverpod์™€ BLoC์˜ ์„ ํƒ์€ ์—ฌ๋Ÿฌ ์ƒํ™ฉ์  ์š”์ธ์— ๋”ฐ๋ผ ๋‹ฌ๋ผ์ง‘๋‹ˆ๋‹ค.

Riverpod๋ฅผ ์„ ํƒํ•˜๋Š” ๊ฒฝ์šฐ:

  • ํŒ€์ด ๊ฐ„๊ฒฐ์„ฑ๊ณผ ์ƒ์‚ฐ์„ฑ์„ ์šฐ์„ ์‹œํ•จ
  • ํ”„๋กœ์ ํŠธ๊ฐ€ ์œ ์—ฐํ•œ ์ƒํƒœ ์ปดํฌ์ง€์…˜์„ ์š”๊ตฌํ•จ
  • ๊ฐœ๋ฐœ์ž๊ฐ€ React ๋˜๋Š” ๋‹ค๋ฅธ ๋ฐ˜์‘ํ˜• ํ”„๋ ˆ์ž„์›Œํฌ ์ถœ์‹ 
  • ์ž๋™ ์บ์‹ฑ์ด ํฐ ์ด์ ์ด ๋จ

BLoC๋ฅผ ์„ ํƒํ•˜๋Š” ๊ฒฝ์šฐ:

  • ํŒ€์ด ์—„๊ฒฉํ•˜๊ณ  ์˜ˆ์ธก ๊ฐ€๋Šฅํ•œ ํŒจํ„ด์„ ์„ ํ˜ธํ•จ
  • ํ”„๋กœ์ ํŠธ๊ฐ€ ์™„์ „ํ•œ ์ด๋ฒคํŠธ ์ถ”์ ์„ ์š”๊ตฌํ•จ
  • ์ฃผ๋‹ˆ์–ด๊ฐ€ ๊ฐ•์ œ๋œ ์•„ํ‚คํ…์ฒ˜์—์„œ ์ด์ ์„ ์–ป์Œ
  • ๋””๋ฒ„๊น…์— ์ „์ด ์ด๋ ฅ์ด ํ•„์š”ํ•จ

๊ฒฐ๋ก 

Riverpod์™€ BLoC๋Š” Flutter์˜ ์ƒํƒœ ๊ด€๋ฆฌ ์š”๊ตฌ๋ฅผ ํšจ๊ณผ์ ์œผ๋กœ ์ถฉ์กฑํ•ฉ๋‹ˆ๋‹ค. Riverpod๋Š” ์‚ฌ์šฉ์„ฑ๊ณผ ์œ ์—ฐ์„ฑ์—์„œ, BLoC๋Š” ๊ตฌ์กฐ์™€ ์˜ˆ์ธก ๊ฐ€๋Šฅ์„ฑ์—์„œ ๋‘๋“œ๋Ÿฌ์ง‘๋‹ˆ๋‹ค. ๋‘ ์†”๋ฃจ์…˜ ๋ชจ๋‘ ์šฐ์ˆ˜ํ•œ ํ…Œ์ŠคํŠธ ์šฉ์ด์„ฑ๊ณผ ์ตœ์ ์˜ ์„ฑ๋Šฅ์„ ์ œ๊ณตํ•ฉ๋‹ˆ๋‹ค.

๊ฒฐ์ • ์ฒดํฌ๋ฆฌ์ŠคํŠธ

  • โœ… ํŒ€์˜ ๊ทœ๋ชจ์™€ ๊ฒฝํ—˜์„ ํ‰๊ฐ€
  • โœ… ๋ฐ์ดํ„ฐ ํ๋ฆ„์˜ ๋ณต์žก๋„๋ฅผ ๊ณ ๋ ค
  • โœ… ์ถ”์ ์„ฑ๊ณผ ๋””๋ฒ„๊น… ์š”๊ตฌ์‚ฌํ•ญ์„ ๋ถ„์„
  • โœ… ๋‘ ์†”๋ฃจ์…˜์„ ํ”„๋กœํ† ํƒ€์ž…์—์„œ ์‹œํ—˜
  • โœ… ๊ธฐ์กด ์•„ํ‚คํ…์ฒ˜์™€์˜ ์ผ๊ด€์„ฑ์„ ํ™•์ธ

์—ฐ์Šต์„ ์‹œ์ž‘ํ•˜์„ธ์š”!

๋ฉด์ ‘ ์‹œ๋ฎฌ๋ ˆ์ดํ„ฐ์™€ ๊ธฐ์ˆ  ํ…Œ์ŠคํŠธ๋กœ ์ง€์‹์„ ํ…Œ์ŠคํŠธํ•˜์„ธ์š”.

์ตœ์„ ์˜ ์„ ํƒ์€ ํŒ€์ด ์ˆ™๋ จ๋˜์–ด ์žˆ๊ณ  ํšจ๊ณผ์ ์œผ๋กœ ์œ ์ง€๋ณด์ˆ˜ํ•  ์ˆ˜ ์žˆ๋Š” ์†”๋ฃจ์…˜์ž…๋‹ˆ๋‹ค. ์ ์šฉ์˜ ์ผ๊ด€์„ฑ์ด ์†”๋ฃจ์…˜ ์ž์ฒด์˜ ์„ ํƒ๋ณด๋‹ค ์šฐ์„ ํ•ฉ๋‹ˆ๋‹ค.

ํƒœ๊ทธ

#flutter
#riverpod
#bloc
#state management
#dart

๊ณต์œ 

๊ด€๋ จ ๊ธฐ์‚ฌ

Flutter ์ƒํƒœ ๊ด€๋ฆฌ 2026: Riverpod, Bloc, GetX ๋น„๊ต ๋‹ค์ด์–ด๊ทธ๋žจ

2026๋…„ Flutter ์ƒํƒœ ๊ด€๋ฆฌ ์™„๋ฒฝ ๊ฐ€์ด๋“œ: Riverpod vs Bloc vs GetX ๋น„๊ต ๋ถ„์„

Riverpod 3.0, Bloc 9.0, GetX ์„ธ ๊ฐ€์ง€ Flutter ์ƒํƒœ ๊ด€๋ฆฌ ์†”๋ฃจ์…˜์„ ์ฝ”๋“œ ์˜ˆ์ œ, ์„ฑ๋Šฅ ๋ถ„์„, ํ…Œ์ŠคํŠธ ์ „๋žต ๊ด€์ ์—์„œ ๋น„๊ต ๋ถ„์„ํ•ฉ๋‹ˆ๋‹ค.

๋ชจ๋ฐ”์ผ ๊ฐœ๋ฐœ์ž๋ฅผ ์œ„ํ•œ Flutter ๋ฉด์ ‘ ์งˆ๋ฌธ ๊ฐ€์ด๋“œ

๋ชจ๋ฐ”์ผ ๊ฐœ๋ฐœ์ž๋ฅผ ์œ„ํ•œ Flutter ๋ฉด์ ‘ ์งˆ๋ฌธ 20์„ 

Flutter ๋ฉด์ ‘์—์„œ ๊ฐ€์žฅ ์ž์ฃผ ์ถœ์ œ๋˜๋Š” 20๊ฐ€์ง€ ์งˆ๋ฌธ์„ ์ค€๋น„ํ•˜์‹ญ์‹œ์˜ค. Widget, ์ƒํƒœ ๊ด€๋ฆฌ, Dart, ์•„ํ‚คํ…์ฒ˜, ๋ชจ๋ฒ” ์‚ฌ๋ก€๋ฅผ ์ƒ์„ธํ•œ ์ฝ”๋“œ ์˜ˆ์ œ์™€ ํ•จ๊ป˜ ์„ค๋ช…ํ•ฉ๋‹ˆ๋‹ค.

Flutter์™€ Dart๋กœ ์ฒซ ๋ฒˆ์งธ ๋ชจ๋ฐ”์ผ ์• ํ”Œ๋ฆฌ์ผ€์ด์…˜์„ ๊ตฌ์ถ•ํ•˜๋Š” ๊ฐ€์ด๋“œ

Flutter: ์ฒซ ๋ฒˆ์งธ ํฌ๋กœ์Šค ํ”Œ๋žซํผ ์•ฑ ๊ตฌ์ถ•ํ•˜๊ธฐ

Flutter์™€ Dart๋ฅผ ์‚ฌ์šฉํ•œ ํฌ๋กœ์Šค ํ”Œ๋žซํผ ๋ชจ๋ฐ”์ผ ์• ํ”Œ๋ฆฌ์ผ€์ด์…˜ ๊ตฌ์ถ• ์™„์ „ ๊ฐ€์ด๋“œ. Widget, ์ƒํƒœ ๊ด€๋ฆฌ, ๋‚ด๋น„๊ฒŒ์ด์…˜, ์ดˆ๋ณด์ž๋ฅผ ์œ„ํ•œ ๋ชจ๋ฒ” ์‚ฌ๋ก€๋ฅผ ๋‹ค๋ฃน๋‹ˆ๋‹ค.