Flutter 개발

Riverpod 심화편: FutureProvider, StreamProvider, NotifierProvider 완벽 가이드

withmilk 2025. 5. 25. 19:07

지난 글에서는 StateProvider를 이용해 버튼을 눌러 숫자를 증가시키는 간단한 예제를 만들었어요.
하지만 앱이 조금만 복잡해지면, StateProvider만으로는 부족한 경우가 생깁니다.

  • 데이터를 서버에서 불러와야 할 때
  • 실시간 데이터를 계속 받아야 할 때
  • 여러 상태를 하나로 묶어 관리하고 싶을 때

이럴 때 등장하는 게 바로 FutureProvider, StreamProvider, NotifierProvider예요!
이번 글에서는 이 세 가지를 왜 써야 하는지부터 어떻게 쓰는지까지 자세히 알려드릴게요 😊


🔮 1. FutureProvider – 비동기 데이터를 다룰 때

왜 필요한가요?

  • StateProvider는 정적인 값만 다룰 수 있어요.
  • 하지만 서버에서 데이터를 불러오거나, 파일을 읽는 등의 비동기 작업은 Future를 사용하죠.
  • 이럴 때 FutureProvider를 쓰면, 비동기 데이터의 상태(로딩, 완료, 에러)를 깔끔하게 처리할 수 있어요.

언제 쓰면 좋은가요?

  • API 호출 (예: 날씨 정보 불러오기)
  • 디바이스 정보 가져오기
  • 초기 설정 값 로딩

예시: 버튼을 누르면 2초 후에 새로운 숫자를 가져오는 카운터

final delayedCounterProvider = FutureProvider<int>((ref) async {
  await Future.delayed(Duration(seconds: 2));
  return 42; // API 응답이라고 가정
});
class DelayedCounter extends ConsumerWidget {
  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final asyncValue = ref.watch(delayedCounterProvider);

    return asyncValue.when(
      data: (value) => Text('값: $value'),
      loading: () => CircularProgressIndicator(),
      error: (err, stack) => Text('에러 발생: $err'),
    );
  }
}

💡 핵심 포인트

  • FutureProvider는 값이 아직 없는 상태(로딩)와 에러 처리까지 포함해서 관리해줘요.
  • ref.watch()의 결과는 AsyncValue 타입이라 .when()으로 다양한 상태를 분기할 수 있어요.
    • data가 있는 경우에는 Text Widget으로 데이터를 보여주거나,
    • loading 중인 경우에는 CircularProgressIndicator를 보여주거나,
    • 값을 불러오다가 error가 발생한 경우에는 error 메세지를 보여줄 수도 있어요!

 

전체 코드

import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';

final delayedCounterProvider = FutureProvider<int>((ref) async {
  await Future.delayed(Duration(seconds: 2));
  return 42; // API 응답이라고 가정
});

class DelayedCounter extends ConsumerWidget {
  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final asyncValue = ref.watch(delayedCounterProvider);

    return asyncValue.when(
      data: (value) => Text('값: $value'),
      loading: () => CircularProgressIndicator(),
      error: (err, stack) => Text('에러 발생: $err'),
    );
  }
}

void main() {
  runApp(
    // 2. 앱을 ProviderScope로 감싸야 riverpod이 작동함!
    ProviderScope(
      child: MyApp(),
    ),
  );
}

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      home: CounterScreen(),
    );
  }
}

// 3. 상태를 사용하려면 ConsumerWidget 또는 ConsumerStatefulWidget이 필요함!
class CounterScreen extends ConsumerWidget {
  @override
  Widget build(BuildContext context, WidgetRef ref) {
    return Scaffold(
      appBar: AppBar(title: Text('Riverpod 카운터')),
      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            DelayedCounter(),
          ],
        ),
      ),
    );
  }
}

앱을 시작하면 DelayedCounter의 로딩이 보이는 모습

 

2초 뒤 42라는 값이 보이는 모습


🔁 2. StreamProvider – 실시간 데이터를 다룰 때

왜 필요한가요?

  • 실시간 데이터는 Stream으로 흘러와요. 예를 들면:
    • 채팅 메시지
    • 위치 정보
    • Firebase 실시간 데이터
  • 이럴 때 StateProvider는 사용할 수 없고, StreamProvider로 받아와야 해요.

언제 쓰면 좋은가요?

  • 실시간 채팅, 알림
  • 주기적으로 바뀌는 센서 데이터
  • WebSocket, Firebase 연동

예시: 1초마다 숫자가 증가하는 스트림 카운터

final streamCounterProvider = StreamProvider<int>((ref) async* {
  int i = 0;
  while (true) {
    await Future.delayed(Duration(seconds: 1));
    yield i++;
  }
});
class StreamCounter extends ConsumerWidget {
  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final asyncValue = ref.watch(streamCounterProvider);

    return asyncValue.when(
      data: (value) => Text('스트림 값: $value'),
      loading: () => CircularProgressIndicator(),
      error: (err, stack) => Text('에러: $err'),
    );
  }
}

💡 핵심 포인트

  • StreamProvider는 시간에 따라 값이 계속 바뀌는 경우에 사용해요. 서버에서 계속 값이 전달되는 경우처럼요.
  • yield 키워드로 실시간 값을 하나씩 계속 흘려보낼 수 있어요.
  • 역시 AsyncValue로 상태 분기를 쉽게 할 수 있어요.

 

전체 코드

import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';

final streamCounterProvider = StreamProvider<int>((ref) async* {
  int i = 0;
  while (true) {
    await Future.delayed(Duration(seconds: 1));
    yield i++;
  }
});

class StreamCounter extends ConsumerWidget {
  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final asyncValue = ref.watch(streamCounterProvider);

