Flutter

[Flutter]Riverpod 간단 압축 정리

Dev.Hansangwook 2026. 8. 13. 21:06

Riverpod을 알아보자

최근 합류한 곳에서는 상태 관리 라이브러리로 Riverpod을 사용하고 있다.

이전 회사에서는 주로 BLoC과 GetX를 사용했기 때문에, 온보딩 기간 동안 Riverpod을 공부하면서 핵심 개념을 정리해보려고 한다.



이 글은 Riverpod 3.x 이후 버전을 기준으로 작성한다.

 

Riverpod이란?

Riverpod은 Flutter/Dart에서 사용하는 상태 관리 및 반응형 캐싱 라이브러리다.

Riverpod 공식 문서에서는 Provider를 다음과 같이 설명한다.

Provider는 부가 기능이 추가된 메모이제이션 함수다.

 

처음에는 이 설명이 조금 와닿지 않을 수 있다.

간단하게 생각하면,

@riverpod
Future<User> user(Ref ref) async {
  return userRepository.fetchUser();
}

 

위 Provider를 여러 화면에서 사용하더라도 fetchUser()를 사용하는 화면마다 무조건 새롭게 실행하는 것이 아니라, Provider의 상태와 계산된 결과를 캐싱하여 공유할 수 있다.

 

 

Riverpod은 여기에 다음과 같은 기능을 제공한다.

  • 상태 캐싱
  • Provider 간 의존성 관리
  • 캐시 무효화
  • 사용하지 않는 상태 자동 폐기
  • 비동기 로딩/에러 상태 관리
  • 테스트에서 의존성 교체
  • 상태와 UI의 데이터 바인딩
  • Provider 실패 시 자동 재시도

 

기존 Provider의 단점

Flutter에서 기존 provider 패키지를 사용해본 개발자라면 BuildContext를 통해 Provider를 조회하는 방식에 익숙할 것이다.

final user = context.watch<User>();

 

Provider는 Flutter의 InheritedWidget을 기반으로 동작하기 때문에 위젯 트리와 밀접한 관계를 가지고 있다.

이 구조 자체가 잘못된 것은 아니다. 오히려 Flutter의 구조와 자연스럽게 연결된다는 장점이 있지만, 프로젝트가 커질수록 몇 가지 불편함이 발생할 수 있다.

 

대표적으로 다음과 같은 것들이 있다.

  • Provider 접근이 BuildContext에 의존한다.
  • 위젯 트리 어디에 Provider가 선언되어 있는지 신경 써야 한다.
  • 같은 타입의 Provider가 중첩되면 Scope에 따라 다른 값을 참조할 수 있다.
  • Provider끼리 의존성을 구성할 때 구조가 복잡해질 수 있다.
  • 위젯 외부의 비즈니스 로직에서 상태에 접근하기 상대적으로 불편하다.

특히 상태 관리가 위젯 트리 구조와 강하게 결합되어 있다는 점이 프로젝트 규모가 커질수록 부담이 될 수 있다.

그래서 Riverpod

Riverpod은 Provider를 Flutter 위젯 트리에 직접 선언하지 않는다.

보통 다음과 같이 파일 최상위에 선언한다.

final apiClientProvider = Provider<ApiClient>((ref) {
  return ApiClient();
});

 

코드 생성을 사용한다면 다음과 같이 작성할 수도 있다.

@riverpod
ApiClient apiClient(Ref ref) {
  return ApiClient();
}

 

여기서 중요한 점이 하나 있다.

final apiClientProvider

 

 

라는 변수가 상태 그 자체를 가지고 있는 것은 아니다.

Provider는 상태를 어떻게 생성할지 정의하는 불변 객체다.

 

실제 Provider의 상태는 ProviderContainer가 관리한다.

Flutter에서는 일반적으로 앱 최상단의 ProviderScope가 ProviderContainer와 위젯 트리를 연결한다.

void main() {
  runApp(
    const ProviderScope(
      child: MyApp(),
    ),
  );
}

 

구조를 간단하게 표현하면 다음과 같다.

ProviderScope
    ↓
ProviderContainer
    ↓
Provider
    ↓
Ref
    ↓
ConsumerWidget

 

따라서 Riverpod도 Flutter에서 사용할 때는 ProviderScope라는 Scope가 존재한다.

