Riverpod — Provider의 진화, 컴파일타임 안전성

Riverpod은 Provider 패키지의 저자가 Provider의 한계를 극복하기 위해 새로 만든 상태 관리 라이브러리입니다. 이름도 Provider의 아나그램(글자 재배열)입니다.


Provider vs Riverpod

비교 항목ProviderRiverpod
BuildContext 의존필요불필요
같은 타입 여러 개불가가능
컴파일타임 안전부분적완전
테스트위젯 필요쉬움
Provider 조합ProxyProviderref.watch

설치

YAML
# pubspec.yaml
dependencies:
  flutter_riverpod: ^2.5.0

dev_dependencies:
  riverpod_generator: ^2.4.0
  build_runner: ^2.4.0
  riverpod_annotation: ^2.3.0

기본 설정

DART
import 'package:flutter_riverpod/flutter_riverpod.dart';

void main() {
  runApp(
    // ProviderScope로 전체 앱을 감싸기
    const ProviderScope(
      child: MyApp(),
    ),
  );
}

Provider 종류

Provider (읽기 전용)

DART
// 변하지 않는 값
final greetingProvider = Provider<String>((ref) {
  return '안녕하세요';
});

StateProvider (단순 상태)

DART
// 단순한 값 하나를 관리
final counterProvider = StateProvider<int>((ref) => 0);

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

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final count = ref.watch(counterProvider);

    return Scaffold(
      body: Center(child: Text('$count')),
      floatingActionButton: FloatingActionButton(
        onPressed: () {
          ref.read(counterProvider.notifier).state++;
        },
        child: const Icon(Icons.add),
      ),
    );
  }
}

StateNotifierProvider (복잡한 상태)

DART
// 상태 모델
class TodoState {
  final List<Todo> todos;
  final bool isLoading;

  const TodoState({this.todos = const [], this.isLoading = false});

  TodoState copyWith({List<Todo>? todos, bool? isLoading}) {
    return TodoState(
      todos: todos ?? this.todos,
      isLoading: isLoading ?? this.isLoading,
    );
  }
}

// StateNotifier
class TodoNotifier extends StateNotifier<TodoState> {
  TodoNotifier() : super(const TodoState());

  void add(String title) {
    state = state.copyWith(
      todos: [...state.todos, Todo(title: title)],
    );
  }

  void toggle(int index) {
    final newTodos = [...state.todos];
    newTodos[index] = newTodos[index].copyWith(
      isDone: !newTodos[index].isDone,
    );
    state = state.copyWith(todos: newTodos);
  }
}

// Provider 선언
final todoProvider = StateNotifierProvider<TodoNotifier, TodoState>((ref) {
  return TodoNotifier();
});

FutureProvider (비동기 데이터)

DART
final userProvider = FutureProvider<User>((ref) async {
  final response = await http.get(Uri.parse('/api/user'));
  return User.fromJson(jsonDecode(response.body));
});

// 사용 — AsyncValue로 로딩/에러/데이터 처리
class UserScreen extends ConsumerWidget {
  const UserScreen({super.key});

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final userAsync = ref.watch(userProvider);

    return userAsync.when(
      loading: () => const CircularProgressIndicator(),
      error: (error, stack) => Text('에러: $error'),
      data: (user) => Text('이름: ${user.name}'),
    );
  }
}

StreamProvider

DART
final messagesProvider = StreamProvider<List<Message>>((ref) {
  return firestore.collection('messages').snapshots().map(
    (snapshot) => snapshot.docs.map(Message.fromDoc).toList(),
  );
});

ConsumerWidget vs Consumer

DART
// ConsumerWidget: 전체 위젯이 리빌드
class MyScreen extends ConsumerWidget {
  const MyScreen({super.key});

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final count = ref.watch(counterProvider);
    return Text('$count');
  }
}

// Consumer: 특정 부분만 리빌드 (성능 최적화)
class MyScreen2 extends StatelessWidget {
  const MyScreen2({super.key});

  @override
  Widget build(BuildContext context) {
    return Column(
      children: [
        const Text('리빌드 안 됨'),
        Consumer(
          builder: (context, ref, child) {
            final count = ref.watch(counterProvider);
            return Text('$count');  // 이 부분만 리빌드
          },
        ),
      ],
    );
  }
}

// ConsumerStatefulWidget: StatefulWidget에서 Riverpod 사용
class MyScreen3 extends ConsumerStatefulWidget {
  const MyScreen3({super.key});

  @override
  ConsumerState<MyScreen3> createState() => _MyScreen3State();
}

class _MyScreen3State extends ConsumerState<MyScreen3> {
  @override
  void initState() {
    super.initState();
    // ref 사용 가능
    ref.read(counterProvider);
  }

  @override
  Widget build(BuildContext context) {
    final count = ref.watch(counterProvider);
    return Text('$count');
  }
}

ref.watch vs ref.read vs ref.listen

DART
@override
Widget build(BuildContext context, WidgetRef ref) {
  // watch: 값이 변할 때 리빌드 (build 안에서 사용)
  final count = ref.watch(counterProvider);

  // listen: 값이 변할 때 사이드 이펙트 실행
  ref.listen<int>(counterProvider, (previous, next) {
    if (next == 10) {
      ScaffoldMessenger.of(context).showSnackBar(
        const SnackBar(content: Text('10에 도달!')),
      );
    }
  });

  return ElevatedButton(
    // read: 현재 값만 읽기 (콜백 안에서 사용)
    onPressed: () => ref.read(counterProvider.notifier).state++,
    child: Text('$count'),
  );
}

Provider 간 의존성

DART
final authProvider = StateProvider<String?>((ref) => null);

// 다른 Provider의 값에 의존
final apiServiceProvider = Provider<ApiService>((ref) {
  final token = ref.watch(authProvider);
  return ApiService(token: token);
});

// apiServiceProvider에 의존하는 데이터 Provider
final postsProvider = FutureProvider<List<Post>>((ref) async {
  final api = ref.watch(apiServiceProvider);
  return api.fetchPosts();
});

authProvider가 변경되면 apiServiceProvider와 postsProvider도 자동으로 갱신됩니다.


family와 autoDispose

DART
// family: 매개변수를 받는 Provider
final userByIdProvider = FutureProvider.family<User, int>((ref, userId) async {
  return await fetchUser(userId);
});

// 사용
final user = ref.watch(userByIdProvider(42));

// autoDispose: 더 이상 사용되지 않을 때 자동 해제
final searchProvider = FutureProvider.autoDispose<List<Item>>((ref) async {
  // 화면을 떠나면 자동으로 폐기됨
  return await searchItems();
});

정리

  • Riverpod은 BuildContext 없이도 상태에 접근할 수 있어 테스트가 쉽습니다
  • AsyncValue.when()으로 로딩/에러/데이터를 깔끔하게 처리할 수 있습니다
  • ref.watch(빌드), ref.read(콜백), ref.listen(사이드 이펙트)을 구분해서 사용하세요
  • Provider 간 의존성이 자동으로 관리되는 것이 큰 장점입니다
  • family로 매개변수를 받고, autoDispose로 메모리를 관리합니다
댓글 로딩 중...