[Flutter 공부] Riverpod
- Developer/Flutter
- 2025. 3. 18.
안녕하세요. 오늘은 Riverpod의 장단점과 실제 활용 패턴을 공유하려 합니다. 단순히 "이렇게 쓰세요"가 아닌 "왜 이렇게 써야 하는지"에 초점을 맞추겠습니다.

목차
Provider vs Riverpod: 근본적 차이점
Provider는 Flutter 앱에서 가장 많이 사용되는 상태 관리 솔루션 중 하나다. 하지만 대규모 앱을 개발하다 보면 여러 한계점에 부딪힌다. Riverpod는 이런 한계를 극복하기 위해 만들어진 솔루션으로, Provider의 창시자가 직접 개발했다. "Re-imagined provider"라는 이름에서 알 수 있듯이 Provider의 문제점을 해결하기 위해 처음부터 다시 설계됐다.
Provider는 InheritedWidget을 기반으로 하며 위젯 트리에 의존적이다. 반면 Riverpod는 위젯 트리와 완전히 독립적으로 동작한다. 이 근본적인 차이가 여러 장점을 가져온다. 컴파일 타임 안전성, 글로벌 접근성, 그리고 provider 충돌 문제 해결 등이 대표적이다.
솔직히 말하자면, Riverpod의 도입은 소규모 프로젝트에서는 과한 선택일 수 있다. 하지만 앱 규모가 커지면 커질수록 그 진가를 발휘한다. 특히 앱의 모듈화와 테스트 용이성 측면에서 Provider보다 훨씬 유리하다.
Riverpod를 활용한 효과적인 의존성 주입
의존성 주입(DI)은 클린 아키텍처의 핵심이다. Riverpod는 의존성 주입을 놀랍도록 간단하게 구현할 수 있게 해준다. Riverpod를 사용하면 서비스 로케이터를 구현하거나 복잡한 설정 없이도 효과적인 DI 패턴을 적용할 수 있다.
가장 큰 장점은 계층화된 의존성을 쉽게 구현할 수 있다는 점이다. Repository는 DataSource에 의존하고, ViewModel은 Repository에 의존하는 구조를 Riverpod의 Provider 체인으로 간결하게 표현할 수 있다. 이렇게 하면 테스트 시 각 계층을 쉽게 모킹할 수 있어 테스트 가능성이 대폭 향상된다.
| DI 패턴 | Provider | Riverpod | 복잡성 |
|---|---|---|---|
| 생성자 주입 | 지원 (수동) | 지원 (자동) | 낮음 |
| 프로바이더 참조 | 위젯 트리 통과 필요 | 직접 참조 가능 | 중간 |
| 환경별 오버라이드 | 복잡함 | 간단함 | 높음 |
| 스코프 관리 | 트리 기반 (복잡) | 선언적 (단순) | 높음 |
| 런타임 재구성 | 제한적 | 강력 지원 | 매우 높음 |
Riverpod를 사용한 의존성 주입의 가장 좋은 패턴은 '계층별 Provider' 접근 방식이다. 이는 애플리케이션의 각 레이어마다 별도의 Provider를 만들고, 이들을 참조하는 구조다. 이렇게 하면 코드 구조가 명확해지고 테스트도 용이해진다.
Riverpod Provider 종류별 활용 전략
Riverpod는 다양한 종류의 Provider를 제공한다. 각각은 특정 사용 사례에 최적화되어 있으며, 적절한 상황에 맞게 선택해야 한다. 잘못된 Provider 선택은 성능 문제나 버그로 이어질 수 있다.
- Provider: 가장 기본적인 형태로, 변경되지 않는 값을 제공할 때 사용한다. API 클라이언트나 Repository 인스턴스 같은 서비스 객체에 적합하다.
- StateProvider: 간단한 상태를 관리할 때 사용한다. 불리언 플래그나 드롭다운 선택 값과 같은 단순 상태에 적합하다.
- StateNotifierProvider: 복잡한 상태 로직이 필요할 때 사용한다. 불변성을 강제하고 상태 변경을 명시적으로 만든다.
- FutureProvider: 비동기 데이터를 로드할 때 사용한다. API 호출 결과나 데이터베이스 쿼리에 적합하다.
- StreamProvider: 실시간으로 변경되는 데이터를 구독할 때 사용한다. FireStore 컬렉션이나 웹소켓 연결에 이상적이다.
- ChangeNotifierProvider: 레거시 코드와의 호환성을 위해 제공된다. 새 프로젝트에서는 사용을 피하는 것이 좋다.
각 Provider 타입의 사용 예는 아래 섹션에서 실제 코드로 확인할 수 있다. 하지만 Provider 선택은 단순히 기능만이 아니라 성능과 유지보수성도 고려해야 한다.
소규모 프로젝트나 빠른 프로토타이핑에는 Provider가 충분합니다. 하지만 앱이 복잡해지고 테스트 가능성이 중요해지면 Riverpod를 선택하는 것이 좋습니다.
앱 복잡성, 팀 규모, 그리고 장기적 유지보수 계획에 따라 선택해야 합니다. 간단한 앱이라면 Provider로도 충분하지만, 중대형 앱, 특히 여러 개발자가 작업하는 프로젝트에서는 Riverpod의 컴파일 타임 안전성과 테스트 용이성이 큰 장점입니다. 특히 앱이 기능적으로 성장할 것으로 예상된다면 처음부터 Riverpod를 선택하는 것이 나중에 마이그레이션하는 것보다 효율적입니다.
상태 관리 패턴과 안티패턴
Riverpod로 상태 관리를 할 때는 몇 가지 베스트 프랙티스를 따르는 것이 중요하다. 효율적인 패턴을 따르면 코드 유지보수성이 향상되고 버그 발생 가능성이 줄어든다. 반면 안티패턴은 예상치 못한 동작이나 성능 문제로 이어질 수 있다.
권장 패턴
가장 중요한 패턴 중 하나는 '단일 책임 원칙'을 따르는 것이다. 각 Provider는 하나의 책임만 가져야 한다. 여러 기능이 혼합된 거대한 Provider는 유지보수를 어렵게 만든다. 대신 작고 집중된 Provider들을 만들고 이들을 조합하는 방식이 더 효과적이다.
또 다른 중요한 패턴은 '불변성 유지'다. 특히 StateNotifierProvider를 사용할 때는 상태를 직접 수정하지 않고 새로운 상태 객체를 반환해야 한다. 이렇게 하면 버그를 줄이고 상태 변화를 추적하기 쉬워진다.
안티패턴
가장 흔한 안티패턴은 Provider 내부에서 다른 Provider를 동적으로 읽는 것이다. 이는 예측할 수 없는 재빌드와 성능 문제를 일으킬 수 있다. 대신 Provider 파라미터에서 명시적으로 의존성을 주입받아야 한다.
또 다른 일반적인 실수는 UI 코드와 비즈니스 로직을 분리하지 않는 것이다. 위젯 내에서 직접 상태를 변경하는 대신, StateNotifier와 같은 별도의 클래스에 비즈니스 로직을 캡슐화해야 한다. 이는 테스트 가능성을 향상시키고 UI 코드를 간결하게 유지하는 데 도움이 된다.
마지막으로, 지나친 세분화도 피해야 한다. Provider를 너무 작게 쪼개면 코드가 복잡해지고 Provider 간의 의존성 관리가 어려워질 수 있다. 균형을 유지하는 것이 중요하다.
Riverpod 기반 애플리케이션 테스트 전략
Riverpod의 가장 큰 장점 중 하나는 테스트 용이성이다. Provider와 달리 Riverpod는 위젯 트리에 의존하지 않기 때문에 단위 테스트가 훨씬 간단해진다. 특히 ProviderContainer를 활용하면 위젯을 렌더링하지 않고도 Provider 동작을 테스트할 수 있다.
테스트에서는 종속성을 오버라이드하는 기능이 중요하다. Riverpod는 override 메서드를 통해 특정 Provider를 모의 구현으로 대체할 수 있게 해준다. 이를 통해 네트워크나 데이터베이스 같은 외부 의존성 없이도 비즈니스 로직을 철저히 테스트할 수 있다.
| 테스트 유형 | Provider | Riverpod | 복잡성 |
|---|---|---|---|
| 단위 테스트 | 복잡함 | 간단함 | 낮음 |
| 통합 테스트 | 보통 | 간단함 | 중간 |
| 위젯 테스트 | 보통 | 간단함 | 중간 |
| 의존성 모킹 | 복잡함 | 매우 간단함 | 높음 |
| 골든 테스트 | 보통 | 보통 | 매우 높음 |
Riverpod 테스트에서 가장 강력한 접근 방식은 계층별 테스트다. 저수준 Provider(예: Repository)부터 시작해 상위 레벨(예: ViewModel)로 올라가면서 테스트하는 방식이다. 이렇게 하면 버그를 정확히 어느 계층에서 발생했는지 쉽게 파악할 수 있다.
Provider에서 Riverpod로 마이그레이션 전략
기존에 Provider를 사용하던 앱을 Riverpod로 전환하는 것은 도전적인 작업이 될 수 있다. 하지만 점진적인 접근 방식을 통해 위험을 최소화하면서 마이그레이션할 수 있다. 한 번에 모든 코드를 전환하려고 하면 위험하다. 대신 기능별로 나누어 단계적으로 진행하는 것이 좋다.
마이그레이션을 시작하기 전에 코드를 철저히 테스트하고 기준선을 설정하는 것이 중요하다. 이렇게 하면 마이그레이션 후에 기능이 제대로 동작하는지 확인하기 쉬워진다.
- 종속성 추가: flutter_riverpod와 필요한 경우 hooks_riverpod 패키지를 추가한다.
- ProviderScope 추가: 앱의 루트에 ProviderScope를 래핑한다.
- 저수준 Provider 변환: 데이터 소스, 레포지토리 등 하위 계층부터 변환한다.
- 상위 Provider 전환: 비즈니스 로직과 상태 관리 Provider를 변환한다.
- UI 위젯 업데이트: Consumer 또는 ConsumerWidget을 사용하도록 UI를 업데이트한다.
- 기존 코드 제거: 모든 것이 작동하면 provider 패키지와 관련 코드를 제거한다.
특히 주의해야 할 것은 ChangeNotifierProvider에서 StateNotifierProvider로의 전환이다. 두 API는 매우 다르므로 단순히 수정하는 것이 아니라 완전히 다시 작성해야 할 수도 있다. 하지만 이는 코드 품질을 향상시킬 좋은 기회가 될 수 있다.
Riverpod 2.x에서는 .autoDispose 수정자를 사용해야 했는데, 3.0부터는 모든 Provider가 자동으로 해제되도록 바뀌었습니다. 이 변화에 어떻게 대응해야 할까요?
Riverpod 3.0부터는 모든 Provider가 기본적으로 autoDispose 기능을 갖추게 되었습니다. 즉, Provider가 더 이상 사용되지 않으면 자동으로 메모리에서 해제됩니다. 반대로 Provider를 영구적으로 유지하고 싶다면 .keepAlive 수정자를 사용하면 됩니다. 이 변화는 메모리 관리를 간소화하고 메모리 누수 가능성을 줄이는 중요한 개선 사항입니다. 기존에 .autoDispose 수정자를 사용하던 코드는 정상적으로 작동하지만, 새 코드에서는 생략할 수 있습니다.
Riverpod에서 상태 관리를 위해 StateNotifier와 Notifier 두 가지 옵션이 있는데, 어떤 상황에서 어떤 것을 선택해야 할까요?
StateNotifier는 불변성을 강제하는 반면, Notifier는 가변 상태를 허용합니다. 복잡한 상태 객체를 다루거나 Readonly 상태가 중요한 경우 StateNotifier가 더 적합합니다. 상태 변경을 추적하고 디버깅하기 쉽기 때문입니다. 반면 간단한 상태나 매우 빈번한 업데이트가 필요한 경우(예: 애니메이션) Notifier가 더 효율적일 수 있습니다. 일반적으로 예측 가능성과 안정성을 위해 StateNotifier를 선호하는 것이 좋지만, 성능이 중요한 경우 Notifier를 고려해볼 수 있습니다.
Riverpod 구현 예제
아래는 Riverpod를 활용한 완전한 상태 관리 구현의 예시이다. 이 예제는 사용자 인증 상태를 관리하는 간단한 시스템을 구현한다. 데이터 계층, 도메인 계층, 그리고 표현 계층으로 나누어져 있으며, 각 계층은 Riverpod Provider를 통해 연결된다.
// auth_repository.dart
import 'package:flutter_riverpod/flutter_riverpod.dart';
// API 클라이언트 Provider
final apiClientProvider = Provider((ref) => ApiClient());
// Auth Repository Provider
final authRepositoryProvider = Provider((ref) {
final apiClient = ref.watch(apiClientProvider);
return AuthRepository(apiClient);
});
class AuthRepository {
final ApiClient _apiClient;
AuthRepository(this._apiClient);
Future<User> login(String email, String password) async {
try {
final response = await _apiClient.post(
'/auth/login',
data: {'email': email, 'password': password},
);
return User.fromJson(response.data);
} catch (e) {
throw AuthException('로그인 실패: ${e.toString()}');
}
}
Future<void> logout() async {
try {
await _apiClient.post('/auth/logout');
} catch (e) {
throw AuthException('로그아웃 실패: ${e.toString()}');
}
}
}
class ApiClient {
// API 클라이언트 구현
Future<Response> post(String path, {Map<String, dynamic>? data}) async {
// 실제 구현은 생략
return Response(/* 더미 데이터 */);
}
}
class Response {
final dynamic data;
Response(this.data);
}
class User {
final String id;
final String name;
final String email;
User({required this.id, required this.name, required this.email});
factory User.fromJson(Map<String, dynamic> json) {
return User(
id: json['id'],
name: json['name'],
email: json['email'],
);
}
}
class AuthException implements Exception {
final String message;
AuthException(this.message);
}
// auth_state_notifier.dart
import 'package:flutter_riverpod/flutter_riverpod.dart';
// 불변성을 가진 상태 클래스
class AuthState {
final User? user;
final bool isLoading;
final String? errorMessage;
const AuthState({
this.user,
this.isLoading = false,
this.errorMessage,
});
// 불변성 유지를 위한 복사 메서드
AuthState copyWith({
User? user,
bool? isLoading,
String? errorMessage,
}) {
return AuthState(
user: user ?? this.user,
isLoading: isLoading ?? this.isLoading,
errorMessage: errorMessage ?? this.errorMessage,
);
}
bool get isAuthenticated => user != null;
}
// StateNotifier Provider
final authStateProvider = StateNotifierProvider<AuthStateNotifier, AuthState>((ref) {
final repository = ref.watch(authRepositoryProvider);
return AuthStateNotifier(repository);
});
class AuthStateNotifier extends StateNotifier<AuthState> {
final AuthRepository _repository;
AuthStateNotifier(this._repository) : super(const AuthState());
Future<void> login(String email, String password) async {
// 로딩 상태로 변경
state = state.copyWith(isLoading: true, errorMessage: null);
try {
final user = await _repository.login(email, password);
// 성공 상태로 변경
state = state.copyWith(user: user, isLoading: false);
} catch (e) {
// 에러 상태로 변경
state = state.copyWith(
isLoading: false,
errorMessage: e is AuthException ? e.message : '알 수 없는 오류가 발생했습니다.',
);
}
}
Future<void> logout() async {
state = state.copyWith(isLoading: true);
try {
await _repository.logout();
state = const AuthState(); // 초기 상태로 리셋
} catch (e) {
state = state.copyWith(
isLoading: false,
errorMessage: e is AuthException ? e.message : '로그아웃 중 오류가 발생했습니다.',
);
}
}
}
// login_screen.dart
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
class LoginScreen extends ConsumerWidget {
final emailController = TextEditingController();
final passwordController = TextEditingController();
LoginScreen({Key? key}) : super(key: key);
@override
Widget build(BuildContext context, WidgetRef ref) {
// Provider 구독
final authState = ref.watch(authStateProvider);
// 로그인 성공 감지 및 처리
ref.listen(authStateProvider, (previous, current) {
if (!previous!.isAuthenticated && current.isAuthenticated) {
// 로그인 성공 시 홈 화면으로 이동
Navigator.of(context).pushReplacementNamed('/home');
}
});
return Scaffold(
appBar: AppBar(title: const Text('로그인')),
body: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
// 에러 메시지 표시
if (authState.errorMessage != null)
Container(
padding: const EdgeInsets.all(8.0),
margin: const EdgeInsets.only(bottom: 16.0),
color: Colors.red.shade100,
child: Text(
authState.errorMessage!,
style: const TextStyle(color: Colors.red),
),
),
// 이메일 입력 필드
TextField(
controller: emailController,
decoration: const InputDecoration(labelText: '이메일'),
keyboardType: TextInputType.emailAddress,
enabled: !authState.isLoading,
),
const SizedBox(height: 16),
// 비밀번호 입력 필드
TextField(
controller: passwordController,
decoration: const InputDecoration(labelText: '비밀번호'),
obscureText: true,
enabled: !authState.isLoading,
),
const SizedBox(height: 24),
// 로그인 버튼
ElevatedButton(
onPressed: authState.isLoading
? null
: () => _handleLogin(ref),
child: authState.isLoading
? const CircularProgressIndicator(color: Colors.white)
: const Text('로그인'),
),
],
),
),
);
}
void _handleLogin(WidgetRef ref) {
final email = emailController.text.trim();
final password = passwordController.text;
if (email.isEmpty || password.isEmpty) {
return;
}
// StateNotifier 메서드 호출
ref.read(authStateProvider.notifier).login(email, password);
}
}
위 코드 예제는 Riverpod의 주요 장점을 잘 보여준다. 의존성 주입이 명확하게 이루어지고, 상태 관리 로직이 UI 코드와 분리되어 있으며, 상태 변경이 불변성 원칙을 통해 예측 가능하게 이루어진다. 특히 ref.listen을 통한 상태 변화 감지와 반응은 Riverpod의 강력한 기능 중 하나다.
실제 프로젝트에서는 위 코드를 바탕으로 환경에 맞게 확장하면 된다. 예를 들어, API 클라이언트는 dio나 http 패키지를 사용해 구현할 수 있고, 인증 토큰 관리를 위해 secure_storage와 연동할 수도 있다. 중요한 것은 각 계층이 Riverpod Provider를 통해 명확하게 연결되어 있어 테스트와 유지보수가 용이하다는 점이다.
마무리: Riverpod의 미래와 결론
지금까지 Riverpod의 다양한 측면을 살펴봤다. Provider와의 근본적인 차이점부터 의존성 주입, 상태 관리 패턴, 테스트 전략, 그리고 마이그레이션 방법까지 다루었다. Riverpod는 분명 러닝 커브가 있지만, 대규모 애플리케이션에서는 그 가치가 분명히 드러난다.
Riverpod 3.0의 출시는 자동 디스포즈와 같은 편의 기능을 추가하면서도 기존 API와의 호환성을 유지했다. 또한 Flutter 3.0부터 도입된 개선된 위젯 재빌드 알고리즘과 최적화 기능도 활용할 수 있다. 이는 Riverpod가 Flutter 에코시스템의 진화에 맞춰 지속적으로 발전하고 있음을 보여준다.
결론적으로, Riverpod는 단순히 상태 관리 라이브러리를 넘어 애플리케이션 아키텍처의 핵심 요소로 자리 잡고 있다. 특히 클린 아키텍처, 의존성 주입, 그리고 테스트 용이성을 중요시하는 프로젝트에서 더욱 빛을 발한다. 물론 모든 프로젝트에 Riverpod가 필요한 것은 아니다. 작은 앱이나 프로토타입에는 과한 선택일 수 있다. 하지만 앱의 규모와 복잡성이 증가할수록 Riverpod의 장점이 더욱 명확해진다.
'Developer > Flutter' 카테고리의 다른 글
| [Flutter 공부] 커스텀 페인팅(CustomPainter) (1) | 2025.03.19 |
|---|---|
| [Flutter 공부] 복잡한 애니메이션 구현하기 (0) | 2025.03.18 |
| [Flutter 공부] GetX 정리 (0) | 2025.03.18 |
| [Flutter 공부] 상태관리, BLoC 패턴 (0) | 2025.03.17 |
| [Flutter 공부] 성능 최적화, 앱 성능 모니터링과 개선 방법 (0) | 2025.03.16 |