다만 기존 Provider처럼 Provider의 선언 위치 자체가 위젯 트리에 종속되지 않는다는 차이가 있다.

 

BuildContext 대신 Ref

 

Riverpod을 처음 공부할 때 가장 먼저 익숙해져야 하는 것이 Ref다.

 

Flutter에서

BuildContext

 

가 위젯 트리와 상호작용하기 위한 객체라면, Riverpod에서는

Ref

 

가 Provider와 상호작용하기 위한 객체라고 이해하면 쉽다.

 

예를 들어 Repository Provider가 있다고 해보자.

@riverpod
UserRepository userRepository(Ref ref) {
  final apiClient = ref.watch(apiClientProvider);

  return UserRepositoryImpl(apiClient);
}

 

그리고 User 정보를 가져오는 Provider에서 해당 Repository를 사용할 수 있다.

@riverpod
Future<User> user(Ref ref, String userId) async {
  final repository = ref.watch(userRepositoryProvider);

  return repository.getUser(userId);
}

 

의존 관계는 자연스럽게 다음과 같이 만들어진다.

apiClientProvider
       ↓
userRepositoryProvider
       ↓
userProvider
       ↓
UI

 

그리고 apiClientProvider가 변경되면 이를 구독하는 userRepositoryProvider, 그리고 필요한 하위 Provider가 다시 계산된다.

개인적으로 Riverpod에서 가장 마음에 들었던 부분 중 하나다. 별도의 DI 라이브러리를 사용하지 않아도 Provider의 의존 관계를 선언적으로 구성할 수 있다.

ref.watch, ref.read, ref.listen

Riverpod을 사용하면서 가장 많이 보게 될 세 가지 API다.

ref.watch

Provider의 상태를 구독한다.

final user = ref.watch(userProvider);

 

Provider의 값이 변경되면 이를 사용하는 Widget이나 Provider가 다시 실행된다.

UI에 Provider의 상태를 표시할 때는 대부분 watch를 사용한다.

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

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final count = ref.watch(counterProvider);

    return Text('$count');
  }
}

 

ref.read

Provider의 현재 값을 한 번 읽는다.

ref.read(counterProvider.notifier).increment();

 

주로 버튼 클릭과 같은 이벤트에서 사용한다.

ElevatedButton(
  onPressed: () {
    ref.read(counterProvider.notifier).increment();
  },
  child: const Text('증가'),
)

 

반대로 UI에 보여줄 값을 다음처럼 read하는 것은 피하는 것이 좋다.

final count = ref.read(counterProvider);

 

read는 Provider를 구독하지 않기 때문에 count가 변경되어도 Widget이 다시 빌드되지 않는다.

ref.listen

상태 변경에 따라 Side Effect를 실행할 때 사용한다.

예를 들어 로그인에 실패했을 때 SnackBar를 표시한다고 해보자.

ref.listen(loginProvider, (previous, next) {
  if (next case AsyncError(:final error)) {
    ScaffoldMessenger.of(context).showSnackBar(
      SnackBar(
        content: Text('$error'),
      ),
    );
  }
});

 

대표적으로 다음과 같은 경우에 사용할 수 있다.

  • SnackBar
  • Dialog
  • 화면 이동
  • 로그 기록
  • Analytics 이벤트

정리하면 이렇게 기억하면 쉽다.

watch  → 상태를 보여줄 때
read   → 이벤트를 실행할 때
listen → 상태 변화로 Side Effect를 발생시킬 때

 

Provider는 어떤 것을 사용해야 할까?

Riverpod 3.x에서는 크게 다음과 같이 생각하면 된다.

 

동기 값을 읽기만 함 Provider
비동기 값을 읽기만 함 FutureProvider
Stream을 구독함 StreamProvider
동기 상태를 직접 변경 NotifierProvider
비동기 상태를 조회하고 변경 AsyncNotifierProvider
변경 가능한 Stream 상태 StreamNotifierProvider

 

여기서 두가지만 생각해보자.

 

1. 어떤 값을 반환하는가?

T
Future<T>
Stream<T>

 

 

2. 외부에서 상태를 변경해야 하는가?

 

단순 조회라면 함수형 Provider를 사용하고,

