DAY 33-1 | Riverpod, 알아야 쓸 수 있다

Pt J·2026년 9월 22일
post-thumbnail

DAY 33-1 | Riverpod, 알아야 쓸 수 있다

프로젝트에서 Riverpod을 본격적으로 다루기 전에 어떻게 쓰는 녀석인지 살펴보자. 완전히 익히고 시작하기보다는 일단 건드려 보는 게 효율적이긴 하지만 그래도 최소한의 흐름은 알아야 제대로 쓸 수 있겠지.

[Riverpod이 무엇인가]

Riverpod은 Flutter에서 가장 널리 쓰이는 상태 관리(State Management)이자 의존성 주입(DI) 도구 입니다. 기존의 Provider 패키지를 만들었던 개발자(Remi Rousselet)가 Provider의 한계(BuildContext에 묶여 발생하는 런타임 에러 ProviderNotFoundException 등)를 극복하기 위해 아예 새롭게 만든 2세대 라이브러리입니다. (이름도 Provider의 철자를 재배열한 아나그램입니다!)

핵심 철학: "위젯 트리에 갇히지 않는 컴파일 타임 안전성"

Riverpod은 Provider들을 전역 상수(Global Final) 로 선언합니다. 덕분에 컴파일 시점에 문법과 타입이 100% 검증되므로 런타임에 "찾을 수 없다"는 에러가 원천 차단됩니다.

Riverpod의 구성 요소

ProviderScope

  • 앱 전체의 모든 상태 데이터가 실제로 저장되는 단 하나의 전역 컨테이너
  • main.dart 의 runApp(ProviderScope(child: ...)) 처럼 앱 최상단에 딱 한 번 wrap

Provider

  • "어떤 객체(Repository, ViewModel)를 어떻게 만들고 관리할 것인가?"를 선언하는 설명서

단순 객체 제공용: Provider<T>

  • Repository, Database, HTTP Client 등 인스턴스 자체를 제공할 때 사용
  • 순수 Provider를 사용하는 것처럼 한 파일에 모아놓는 편
  • Flutter 공식 Compass 예제에서는 lib/config/dependencies.dart

비동기 상태 관리용: AsyncNotifierProvider

  • 서버 통신, DB 조회처럼 로딩/성공/에러가 발생하는 화면의 상태(ViewModel)를 만들 때 사용
  • UI 레이어의 각 기능별 디렉토리 내에서 AsyncNotifier<T> 를 extends 하는 Notifier class 생성
  • 따로 관리용 파일을 만들지 않고, 인터페이스가 정의된 파일 내부에 주입용 Provider를 선언하는 경향
이름담당 계층역할 비유주로 사용하는 Provider 종류
<Feature>ServiceProvider외부 통신 / I/O (Data Source)외주 일꾼 (외부 API 통신, DB, 기기 센서 직접 조작)Provider<Service> (단순 객체 주입)
<Feature>RepositoryProvider데이터 도메인 (Data Layer)창고 관리자 (여러 Service를 조합해 가공된 비즈니스 데이터 제공)Provider<Repository> (단순 객체 주입)
<Feature>ViewModelProvider화면 상태 (Presentation / ViewModel)매장 매니저 (UI가 바라볼 화면 상태를 관리하고 화면과 소통)AsyncNotifierProvider / NotifierProvider
  • API 주소가 바뀌거나 HTTP 라이브러리를 Dio에서 http로 바꾸면? ➔ Service 만 수정
  • 토큰 저장 방식이 SharedPreferences에서 SecureStorage로 바뀌면? ➔ Repository 만 수정
  • 화면 디자인이 바뀌거나 로딩 스피너 디자인이 바뀌면? ➔ ViewModel / UI 만 수정

Ref 또는 WidgetRef

  • Provider의 값에 접근하거나, 메서드를 실행하거나, 상태를 구독하기 위해 사용하는 매개체
  • 다른 Provider 안에서는 ref, 위젯 안에서는 WidgetRef ref 를 사용

