주요 콘텐츠로 건너뛰기

도입 배경

이 글에서는 Riverpod이 왜 필요한지 깊이 있게 살펴봅니다.

특히 이 섹션에서는 다음 질문에 답합니다:

  • Provider가 이미 널리 쓰이는데, 왜 Riverpod으로 마이그레이션해야 하나요?
  • 구체적으로 어떤 이점이 있나요?
  • Riverpod으로 어떻게 마이그레이션하나요?
  • 점진적으로 마이그레이션할 수 있나요?
  • 기타 등등

이 섹션을 다 읽고 나면 Provider보다 Riverpod을 선택해야 한다고 확신하게 될 것입니다.

Riverpod은 Provider에 비해 확실히 더 현대적이고, 권장되며, 신뢰할 수 있는 방식입니다.

Riverpod은 더 나은 상태 관리 기능과 캐시 전략, 그리고 단순해진 반응성 모델을 제공합니다. 반면 Provider는 현재 여러 면에서 부족하며, 이를 개선할 방법도 없습니다.

Provider의 한계​

Provider는 InheritedWidget API의 제약을 받기 때문에 근본적인 문제를 안고 있습니다.
본질적으로 Provider는 "더 단순한 InheritedWidget"입니다. Provider는 InheritedWidget을 감싼 래퍼에 불과하므로 그 한계를 그대로 물려받습니다.

다음은 알려진 Provider의 문제점입니다.

Provider는 같은 "타입"의 provider를 두 개 이상 둘 수 없습니다​

Provider<Item>을 두 개 선언하면 동작을 신뢰할 수 없게 됩니다. InheritedWidget의 API는 둘 중 하나, 즉 가장 가까운 Provider<Item> 조상만 가져오기 때문입니다.
Provider 문서에 우회 방법이 설명되어 있긴 하지만, Riverpod에는 애초에 이런 문제가 없습니다.

이 제약이 사라지면 다음과 같이 로직을 작은 조각으로 자유롭게 나눌 수 있습니다:

final itemsProvider = Provider.autoDispose(
(ref) => <Item>[], // ...
);

final evenItemsProvider = Provider.autoDispose((ref) {
final items = ref.watch(itemsProvider);
return [...items.whereIndexed((index, element) => index.isEven)];
});

Provider는 한 번에 하나의 값만 내보낼 수 있습니다​

외부 RESTful API를 읽을 때는, 새 호출이 다음 값을 불러오는 동안 마지막으로 읽은 값을 계속 보여 주는 경우가 흔합니다.
Riverpod은 AsyncValue API를 통해 한 번에 두 개의 값(즉, 이전 데이터 값과 새로 들어오는 로딩 값)을 내보냄으로써 이런 동작을 지원합니다:

final itemsApiProvider = FutureProvider.autoDispose((ref) async {
final client = Dio();
final result = await client.get<List<dynamic>>('your-favorite-api');
final parsed = [...result.data!.map((e) => Item.fromJson(e as Json))];
return parsed;
});

final evenItemsProvider = Provider.autoDispose((ref) {
final asyncValue = ref.watch(itemsApiProvider);
if (asyncValue.isLoading) return <Item>[];
if (asyncValue.hasError) return const [Item(id: -1)];

final items = asyncValue.requireValue;

return [...items.whereIndexed((index, element) => index.isEven)];
});

위 코드에서 evenItemsProvider를 watch하면 다음과 같이 동작합니다:

  1. 처음에는 요청이 진행 중이므로 빈 리스트를 얻습니다.
  2. 이후 오류가 발생했다고 가정하면 [Item(id: -1)]을 얻습니다.
  3. 그다음 당겨서 새로고침 로직(예: ref.invalidate)으로 요청을 재시도합니다.
  4. 첫 번째 provider를 다시 불러오는 동안에도 두 번째 provider는 여전히 [Item(id: -1)]을 노출합니다.
  5. 이번에는 파싱된 데이터를 제대로 받아, 짝수 항목들이 올바르게 반환됩니다.

Provider로는 위와 같은 기능을 구현하는 것이 사실상 불가능하며, 우회하기는 더더욱 어렵습니다.

provider를 조합하기가 어렵고 오류가 발생하기 쉽습니다​