    return asyncValue.when(
      data: (value) => Text('스트림 값: $value'),
      loading: () => CircularProgressIndicator(),
      error: (err, stack) => Text('에러: $err'),
    );
  }
}

void main() {
  runApp(
    // 2. 앱을 ProviderScope로 감싸야 riverpod이 작동함!
    ProviderScope(
      child: MyApp(),
    ),
  );
}

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      home: CounterScreen(),
    );
  }
}

// 3. 상태를 사용하려면 ConsumerWidget 또는 ConsumerStatefulWidget이 필요함!
class CounterScreen extends ConsumerWidget {
  @override
  Widget build(BuildContext context, WidgetRef ref) {
    return Scaffold(
      appBar: AppBar(title: Text('Riverpod 카운터')),
      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            StreamCounter(),
          ],
        ),
      ),
    );
  }
}

앱을 시작하면 스트림 값이 0으로 보이는 모습
1초마다 1씩 증가해서 10초뒤 값이 10이 된 모습


🧠 3. NotifierProvider – 복잡한 상태와 로직을 함께 다룰 때

왜 필요한가요?

  • StateProvider는 값만 저장하지, 로직(함수)은 따로 처리해야 해요.
  • 여러 개의 상태(예: 카운터 + 버튼 상태)를 함께 다루거나,
    값 변경 시 무언가 계산, 로직 처리까지 함께 하고 싶을 땐 NotifierProvider가 좋아요.

언제 쓰면 좋은가요?

  • 상태 변경에 따라 내부 로직이 있는 경우
  • 여러 상태를 묶어서 함께 관리하고 싶을 때
  • 로그인, 장바구니, 필터 등 구조가 복잡한 상태일 때

예시: 증가/감소 기능이 포함된 카운터 클래스

class CounterNotifier extends Notifier<int> {
  @override
  int build() => 0;

  void increment() => state++;
  void decrement() => state--;
}

final notifierCounterProvider = NotifierProvider<CounterNotifier, int>(() => CounterNotifier());
class NotifierCounter extends ConsumerWidget {
  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final count = ref.watch(notifierCounterProvider);
    final notifier = ref.read(notifierCounterProvider.notifier);

    return Column(
      mainAxisAlignment: MainAxisAlignment.center,
      children: [
        Text('값: $count', style: TextStyle(fontSize: 30)),
        Row(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            IconButton(onPressed: notifier.increment, icon: Icon(Icons.add)),
            IconButton(onPressed: notifier.decrement, icon: Icon(Icons.remove)),
          ],
        )
      ],
    );
  }
}

💡 핵심 포인트

  • 상태와 관련된 로직(increment, decrement)을 하나의 클래스에 담아 관리할 수 있어요.
    • 상태 관리를 할때 필요한 곳에서 state를 직접 변경하지 않고, 객체의 함수를 호출해서 변경할 수 있죠!
    • 외부에서 상태를 변경할 수 있는 조건과 환경을 딱 정의해놓고 재사용할 수 있기 때문에 상태를 원하는 대로 관리할 수 있어요.
  • NotifierProvider는 구조화된 상태 관리가 가능해서 코드 재사용성과 유지보수성이 훨씬 좋아요.

 

전체 코드

import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';

class CounterNotifier extends Notifier<int> {
  @override
  int build() => 0;

  void increment() => state++;
  void decrement() => state--;
}

final notifierCounterProvider =
    NotifierProvider<CounterNotifier, int>(() => CounterNotifier());

class NotifierCounter extends ConsumerWidget {
  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final count = ref.watch(notifierCounterProvider);
    final notifier = ref.read(notifierCounterProvider.notifier);

    return Column(
      mainAxisAlignment: MainAxisAlignment.center,
      children: [
        Text('값: $count', style: TextStyle(fontSize: 30)),
        Row(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            IconButton(onPressed: notifier.increment, icon: Icon(Icons.add)),
            IconButton(onPressed: notifier.decrement, icon: Icon(Icons.remove)),
          ],
        )
      ],
    );
  }
}

void main() {
  runApp(
    // 2. 앱을 ProviderScope로 감싸야 riverpod이 작동함!
    ProviderScope(
      child: MyApp(),
    ),
  );
}

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      home: CounterScreen(),
    );
  }
}

// 3. 상태를 사용하려면 ConsumerWidget 또는 ConsumerStatefulWidget이 필요함!
class CounterScreen extends ConsumerWidget {
  @override
  Widget build(BuildContext context, WidgetRef ref) {
    return Scaffold(
      appBar: AppBar(title: Text('Riverpod 카운터')),
      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            NotifierCounter(),
          ],
        ),
      ),
    );
  }
}

 

- 버튼을 눌러서 값을 -1로 바꾼 모습

 

+ 버튼을 눌러서 값을 1로 바꾼 모습


🎯 정리 요약

Provider 종류 용도 언제 사용하면 좋을까?
StateProvider 간단한 상태 (숫자, 문자열 등) 버튼 클릭 카운터 등
FutureProvider 비동기 값 (API 호출 등) 서버 데이터 불러오기
StreamProvider 실시간 데이터 (스트림) 채팅, 센서, 실시간 알림
NotifierProvider 상태 + 로직 같이 관리 로그인, 장바구니, 필터 등 복잡한 로직 처리

마무리하며...

이번 글에서는 riverpod의 고급 Provider들인 FutureProvider, StreamProvider, NotifierProvider에 대해 배워봤어요.
다음 글에서는 이들을 실제 앱 기능에 어떻게 적용할 수 있는지,
예를 들어 로그인 처리, API 연동, Firebase 스트림 활용 같은 예제 중심으로 소개해볼게요.