Riverpod 상태가 자꾸 초기화돼요 (autoDispose의 배신?)
1. "뒤로가기 했다가 다시 왔는데..."
쇼핑몰 앱의 상품 상세 페이지를 작업할 때였습니다. 사용자가 상품 옵션(색상, 사이즈)을 선택하고, 잠시 다른 상품을 보러 '뒤로가기'를 눌렀다가 다시 돌아왔습니다. 그런데 아까 선택해둔 옵션이 싹 초기화되어 있었습니다.
"뭐야? 전역 상태(Global State)라며? 왜 데이터가 날아가?"
범인은 제가 무지성으로 붙여둔 .autoDispose 였습니다.
2. 원리 이해: "누구도 보지 않으면 버린다"
Riverpod의 가장 강력한 기능 중 하나는 autoDispose입니다.
final productProvider = StateProvider.autoDispose<Product>((ref) => ...);
이 수식어가 붙은 Provider는 아주 스마트하면서도 냉정합니다. "나를 구독(Watch/Read)하는 위젯이 단 하나도 없으면(0개), 즉시 메모리에서 삭제하고 초기화한다."
- 상세 페이지(
DetailPage)에 진입함. -> 구독자 1명 (생성) - 사용자가 뒤로가기를 누름(
pop). ->DetailPage파괴됨. - 구독자 0명. -> Provider 파괴 (Dispose).
- 사용자가 다시 진입함. -> Provider 새로 생성 (초기값 리셋).
이 기능은 메모리 누수를 막는 데 탁월하지만, "상태 유지"가 필요한 경우에는 독이 됩니다.
3. 해결책 1: autoDispose 제거 (Simple is Best)
만약 이 데이터가 앱이 켜져 있는 동안 계속 유지되어야 한다면(예: 장바구니, 사용자 프로필), 그냥 autoDispose를 빼면 됩니다.
final cartProvider = StateProvider<List<Item>>((ref) => []);
이렇게 하면 앱이 종료될 때까지 상태가 살아있습니다. 하지만 모든 상태를 이렇게 만들면 앱 사용 시간이 길어질수록 메모리가 터져 나갈 것입니다. (Singleton처럼 동작)
4. 해결책 2: ref.keepAlive() (타임아웃 캐싱)
"잠깐은 유지하고 싶은데, 영원히는 싫어." 예를 들어, 사용자가 상세 페이지를 왔다 갔다 할 때는 데이터를 유지하고 싶지만, 아예 다른 탭으로 가서 5분 동안 안 돌아온다면 굳이 메모리를 잡고 있을 필요가 없겠죠.
이럴 때 ref.keepAlive()를 씁니다.
final detailProvider = FutureProvider.autoDispose.family<Detail, int>((ref, id) async {
// 1. keepAlive 링크 확보 (구독자가 0명이 되어도 죽지 않게 잡음)
final link = ref.keepAlive();
// 2. 타이머 설정 (예: 3분 뒤에 파괴)
final timer = Timer(const Duration(minutes: 3), () {
link.close(); // 3. 3분 뒤에 링크를 끊어서 Dispose 허용
});
// 4. 만약 3분 안에 다시 구독되면(재진입) 타이머 취소하고 연장
ref.onDispose(() => timer.cancel());
return fetchDetail(id);
});
Riverpod 2.0부터는 더 쉽게 ref.cacheFor(Duration) 같은 확장 함수(Extension)를 만들어서 씁니다.
이제 "최소 3분간은 캐싱, 그 뒤엔 자동 해제"라는 똑똑한 전략을 짤 수 있습니다.
5. 심화: 강제로 초기화하기 (invalidate vs refresh)
반대로, autoDispose를 안 썼는데 강제로 초기화하고 싶을 때가 있습니다.
예를 들어, "새로고침(Pull to Refresh)" 기능을 만들 때입니다.
// 1. invalidate (게으른 초기화)
ref.invalidate(productProvider);
invalidate는 "이 데이터는 낡았어 (Stale). 다음에 누군가 필요로 할 때(Read/Watch) 다시 가져와."라고 표시만 해둡니다. 즉시 리로드하지 않습니다. 효율적입니다.
// 2. refresh (즉시 초기화)
ref.refresh(productProvider);
refresh는 그 자리에서 즉시 데이터를 다시 가져와서 덮어씁니다. FutureProvider를 다시 실행하고 결과를 리턴받고 싶을 때 씁니다.
6. 심화: Riverpod Generator (@Riverpod)
최신 Riverpod Generator 문법을 쓰면 keepAlive를 더 간단하게 쓸 수 있습니다.
기본적으로 모든 Generator Provider는 autoDispose입니다.
@Riverpod(keepAlive: true)
class UserCarts extends _$UserCarts {
// ... 영구 유지
}
@riverpod
Future<Product> productDetail(ProductDetailRef ref, int id) {
// 기본적으로 autoDispose
ref.keepAlive(); // 이렇게 하면 조건부 유지 가능
return fetch(id);
}
Generator를 쓰면 Family, AutoDispose 같은 헷갈리는 수식어를 안 써도 돼서 실수를 줄일 수 있습니다.
8. Deep Dive: StateNotifier vs Notifier (Riverpod 2.0)
아직도 StateNotifier를 쓰고 계신가요? Riverpod 2.0부터는 Notifier 사용을 권장합니다.
keepAlive나 invalidate가 훨씬 자연스럽게 동작하기 때문입니다.
Legacy (StateNotifier):
class Counter extends StateNotifier<int> {
Counter(): super(0);
void increment() => state++;
}
// Provider 선언과 클래스가 분리되어 있어서 keepAlive 설정이 귀찮음
Modern (Notifier):
@riverpod
class Counter extends _$Counter {
@override
int build() => 0; // 초기값
void increment() => state++;
}
// 어노테이션 하나로 autoDispose, keepAlive, family 다 해결됨
특히 ref.onDispose 같은 생명주기 메서드를 클래스 내부(build)에서 직접 쓸 수 있다는 점이 Notifier의 가장 큰 장점입니다. "상태가 초기화될 때 소켓을 닫는다" 같은 로직을 응집도 있게 짤 수 있죠.
9. Case Study: 테스트 코드 작성 (Testing)
"상태가 리셋되는지 어떻게 테스트하나요?" Unit Test를 짜면 앱을 껐다 켰다 하지 않아도 1초 만에 검증할 수 있습니다.
test('dispose되면 상태가 초기화된다', () {
final container = ProviderContainer();
final subscription = container.listen(counterProvider, (_, __) {});
// 1. 값 변경
container.read(counterProvider.notifier).increment();
expect(container.read(counterProvider), 1);
// 2. 구독 취소 (Dispose 유발)
subscription.close();
// 3. 재구독 (State 초기화 확인)
expect(container.read(counterProvider), 0);
});
ProviderContainer를 직접 만들어서 수동으로 조작해보면 Riverpod의 생명주기를 완벽하게 이해할 수 있습니다.
10. Refactoring Challenge: ChangeNotifier to Notifier
코드를 더 깔끔하게 만들고 싶다면, 기존 ChangeNotifier를 Notifier로 바꿔보세요.
Before (ChangeNotifier):
class UserNotifier extends ChangeNotifier {
User? user;
void login(User u) {
user = u;
notifyListeners();
}
}
final userProvider = ChangeNotifierProvider((ref) => UserNotifier());
단점:
user가 null인지 아닌지 매번 체크해야 함. (User?타입)state가 불변(Immutable)이 아님.
After (Notifier):
@riverpod
class User extends _$User {
@override
User? build() => null; // 초기값
void login(User u) {
state = u; // 덮어씌우면 끝 (notifyListeners 호출 불필요)
}
}
장점:
- 코드가 절반으로 줄어듦.
- 불변성 보장 (State Immutability).
- 디버깅 시 상태 변화 추적이 쉬움.
11. Case Study: 설문조사 입력 데이터 증발 사건
설문조사 앱을 만들 때였습니다. 1단계(기본정보) -> 2단계(상세정보) -> 3단계(완료)로 넘어가는 화면이었습니다. 각 단계는 별도의 Route(Page)로 되어 있었습니다.
사용자가 2단계에서 "아 맞다, 이름 잘못 썼네" 하고 1단계로 뒤로가기를 눌렀습니다. 1단계 입력폼이 싹 비워져 있었습니다.
이유는 FormProvider에 autoDispose를 비활성화하지 않았기 때문입니다.
2단계로 넘어갈 때 1단계 페이지가 스택에서 사라지진 않았지만(push), 1단계로 돌아왔다가 다시 2단계로 갈 때 데이터가 날아갔습니다. (정확히는 위젯 트리 구조에 따라 구독이 끊겼음)
해결:
설문조사 같은 Wizard Form 형식에서는 autoDispose를 끄거나, keepAlive를 써서 "설문이 완료될 때까지"는 무조건 살려둬야 합니다.
그리고 설문 완료(Submit) 버튼을 누를 때 ref.invalidate(formProvider)를 호출해서 수동으로 청소해주는 것이 정석입니다.
12. Glossary
- ProviderScope: The widget that stores the state of all providers. Usually at the root of the app.
- Dispose: The process of cleaning up resources (memory, listeners) when they are no longer needed.
- Stale Data: Data that is old and potentially incorrect compared to the server.
- Singleton: A design pattern where only one instance of a class exists throughout the app life (similar to keepAlive: true).
13. Summary
Riverpod 상태가 사라지는 건 버그가 아니라 기능입니다.
autoDispose는 스코프가 끝나면 데이터를 버린다. (방청소)- 영구 유지가 필요하면
autoDispose를 빼라. (창고 보관) - 임시 유지가 필요하면
keepAlive()로 캐싱 시간을 줘라. (임시 보관함) - 입력 폼(Form) 데이터는 완료 전까지 절대 버리지 마라.
데이터의 수명(Lifecycle)을 설계하는 것, 그것이 앱의 안정성을 결정합니다.