widget에서 Riverpod 사용하기

  • StatelessWidget ➔ ConsumerWidget
  • StatefulWidget ➔ ConsumerStatefulWidget

ConsumerWidget 에서는 Widget build(BuildContext context, WidgetRef ref) 와 같이 build() 의 두 번째 parameter로 WidgetRef ref 가 제공된다.

ConsumerStatefulWidget 에서는 ConsumerState 의 기본 property로 들어 있어 연결된 State class에서 자유롭게 사용할 수 있다.

// ConsumerWidget (Stateless 대안)
class MyScreen extends ConsumerWidget {
  
  Widget build(BuildContext context, WidgetRef ref) { // 👈 파라미터로 받음
    final auth = ref.watch(authProvider);
    return ...;
  }
}
// ConsumerStatefulWidget (Stateful 대안)
class MyScreen extends ConsumerStatefulWidget {
  
  ConsumerState<MyScreen> createState() => _MyScreenState();
}

class _MyScreenState extends ConsumerState<MyScreen> {
  
  void initState() {
    super.initState();
    // 👈 build() 뿐만 아니라 initState(), dispose() 등에서도 ref를 바로 쓸 수 있습니다!
    ref.read(authProvider.notifier);
  }

  
  Widget build(BuildContext context) { // 👈 파라미터에는 context만 존재
    final auth = ref.watch(authProvider); // 👈 State의 멤버 변수처럼 ref 사용
    return ...;
  }
}

ref 의 method

method언제 쓰는가?동작 방식예시 위치
ref.watch(...)상태가 바뀌면 화면을 다시 그려야 할 때상태를 실시간 구독. 데이터가 바뀌면 build()가 자동 재실행됨build() 메서드 본문 내부
ref.read(...)상태를 구독하지 않고 함수(액션)만 딱 1번 실행할 때화면 재빌드 없이 인스턴스의 메서드만 호출버튼의 onPressed: () => ...
ref.listen(...)상태가 바뀌었을 때 스낵바, 다이얼로그, 화면 이동을 띄울 때화면을 다시 그리지 않고 부수 효과(Side-effect)만 실행build() 메서드 시작 부분

동기 Provider는 있는 그대로의 값을 return하지만, 비동기 Provider에서 ref.watch() 하면 AsyncValue<T> instance가 return된다. 이것은 로딩(Loading), 성공(Data), 실패(Error)를 하나로 담는 상태 상자로, 이를 통해 로딩/성공/에러 상태에 따라 적절한 작업을 작성한다.

AsyncValue<T> 에 when() method를 사용하여 상태별 분기를 구현할 수 있다.


Widget build(BuildContext context, WidgetRef ref) {
  final authState = ref.watch(authNotifierProvider);

  return authState.when(
    // 1. 로딩 중일 때 보여줄 위젯
    loading: () => const Center(child: CircularProgressIndicator()),

    // 2. 에러가 났을 때 보여줄 위젯
    error: (err, stack) => Center(child: Text('로그인 실패: $err')),

    // 3. 성공해서 데이터(User)가 있을 때 보여줄 위젯
    data: (user) {
      if (user == null) return const LoginForm();
      return HomeScreen(user: user);
    },
  );
}

[사용자가 로그인 버튼을 눌렀을 때의 흐름도 예시]

Notifier의 함수를 호출하기

  • 상태 값 자체를 읽는 것이 아니라 상태를 바꾸는 클래스(Notifier)의 메서드를 호출
ElevatedButton(
  onPressed: () {
    // ❌ ref.read(authNotifierProvider).login(...) -> 에러! (상태값엔 login 메서드가 없음)
    // ⭕ ref.read(authNotifierProvider.notifier).login(...);
  },
  child: const Text('로그인'),
)

한 화면이 여러 개의 상태를 가질 때

전통적인 방식에서는 View 하나당 State 하나가 연결된다. 여러 개의 State를 다루는 화면은 여러 개의 View로 쪼개야 한다. 하나의 View에 담고자 한다면 모든 State를 하나의 class에 때려넣고 ChangeNotifier 를 extends 하여 사용하기도 한다. 이 경우, 어디서나 해당 class의 property를 mutable하게 조작할 수 있고, 값이 바뀔 때마다 notifyListeners(); 를 호출해야 하며, 상태 관리가 어려워진다.