@riverpod
Future<User> user(Ref ref, String userId) async {
  return ref.watch(userRepositoryProvider).getUser(userId);
}

 

UI 이벤트를 통해 상태를 변경해야 한다면 Notifier를 사용한다.

@riverpod
class Counter extends _$Counter {
  @override
  int build() => 0;

  void increment() {
    state++;
  }

  void decrement() {
    state--;
  }
}

 

UI에서는 이렇게 사용한다.

final count = ref.watch(counterProvider);

ref.read(counterProvider.notifier).increment();

 

BLoC을 사용해본 사람이라면 어느 정도 익숙한 형태다.

ref.watch(counterProvider)
       ↓
      State

ref.read(counterProvider.notifier)
       ↓
   상태 변경 로직

 

State를 읽는 부분과 상태를 변경하는 로직을 분리할 수 있다.

비동기 상태에서는 AsyncNotifier

실제 앱에서는 단순한 Counter보다 서버 데이터를 다루는 경우가 훨씬 많다.

예를 들어 Todo 목록이 있다고 해보자.

단순 조회만 한다면 FutureProvider로 충분하다.

@riverpod
Future<List<Todo>> todos(Ref ref) async {
  return ref.watch(todoRepositoryProvider).getTodos();
}

 

하지만 다음과 같은 작업까지 필요하다면 이야기가 달라진다.

GET    Todo 조회
POST   Todo 추가
PUT    Todo 수정
DELETE Todo 삭제

 

이럴 때 AsyncNotifier가 유용하다.

@riverpod
class Todos extends _$Todos {
  @override
  Future<List<Todo>> build() async {
    final repository = ref.watch(todoRepositoryProvider);

    return repository.getTodos();
  }

  Future<void> addTodo(String title) async {
    final previous = state.valueOrNull ?? [];

    state = const AsyncLoading();

    state = await AsyncValue.guard(() async {
      final repository = ref.read(todoRepositoryProvider);
      final created = await repository.createTodo(title);

      return [
        ...previous,
        created,
      ];
    });
  }
}

 

Riverpod에서는 비동기 상태를 AsyncValue<T>로 표현한다.

AsyncLoading<T>
AsyncData<T>
AsyncError<T>

 

그래서 UI에서는 다음처럼 처리할 수 있다.

final todos = ref.watch(todosProvider);

return switch (todos) {
  AsyncData(:final value) => TodoList(todos: value),
  AsyncError(:final error) => Text('오류: $error'),
  _ => const CircularProgressIndicator(),
};

 

별도의

bool isLoading;
Object? error;
List<Todo>? data;

 

같은 필드를 직접 만들어 관리할 필요가 줄어든다.

Family

같은 Provider인데 인자에 따라 서로 다른 상태를 관리하고 싶은 경우가 있다.

대표적인 것이 상세 페이지다.

/users/1
/users/2
/users/3

 

이때 ID마다 Provider를 따로 만들 필요는 없다.

@riverpod
Future<User> user(
  Ref ref,
  String userId,
) async {
  return ref.watch(userRepositoryProvider).getUser(userId);
}

 

사용할 때 인자를 넘긴다.

final user = ref.watch(
  userProvider('user-123'),
);

 

그러면

userProvider('user-123')
userProvider('user-456')

 

은 서로 다른 상태와 캐시를 가진다. 개념적으로 보면 다음과 비슷하다.

Map<Parameter, State>

 

검색이나 Pagination에서도 유용하게 사용할 수 있다.

searchProvider('flutter')
searchProvider('riverpod')

pageProvider(1)
pageProvider(2)

 

코드 생성 방식을 사용하면 별도로 .family 문법을 작성하지 않고 함수의 매개변수만 추가하면 되기 때문에 상당히 편하다.

Auto Dispose와 KeepAlive

Riverpod은 사용하지 않는 Provider의 상태를 자동으로 제거할 수도 있다.

특히 코드 생성을 사용하는 Riverpod에서는 자동 폐기가 기본값이다.

@riverpod
Future<User> user(Ref ref, String id) async {
  return repository.getUser(id);
}

 

더 이상 해당 Provider를 구독하는 곳이 없다면 상태가 제거될 수 있다.

반대로 앱 전체에서 계속 유지해야 하는 상태라면 keepAlive를 사용할 수 있다.

