
Riverpod 상태가 자꾸 초기화돼요 (autoDispose의 배신?)
페이지를 나갔다 돌아오니 열심히 입력한 데이터가 다 날아갔습니다. Riverpod의 autoDispose가 범인일까요? 캐싱 전략과 keepAlive, 그리고 invalidate의 차이를 명확히 구분해 봅니다.

페이지를 나갔다 돌아오니 열심히 입력한 데이터가 다 날아갔습니다. Riverpod의 autoDispose가 범인일까요? 캐싱 전략과 keepAlive, 그리고 invalidate의 차이를 명확히 구분해 봅니다.
로그인 화면을 만들었는데 키보드가 올라오니 노란 줄무늬 에러가 뜹니다. resizeToAvoidBottomInset부터 스크롤 뷰, 그리고 채팅 앱을 위한 reverse 팁까지, 키보드 대응의 모든 것을 정리해봤습니다.

안드로이드는 오는데 iOS는 조용합니다. 혹은 앱이 켜져 있을 때만 옵니다. Background/Terminated 상태 처리, APNs 인증서, 그리고 Notification Channel 설정까지 완벽하게 해결합니다.

안드로이드는 Xcode보다 낫다고요? Gradle 지옥에 빠져보면 그 말이 쏙 들어갈 겁니다. minSdkVersion 충돌, Multidex 에러, Namespace 변경(Gradle 8.0), JDK 버전 문제, 그리고 의존성 트리 분석까지 완벽하게 해결해 봅니다.

Debug에선 잘 되는데 Release에서만 죽나요? 범인은 '난독화'입니다. R8의 원리, Mapping 파일 분석, 그리고 Reflection을 사용하는 라이브러리를 지켜내는 방법(@Keep)을 정리해봤습니다.

쇼핑몰 앱의 상품 상세 페이지를 작업할 때였습니다. 사용자가 상품 옵션(색상, 사이즈)을 선택하고, 잠시 다른 상품을 보러 '뒤로가기'를 눌렀다가 다시 돌아왔습니다. 그런데 아까 선택해둔 옵션이 싹 초기화되어 있었습니다.
"뭐야? 전역 상태(Global State)라며? 왜 데이터가 날아가?"
범인은 제가 무지성으로 붙여둔 .autoDispose 였습니다.
Riverpod의 가장 강력한 기능 중 하나는 autoDispose입니다.
final productProvider = StateProvider.autoDispose<Product>((ref) => ...);
이 수식어가 붙은 Provider는 아주 스마트하면서도 냉정합니다. "나를 구독(Watch/Read)하는 위젯이 단 하나도 없으면(0개), 즉시 메모리에서 삭제하고 초기화한다."
DetailPage)에 진입함. -> 구독자 1명 (생성)pop). -> DetailPage 파괴됨.이 기능은 메모리 누수를 막는 데 탁월하지만, "상태 유지"가 필요한 경우에는 독이 됩니다.
만약 이 데이터가 앱이 켜져 있는 동안 계속 유지되어야 한다면(예: 장바구니, 사용자 프로필), 그냥 autoDispose를 빼면 됩니다.
final cartProvider = StateProvider<List<Item>>((ref) => []);
이렇게 하면 앱이 종료될 때까지 상태가 살아있습니다. 하지만 모든 상태를 이렇게 만들면 앱 사용 시간이 길어질수록 메모리가 터져 나갈 것입니다. (Singleton처럼 동작)
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분간은 캐싱, 그 뒤엔 자동 해제"라는 똑똑한 전략을 짤 수 있습니다.
invalidate vs refresh)반대로, autoDispose를 안 썼는데 강제로 초기화하고 싶을 때가 있습니다.
예를 들어, "새로고침(Pull to Refresh)" 기능을 만들 때입니다.
// 1. invalidate (게으른 초기화)
ref.invalidate(productProvider);
invalidate는 "이 데이터는 낡았어 (Stale). 다음에 누군가 필요로 할 때(Read/Watch) 다시 가져와."라고 표시만 해둡니다. 즉시 리로드하지 않습니다. 효율적입니다.
// 2. refresh (즉시 초기화)
ref.refresh(productProvider);
refresh는 그 자리에서 즉시 데이터를 다시 가져와서 덮어씁니다. FutureProvider를 다시 실행하고 결과를 리턴받고 싶을 때 씁니다.
최신 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 같은 헷갈리는 수식어를 안 써도 돼서 실수를 줄일 수 있습니다.
아직도 StateNotifier를 쓰고 계신가요? Riverpod 2.0부터는 Notifier 사용을 권장합니다.
keepAlive나 invalidate가 훨씬 자연스럽게 동작하기 때문입니다.
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의 가장 큰 장점입니다. "상태가 초기화될 때 소켓을 닫는다" 같은 로직을 응집도 있게 짤 수 있죠.
"상태가 리셋되는지 어떻게 테스트하나요?" 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의 생명주기를 완벽하게 이해할 수 있습니다.
코드를 더 깔끔하게 만들고 싶다면, 기존 ChangeNotifier를 Notifier로 바꿔보세요.
class UserNotifier extends ChangeNotifier {
User? user;
void login(User u) {
user = u;
notifyListeners();
}
}
final userProvider = ChangeNotifierProvider((ref) => UserNotifier());
단점:
user가 null인지 아닌지 매번 체크해야 함. (User? 타입)state가 불변(Immutable)이 아님.@riverpod
class User extends _$User {
@override
User? build() => null; // 초기값
void login(User u) {
state = u; // 덮어씌우면 끝 (notifyListeners 호출 불필요)
}
}
장점:
설문조사 앱을 만들 때였습니다. 1단계(기본정보) -> 2단계(상세정보) -> 3단계(완료)로 넘어가는 화면이었습니다. 각 단계는 별도의 Route(Page)로 되어 있었습니다.
사용자가 2단계에서 "아 맞다, 이름 잘못 썼네" 하고 1단계로 뒤로가기를 눌렀습니다. 1단계 입력폼이 싹 비워져 있었습니다.
이유는 FormProvider에 autoDispose를 비활성화하지 않았기 때문입니다.
2단계로 넘어갈 때 1단계 페이지가 스택에서 사라지진 않았지만(push), 1단계로 돌아왔다가 다시 2단계로 갈 때 데이터가 날아갔습니다. (정확히는 위젯 트리 구조에 따라 구독이 끊겼음)
해결:
설문조사 같은 Wizard Form 형식에서는 autoDispose를 끄거나, keepAlive를 써서 "설문이 완료될 때까지"는 무조건 살려둬야 합니다.
그리고 설문 완료(Submit) 버튼을 누를 때 ref.invalidate(formProvider)를 호출해서 수동으로 청소해주는 것이 정석입니다.
Riverpod 상태가 사라지는 건 버그가 아니라 기능입니다.
autoDispose는 스코프가 끝나면 데이터를 버린다. (방청소)autoDispose를 빼라. (창고 보관)keepAlive()로 캐싱 시간을 줘라. (임시 보관함)데이터의 수명(Lifecycle)을 설계하는 것, 그것이 앱의 안정성을 결정합니다.