Riverpod은 immutable한 AsyncNotifier<T> 를 extends 하도록 하여 관리 편의성을 높인다. Riverpod의 AsyncNotifier<T> 는 오직 단 하나의 상태 T 만 관리하도록 강제되는 generic class다.

구분ChangeNotifier (Compass 앱 스타일)AsyncNotifier<T> (Riverpod 표준 정석)
상태 관리 철학가변(Mutable)불변(Immutable)
State class 유무❌ 필요 없음 (ViewModel 자체가 상태 바구니)⭕ 필수 (HomeState)
상태 갱신 방식내부 변수 직접 수정 후 notifyListeners()state = AsyncData(state.value.copyWith(...))
로딩 / 에러 처리bool isLoading, String? error 직접 선언AsyncValue 가 로딩/에러/성공을 자동 래핑
코드 안정성빠르고 직관적이나 규모가 커지면 상태 추적 난이도 상승상태가 어디서 어떻게 바뀌었는지 100% 예측 가능

물론 관리해야 할 상태가 하나뿐인 경우에는 State class를 따로 만들지 않고 그 상태 자체를 AsyncNotifier<T> 의 T 로 사용하면 된다.

@riverpod annotation

  • 현재 Riverpod 공식 문서에서는 AsyncNotifierProvider 등을 손으로 직접 타이핑하기보다, riverpod_generator 패키지를 통한 @riverpod annotation 방식을 가장 권장
import 'package:riverpod_annotation/riverpod_annotation.dart';

part 'auth_notifier.g.dart'; // 👈 파일명과 동일하게 .g.dart 선언

// 수동 작성 대신 이렇게 쓰면 Riverpod이 알아서 Provider를 생성해 줍니다.

class AuthNotifier extends _$AuthNotifier {
  
  FutureOr<User?> build() async {
    return null;
  }

  Future<void> login(String id, String pw) async { ... }
}

dart run build_runner build 로 빌드하면 class 이름에 따라 authNotifierProvider 와 같은 형태로 전역 상수가 자동으로 생성되어 widget에서 바로 쓸 수 있게 된다. 항상 이 방식이 정답은 아니긴 하다. 직접 다 작성하는 것과 이 방식을 사용하는 것은 다음과 같은 장단점이 있으니 프로젝트 성격에 따라 결정하면 된다.

비교 항목수동 작성 방식@riverpod 코드 생성 방식
작성 방식AsyncNotifierProvider<VM, State>(...) 직접 타이핑@riverpod annotation + _$class명 extend
개발 워크플로우코드 작성 후 즉시 저장 ➔ 핫 리로드 바로 반영저장 후 build_runner가 .g.dart 생성할 때까지 대기
파일 관리home_viewmodel.dart 단 1개 파일로 깔끔원본 파일 외에 *.g.dart 생성 파일이 파일트리에 계속 추가됨
코드 투명성모든 동작(타입, 인스턴스 생성 등)이 코드에 직접 보여 추적과 디버깅이 쉬움내부 로직이 생성된 파일 뒤로 숨겨져 있어 초기 진입 장벽 존재
의존성 & 도구flutter_riverpod 기본 패키지만 있으면 됨riverpod_annotation, riverpod_generator, build_runner 필요
Family (동적 인자)family 문법을 직접 작성해야 함 (약간의 번거로움)메서드 파라미터만 넘기면 알아서 Family 자동 생성
빌드 충돌 리스크없음 (순수 Dart 코드)가끔 빌드 캐시 꼬임 발생 시 --delete-conflicting-outputs 재실행 필요

Gemini 응답을 기반으로 학습하였음을 밝힙니다.

profile
Peter J Online Space - since July 2020 | 아무데서나 채용해줬으면 좋겠다 (지금은 학생 때 하던 거 아무거나 공부하고 있고요, 취업시켜 주시면 그 분야로 공부할게요)

0개의 댓글