마스터하기 실전 가이드
마스터하기의 핵심 개념과 실무 활용
학습 항목
이미지 로딩 중...
Riverpod 3.0 상태관리 마스터하기
Flutter 개발의 새로운 표준이 된 Riverpod 3.0의 핵심 개념과 실무 활용법을 완벽하게 정리했습니다. 초급자도 쉽게 따라할 수 있는 실전 예제와 함께 Provider, Notifier, AsyncValue 등 필수 개념을 깊이 있게 다룹니다.
목차
- Provider 기본
- NotifierProvider
- AsyncNotifierProvider
- FutureProvider
- StreamProvider
- Family Modifier
- AutoDispose
- Ref와 WidgetRef
- Select 최적화
- Provider Override
1. Provider 기본
시작하며
여러분이 Flutter 앱에서 테마 설정, 환경 변수, 또는 앱 전역에서 사용하는 불변 데이터를 관리할 때 어떻게 하시나요? StatelessWidget의 생성자를 통해 계속 전달하거나, 글로벌 변수를 사용하는 건 유지보수가 어렵고 테스트하기도 힘듭니다.
이런 문제는 실제 개발 현장에서 자주 발생합니다. 예를 들어, 앱의 API URL을 개발/운영 환경에 따라 다르게 설정해야 하는데, 이 값을 모든 화면과 서비스 클래스에 일일이 전달하는 것은 비효율적입니다.
코드가 복잡해지고, 새로운 기능을 추가할 때마다 생성자 매개변수가 늘어나죠. 바로 이럴 때 필요한 것이 Provider입니다.
Riverpod의 가장 기본이 되는 Provider는 앱 전역에서 접근 가능한 불변 값을 제공하며, 의존성 주입을 깔끔하게 처리할 수 있게 해줍니다.
개요
간단히 말해서, Provider는 변경되지 않는 값을 앱 전역에서 제공하는 가장 단순한 형태의 상태 제공자입니다. 왜 이 개념이 필요한지 실무 관점에서 설명하면, 앱 설정, 환경 변수, 서비스 인스턴스처럼 한 번 생성되면 변경되지 않는 값들을 효율적으로 관리할 수 있습니다.
예를 들어, HTTP 클라이언트, 로거, 데이터베이스 연결 같은 경우에 매우 유용합니다. 기존에는 싱글톤 패턴이나 InheritedWidget을 사용해 값을 전달했다면, 이제는 Provider 하나만 선언하면 어디서든 간편하게 접근할 수 있습니다.
또한 테스트할 때도 overrides를 통해 쉽게 모킹할 수 있습니다. Provider의 핵심 특징은 세 가지입니다.
첫째, 불변성 보장으로 예측 가능한 동작을 제공합니다. 둘째, 지연 초기화(lazy initialization)로 실제로 사용될 때만 값이 생성됩니다.
셋째, 컴파일 타임 안정성으로 런타임 에러를 방지합니다. 이러한 특징들이 대규모 앱에서 안정적인 상태 관리를 가능하게 합니다.
코드 예제
// API 기본 URL을 제공하는 Provider
final apiBaseUrlProvider = Provider<String>((ref) {
// 환경에 따라 다른 URL 반환
const bool isProduction = bool.fromEnvironment('dart.vm.product');
return isProduction
? 'https://api.production.com'
: 'https://api.dev.com';
});
// HTTP 클라이언트를 제공하는 Provider (다른 Provider 의존)
final httpClientProvider = Provider<HttpClient>((ref) {
final baseUrl = ref.watch(apiBaseUrlProvider);
// 실제 HTTP 클라이언트 생성
return HttpClient(baseUrl: baseUrl);
});
// UI에서 사용하기
class MyWidget extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final baseUrl = ref.watch(apiBaseUrlProvider);
return Text('API: $baseUrl');
}
}
설명
이것이 하는 일: Provider는 앱이 실행되는 동안 변경되지 않는 값을 선언하고, 앱의 어디서든 해당 값에 접근할 수 있게 해줍니다. 마치 전역 변수처럼 동작하지만, 훨씬 안전하고 테스트 가능한 방식입니다.
첫 번째로, final apiBaseUrlProvider = Provider<String>((ref) => ...)는 Provider를 선언하는 부분입니다. 여기서 중요한 것은 ref 객체인데, 이를 통해 다른 Provider를 읽거나 생명주기를 관리할 수 있습니다.
왜 이렇게 하는지 이유는 간단합니다. 함수 내부에 값 생성 로직을 캡슐화하면, 실제로 필요할 때만 값이 생성되고(지연 초기화), 테스트할 때 쉽게 교체할 수 있기 때문입니다.
두 번째로, httpClientProvider에서 ref.watch(apiBaseUrlProvider)가 실행되면서 Provider 간의 의존성이 형성됩니다. 내부에서 어떤 일이 일어나는지 살펴보면, Riverpod은 이 의존성 그래프를 자동으로 추적하고, apiBaseUrlProvider가 변경되면(오버라이드되면) httpClientProvider도 자동으로 재생성됩니다.
이것이 바로 Riverpod의 강력한 의존성 주입 메커니즘입니다. 세 번째로, ConsumerWidget의 build 메서드에서 ref.watch(apiBaseUrlProvider)를 호출하여 최종적으로 값을 사용합니다.
마지막으로, 이 호출이 실행되는 순간 Provider의 함수가 처음 실행되고, 그 결과값이 캐시되어 다음 호출부터는 캐시된 값을 반환합니다. 여러분이 이 코드를 사용하면 앱 전체에서 일관된 설정 값을 사용할 수 있고, 테스트할 때는 간단히 오버라이드하여 다른 값을 주입할 수 있습니다.
실무에서의 이점은 세 가지입니다. 첫째, 코드가 훨씬 깔끔해지고 의존성이 명확해집니다.
둘째, 테스트 작성이 쉬워집니다. 셋째, 환경별로 다른 설정을 쉽게 관리할 수 있습니다.
실전 팁
💡 Provider는 불변 값에만 사용하세요. 값이 변경되어야 한다면 NotifierProvider나 StateProvider를 사용해야 합니다. 실수로 Provider 내부에서 변경 가능한 객체를 반환하면 예상치 못한 버그가 발생할 수 있습니다.
💡 Provider 간 의존성을 만들 때는 ref.watch를 사용하세요. ref.read를 사용하면 의존성이 추적되지 않아 값이 변경될 때 자동으로 업데이트되지 않습니다.
💡 전역 변수 대신 항상 Provider를 사용하세요. 특히 서비스 인스턴스(HTTP 클라이언트, 데이터베이스 등)는 Provider로 관리하면 테스트할 때 모킹이 훨씬 쉬워집니다.
💡 Provider 이름은 항상 xxxProvider 형식으로 짓는 것이 컨벤션입니다. 이렇게 하면 코드를 읽을 때 어떤 것이 Provider인지 한눈에 알 수 있습니다.
💡 환경별 설정은 bool.fromEnvironment나 String.fromEnvironment를 활용하면 빌드 타임에 값을 주입할 수 있어 보안에도 좋습니다.
2. NotifierProvider
시작하며
여러분이 장바구니 기능을 만들 때, 상품 추가/삭제, 수량 변경 같은 복잡한 상태 변경 로직을 어디에 작성하시나요? StatefulWidget에 모든 로직을 넣으면 코드가 지저분해지고, 같은 로직을 다른 화면에서 재사용하기도 어렵습니다.
이런 문제는 실제 개발 현장에서 매우 흔합니다. 특히 상태 변경 로직이 복잡하거나, 여러 화면에서 같은 상태를 공유해야 할 때 더욱 그렇습니다.
예를 들어, 장바구니에 상품을 추가할 때 재고 확인, 중복 체크, 최대 수량 제한 같은 비즈니스 로직이 필요한데, 이를 위젯에 섞어 놓으면 테스트도 어렵고 유지보수도 힘들어집니다. 바로 이럴 때 필요한 것이 NotifierProvider입니다.
변경 가능한 상태와 그 상태를 변경하는 메서드들을 하나의 클래스로 캡슐화하여, UI와 비즈니스 로직을 깔끔하게 분리할 수 있습니다.
개요
간단히 말해서, NotifierProvider는 변경 가능한 상태와 그 상태를 변경하는 메서드들을 제공하는 상태 관리 솔루션입니다. 왜 이 개념이 필요한지 실무 관점에서 설명하면, 복잡한 상태 변경 로직을 UI에서 분리하고, 여러 화면에서 재사용할 수 있게 만들어줍니다.
예를 들어, 사용자 인증 상태, 장바구니, 좋아하는 항목 목록 같은 경우에 매우 유용합니다. 상태 변경 로직이 집중되어 있어 버그를 찾기도 쉽고, 테스트 코드 작성도 간편합니다.
기존에는 ChangeNotifier나 StateNotifier를 사용했다면, 이제는 Riverpod 3.0의 Notifier 클래스를 상속받아 훨씬 더 타입 안전하고 간결하게 작성할 수 있습니다. 더 이상 dispose를 수동으로 호출할 필요도 없습니다.
NotifierProvider의 핵심 특징은 세 가지입니다. 첫째, 상태와 로직이 하나의 클래스에 캡슐화되어 응집도가 높습니다.
둘째, state 속성을 통해 현재 상태에 쉽게 접근하고 변경할 수 있습니다. 셋째, 불변성을 강제하지 않아 복잡한 객체도 효율적으로 관리할 수 있습니다.
이러한 특징들이 실무에서 복잡한 상태 관리를 훨씬 쉽게 만들어줍니다.
코드 예제
// 카운터 상태를 관리하는 Notifier
class CounterNotifier extends Notifier<int> {
// 초기 상태 설정
@override
int build() => 0;
// 상태를 증가시키는 메서드
void increment() {
state = state + 1;
}
// 상태를 감소시키는 메서드
void decrement() {
state = state - 1;
}
// 특정 값으로 설정하는 메서드
void setValue(int value) {
state = value;
}
}
// NotifierProvider로 노출
final counterProvider = NotifierProvider<CounterNotifier, int>(
() => CounterNotifier(),
);
// UI에서 사용하기
class CounterWidget extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final count = ref.watch(counterProvider);
final notifier = ref.read(counterProvider.notifier);
return Column(
children: [
Text('Count: $count'),
ElevatedButton(
onPressed: notifier.increment,
child: Text('Increment'),
),
],
);
}
}
설명
이것이 하는 일: NotifierProvider는 상태(state)와 그 상태를 변경하는 메서드들을 하나의 Notifier 클래스에 모아서 관리합니다. UI는 단순히 상태를 읽고 메서드를 호출하기만 하면 되므로, 관심사의 분리가 명확해집니다.
첫 번째로, CounterNotifier extends Notifier<int>는 정수형 상태를 관리하는 Notifier를 정의합니다. 여기서 build() 메서드는 초기 상태를 반환하는 역할을 하며, Provider가 처음 생성될 때 단 한 번만 호출됩니다.
왜 생성자가 아닌 build 메서드를 사용하는지 궁금하실 텐데, 이는 ref 객체에 접근하여 다른 Provider를 읽을 수 있게 하기 위함입니다. 두 번째로, state = state + 1 같은 상태 변경 코드가 실행되면서 내부적으로 Riverpod이 변경을 감지하고 이 Provider를 watch하고 있는 모든 위젯에 자동으로 알립니다.
내부에서 어떤 일이 일어나는지 살펴보면, state setter가 호출될 때마다 리스너들이 트리거되고, 해당 위젯들이 리빌드되는 것입니다. 이 과정이 매우 효율적으로 최적화되어 있어 성능 걱정 없이 사용할 수 있습니다.
세 번째로, UI에서 ref.watch(counterProvider)는 현재 상태 값을 가져오고, ref.read(counterProvider.notifier)는 Notifier 인스턴스 자체를 가져옵니다. 마지막으로, notifier의 메서드를 호출하면 상태가 변경되고, watch하고 있던 위젯이 자동으로 리빌드되어 최종적으로 UI가 업데이트됩니다.
여러분이 이 코드를 사용하면 상태 관리 로직을 완전히 UI에서 분리할 수 있고, 같은 Notifier를 여러 화면에서 재사용할 수 있습니다. 실무에서의 이점은 다음과 같습니다.
첫째, 테스트하기 쉬워집니다. Notifier 클래스만 단독으로 테스트할 수 있습니다.
둘째, 디버깅이 편해집니다. 상태 변경 로직이 한 곳에 모여 있어 버그를 추적하기 쉽습니다.
셋째, 코드 재사용성이 높아져 개발 속도가 빨라집니다.
실전 팁
💡 상태를 변경할 때는 항상 새로운 객체를 할당하세요. 리스트나 맵 같은 컬렉션을 다룰 때 state.add(item) 대신 state = [...state, item]처럼 새 리스트를 만들어야 변경이 감지됩니다.
💡 복잡한 비즈니스 로직은 private 메서드로 분리하고, public 메서드는 간단하게 유지하세요. 이렇게 하면 코드 가독성이 높아지고 테스트하기도 쉬워집니다.
💡 ref.watch는 build 메서드 안에서만 사용하고, 이벤트 핸들러에서는 ref.read를 사용하세요. 이벤트 핸들러에서 watch를 사용하면 불필요한 리스너가 등록됩니다.
💡 Notifier의 build 메서드에서 다른 Provider를 watch하면 해당 Provider가 변경될 때 자동으로 상태가 재초기화됩니다. 이를 활용하면 Provider 간 의존성을 우아하게 처리할 수 있습니다.
💡 상태가 복잡한 객체일 때는 copyWith 패턴을 사용하세요. 예: state = state.copyWith(name: newName). 이렇게 하면 불변성을 유지하면서도 효율적으로 상태를 업데이트할 수 있습니다.
3. AsyncNotifierProvider
시작하며
여러분이 사용자 프로필을 서버에서 불러와서 화면에 표시하고, 사용자가 수정한 내용을 다시 서버에 저장해야 하는 기능을 만든다고 생각해보세요. 로딩 상태, 에러 처리, 데이터 캐싱을 어떻게 관리하시나요?
FutureBuilder를 중첩해서 사용하면 코드가 복잡해지고, 에러 처리도 일관성이 없어집니다. 이런 문제는 실제 개발 현장에서 가장 자주 마주치는 어려움입니다.
API 호출을 포함한 비동기 작업은 성공, 실패, 로딩 세 가지 상태를 모두 처리해야 하는데, 이를 수동으로 관리하면 boilerplate 코드가 엄청나게 늘어납니다. 게다가 상태를 변경하는 메서드도 비동기여야 하는 경우가 많아서 복잡도가 더욱 증가합니다.
바로 이럴 때 필요한 것이 AsyncNotifierProvider입니다. 비동기 데이터 로딩과 상태 변경을 하나의 클래스로 통합하여, 로딩/에러/데이터 상태를 자동으로 관리하고, UI는 간단한 패턴 매칭만으로 각 상태를 표현할 수 있게 해줍니다.
개요
간단히 말해서, AsyncNotifierProvider는 비동기 작업을 포함한 상태 관리를 위한 Provider로, AsyncValue 타입으로 로딩/에러/데이터 상태를 자동으로 처리합니다. 왜 이 개념이 필요한지 실무 관점에서 설명하면, 서버와 통신하는 거의 모든 기능에서 필수적으로 사용됩니다.
예를 들어, 사용자 프로필 관리, 게시물 목록, 검색 결과 같은 경우에 매우 유용합니다. 비동기 작업의 모든 상태를 자동으로 추적하므로, 개발자는 비즈니스 로직에만 집중할 수 있습니다.
기존에는 FutureProvider와 StateNotifier를 조합하거나, 복잡한 상태 관리 패턴을 직접 구현해야 했다면, 이제는 AsyncNotifier 하나로 초기 로딩, 새로고침, 상태 업데이트를 모두 처리할 수 있습니다. 코드량이 줄어들고, 에러 처리도 일관성 있게 할 수 있습니다.
AsyncNotifierProvider의 핵심 특징은 세 가지입니다. 첫째, AsyncValue<T> 타입으로 로딩/에러/데이터 상태를 표현하여 UI 코드가 간결해집니다.
둘째, build 메서드가 Future를 반환하여 초기 데이터를 비동기로 로드합니다. 셋째, state를 AsyncValue로 업데이트하여 상태 변경 중 로딩 표시나 에러 처리를 자동화할 수 있습니다.
이러한 특징들이 복잡한 비동기 로직을 놀라울 정도로 단순하게 만들어줍니다.
코드 예제
// 사용자 프로필을 관리하는 AsyncNotifier
class UserProfileNotifier extends AsyncNotifier<UserProfile> {
// 초기 데이터를 비동기로 로드
@override
Future<UserProfile> build() async {
// API 호출로 사용자 프로필 가져오기
final api = ref.watch(apiClientProvider);
return await api.getUserProfile();
}
// 프로필을 업데이트하는 메서드
Future<void> updateProfile(String name, String email) async {
// 로딩 상태로 변경
state = const AsyncValue.loading();
// 비동기 작업 수행 및 에러 처리
state = await AsyncValue.guard(() async {
final api = ref.read(apiClientProvider);
final updated = await api.updateUserProfile(name, email);
return updated;
});
}
// 프로필 새로고침
Future<void> refresh() async {
state = const AsyncValue.loading();
state = await AsyncValue.guard(() => build());
}
}
// Provider 선언
final userProfileProvider = AsyncNotifierProvider<UserProfileNotifier, UserProfile>(
() => UserProfileNotifier(),
);
// UI에서 사용
class ProfileScreen extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final profileAsync = ref.watch(userProfileProvider);
return profileAsync.when(
data: (profile) => Text('Name: ${profile.name}'),
loading: () => CircularProgressIndicator(),
error: (err, stack) => Text('Error: $err'),
);
}
}
설명
이것이 하는 일: AsyncNotifierProvider는 서버에서 데이터를 가져오고, 수정하고, 다시 저장하는 전체 흐름을 하나의 클래스로 관리하면서, 각 단계의 로딩과 에러 상태를 자동으로 추적합니다. 첫 번째로, Future<UserProfile> build()는 비동기 함수로 초기 데이터를 로드합니다.
Provider가 처음 생성되거나 refresh()가 호출될 때 실행되며, 반환된 Future가 완료될 때까지 상태는 AsyncValue.loading()입니다. 왜 이렇게 설계되었는지 이유는, 초기 로딩 상태를 개발자가 수동으로 관리할 필요 없이 자동으로 처리하기 위함입니다.
Future가 성공하면 AsyncValue.data(), 실패하면 AsyncValue.error()로 자동 변환됩니다. 두 번째로, updateProfile 메서드에서 AsyncValue.guard()를 사용하여 비동기 작업을 감싸면, 내부에서 발생하는 모든 에러가 자동으로 캡처되어 AsyncValue.error()로 변환됩니다.
내부에서 어떤 일이 일어나는지 살펴보면, guard는 try-catch 블록을 내부적으로 처리하고, 성공 시 AsyncValue.data(), 실패 시 AsyncValue.error()를 반환합니다. 이렇게 하면 개발자는 에러 처리 코드를 일일이 작성할 필요가 없습니다.
세 번째로, UI에서 profileAsync.when()을 호출하면 현재 AsyncValue의 상태에 따라 적절한 콜백이 실행됩니다. data 상태면 첫 번째 콜백, loading이면 두 번째, error면 세 번째 콜백이 호출되어, 마지막으로 각 상태에 맞는 UI가 렌더링됩니다.
이 패턴은 매우 직관적이고 안전해서, 상태 처리를 빠뜨릴 염려가 없습니다. 여러분이 이 코드를 사용하면 복잡한 비동기 로직을 훨씬 간단하게 작성할 수 있고, 로딩 표시나 에러 메시지를 일관되게 처리할 수 있습니다.
실무에서의 이점은 다음과 같습니다. 첫째, boilerplate 코드가 대폭 줄어듭니다.
로딩 플래그, 에러 변수 등을 직접 관리할 필요가 없습니다. 둘째, 에러 처리가 빠지지 않습니다.
guard를 사용하면 모든 에러가 자동으로 캡처됩니다. 셋째, 테스트하기 쉽습니다.
AsyncValue의 각 상태를 개별적으로 테스트할 수 있습니다.
실전 팁
💡 상태를 업데이트할 때는 항상 AsyncValue.guard()를 사용하세요. 직접 try-catch를 작성하는 것보다 훨씬 간결하고, 에러를 놓칠 위험이 없습니다.
💡 로딩 중에 기존 데이터를 유지하고 싶다면 state = AsyncValue.loading().copyWithPrevious(state)를 사용하세요. 이렇게 하면 로딩 인디케이터를 보여주면서도 기존 데이터를 화면에 남길 수 있습니다.
💡 UI에서 when 대신 maybeWhen을 사용하면 일부 상태만 처리하고 나머지는 기본 동작으로 처리할 수 있습니다. 예를 들어, 에러만 특별히 처리하고 싶을 때 유용합니다.
💡 build 메서드 안에서 다른 Provider를 watch하면, 그 Provider가 변경될 때 자동으로 데이터를 다시 로드합니다. 예를 들어, 사용자 ID를 watch하면 사용자가 바뀔 때마다 프로필을 자동으로 새로 불러옵니다.
💡 Optimistic update를 구현하려면, API 호출 전에 먼저 state = AsyncValue.data(newValue)로 UI를 업데이트하고, 실패하면 이전 상태로 롤백하세요. 사용자 경험이 훨씬 빨라집니다.
4. FutureProvider
시작하며
여러분이 앱 시작 시 서버에서 앱 설정 정보를 한 번만 가져와야 하는 상황이라면 어떻게 하시겠어요? 이 데이터는 변경할 필요가 없고, 단순히 읽기만 하면 됩니다.
AsyncNotifierProvider를 사용하기엔 과한 것 같고, FutureBuilder를 사용하자니 여러 화면에서 재사용하기 어렵습니다. 이런 문제는 실제 개발 현장에서 자주 발생합니다.
초기 설정 로드, 일회성 API 호출, 리소스 파일 읽기처럼 한 번만 실행되고 변경되지 않는 비동기 작업들이 많은데, 이를 위해 복잡한 상태 관리를 도입하는 것은 비효율적입니다. 코드가 불필요하게 복잡해지고, 의도도 명확하지 않게 됩니다.
바로 이럴 때 필요한 것이 FutureProvider입니다. 단 한 번 실행되는 비동기 작업의 결과를 앱 전역에서 공유할 수 있으며, 로딩과 에러 상태도 자동으로 관리됩니다.
변경 불가능한 비동기 데이터를 위한 가장 간단하고 효율적인 선택입니다.
개요
간단히 말해서, FutureProvider는 한 번 실행되는 비동기 작업의 결과를 제공하는 읽기 전용 Provider입니다. 왜 이 개념이 필요한지 실무 관점에서 설명하면, 초기 설정 로드, 앱 버전 체크, 약관 정보 가져오기처럼 한 번만 실행되고 캐시되어야 하는 작업에 최적화되어 있습니다.
예를 들어, 앱 시작 시 서버에서 feature flag를 가져와서 특정 기능을 활성화/비활성화하는 경우에 매우 유용합니다. 기존에는 전역 변수에 Future를 저장하거나, StatefulWidget에서 initState에 로직을 넣었다면, 이제는 FutureProvider 하나로 깔끔하게 처리할 수 있습니다.
결과가 자동으로 캐시되어 여러 곳에서 접근해도 API 호출은 단 한 번만 발생합니다. FutureProvider의 핵심 특징은 세 가지입니다.
첫째, 지연 초기화로 실제로 필요할 때만 Future가 실행됩니다. 둘째, 결과가 영구적으로 캐시되어 동일한 Provider를 여러 번 watch해도 한 번만 실행됩니다.
셋째, AsyncValue 타입을 반환하여 로딩/에러/데이터 상태를 쉽게 처리할 수 있습니다. 이러한 특징들이 일회성 비동기 작업을 매우 효율적으로 만들어줍니다.
코드 예제
// 앱 설정을 가져오는 FutureProvider
final appConfigProvider = FutureProvider<AppConfig>((ref) async {
// API 클라이언트를 다른 Provider에서 가져오기
final api = ref.watch(apiClientProvider);
// 서버에서 설정 정보 로드 (단 한 번만 실행됨)
final config = await api.getAppConfig();
// 선택적: 로드된 설정을 로그로 기록
print('App config loaded: ${config.version}');
return config;
});
// 다른 Provider에서 앱 설정 사용하기
final featureFlagProvider = Provider<bool>((ref) {
// appConfigProvider의 데이터를 기다렸다가 사용
final configAsync = ref.watch(appConfigProvider);
// 데이터가 로드되면 feature flag 반환, 아니면 기본값
return configAsync.when(
data: (config) => config.newFeatureEnabled,
loading: () => false, // 로딩 중에는 기능 비활성화
error: (_, __) => false, // 에러 시에도 비활성화
);
});
// UI에서 사용하기
class HomeScreen extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final configAsync = ref.watch(appConfigProvider);
return configAsync.when(
data: (config) => Text('App Version: ${config.version}'),
loading: () => CircularProgressIndicator(),
error: (err, stack) => Text('Failed to load config'),
);
}
}
설명
이것이 하는 일: FutureProvider는 비동기 함수를 실행하여 그 결과를 앱 전역에서 사용할 수 있게 하되, 한 번 실행된 후에는 결과를 캐시하여 재사용합니다. 마치 싱글톤 패턴의 비동기 버전이라고 생각하면 됩니다.
첫 번째로, FutureProvider<AppConfig>((ref) async => ...)는 비동기 함수를 받아서 Provider를 생성합니다. 여기서 중요한 것은 이 함수가 Provider가 처음 접근될 때 단 한 번만 실행되고, 그 결과가 영구적으로 저장된다는 점입니다.
왜 이렇게 설계되었는지 이유는, 앱 설정처럼 변하지 않는 데이터를 매번 다시 가져오는 것은 낭비이기 때문입니다. 두 번째로, ref.watch(apiClientProvider)를 통해 다른 Provider에 의존할 수 있습니다.
내부에서 어떤 일이 일어나는지 살펴보면, Riverpod이 의존성 그래프를 만들고, apiClientProvider가 변경되면(예: 테스트 오버라이드) appConfigProvider도 자동으로 무효화되어 다시 실행됩니다. 이것이 바로 의존성 주입의 강력함입니다.
세 번째로, featureFlagProvider에서 보듯이 다른 Provider가 FutureProvider의 결과를 동기적으로 사용할 수 있습니다. when 메서드로 각 상태를 처리하여, 마지막으로 안전한 기본값을 제공할 수 있습니다.
로딩 중이거나 에러가 발생해도 앱이 크래시하지 않고 적절히 대응합니다. 여러분이 이 코드를 사용하면 앱 초기화 로직이 훨씬 깔끔해지고, 설정 정보를 앱 전역에서 일관되게 사용할 수 있습니다.
실무에서의 이점은 다음과 같습니다. 첫째, 중복 API 호출을 완전히 방지합니다.
여러 화면에서 동시에 접근해도 한 번만 실행됩니다. 둘째, 의존성이 명확해집니다.
어떤 Provider가 설정 정보를 사용하는지 코드에서 바로 알 수 있습니다. 셋째, 테스트하기 쉽습니다.
overrideWithValue를 사용해 간단히 모킹할 수 있습니다.
실전 팁
💡 FutureProvider는 값을 변경할 수 없습니다. 만약 새로고침이 필요하다면 ref.invalidate()를 호출하거나, AsyncNotifierProvider를 사용하세요.
💡 FutureProvider 내부에서 에러가 발생하면 AsyncValue.error로 자동 변환됩니다. 따라서 try-catch를 직접 작성할 필요가 없습니다.
💡 autoDispose modifier를 추가하면 더 이상 watch하는 위젯이 없을 때 자동으로 캐시가 삭제됩니다. 메모리 관리에 유용합니다: FutureProvider.autoDispose<T>(...)
💡 Provider 간 의존성이 복잡할 때는 FutureProvider보다 AsyncNotifierProvider의 build 메서드가 더 명확합니다. FutureProvider는 단순한 일회성 작업에만 사용하세요.
💡 앱 시작 시 여러 설정을 동시에 로드해야 한다면, Future.wait()을 사용해 병렬로 실행하면 초기 로딩 시간이 단축됩니다.
5. StreamProvider
시작하며
여러분이 실시간 채팅 기능을 만들거나, 서버에서 푸시되는 알림을 받아야 한다면 어떻게 구현하시겠어요? Stream을 직접 listen하고, StreamSubscription을 관리하고, dispose도 직접 호출해야 합니다.
화면이 많아지면 각각의 화면에서 이런 보일러플레이트를 반복해야 하죠. 이런 문제는 실시간 데이터를 다루는 앱에서 매우 흔합니다.
WebSocket 연결, Firebase Realtime Database, 센서 데이터처럼 지속적으로 값이 변경되는 데이터를 관리할 때, 수동으로 구독과 구독 해제를 처리하다 보면 메모리 누수가 발생하거나 불필요한 리스너가 남아있는 버그가 생깁니다. 특히 화면 전환이 많은 앱에서는 더욱 복잡해집니다.
바로 이럴 때 필요한 것이 StreamProvider입니다. Stream을 Provider로 감싸면 자동으로 구독 관리, 에러 처리, 생명주기 관리를 해주어, 개발자는 단순히 데이터를 읽기만 하면 됩니다.
실시간 데이터를 다루는 가장 안전하고 간편한 방법입니다.
개요
간단히 말해서, StreamProvider는 Stream을 Provider로 변환하여 실시간으로 변경되는 데이터를 자동으로 관리하고 UI에 반영합니다. 왜 이 개념이 필요한지 실무 관점에서 설명하면, 실시간 채팅, 위치 추적, 센서 데이터, WebSocket 연결처럼 지속적으로 값이 업데이트되는 모든 상황에서 필수입니다.
예를 들어, Firestore의 snapshot stream이나 WebSocket에서 받는 메시지를 화면에 표시할 때 매우 유용합니다. 구독 생명주기를 직접 관리할 필요 없이 Riverpod이 알아서 처리해줍니다.
기존에는 StreamBuilder를 사용하거나 StatefulWidget에서 StreamSubscription을 관리했다면, 이제는 StreamProvider 하나로 모든 화면에서 동일한 Stream을 공유하고, 자동으로 구독/구독 해제가 처리됩니다. 메모리 누수 걱정도 없고, 코드도 훨씬 간결해집니다.
StreamProvider의 핵심 특징은 세 가지입니다. 첫째, Stream을 watch하는 위젯이 생기면 자동으로 구독하고, 모든 위젯이 사라지면 자동으로 구독 해제합니다.
둘째, AsyncValue<T> 타입으로 현재 값, 로딩 상태, 에러를 표현합니다. 셋째, 여러 위젯이 같은 StreamProvider를 watch해도 Stream은 한 번만 구독됩니다.
이러한 특징들이 실시간 데이터 관리를 매우 효율적으로 만들어줍니다.
코드 예제
// 실시간 메시지를 제공하는 StreamProvider
final messagesStreamProvider = StreamProvider<List<Message>>((ref) {
// API 클라이언트에서 WebSocket 스트림 가져오기
final api = ref.watch(apiClientProvider);
// 실시간 메시지 스트림 반환
return api.getMessagesStream();
});
// 특정 사용자의 온라인 상태를 제공하는 StreamProvider
final userOnlineStatusProvider = StreamProvider.family<bool, String>((ref, userId) {
final db = ref.watch(databaseProvider);
// Firestore의 snapshot stream 반환
return db.collection('users').doc(userId).snapshots().map(
(snapshot) => snapshot.data()?['isOnline'] ?? false,
);
});
// UI에서 사용하기
class ChatScreen extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final messagesAsync = ref.watch(messagesStreamProvider);
return messagesAsync.when(
data: (messages) => ListView.builder(
itemCount: messages.length,
itemBuilder: (context, index) => MessageTile(messages[index]),
),
loading: () => Center(child: CircularProgressIndicator()),
error: (err, stack) => Text('Error: $err'),
);
}
}
// 사용자 상태 표시
class UserStatusWidget extends ConsumerWidget {
final String userId;
@override
Widget build(BuildContext context, WidgetRef ref) {
final statusAsync = ref.watch(userOnlineStatusProvider(userId));
return statusAsync.maybeWhen(
data: (isOnline) => Icon(
Icons.circle,
color: isOnline ? Colors.green : Colors.grey,
size: 12,
),
orElse: () => SizedBox.shrink(),
);
}
}
설명
이것이 하는 일: StreamProvider는 Stream을 Riverpod Provider로 감싸서, Stream이 emit하는 각각의 값을 자동으로 AsyncValue로 변환하고, 이를 watch하는 모든 위젯에 자동으로 알립니다. 첫 번째로, StreamProvider<List<Message>>((ref) => ...)는 Stream을 반환하는 함수를 받습니다.
여기서 중요한 것은 Provider가 처음 watch될 때 Stream이 구독되고, 마지막 리스너가 사라지면 자동으로 구독이 취소된다는 점입니다. 왜 이렇게 동작하는지 이유는, 불필요한 리소스 사용을 방지하고 메모리 누수를 막기 위함입니다.
개발자가 직접 dispose를 호출할 필요가 전혀 없습니다. 두 번째로, Stream이 새로운 값을 emit하면 내부적으로 Riverpod이 이를 감지하여 AsyncValue.data(newValue)로 상태를 업데이트합니다.
내부에서 어떤 일이 일어나는지 살펴보면, Stream의 listen 콜백이 호출될 때마다 Provider의 상태가 변경되고, 이를 watch하고 있던 모든 위젯이 자동으로 리빌드됩니다. 에러가 발생하면 AsyncValue.error로, 처음 값이 오기 전에는 AsyncValue.loading으로 표현됩니다.
세 번째로, UI에서 ref.watch(messagesStreamProvider)를 호출하면 현재 AsyncValue를 받아옵니다. 마지막으로, when 메서드로 data/loading/error 상태를 각각 처리하여 적절한 UI를 렌더링합니다.
Stream이 새로운 메시지를 emit할 때마다 위젯이 자동으로 리빌드되어 최신 상태가 화면에 반영됩니다. 여러분이 이 코드를 사용하면 실시간 데이터를 다루는 복잡한 로직을 대폭 단순화할 수 있고, 메모리 관리도 자동으로 처리됩니다.
실무에서의 이점은 다음과 같습니다. 첫째, 메모리 누수가 발생하지 않습니다.
화면이 dispose될 때 자동으로 구독이 취소됩니다. 둘째, 여러 위젯이 같은 Stream을 공유할 수 있습니다.
StreamProvider를 여러 곳에서 watch해도 Stream은 한 번만 구독됩니다. 셋째, 에러 처리가 일관됩니다.
Stream에서 발생한 에러가 자동으로 AsyncValue.error로 변환되어 UI에서 쉽게 처리할 수 있습니다.
실전 팁
💡 Stream이 완료되면(done) AsyncValue.data로 마지막 값을 유지합니다. 만약 Stream이 완료된 후 다시 시작해야 한다면 ref.invalidate()를 호출하세요.
💡 autoDispose modifier를 거의 항상 사용하세요: StreamProvider.autoDispose<T>(...). 이렇게 하면 화면을 벗어날 때 자동으로 구독이 취소되어 리소스를 절약할 수 있습니다.
💡 Stream이 버퍼링이 필요한 경우(예: 네트워크 재연결 시 누락된 메시지 복구) Stream의 transform 메서드를 활용하세요.
💡 실시간 데이터를 수정해야 한다면 StreamProvider만으로는 부족합니다. AsyncNotifierProvider와 함께 사용하여, Stream은 읽기용으로, Notifier는 쓰기용으로 분리하세요.
💡 Firestore나 WebSocket처럼 연결 비용이 큰 Stream은 가능한 한 전역 Provider로 만들고 여러 화면에서 공유하세요. 각 화면마다 새로 연결하면 성능이 저하됩니다.
6. Family Modifier
시작하며
여러분이 사용자 프로필 화면을 만드는데, 각 사용자마다 다른 데이터를 보여줘야 한다면 어떻게 하시겠어요? 모든 사용자의 데이터를 하나의 Provider에 저장하고 필터링하자니 비효율적이고, 사용자마다 Provider를 수동으로 만들자니 코드가 반복됩니다.
이런 문제는 실제 개발 현장에서 매우 자주 발생합니다. 리스트의 각 항목, 특정 ID의 상세 정보, 검색어별 결과처럼 동적 매개변수에 따라 다른 데이터를 제공해야 하는 경우가 많습니다.
같은 로직을 매개변수만 다르게 해서 여러 번 사용하고 싶은데, 전통적인 방법으로는 이를 우아하게 처리하기 어렵습니다. 바로 이럴 때 필요한 것이 Family modifier입니다.
하나의 Provider 정의에 매개변수를 추가하여, 매개변수 값마다 독립적인 Provider 인스턴스가 생성되고 캐시됩니다. 마치 함수를 호출하듯이 Provider를 사용할 수 있어, 동적 데이터 관리가 매우 간편해집니다.
개요
간단히 말해서, Family modifier는 Provider에 매개변수를 추가하여 동적으로 서로 다른 Provider 인스턴스를 생성할 수 있게 해줍니다. 왜 이 개념이 필요한지 실무 관점에서 설명하면, 사용자 프로필, 상품 상세, 게시물 댓글처럼 ID나 파라미터에 따라 다른 데이터를 가져와야 하는 거의 모든 경우에 필수입니다.
예를 들어, 사용자 목록에서 각 사용자의 프로필 이미지를 표시할 때, 각 사용자 ID마다 독립적으로 데이터를 로드하고 캐시할 수 있습니다. 기존에는 Map을 사용해 ID별로 데이터를 저장하거나, ID를 위젯의 상태로 관리했다면, 이제는 provider(userId) 형태로 간단히 호출하면 됩니다.
각 매개변수 값마다 독립적인 캐시가 유지되어, 같은 ID로 다시 접근하면 이전 데이터를 재사용합니다. Family modifier의 핵심 특징은 세 가지입니다.
첫째, 매개변수 값마다 독립적인 Provider 인스턴스가 생성되고 캐시됩니다. 둘째, 매개변수는 == 비교로 동일성을 판단하므로, primitive 타입이나 immutable 객체를 사용해야 합니다.
셋째, 모든 종류의 Provider(NotifierProvider, FutureProvider 등)에 Family를 적용할 수 있습니다. 이러한 특징들이 동적 데이터 관리를 매우 유연하게 만들어줍니다.
코드 예제
// 사용자 ID를 받아 프로필을 제공하는 FutureProvider.family
final userProfileProvider = FutureProvider.family<UserProfile, String>((ref, userId) async {
final api = ref.watch(apiClientProvider);
// userId별로 독립적으로 API 호출 및 캐시
return await api.getUserProfile(userId);
});
// 여러 매개변수를 받는 경우 - 레코드 타입 사용 (Dart 3.0+)
final searchResultsProvider = FutureProvider.family<List<Product>, ({String query, int page})>(
(ref, params) async {
final api = ref.watch(apiClientProvider);
// query와 page 조합마다 독립적인 캐시
return await api.searchProducts(params.query, params.page);
},
);
// AsyncNotifier와 Family 조합
class TodoNotifier extends FamilyAsyncNotifier<Todo, int> {
@override
Future<Todo> build(int todoId) async {
// todoId별로 독립적인 인스턴스
final api = ref.watch(apiClientProvider);
return await api.getTodo(todoId);
}
Future<void> toggleComplete() async {
state = await AsyncValue.guard(() async {
final todo = state.requireValue;
return await api.updateTodo(todo.copyWith(completed: !todo.completed));
});
}
}
final todoProvider = AsyncNotifierProvider.family<TodoNotifier, Todo, int>(
() => TodoNotifier(),
);
// UI에서 사용하기
class UserProfileCard extends ConsumerWidget {
final String userId;
@override
Widget build(BuildContext context, WidgetRef ref) {
// userId를 매개변수로 전달
final profileAsync = ref.watch(userProfileProvider(userId));
return profileAsync.when(
data: (profile) => Text(profile.name),
loading: () => CircularProgressIndicator(),
error: (err, _) => Text('Error loading user'),
);
}
}
// 여러 매개변수 사용 예제
class SearchResults extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final resultsAsync = ref.watch(
searchResultsProvider((query: 'laptop', page: 1)),
);
return resultsAsync.when(
data: (products) => ProductList(products),
loading: () => LoadingIndicator(),
error: (err, _) => ErrorMessage(err),
);
}
}
설명
이것이 하는 일: Family modifier는 Provider에 매개변수를 추가하여, 마치 함수를 호출하듯이 provider(argument) 형태로 사용할 수 있게 만듭니다. 각 매개변수 값마다 별도의 Provider 인스턴스가 생성되고 캐시됩니다.
첫 번째로, FutureProvider.family<UserProfile, String>에서 두 개의 제네릭 타입을 지정합니다. 첫 번째는 반환 타입(UserProfile), 두 번째는 매개변수 타입(String)입니다.
함수 시그니처는 (ref, userId) => ... 형태로, 두 번째 매개변수가 family 파라미터입니다. 왜 이렇게 설계되었는지 이유는, 타입 안정성을 보장하면서도 다양한 매개변수 타입을 지원하기 위함입니다.
두 번째로, ref.watch(userProfileProvider(userId))가 호출되면 내부적으로 Riverpod이 userId 값을 키로 사용하여 캐시를 확인합니다. 내부에서 어떤 일이 일어나는지 살펴보면, 해당 userId로 이미 생성된 Provider 인스턴스가 있으면 캐시된 값을 반환하고, 없으면 새로운 인스턴스를 생성하여 함수를 실행합니다.
예를 들어, userProfileProvider('user1')과 userProfileProvider('user2')는 완전히 독립적인 Provider로 동작합니다. 세 번째로, 여러 매개변수가 필요한 경우 레코드 타입 ({String query, int page})를 사용합니다.
레코드는 불변이고 == 연산자가 자동으로 구현되어 있어 Family 매개변수로 완벽합니다. 마지막으로, UI에서 searchResultsProvider((query: 'laptop', page: 1))처럼 호출하면, query와 page 조합이 같으면 캐시된 결과를 반환하고, 다르면 새로운 API 호출이 발생합니다.
여러분이 이 코드를 사용하면 동적 데이터를 매우 효율적으로 관리할 수 있고, 각 데이터의 로딩 상태와 에러를 독립적으로 처리할 수 있습니다. 실무에서의 이점은 다음과 같습니다.
첫째, 코드 중복이 사라집니다. 같은 로직을 매개변수만 바꿔서 재사용할 수 있습니다.
둘째, 캐시가 자동으로 관리됩니다. 같은 ID로 여러 번 접근해도 한 번만 로드됩니다.
셋째, 성능이 최적화됩니다. 리스트에서 각 항목이 독립적으로 로드되고 업데이트되어, 하나의 항목 변경이 다른 항목에 영향을 주지 않습니다.
실전 팁
💡 Family 매개변수는 반드시 == 연산자로 비교 가능해야 합니다. 커스텀 클래스를 사용한다면 ==와 hashCode를 오버라이드하거나, freezed 패키지를 사용하세요.
💡 여러 매개변수가 필요하면 Dart 3.0의 레코드 타입을 사용하세요: (String, int) 또는 ({String query, int page}). 레코드는 자동으로 ==가 구현되어 있어 안전합니다.
💡 Family Provider는 자동으로 dispose되지 않습니다. 메모리 관리를 위해 autoDispose modifier를 함께 사용하세요: FutureProvider.autoDispose.family<T, P>(...)
💡 무한 스크롤처럼 매개변수가 계속 증가하는 경우, autoDispose를 사용하지 않으면 캐시가 무한정 쌓입니다. 반드시 autoDispose와 함께 사용하세요.
💡 리스트의 각 항목을 Family Provider로 관리하면, 한 항목이 업데이트될 때 다른 항목은 리빌드되지 않아 성능이 크게 향상됩니다. 이것이 바로 세밀한 리빌드 제어의 핵심입니다.
7. AutoDispose
시작하며
여러분이 사용자가 화면을 벗어났을 때, 그 화면에서 사용하던 데이터를 계속 메모리에 유지해야 할까요? 대부분의 경우 필요 없는 데이터인데, Provider가 자동으로 삭제되지 않으면 메모리 사용량이 계속 증가하고, 앱이 느려지거나 크래시될 수 있습니다.
이런 문제는 실제 개발 현장에서 메모리 누수로 이어집니다. 특히 이미지가 많은 리스트, 무한 스크롤, 검색 결과처럼 일시적인 데이터를 다루는 화면에서 심각합니다.
사용자가 화면을 여러 번 이동하다 보면 사용하지 않는 Provider 인스턴스가 수십 개씩 쌓여서, 앱의 메모리 사용량이 기하급수적으로 증가합니다. 바로 이럴 때 필요한 것이 AutoDispose modifier입니다.
Provider를 더 이상 watch하는 위젯이 없을 때 자동으로 상태를 삭제하고 메모리를 해제합니다. 메모리 관리를 수동으로 할 필요 없이, Riverpod이 생명주기를 완벽하게 자동화해줍니다.
개요
간단히 말해서, AutoDispose는 Provider를 더 이상 사용하지 않을 때 자동으로 상태를 삭제하고 리소스를 해제하는 modifier입니다. 왜 이 개념이 필요한지 실무 관점에서 설명하면, 일시적인 데이터를 다루는 거의 모든 화면에서 필수적으로 사용해야 합니다.
예를 들어, 검색 화면, 상세 페이지, 필터링된 리스트처럼 사용자가 떠나면 더 이상 필요 없는 데이터는 AutoDispose로 자동 정리해야 합니다. 그렇지 않으면 앱을 오래 사용할수록 메모리가 계속 증가합니다.
기존에는 StatefulWidget의 dispose 메서드에서 수동으로 리소스를 해제했다면, 이제는 .autoDispose modifier 하나만 추가하면 Riverpod이 알아서 처리합니다. 위젯이 dispose되고 일정 시간이 지나면 자동으로 Provider도 삭제됩니다.
AutoDispose의 핵심 특징은 세 가지입니다. 첫째, 마지막 리스너가 사라진 후 자동으로 Provider가 dispose됩니다.
둘째, Provider를 다시 watch하면 자동으로 재생성되므로 투명하게 동작합니다. 셋째, keepAlive 기능으로 특정 조건에서는 dispose를 방지할 수 있습니다.
이러한 특징들이 메모리 관리를 완전히 자동화해줍니다.
코드 예제
// AutoDispose를 사용하는 FutureProvider
final userDetailsProvider = FutureProvider.autoDispose.family<UserDetails, String>(
(ref, userId) async {
// 화면을 벗어나면 자동으로 dispose됨
final api = ref.watch(apiClientProvider);
print('Loading user: $userId');
final user = await api.getUserDetails(userId);
// 데이터가 성공적으로 로드되면 캐시 유지
ref.keepAlive();
return user;
},
);
// AutoDispose AsyncNotifier 예제
class SearchNotifier extends AutoDisposeAsyncNotifier<List<Product>> {
@override
Future<List<Product>> build() async {
// 검색 화면을 벗어나면 자동 dispose
return [];
}
Future<void> search(String query) async {
state = const AsyncValue.loading();
state = await AsyncValue.guard(() async {
final api = ref.read(apiClientProvider);
return await api.searchProducts(query);
});
}
}
final searchProvider = AsyncNotifierProvider.autoDispose<SearchNotifier, List<Product>>(
() => SearchNotifier(),
);
// 조건부 keepAlive 사용 예제
final cacheableDataProvider = FutureProvider.autoDispose<ExpensiveData>((ref) async {
final api = ref.watch(apiClientProvider);
final data = await api.getExpensiveData();
// 데이터를 성공적으로 가져왔고 캐시하고 싶은 경우
if (data.shouldCache) {
// 이 Provider는 더 이상 dispose되지 않음
ref.keepAlive();
}
// 또는 특정 시간 동안만 keepAlive
final link = ref.keepAlive();
Timer(Duration(minutes: 5), () {
link.close(); // 5분 후 다시 autoDispose 활성화
});
return data;
});
// UI에서 사용하기
class UserDetailsScreen extends ConsumerWidget {
final String userId;
@override
Widget build(BuildContext context, WidgetRef ref) {
// 화면이 dispose되면 Provider도 자동으로 dispose됨
final userAsync = ref.watch(userDetailsProvider(userId));
return userAsync.when(
data: (user) => UserDetailsView(user),
loading: () => LoadingSpinner(),
error: (err, _) => ErrorMessage(err),
);
}
}
설명
이것이 하는 일: AutoDispose modifier는 Provider를 watch하는 위젯이 모두 dispose되면, 일정 시간 후 Provider의 상태를 삭제하고 메모리를 해제합니다. 마치 자동 청소기처럼 사용하지 않는 데이터를 자동으로 정리해줍니다.
첫 번째로, .autoDispose를 Provider 선언에 추가하면 해당 Provider는 자동 관리 모드로 전환됩니다. 내부적으로 Riverpod은 해당 Provider를 watch하는 리스너의 개수를 추적합니다.
왜 이렇게 동작하는지 이유는, Provider가 실제로 사용되고 있는지를 정확히 파악하여, 불필요한 메모리를 즉시 해제하기 위함입니다. 두 번째로, 마지막 리스너(위젯)가 dispose되면 내부 타이머가 시작되고, 일정 시간(기본 1프레임) 후에도 새로운 리스너가 나타나지 않으면 Provider가 dispose됩니다.
내부에서 어떤 일이 일어나는지 살펴보면, dispose 시 Stream은 구독 취소되고, HTTP 요청은 취소되며, 모든 메모리가 해제됩니다. 이 과정이 완전히 자동화되어 있어 메모리 누수가 발생할 여지가 없습니다.
세 번째로, ref.keepAlive()를 호출하면 해당 Provider는 더 이상 자동으로 dispose되지 않습니다. 예를 들어, 데이터를 성공적으로 로드한 후 캐시로 유지하고 싶을 때 사용합니다.
마지막으로, keepAlive는 KeepAliveLink 객체를 반환하는데, link.close()를 호출하면 다시 autoDispose가 활성화됩니다. 이를 통해 "5분간 캐시 후 삭제" 같은 정교한 메모리 관리가 가능합니다.
여러분이 이 코드를 사용하면 메모리 관리를 전혀 신경 쓰지 않아도 앱이 효율적으로 동작하고, 메모리 누수 걱정 없이 안정적인 앱을 만들 수 있습니다. 실무에서의 이점은 다음과 같습니다.
첫째, 메모리 사용량이 최적화됩니다. 사용하지 않는 데이터가 자동으로 삭제되어 앱이 가벼워집니다.
둘째, 개발자가 dispose 로직을 작성할 필요가 없습니다. Riverpod이 알아서 처리합니다.
셋째, 버그가 줄어듭니다. 수동 dispose를 깜빡해서 생기는 메모리 누수나, 너무 빨리 dispose해서 생기는 에러가 사라집니다.
실전 팁
💡 거의 모든 화면별 Provider에는 autoDispose를 사용하세요. 전역 상태(사용자 인증, 앱 설정 등)가 아니라면 기본적으로 autoDispose를 적용하는 것이 좋습니다.
💡 Family Provider를 사용할 때는 autoDispose가 특히 중요합니다. 그렇지 않으면 매개변수 값마다 인스턴스가 쌓여서 메모리가 급격히 증가합니다: FutureProvider.autoDispose.family<T, P>(...)
💡 keepAlive는 성공적으로 데이터를 로드한 후에만 호출하세요. 에러가 발생했을 때까지 캐시하면 사용자가 다시 시도해도 에러 상태가 유지됩니다.
💡 디버그 모드에서 Provider가 dispose되는 시점을 확인하려면 Provider의 dispose 콜백을 사용하세요: ref.onDispose(() => print('Disposed'))
💡 AutoDispose는 ref를 통한 의존성에도 적용됩니다. AutoDispose Provider가 다른 Provider를 watch하면, 해당 Provider도 더 이상 리스너가 없을 때 dispose될 수 있습니다.
8. Ref와 WidgetRef
시작하며
여러분이 Riverpod 코드를 보다 보면 ref라는 객체가 여기저기 등장하는 것을 보셨을 겁니다. Notifier 클래스 안에서도, ConsumerWidget의 build 메서드에서도, 심지어 일반 함수에서도 ref를 사용하는데, 이게 정확히 무엇이고 어떤 차이가 있는지 헷갈리시나요?
이런 혼란은 실제 개발 현장에서 초보자들이 가장 자주 겪는 어려움입니다. ref를 잘못된 곳에서 사용하거나, ref.watch와 ref.read를 혼동해서 사용하면 원하지 않는 리빌드가 발생하거나 상태 변경이 감지되지 않는 버그가 생깁니다.
특히 언제 watch를 쓰고 언제 read를 써야 하는지 명확히 이해하지 못하면 성능 문제가 발생할 수 있습니다. 바로 이럴 때 필요한 것이 Ref와 WidgetRef에 대한 명확한 이해입니다.
이 둘은 Provider와 상호작용하는 인터페이스이지만, 사용되는 컨텍스트와 목적이 다릅니다. 제대로 이해하면 Riverpod을 훨씬 효과적으로 활용할 수 있습니다.
개요
간단히 말해서, Ref는 Provider 내부에서 다른 Provider를 읽거나 생명주기를 관리하는 객체이고, WidgetRef는 위젯에서 Provider를 읽거나 listen하는 객체입니다. 왜 이 개념이 필요한지 실무 관점에서 설명하면, Provider 간의 의존성을 만들고, 위젯에서 상태를 읽고, 이벤트 핸들러에서 상태를 변경하는 모든 작업의 기반이 됩니다.
예를 들어, Notifier에서 다른 Provider를 읽어야 하거나, 위젯에서 버튼 클릭 시 상태를 변경해야 할 때 반드시 필요합니다. 기존에는 BuildContext를 통해 InheritedWidget을 찾아야 했다면, 이제는 ref를 통해 타입 안전하게 Provider에 접근할 수 있습니다.
컴파일 타임에 에러를 잡을 수 있어 훨씬 안전합니다. Ref와 WidgetRef의 핵심 특징은 세 가지입니다.
첫째, watch는 Provider 값을 읽고 변경 시 자동으로 리빌드되지만, read는 단순히 현재 값만 읽고 리빌드되지 않습니다. 둘째, listen은 Provider 변경 시 side effect를 실행할 수 있습니다.
셋째, invalidate와 refresh로 Provider를 수동으로 재생성할 수 있습니다. 이러한 특징들이 세밀한 상태 제어를 가능하게 합니다.
코드 예제
// Notifier 내부에서 Ref 사용
class CartNotifier extends Notifier<List<Product>> {
@override
List<Product> build() {
// ref.watch: 다른 Provider를 의존하면 자동으로 재초기화됨
final userId = ref.watch(currentUserIdProvider);
// ref.listen: Provider 변경 시 side effect 실행
ref.listen(authStateProvider, (previous, next) {
if (next == AuthState.loggedOut) {
state = []; // 로그아웃 시 장바구니 비우기
}
});
// ref.onDispose: Provider dispose 시 정리 작업
ref.onDispose(() {
print('CartNotifier disposed');
});
return [];
}
void addProduct(Product product) {
// ref.read: 이벤트 핸들러에서 다른 Provider의 현재 값만 읽기
final userId = ref.read(currentUserIdProvider);
state = [...state, product];
// ref.read로 notifier 메서드 호출
ref.read(analyticsProvider).logEvent('add_to_cart');
}
}
// ConsumerWidget에서 WidgetRef 사용
class CartScreen extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
// ref.watch: build 메서드에서 사용, 값이 변경되면 리빌드
final cart = ref.watch(cartProvider);
final itemCount = ref.watch(cartProvider.select((c) => c.length));
// ref.listen: build 외부에서 side effect 실행
ref.listen<List<Product>>(cartProvider, (previous, next) {
if (next.length > previous!.length) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Item added to cart')),
);
}
});
return Column(
children: [
Text('$itemCount items'),
ElevatedButton(
onPressed: () {
// ref.read: 이벤트 핸들러에서 사용
ref.read(cartProvider.notifier).addProduct(product);
// ref.invalidate: Provider를 무효화하고 재생성
ref.invalidate(cartProvider);
// ref.refresh: 즉시 재생성하고 새 값 반환
final newCart = ref.refresh(cartProvider);
},
child: Text('Add to cart'),
),
],
);
}
}
// Consumer 위젯 사용 (StatelessWidget 내부에서)
class MyStatelessWidget extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Consumer(
builder: (context, ref, child) {
// 여기서 ref는 WidgetRef
final count = ref.watch(counterProvider);
return Text('$count');
},
);
}
}
설명
이것이 하는 일: Ref와 WidgetRef는 Provider 시스템과 상호작용하는 인터페이스로, Provider를 읽고, 의존성을 추적하고, 생명주기를 관리하는 모든 작업의 핵심입니다. 첫 번째로, ref.watch(provider)는 Provider의 현재 값을 읽으면서 동시에 해당 Provider를 구독합니다.
build 메서드나 Notifier의 build에서 사용되며, Provider가 변경되면 자동으로 리빌드(또는 재초기화)가 트리거됩니다. 왜 이렇게 동작하는지 이유는, 상태와 UI를 자동으로 동기화하여 개발자가 수동으로 setState를 호출할 필요가 없게 하기 위함입니다.
이것이 리액티브 프로그래밍의 핵심입니다. 두 번째로, ref.read(provider)는 Provider의 현재 값만 읽고 구독하지 않습니다.
내부에서 어떤 일이 일어나는지 살펴보면, 값은 가져오지만 리스너가 등록되지 않아서 Provider가 변경되어도 아무 일도 일어나지 않습니다. 이벤트 핸들러(버튼 클릭, onSubmit 등)에서 사용해야 하며, build 메서드에서 read를 사용하면 상태 변경이 UI에 반영되지 않는 버그가 발생합니다.
세 번째로, ref.listen(provider, callback)은 Provider 값을 구독하지만 리빌드는 트리거하지 않고, 대신 콜백 함수를 실행합니다. 이전 값과 새 값을 모두 받을 수 있어서, 스낵바 표시, 네비게이션, 로깅 같은 side effect를 처리하는 데 완벽합니다.
마지막으로, ref.invalidate(provider)는 Provider를 무효화하여 다음에 접근할 때 재생성되게 하고, ref.refresh(provider)는 즉시 재생성하여 새 값을 반환합니다. 여러분이 이 개념을 제대로 이해하면 불필요한 리빌드를 방지하고, 정확한 시점에 상태를 변경하며, side effect를 깔끔하게 처리할 수 있습니다.
실무에서의 이점은 다음과 같습니다. 첫째, 성능이 최적화됩니다.
watch와 read를 올바르게 구분하면 불필요한 리빌드가 사라집니다. 둘째, 코드 의도가 명확해집니다.
watch는 "이 값이 변경되면 리빌드", read는 "지금 값만 필요" 라는 의도를 명확히 표현합니다. 셋째, 버그가 줄어듭니다.
컴파일 타임에 타입 체크가 되어 런타임 에러를 방지할 수 있습니다.
실전 팁
💡 build 메서드 안에서는 항상 watch를 사용하고, 이벤트 핸들러에서는 read를 사용하세요. 이것이 가장 중요한 규칙입니다.
💡 ref.listen은 build 메서드 안에서 호출해야 합니다. initState나 이벤트 핸들러에서 호출하면 제대로 동작하지 않습니다.
💡 스낵바, 다이얼로그, 네비게이션처럼 UI에 반영되지 않는 side effect는 listen을 사용하세요. watch를 사용하면 불필요한 리빌드가 발생합니다.
💡 ref.read(provider.notifier)로 Notifier 인스턴스를 가져와서 메서드를 호출할 수 있습니다. 상태 값이 아닌 Notifier 자체가 필요할 때 유용합니다.
💡 ConsumerStatefulWidget을 사용하면 StatefulWidget의 모든 생명주기 메서드에서 ref를 사용할 수 있습니다. initState, didChangeDependencies 등에서도 ref에 접근 가능합니다.
9. Select 최적화
시작하며
여러분이 복잡한 객체를 Provider로 관리하는데, UI에서는 그 객체의 특정 필드 하나만 필요하다면 어떻게 하시겠어요? 전체 객체를 watch하면 다른 필드가 변경될 때도 위젯이 리빌드되어서 성능이 저하됩니다.
이런 문제는 실제 개발 현장에서 성능 병목의 주요 원인입니다. 예를 들어, 사용자 프로필 객체에 이름, 이메일, 프로필 이미지, 설정 등 여러 필드가 있는데, 화면에서는 이름만 표시한다면, 이메일이 변경될 때마다 이름을 표시하는 위젯까지 리빌드되는 것은 낭비입니다.
리스트가 100개 항목이라면 이 낭비가 100배로 증폭됩니다. 바로 이럴 때 필요한 것이 Select 최적화입니다.
Provider의 일부분만 선택적으로 구독하여, 해당 부분이 변경될 때만 리빌드되게 만듭니다. 불필요한 리빌드를 대폭 줄여서 앱 성능을 크게 향상시킬 수 있습니다.
개요
간단히 말해서, Select는 Provider의 특정 부분만 선택적으로 구독하여, 그 부분이 변경될 때만 위젯을 리빌드하는 최적화 기법입니다. 왜 이 개념이 필요한지 실무 관점에서 설명하면, 복잡한 상태 객체를 다루는 거의 모든 경우에 성능 최적화를 위해 필수적입니다.
예를 들어, 사용자 프로필, 장바구니, 폼 데이터처럼 여러 필드를 가진 객체를 관리할 때, 각 위젯이 필요한 필드만 구독하면 리빌드 횟수가 대폭 줄어듭니다. 기존에는 상태를 여러 개의 작은 Provider로 쪼개거나, shouldRebuild를 직접 구현해야 했다면, 이제는 select 하나로 간단히 해결할 수 있습니다.
코드도 깔끔하고, 의도도 명확합니다. Select의 핵심 특징은 세 가지입니다.
첫째, selector 함수로 필요한 부분만 추출합니다. 둘째, 추출된 값이 == 비교로 같으면 리빌드를 건너뜁니다.
셋째, 여러 필드를 선택해야 한다면 레코드를 반환하여 한 번에 처리할 수 있습니다. 이러한 특징들이 세밀한 리빌드 제어를 가능하게 합니다.
코드 예제
// 복잡한 사용자 프로필 모델
class UserProfile {
final String id;
final String name;
final String email;
final String avatarUrl;
final int points;
final bool isPremium;
UserProfile({
required this.id,
required this.name,
required this.email,
required this.avatarUrl,
required this.points,
required this.isPremium,
});
UserProfile copyWith({String? name, String? email, int? points}) {
return UserProfile(
id: id,
name: name ?? this.name,
email: email ?? this.email,
avatarUrl: avatarUrl,
points: points ?? this.points,
isPremium: isPremium,
);
}
}
final userProfileProvider = NotifierProvider<UserProfileNotifier, UserProfile>(
() => UserProfileNotifier(),
);
// 비효율적인 방법 - 전체 객체를 watch
class UserNameBad extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
// 프로필의 어떤 필드가 변경되어도 리빌드됨 (비효율적!)
final profile = ref.watch(userProfileProvider);
return Text(profile.name);
}
}
// 효율적인 방법 - name만 select
class UserNameGood extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
// name이 변경될 때만 리빌드됨 (효율적!)
final name = ref.watch(userProfileProvider.select((p) => p.name));
return Text(name);
}
}
// 여러 필드를 select - 레코드 사용
class UserSummary extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
// name과 points가 변경될 때만 리빌드
final (name, points) = ref.watch(
userProfileProvider.select((p) => (p.name, p.points)),
);
return Text('$name: $points points');
}
}
// 계산된 값을 select
class IsPremiumBadge extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
// isPremium이 변경될 때만 리빌드
final isPremium = ref.watch(
userProfileProvider.select((p) => p.isPremium),
);
return isPremium ? Icon(Icons.star, color: Colors.gold) : SizedBox();
}
}
// 복잡한 조건을 select
class HighScoreIndicator extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
// points가 1000 이상인지 여부가 변경될 때만 리빌드
final isHighScore = ref.watch(
userProfileProvider.select((p) => p.points >= 1000),
);
return isHighScore ? Text('High Score!') : SizedBox();
}
}
// AsyncValue와 select 조합
class UserNameAsync extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final nameAsync = ref.watch(
asyncUserProvider.select((asyncValue) {
return asyncValue.whenData((user) => user.name);
}),
);
return nameAsync.when(
data: (name) => Text(name ?? ''),
loading: () => CircularProgressIndicator(),
error: (err, _) => Text('Error'),
);
}
}
설명
이것이 하는 일: Select는 Provider의 전체 값을 구독하는 대신, selector 함수로 필요한 부분만 추출하고, 그 부분의 값이 실제로 변경될 때만 위젯을 리빌드합니다. 첫 번째로, .select((p) => p.name)은 Provider 값에서 name 필드만 추출하는 selector 함수입니다.
내부적으로 Riverpod은 이 selector 함수를 실행하여 추출된 값을 이전 값과 비교합니다. 왜 이렇게 동작하는지 이유는, 상태의 일부만 변경되었을 때 전체 위젯 트리를 리빌드하는 것을 방지하기 위함입니다.
이것이 바로 세밀한 리빌드 제어의 핵심입니다. 두 번째로, 추출된 값이 == 연산자로 비교되어 이전 값과 같으면 리빌드가 건너뛰어집니다.
내부에서 어떤 일이 일어나는지 살펴보면, 예를 들어 email이 "a@b.com"에서 "c@d.com"으로 변경되어도, name이 "John"으로 동일하면 UserNameGood 위젯은 리빌드되지 않습니다. 이를 통해 불필요한 연산을 대폭 줄일 수 있습니다.
세 번째로, 여러 필드가 필요한 경우 레코드 (p.name, p.points)를 반환하면, name이나 points 중 하나라도 변경될 때만 리빌드됩니다. 레코드는 자동으로 == 연산자가 구현되어 있어서 각 요소를 개별적으로 비교합니다.
마지막으로, selector 함수에서 boolean 값이나 계산된 값을 반환할 수도 있어서, p.points >= 1000 같은 조건의 결과가 변경될 때만 리빌드되게 할 수 있습니다. 여러분이 이 기법을 사용하면 앱의 성능이 눈에 띄게 향상되고, 프레임 드롭이 줄어들며, 배터리 소모도 감소합니다.
실무에서의 이점은 다음과 같습니다. 첫째, 리빌드 횟수가 대폭 줄어듭니다.
복잡한 객체를 다루는 앱에서 리빌드가 50-90% 감소할 수 있습니다. 둘째, 코드 의도가 명확해집니다.
어떤 위젯이 어떤 필드에 의존하는지 한눈에 알 수 있습니다. 셋째, 리팩토링이 쉬워집니다.
상태 구조가 변경되어도 select를 사용한 부분만 수정하면 됩니다.
실전 팁
💡 Primitive 타입(String, int, bool)을 select하면 == 비교가 정확하게 동작합니다. 객체를 select하는 경우 해당 객체의 ==가 올바르게 구현되어 있는지 확인하세요.
💡 여러 필드를 select할 때는 레코드를 사용하세요: (p.name, p.email). 이전에는 별도의 클래스를 만들어야 했지만, 이제는 레코드로 간단히 처리할 수 있습니다.
💡 계산 비용이 큰 selector 함수는 피하세요. selector는 Provider가 변경될 때마다 실행되므로, 복잡한 연산은 별도의 Provider로 분리하는 것이 좋습니다.
💡 select는 watch에만 적용됩니다. read나 listen에는 select를 사용할 수 없습니다.
💡 성능 프로파일링을 통해 실제로 리빌드가 줄어드는지 확인하세요. Flutter DevTools의 "Track widget rebuilds" 기능을 활용하면 select의 효과를 시각적으로 확인할 수 있습니다.
10. Provider Override
시작하며
여러분이 테스트 코드를 작성하거나, 개발 중에 실제 API 대신 가짜 데이터를 사용하고 싶을 때 어떻게 하시나요? Provider가 하드코딩되어 있으면 테스트할 때마다 실제 서버에 요청을 보내거나, 코드를 수정해야 해서 매우 불편합니다.
이런 문제는 실제 개발 현장에서 테스트 작성을 어렵게 만드는 주요 원인입니다. 단위 테스트, 위젯 테스트, 통합 테스트를 작성할 때 실제 네트워크나 데이터베이스에 의존하면 테스트가 느리고, 불안정하고, 외부 환경에 의존하게 됩니다.
또한 에러 상황을 재현하기도 어렵습니다. 바로 이럴 때 필요한 것이 Provider Override입니다.
런타임에 Provider의 구현을 다른 것으로 교체하여, 테스트 환경에서는 Mock 객체를 사용하고, 실제 환경에서는 진짜 구현을 사용할 수 있게 합니다. 의존성 주입의 강력함을 최대한 활용하는 방법입니다.
개요
간단히 말해서, Provider Override는 특정 Provider의 구현을 런타임에 다른 것으로 교체하는 기능으로, 주로 테스트와 개발 환경에서 사용됩니다. 왜 이 개념이 필요한지 실무 관점에서 설명하면, 테스트 가능한 코드를 작성하고, 개발 중에 다양한 시나리오를 시뮬레이션하는 데 필수적입니다.
예를 들어, API Provider를 Mock으로 교체하여 네트워크 없이 테스트하거나, 에러 상태를 강제로 만들어서 에러 처리 로직을 검증할 수 있습니다. 기존에는 생성자 주입이나 Service Locator 패턴을 사용해야 했다면, 이제는 ProviderScope의 overrides 매개변수만으로 깔끔하게 처리할 수 있습니다.
프로덕션 코드는 전혀 수정할 필요 없이 테스트만 다르게 동작합니다. Provider Override의 핵심 특징은 세 가지입니다.
첫째, overrideWithValue로 고정된 값을 제공할 수 있습니다. 둘째, overrideWith로 완전히 다른 구현으로 교체할 수 있습니다.
셋째, Family Provider도 특정 매개변수 값에 대해서만 오버라이드할 수 있습니다. 이러한 특징들이 매우 유연한 테스트 환경을 제공합니다.
코드 예제
// 실제 API 클라이언트 Provider
final apiClientProvider = Provider<ApiClient>((ref) {
return RealApiClient(baseUrl: 'https://api.prod.com');
});
// 사용자 프로필을 가져오는 Provider
final userProfileProvider = FutureProvider.autoDispose.family<UserProfile, String>(
(ref, userId) async {
final api = ref.watch(apiClientProvider);
return await api.getUserProfile(userId);
},
);
// 테스트에서 Provider override 사용하기
void main() {
testWidgets('User profile displays correctly', (tester) async {
// Mock API 클라이언트 생성
final mockApi = MockApiClient();
when(mockApi.getUserProfile('user1')).thenAnswer(
(_) async => UserProfile(id: 'user1', name: 'Test User'),
);
// Provider를 Mock으로 override
await tester.pumpWidget(
ProviderScope(
overrides: [
apiClientProvider.overrideWithValue(mockApi),
],
child: MyApp(),
),
);
// 테스트 로직...
expect(find.text('Test User'), findsOneWidget);
});
testWidgets('Error state displays correctly', (tester) async {
// 에러를 반환하는 Mock
final mockApi = MockApiClient();
when(mockApi.getUserProfile(any)).thenThrow(
Exception('Network error'),
);
await tester.pumpWidget(
ProviderScope(
overrides: [
apiClientProvider.overrideWithValue(mockApi),
],
child: MyApp(),
),
);
await tester.pump(); // 에러 상태로 전환
expect(find.text('Error'), findsOneWidget);
});
}
// 개발 환경에서 Provider override 사용
void main() {
runApp(
ProviderScope(
overrides: [
// 개발 환경에서는 개발 서버 사용
if (kDebugMode)
apiClientProvider.overrideWithValue(
RealApiClient(baseUrl: 'https://api.dev.com'),
),
// 특정 사용자 프로필을 고정 데이터로 override
userProfileProvider('user1').overrideWith((ref) async {
return UserProfile(id: 'user1', name: 'Dev User');
}),
],
child: MyApp(),
),
);
}
// Notifier override 예제
final counterProvider = NotifierProvider<CounterNotifier, int>(
() => CounterNotifier(),
);
void main() {
test('Counter increments correctly', () {
final container = ProviderContainer(
overrides: [
// Notifier를 override
counterProvider.overrideWith(() => CounterNotifier()),
],
);
expect(container.read(counterProvider), 0);
container.read(counterProvider.notifier).increment();
expect(container.read(counterProvider), 1);
container.dispose();
});
test('Counter starts with custom value', () {
final container = ProviderContainer(
overrides: [
// 초기값을 직접 설정
counterProvider.overrideWith(() {
return CounterNotifier()..state = 10;
}),
],
);
expect(container.read(counterProvider), 10);
container.dispose();
});
}
// Family Provider override
test('User profile for specific user', () {
final container = ProviderContainer(
overrides: [
// 특정 userId에 대해서만 override
userProfileProvider('user123').overrideWith((ref) async {
return UserProfile(id: 'user123', name: 'Specific User');
}),
],
);
// user123은 override된 값 반환
final profile123 = container.read(userProfileProvider('user123'));
// 다른 userId는 원래 Provider 사용
final profileOther = container.read(userProfileProvider('user456'));
container.dispose();
});
설명
이것이 하는 일: Provider Override는 ProviderScope에 overrides 리스트를 전달하여, 특정 Provider가 실제 구현 대신 테스트용 Mock이나 다른 구현을 사용하게 만듭니다. 첫 번째로, ProviderScope(overrides: [...])는 앱의 루트 또는 테스트 위젯을 감싸서 해당 scope 내에서만 override가 적용되게 합니다.
여기서 중요한 것은 override는 해당 ProviderScope의 자식 위젯에만 영향을 미치며, 다른 scope에는 영향을 주지 않는다는 점입니다. 왜 이렇게 설계되었는지 이유는, 테스트별로 독립적인 환경을 만들고, 프로덕션 코드와 테스트 코드를 완전히 분리하기 위함입니다.
두 번째로, provider.overrideWithValue(value)는 Provider의 반환 값을 고정된 값으로 교체합니다. 내부에서 어떤 일이 일어나는지 살펴보면, Provider의 함수가 전혀 실행되지 않고, 항상 주어진 값을 반환합니다.
예를 들어, API 호출이 필요한 Provider를 미리 준비된 데이터로 교체하면, 네트워크 없이도 테스트가 가능합니다. AsyncValue를 오버라이드할 때는 AsyncValue.data(value) 형태로 감싸야 합니다.
세 번째로, provider.overrideWith((ref) => ...)는 Provider의 구현 자체를 교체합니다. 새로운 함수를 제공하여, 원하는 로직을 실행할 수 있습니다.
예를 들어, 에러를 던지는 구현으로 교체하면 에러 처리 로직을 테스트할 수 있습니다. 마지막으로, Family Provider의 경우 provider(param).overrideWith(...)처럼 특정 매개변수에 대해서만 override할 수 있어서, 매우 세밀한 제어가 가능합니다.
여러분이 이 기법을 사용하면 테스트 작성이 훨씬 쉬워지고, 다양한 시나리오를 안전하게 시뮬레이션할 수 있으며, CI/CD 파이프라인에서도 안정적으로 테스트를 실행할 수 있습니다. 실무에서의 이점은 다음과 같습니다.
첫째, 테스트가 빠르고 안정적입니다. 네트워크나 데이터베이스에 의존하지 않아 몇 초 만에 수백 개의 테스트를 실행할 수 있습니다.
둘째, 에러 상황을 쉽게 재현할 수 있습니다. 타임아웃, 네트워크 에러, 권한 거부 등을 Mock으로 시뮬레이션할 수 있습니다.
셋째, 프로덕션 코드가 깔끔해집니다. 테스트를 위한 특별한 코드를 프로덕션에 넣을 필요가 없습니다.
실전 팁
💡 overrideWithValue는 간단한 값을 교체할 때, overrideWith는 로직을 교체할 때 사용하세요. 대부분의 경우 overrideWith가 더 유연합니다.
💡 테스트에서 ProviderContainer를 직접 생성하면 위젯 없이도 Provider를 테스트할 수 있습니다. 순수한 로직 테스트에 적합합니다.
💡 여러 테스트에서 공통으로 사용하는 override는 헬퍼 함수로 추출하세요. 예: ProviderScope createTestScope(overrides) {...}
💡 개발 환경과 프로덕션 환경을 구분할 때도 override를 활용할 수 있습니다. main 함수에서 kDebugMode를 체크하여 개발 서버를 주입하세요.
💡 Family Provider를 override할 때는 모든 매개변수에 대해 override하려면 overrideWith, 특정 매개변수만 override하려면 provider(param).overrideWith를 사용하세요.