개발

Flutter Riverpod 이해하기 3편 - FutureProvider

비동기 데이터 로딩의 모든 것

2025년 9월 20일
14분 읽기
Flutter Riverpod 이해하기 3편 - FutureProvider

FutureProvider란?

FutureProvider는 Riverpod에서 비동기 작업의 결과를 관리하는 도구입니다. API 호출, 파일 읽기, 데이터베이스 조회처럼 시간이 걸리는 작업의 결과를 쉽게 관리할 수 있게 해줘요.

쉽게 말해서 "Future를 위젯에서 편리하게 사용할 수 있게 해주는 도구"입니다. 일반 Future와 달리 FutureProvider는 로딩 상태, 에러 상태, 완료 상태를 자동으로 관리해주고, 결과가 나오면 관련된 위젯들을 자동으로 다시 그려줍니다.

FutureProvider는 한 번 실행되면 결과를 캐시해서 같은 데이터를 여러 번 요청하지 않아요. 하지만 필요하다면 강제로 새로고침할 수도 있습니다.

언제 FutureProvider를 사용하는가?

FutureProvider는 일회성 비동기 작업에 사용해야 합니다.

사용하기 좋은 경우:
• API에서 데이터 가져오기 (사용자 정보, 상품 목록)
• 파일에서 설정 읽어오기 (JSON 설정 파일, 이미지 파일)
• 데이터베이스 조회 (사용자 목록, 주문 내역)
• 시간이 걸리는 계산 작업 (복잡한 수학 계산, 이미지 처리)

사용하면 안 되는 경우:
• 실시간으로 계속 바뀌는 데이터 (채팅 메시지, 주식 가격)
• 사용자 입력에 따라 즉시 바뀌는 상태 (토글, 카운터)
• 이미 동기적으로 사용할 수 있는 값 (설정값, 상수)

만약 데이터가 실시간으로 계속 업데이트된다면 FutureProvider 대신 StreamProvider를 사용해야 합니다.

기본 사용법

1. FutureProvider 만들기

Dart
// 사용자 정보 가져오기
final userProvider = FutureProvider<User>((ref) async {
  final response = await http.get('/api/user');
  return User.fromJson(response.data);
});

// 설정 파일 읽기
final configProvider = FutureProvider<AppConfig>((ref) async {
  final configFile = await rootBundle.loadString('assets/config.json');
  return AppConfig.fromJson(jsonDecode(configFile));
});

// 복잡한 계산
final heavyComputationProvider = FutureProvider<List<int>>((ref) async {
  return await compute(heavyCalculation, 1000000);
});
FutureProvider는 async 함수를 받아서 그 함수가 반환하는 Future의 결과를 관리해요. 함수가 실행되는 동안은 로딩 상태가 되고, 완료되거나 에러가 발생하면 그에 맞는 상태로 바뀝니다.

2. FutureProvider 사용하기

Dart
class UserProfile extends ConsumerWidget {
  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final userAsync = ref.watch(userProvider);
    
    return userAsync.when(
      loading: () => CircularProgressIndicator(),
      error: (error, stack) => Text('에러: $error'),
      data: (user) => Column(
        children: [
          Text('이름: ${user.name}'),
          Text('이메일: ${user.email}'),
        ],
      ),
    );
  }
}
FutureProvider의 결과는 AsyncValue라는 타입으로 반환돼요. when() 메소드를 사용하면 로딩, 에러, 데이터 상태를 각각 다르게 처리할 수 있습니다.

3. 매개변수가 있는 FutureProvider

Dart
// 특정 사용자 정보 가져오기
final userByIdProvider = FutureProvider.family<User, String>((ref, userId) async {
  final response = await http.get('/api/user/$userId');
  return User.fromJson(response.data);
});

// 사용할 때
class UserDetail extends ConsumerWidget {
  final String userId;
  
  UserDetail({required this.userId});
  
  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final userAsync = ref.watch(userByIdProvider(userId));
    
    return userAsync.when(
      loading: () => CircularProgressIndicator(),
      error: (error, stack) => Text('사용자를 찾을 수 없습니다'),
      data: (user) => Text('사용자: ${user.name}'),
    );
  }
}
family 키워드를 사용하면 매개변수를 받는 FutureProvider를 만들 수 있어요. 같은 매개변수로 여러 번 호출해도 결과가 캐시되므로 네트워크 요청이 중복되지 않습니다.

실무 예제

API 데이터 가져오기

Dart
// 상품 목록 API
final productsProvider = FutureProvider<List<Product>>((ref) async {
  try {
    final response = await http.get('/api/products');
    
    if (response.statusCode == 200) {
      final List<dynamic> data = jsonDecode(response.body);
      return data.map((json) => Product.fromJson(json)).toList();
    } else {
      throw Exception('상품 목록을 가져올 수 없습니다');
    }
  } catch (e) {
    throw Exception('네트워크 에러: $e');
  }
});