@Riverpod(keepAlive: true)
Future<AppConfig> appConfig(Ref ref) async {
  return repository.loadConfig();
}

 

보통 다음과 같이 판단할 수 있다.

Auto Dispose가 어울리는 상태

  • 상세 페이지
  • 검색 결과
  • 화면 전용 API 데이터
  • 특정 ID에 종속되는 상태
  • 화면을 벗어나면 필요 없는 데이터

KeepAlive를 고려할 수 있는 상태

  • 로그인 세션
  • 앱 설정
  • 앱 전체에서 지속적으로 사용하는 데이터
  • 다시 가져오는 비용이 큰 데이터

단순히 API를 다시 호출하기 싫으니까 전부 keepAlive 를 쓰는 것은 좋지 못하다.

상태가 계속 메모리에 유지될 수 있기 때문이다.

invalidate와 refresh

Riverpod을 사용하다 보면 캐시된 데이터를 다시 불러와야 하는 상황도 자주 발생한다.

이때 invalidate와 refresh를 사용한다.

invalidate

현재 Provider 상태를 무효화한다.

ref.invalidate(
  userProvider(userId),
);

다음에 Provider가 필요해지면 다시 계산된다.

refresh

상태를 무효화하고 즉시 다시 읽는다.

final value = ref.refresh(
  userProvider(userId),
);

사실 다음 코드를 한 번에 수행한다고 볼 수 있다.

ref.invalidate(userProvider(userId));

final value = ref.read(
  userProvider(userId),
);

즉,

반환값 필요 없음 → invalidate
새 값을 즉시 받고 싶음 → refresh

정도로 구분하면 된다.


테스트

Riverpod의 큰 장점 중 하나가 테스트다.

Provider의 상태가 Flutter Widget 자체에 묶여 있는 것이 아니기 때문에 Widget 없이 Provider만 테스트할 수도 있다.

Riverpod 3.x에서는 ProviderContainer.test()를 제공한다.

void main() {
  test('카운터를 증가시킨다', () {
    final container = ProviderContainer.test();

    expect(
      container.read(counterProvider),
      0,
    );

    container
        .read(counterProvider.notifier)
        .increment();

    expect(
      container.read(counterProvider),
      1,
    );
  });
}

Repository 같은 의존성도 Override할 수 있다.

final container = ProviderContainer.test(
  overrides: [
    userRepositoryProvider.overrideWith(
      (ref) => FakeUserRepository(),
    ),
  ],
);

Production에서는

UserRepositoryImpl

을 사용하고 테스트에서는

FakeUserRepository

를 넣는 방식이다.

Flutter Widget Test에서도 동일하다.

await tester.pumpWidget(
  ProviderScope(
    overrides: [
      userRepositoryProvider.overrideWith(
        (ref) => FakeUserRepository(),
      ),
    ],
    child: const UserPage(),
  ),
);

 

의존성 주입과 상태 관리를 같은 시스템에서 처리할 수 있기 때문에 테스트 구조를 만들기 편하다.

코드 제네레이션

Riverpod에서는 Provider를 직접 선언할 수도 있지만 코드 생성도 지원한다.

필요한 패키지는 다음과 같다.

flutter pub add flutter_riverpod
flutter pub add riverpod_annotation

flutter pub add dev:riverpod_generator
flutter pub add dev:build_runner

그리고 Provider를 작성한다.

import 'package:riverpod_annotation/riverpod_annotation.dart';

part 'user_provider.g.dart';

@riverpod
Future<User> user(
  Ref ref,
  String userId,
) async {
  return repository.getUser(userId);
}

이후 build_runner를 실행한다.

dart run build_runner watch

그러면

userProvider

등 필요한 Provider 코드가 자동으로 생성된다.

코드 생성을 사용했을 때 대표적인 장점은 다음과 같다.

  • Provider 타입을 직접 선언할 필요가 줄어든다.
  • Family를 자연스러운 함수 매개변수로 표현할 수 있다.
  • Named / Optional Parameter를 사용할 수 있다.
  • Stateful Hot Reload를 지원한다.
  • 디버깅용 메타데이터가 생성된다.
  • Auto Dispose와 Family 관련 Boilerplate가 줄어든다.

다만 코드 생성이 Riverpod 사용에 필수인 것은 아니다.