Provider에서는 provider의 create 안에서 context.watch를 쓰고 싶어질 수 있습니다.
하지만 이 방식은 신뢰할 수 없습니다. 의존성이 전혀 바뀌지 않았는데도 didChangeDependencies가 호출될 수 있기 때문입니다(예: 위젯 트리에 GlobalKey가 있는 경우).

물론 Provider에는 ProxyProvider라는 임시방편이 있지만, 번거롭고 오류가 생기기 쉽다고 평가받습니다.

상태 조합은 Riverpod의 핵심 메커니즘입니다. ref.watch와 ref.listen 같은 단순하지만 강력한 도구로 오버헤드 없이 값을 반응형으로 조합하고 캐시할 수 있습니다:

final numberProvider = Provider.autoDispose((ref) {
return Random().nextInt(10);
});

final doubledProvider = Provider.autoDispose((ref) {
final number = ref.watch(numberProvider);

return number * 2;
});

Riverpod에서는 값을 자연스럽게 조합할 수 있습니다. 의존성이 한눈에 읽히고 API도 그대로입니다.

안전성이 부족합니다​

Provider에서는 리팩터링이나 대규모 변경 중에 ProviderNotFoundException을 마주치는 일이 흔합니다.
실제로 이 런타임 예외는 애초에 Riverpod이 만들어진 주된 이유 중 하나였습니다.

Riverpod은 이것 말고도 훨씬 많은 이점을 제공하지만, 무엇보다 이 예외 자체가 발생할 수 없습니다.

상태를 폐기하기 어렵습니다​

InheritedWidget은 consumer가 구독을 멈춰도 이에 반응할 수 없습니다.
그래서 Provider는 더 이상 사용되지 않는 provider의 상태를 자동으로 폐기할 수 없습니다.
Provider에서는 상태가 더 이상 쓰이지 않을 때 폐기하려면 provider 스코프에 의존할 수밖에 없습니다.
하지만 상태를 여러 페이지에서 공유하는 경우에는 이 방법이 까다로워집니다.

Riverpod은 autodispose와 Ref.keepAlive처럼 이해하기 쉬운 API로 이 문제를 해결합니다.
이 두 API를 이용하면 유연하고 창의적인 캐시 전략(예: 시간 기반 캐시)을 구현할 수 있습니다:

final diceRollProvider = Provider.autoDispose((ref) {
// 이 provider는 .autoDispose이므로, 구독을 해제하면
// 현재 노출된 상태가 폐기됩니다.
// 이후 이 provider를 다시 구독하면
// 새로 주사위를 굴려 그 값을 다시 노출합니다.
final dice = Random().nextInt(10);
return dice.isEven;
});

final cachedDiceRollProvider = Provider.autoDispose((ref) {
final coin = Random().nextInt(10);
if (coin > 5) throw Exception('Way too large.');
// 위 조건은 실패할 수 있습니다.
// 실패하지 않았다면, 아래 명령은 아무도 구독하지 않더라도
// 캐시된 상태를 유지하라고 Provider에게 알립니다.
ref.keepAlive();
return coin.isEven;
});

안타깝게도 순수 InheritedWidget으로는, 따라서 Provider로는 이를 구현할 방법이 없습니다.

신뢰할 수 있는 매개변수화 메커니즘이 없습니다​

Riverpod에서는 .family 수식어로 "매개변수화된" Provider를 선언할 수 있습니다.
실제로 .family는 Riverpod의 가장 강력한 기능 중 하나이자 혁신의 핵심으로, 로직을 크게 단순화해 줍니다.

Provider로 비슷한 것을 구현하려면, 이런 매개변수에 대해 사용 편의성과 타입 안전성을 모두 포기해야 합니다.

게다가 Provider로는 .autoDispose와 비슷한 메커니즘을 구현할 수 없으므로, .family에 해당하는 기능 역시 본질적으로 구현할 수 없습니다. 두 기능은 서로 맞물려 있기 때문입니다.

마지막으로, 앞서 살펴본 것처럼 위젯은 InheritedWidget 구독을 절대 멈추지 않는 것으로 밝혀졌습니다.
이는 provider 상태가 "동적으로 마운트"될 때, 즉 매개변수를 사용해 Provider를 만들 때 심각한 메모리 누수가 생긴다는 뜻이며, 이것이 바로 .family가 하는 일입니다.
따라서 현재로서는 Provider에서 .family에 상응하는 기능을 얻는 것이 근본적으로 불가능합니다.