// 상품 목록 화면
class ProductList extends ConsumerWidget {
  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final productsAsync = ref.watch(productsProvider);
    
    return Scaffold(
      appBar: AppBar(
        title: Text('상품 목록'),
        actions: [
          IconButton(
            icon: Icon(Icons.refresh),
            onPressed: () {
              // 새로고침
              ref.refresh(productsProvider);
            },
          ),
        ],
      ),
      body: productsAsync.when(
        loading: () => Center(child: CircularProgressIndicator()),
        error: (error, stack) => Center(
          child: Column(
            mainAxisAlignment: MainAxisAlignment.center,
            children: [
              Text('$error'),
              ElevatedButton(
                onPressed: () => ref.refresh(productsProvider),
                child: Text('다시 시도'),
              ),
            ],
          ),
        ),
        data: (products) => ListView.builder(
          itemCount: products.length,
          itemBuilder: (context, index) {
            final product = products[index];
            return ListTile(
              title: Text(product.name),
              subtitle: Text('₩${product.price}'),
              leading: Image.network(product.imageUrl),
            );
          },
        ),
      ),
    );
  }
}
API 호출이 실패했을 때 에러 메시지를 보여주고, 새로고침 버튼으로 다시 시도할 수 있게 만든 예제예요.

다른 Provider에 의존하는 FutureProvider

Dart
// 현재 사용자 ID
final currentUserIdProvider = StateProvider<String?>((ref) => null);

// 현재 사용자의 프로필 (사용자 ID가 있을 때만 로드)
final currentUserProfileProvider = FutureProvider<User?>((ref) async {
  final userId = ref.watch(currentUserIdProvider);
  
  if (userId == null) {
    return null; // 로그인하지 않은 상태
  }
  
  final response = await http.get('/api/user/$userId');
  return User.fromJson(response.data);
});

// 현재 사용자의 주문 내역 (프로필이 로드된 후에 실행)
final userOrdersProvider = FutureProvider<List<Order>>((ref) async {
  final user = await ref.watch(currentUserProfileProvider.future);
  
  if (user == null) {
    return []; // 로그인하지 않은 상태
  }
  
  final response = await http.get('/api/orders?userId=${user.id}');
  final List<dynamic> data = jsonDecode(response.body);
  return data.map((json) => Order.fromJson(json)).toList();
});
하나의 FutureProvider가 다른 FutureProvider의 결과에 의존하는 방식이에요. ref.watch().future를 사용하면 다른 FutureProvider의 결과를 기다릴 수 있습니다.

로컬 파일에서 데이터 읽기

Dart
// 앱 설정 파일 읽기
final appSettingsProvider = FutureProvider<AppSettings>((ref) async {
  try {
    final settingsFile = await rootBundle.loadString('assets/settings.json');
    final Map<String, dynamic> data = jsonDecode(settingsFile);
    return AppSettings.fromJson(data);
  } catch (e) {
    // 파일이 없으면 기본 설정 반환
    return AppSettings.defaultSettings();
  }
});

// 사용자 즐겨찾기 목록 (로컬 저장소에서 읽기)
final favoritesProvider = FutureProvider<List<String>>((ref) async {
  final prefs = await SharedPreferences.getInstance();
  return prefs.getStringList('favorites') ?? [];
});

// 큰 이미지 파일 로드
final largeImageProvider = FutureProvider.family<Image, String>((ref, imagePath) async {
  final bytes = await rootBundle.load(imagePath);
  return Image.memory(bytes.buffer.asUint8List());
});
로컬 파일이나 SharedPreferences에서 데이터를 읽어오는 작업도 시간이 걸리므로 FutureProvider를 사용하는 것이 좋아요.

FutureProvider의 특별한 기능들

캐싱과 새로고침

Dart
class RefreshButton extends ConsumerWidget {
  @override
  Widget build(BuildContext context, WidgetRef ref) {
    return Column(
      children: [
        ElevatedButton(
          onPressed: () {
            // 강제 새로고침 (캐시 무시하고 다시 실행)
            ref.refresh(productsProvider);
          },
          child: Text('새로고침'),
        ),
        ElevatedButton(
          onPressed: () {
            // 캐시 무효화 (다음에 사용할 때 다시 실행됨)
            ref.invalidate(productsProvider);
          },
          child: Text('캐시 무효화'),
        ),
      ],
    );
  }
}
FutureProvider는 결과를 자동으로 캐시해서 같은 요청을 여러 번 하지 않아요. 하지만 데이터가 바뀌었을 수도 있으니 강제로 새로고침할 수 있는 기능도 제공합니다.