build_runner를 사용해야 하고 생성 파일과 빌드 시간이 추가된다는 단점도 있기 때문에 프로젝트 상황에 따라 선택하면 된다.

이미 프로젝트에서 Freezed나 json_serializable을 사용하고 있다면 함께 사용하는 것도 괜찮은 선택이다.

주의할 점

Riverpod이라고 모든 상태를 Provider로 관리해야 하는 것은 아니다.

공식 문서에서도 로컬 UI 상태까지 무조건 Provider로 관리하는 것을 권장하지 않는다.

대표적으로 다음과 같은 상태다.

TextEditingController
AnimationController
PageController
화면 내부 Form 입력값
Widget 펼침/접힘
일시적인 Tab 선택 상태

이러한 상태는 StatefulWidget이나 Flutter Hooks 등을 사용하는 것이 자연스럽다.

반대로 Riverpod에 어울리는 것은 다음과 같다.

로그인 사용자
서버 데이터
장바구니
앱 설정
여러 화면에서 공유하는 필터
비즈니스 로직의 결과

결국

Riverpod은 모든 상태가 아니라 공유되는 UI 상태와 비즈니스 상태를 관리하는 도구로 사용하는 것이 좋다.

 

Provider에서 POST 요청하면 안 되나?

이 부분도 꽤 중요하다.

다음과 같은 Provider를 만든다고 해보자.

@riverpod
Future<void> submit(Ref ref) async {
  await api.post('/submit');
}

이 방식은 피하는 것이 좋다.

Provider는 캐싱되거나 다시 계산될 수 있다.

즉,

"Provider가 실행되었다."

라는 것과

"사용자가 Submit 버튼을 눌렀다."

 

라는 것은 전혀 다른 의미다.

 

POST, PUT, DELETE처럼 명시적인 사용자 행동에 의해 실행되어야 하는 작업은 Notifier의 메서드로 만드는 것이 좋다.

@riverpod
class SubmitForm extends _$SubmitForm {
  @override
  FutureOr<void> build() {}

  Future<void> submit(FormData form) async {
    state = const AsyncLoading();

    state = await AsyncValue.guard(() async {
      await ref
          .read(formRepositoryProvider)
          .submit(form);
    });
  }
}

그리고 UI 이벤트에서 명시적으로 호출한다.

onPressed: () {
  ref
      .read(submitFormProvider.notifier)
      .submit(form);
}

이렇게 해야 상태의 계산과 사용자의 Command가 구분된다.


Riverpod 3.x에서 알아둘 부분

Riverpod 3.x에는 몇 가지 중요한 변경사항이 있다.

대표적으로

  • Provider 실패 시 자동 재시도
  • Ref.mounted
  • Provider Listener 일시정지/재개
  • 새로운 테스트 API
  • Provider API 통합
  • Generic Code Generation
  • Mutation
  • Offline Persistence

등이 있다.

그중 하나가 Provider 자동 재시도다.

Riverpod 3.x에서는 Provider 계산 중 예외가 발생하면 기본적으로 자동 재시도를 수행한다.

기본적으로 최대 10회까지 지수 백오프 방식으로 재시도한다.

 

따라서

500 Internal Server Error
Network Error

같은 문제에는 유용할 수 있지만,

401 Unauthorized
400 Bad Request

 

처럼 다시 요청한다고 해결되지 않는 문제까지 무작정 재시도하는 것은 적절하지 않을 수 있다.

프로젝트에서 Riverpod 3.x를 도입한다면 재시도 정책도 한 번쯤 확인할 필요가 있다.

 

또한 Mutation, Offline Persistence는 실험적 기능이기 때문에 Production에 적용할 때는 API 안정성을 확인하는 것이 좋다.

 

마무리

1. Provider는 상태를 어떻게 만들지 선언한다.

@riverpod
Future<User> user(Ref ref) async {}

2. 다른 Provider가 필요하면 Ref로 연결한다.

ref.watch(userRepositoryProvider);

3. UI는 watch로 상태를 구독한다.

ref.watch(userProvider);

4. 상태 변경은 Notifier를 통해 명시적으로 실행한다.

ref.read(
  userProvider.notifier,
).update();

5. 비동기 상태는 AsyncValue가 관리한다.

Loading
Data
Error