테스트가 번거롭습니다​

테스트를 작성하려면 테스트마다 provider를 반드시 다시 정의해야 합니다.

Riverpod에서는 provider를 기본적으로 테스트 안에서 바로 사용할 수 있습니다. 또한 Riverpod은 Provider를 모킹할 때 꼭 필요한 편리한 "오버라이드" 도구들을 제공합니다.

앞에서 본 상태 조합 코드는 다음과 같이 간단하게 테스트할 수 있습니다:

void main() {
test('it doubles the value correctly', () {
final container = ProviderContainer(
overrides: [numberProvider.overrideWith((ref) => 9)],
);
final doubled = container.read(doubledProvider);
expect(doubled, 9 * 2);
});
}

부수 효과를 일으키기가 간단하지 않습니다​

InheritedWidget에는 onChange 콜백이 없으므로 Provider에도 있을 수 없습니다.
이는 스낵바, 모달 등 내비게이션과 관련된 작업에서 문제가 됩니다.

반면 Riverpod은 Flutter와 잘 어우러지는 ref.listen을 제공합니다.

class DiceRollWidget extends ConsumerWidget {
const DiceRollWidget({super.key});


Widget build(BuildContext context, WidgetRef ref) {
ref.listen(diceRollProvider, (previous, next) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Dice roll! We got: $next')),
);
});
return TextButton.icon(
onPressed: () => ref.invalidate(diceRollProvider),
icon: const Icon(Icons.casino),
label: const Text('Roll a dice'),
);
}
}

Riverpod으로​

개념적으로 Riverpod과 Provider는 꽤 비슷합니다. 두 패키지는 비슷한 역할을 하며, 둘 다 다음을 목표로 합니다:

  • 상태를 가진 객체를 캐시하고 폐기하기
  • 테스트 중에 이러한 객체를 모킹하는 방법 제공하기
  • 위젯이 이러한 객체를 간단하게 구독할 수 있는 방법 제공하기

Riverpod은 Provider가 몇 년 더 성숙했다면 되었을 모습이라고 생각하면 됩니다.

왜 별도의 패키지인가요?​

원래는 앞서 말한 문제들을 해결하기 위해 Provider의 메이저 버전을 출시할 계획이었습니다.
하지만 새로운 ConsumerWidget API 때문에 변경 폭이 "너무 크고" 논란의 여지도 있어서 그 계획은 철회되었습니다.
Provider는 여전히 가장 많이 쓰이는 Flutter 패키지 중 하나이기 때문에, 대신 별도의 패키지를 만들기로 했고, 그렇게 Riverpod이 탄생했습니다.

별도의 패키지를 만든 덕분에 다음이 가능해졌습니다:

  • 원하는 사람은 쉽게 마이그레이션할 수 있으며, 두 방식을 동시에 임시로 사용할 수도 있습니다.
  • Riverpod이 원칙적으로 마음에 들지 않거나 아직 신뢰하기 어렵다고 느끼는 사람은 Provider를 계속 사용할 수 있습니다.
  • 실험을 통해, Provider의 여러 기술적 한계에 대한 프로덕션 수준의 해결책을 Riverpod에서 모색할 수 있습니다.

실제로 Riverpod은 Provider의 정신적 후계자로 설계되었습니다. "Riverpod"이라는 이름도 여기서 왔습니다("Provider"의 애너그램입니다).

호환성을 깨는 변경​

Riverpod의 유일한 실질적인 단점은 위젯 타입을 바꿔야 한다는 점입니다:

  • Riverpod에서는 StatelessWidget 대신 ConsumerWidget을 상속해야 합니다.
  • Riverpod에서는 StatefulWidget 대신 ConsumerStatefulWidget을 상속해야 합니다.

하지만 전체적으로 보면 이 불편함은 꽤 사소합니다. 그리고 언젠가는 이 요구 사항이 사라질 수도 있습니다.

알맞은 라이브러리 고르기​

아마 이런 질문이 떠오를 것입니다: "그래서 Provider 사용자인 나는 Provider와 Riverpod 중 무엇을 써야 할까?"

이 질문에는 아주 분명하게 답하고자 합니다:

아마도 Riverpod을 사용하는 것이 좋습니다

Riverpod은 전반적으로 더 잘 설계되었으며, 로직을 획기적으로 단순화해 줄 수 있습니다.