에러 처리

Dart
final userDataProvider = FutureProvider<UserData>((ref) async {
  try {
    final response = await http.get('/api/userdata');
    
    if (response.statusCode == 401) {
      throw AuthException('로그인이 필요합니다');
    } else if (response.statusCode == 404) {
      throw NotFoundException('사용자를 찾을 수 없습니다');
    } else if (response.statusCode != 200) {
      throw ServerException('서버 에러: ${response.statusCode}');
    }
    
    return UserData.fromJson(response.data);
  } on SocketException {
    throw NetworkException('네트워크 연결을 확인해주세요');
  } catch (e) {
    throw UnknownException('알 수 없는 에러가 발생했습니다: $e');
  }
});
다양한 종류의 에러를 구분해서 처리하면 사용자에게 더 명확한 메시지를 보여줄 수 있어요.

주의사항

무한 루프 주의

Dart
// ❌ 이렇게 하지 마세요
final badProvider = FutureProvider<String>((ref) async {
  final value = ref.watch(anotherFutureProvider.future);
  // anotherFutureProvider가 badProvider를 참조하면 무한 루프!
  return 'result: $value';
});

// ✅ 이렇게 하세요
final goodProvider = FutureProvider<String>((ref) async {
  // 다른 Provider를 참조할 때는 순환 참조가 없는지 확인
  final config = ref.watch(configProvider.future);
  return await processWithConfig(config);
});
FutureProvider들이 서로를 참조할 때는 순환 참조가 발생하지 않도록 주의해야 해요.

너무 자주 새로고침하지 마세요

Dart
// ❌ 이렇게 하지 마세요
class BadWidget extends ConsumerWidget {
  @override
  Widget build(BuildContext context, WidgetRef ref) {
    return ElevatedButton(
      onPressed: () {
        // 버튼을 누를 때마다 새로고침 (과도함)
        ref.refresh(heavyComputationProvider);
      },
      child: Text('계산하기'),
    );
  }
}

// ✅ 이렇게 하세요
class GoodWidget extends ConsumerWidget {
  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final computationAsync = ref.watch(heavyComputationProvider);
    
    return Column(
      children: [
        computationAsync.when(
          loading: () => CircularProgressIndicator(),
          error: (error, stack) => Text('에러: $error'),
          data: (result) => Text('결과: ${result.length}'),
        ),
        ElevatedButton(
          onPressed: computationAsync.isLoading ? null : () {
            // 로딩 중이 아닐 때만 새로고침
            ref.refresh(heavyComputationProvider);
          },
          child: Text('다시 계산'),
        ),
      ],
    );
  }
}
무거운 작업을 너무 자주 새로고침하면 성능 문제가 생길 수 있어요.

다른 Provider와 비교

vs Provider
• Provider: 즉시 사용할 수 있는 값 (동기적)
• FutureProvider: 시간이 걸리는 비동기 작업의 결과

vs StateProvider
• StateProvider: 사용자가 직접 변경할 수 있는 상태
• FutureProvider: 비동기 작업의 결과 (한 번 완료되면 직접 변경 불가)

vs StreamProvider
• FutureProvider: 한 번 실행되고 끝나는 작업 (API 호출, 파일 읽기)
• StreamProvider: 계속해서 값이 들어오는 작업 (실시간 데이터, 타이머)

정리

FutureProvider는 Riverpod에서 비동기 작업의 결과를 관리하는 핵심 도구입니다. API 호출이나 파일 읽기처럼 시간이 걸리는 작업을 할 때 사용하세요.

핵심 포인트:
• 비동기 작업의 로딩/에러/완료 상태를 자동 관리
• 결과를 캐시해서 중복 요청 방지
when() 메소드로 각 상태에 맞는 UI 표시
refresh()로 강제 새로고침 가능

실무에서 자주 사용하는 패턴:
• API에서 데이터 가져오기
• 로컬 파일이나 설정 읽기
• 다른 FutureProvider 결과에 의존하는 체인
• 에러 상황에 맞는 적절한 처리

주의할 점:
• 실시간 데이터는 StreamProvider 사용
• 순환 참조 방지
• 적절한 에러 처리와 사용자 경험 고려

FutureProvider만 잘 활용해도 대부분의 비동기 데이터 로딩 작업을 깔끔하게 처리할 수 있습니다. 로딩 스피너, 에러 메시지, 새로고침 기능까지 모두 간단하게 구현할 수 있어요.

다음 편 예고: 다음 글에서는 실시간으로 계속 변하는 데이터를 관리하는 StreamProvider에 대해 알아보겠습니다.
#Flutter
#Riverpod
#FutureProvider
#Async
#State Management