개발

Flutter Riverpod 이해하기 2편 - Provider

불변 값 관리의 기본

2025년 9월 20일
12분 읽기
Flutter Riverpod 이해하기 2편 - Provider

Provider란?

Provider는 Riverpod에서 한 번 생성되면 절대 변하지 않는 값을 관리하는 도구입니다. StateProvider와 달리 Provider는 앱이 시작될 때 값이 결정되면 그 이후로는 바뀌지 않아요.

쉽게 말해서 "상수의 고급 버전"이라고 생각하면 됩니다. 일반 상수와 달리 Provider는 다른 Provider의 값을 참조할 수 있고, 필요할 때만 생성되는 지연 초기화(lazy initialization) 기능도 있어요.

Provider는 주로 앱의 설정값, 서비스 객체, 또는 다른 Provider들을 조합해서 만든 계산된 값을 저장할 때 사용합니다.

언제 Provider를 사용하는가?

Provider는 변하지 않는 값을 관리할 때 사용해야 합니다.

사용하기 좋은 경우:
• 앱 설정값 (API URL, 앱 버전, 테마 색상)
• 서비스 객체 (HTTP 클라이언트, 데이터베이스, 저장소)
• 계산된 값 (다른 Provider들을 조합한 결과)
• 환경별 설정 (개발/운영 환경 구분)

사용하면 안 되는 경우:
• 사용자 입력으로 바뀌는 값 (카운터, 토글, 폼 입력)
• 시간에 따라 변하는 값 (현재 시간, 실시간 데이터)
• 사용자 상호작용으로 변경되는 상태

만약 값이 앱 실행 중에 바뀔 수 있다면 Provider 대신 StateProvider나 다른 Provider를 사용해야 합니다.

기본 사용법

1. Provider 만들기

Dart
// 앱 설정
final appConfigProvider = Provider<AppConfig>((ref) {
  return AppConfig(
    apiUrl: 'https://api.example.com',
    appVersion: '1.0.0',
    timeout: Duration(seconds: 30),
  );
});

// HTTP 클라이언트
final httpClientProvider = Provider<HttpClient>((ref) {
  return HttpClient()..timeout = Duration(seconds: 30);
});

// 간단한 문자열 값
final appNameProvider = Provider<String>((ref) => 'My Flutter App');
Provider는 함수를 받아서 그 함수가 반환하는 값을 저장합니다. 이 함수는 앱에서 해당 Provider를 처음 사용할 때 딱 한 번만 실행되고, 그 이후로는 같은 값을 계속 반환해요.

2. Provider 읽기

Dart
class ApiService extends ConsumerWidget {
  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final config = ref.watch(appConfigProvider);
    final httpClient = ref.read(httpClientProvider);
    
    return Text('API URL: ${config.apiUrl}');
  }
}
Provider의 값을 읽을 때는 ref.watch() 또는 ref.read()를 사용합니다. Provider는 값이 변하지 않으므로 둘 중 어느 것을 써도 결과는 같아요.

3. 다른 Provider 참조하기

Dart
// 기본 설정
final apiUrlProvider = Provider<String>((ref) => 'https://api.example.com');
final timeoutProvider = Provider<Duration>((ref) => Duration(seconds: 30));

// 다른 Provider들을 조합한 HTTP 클라이언트
final httpClientProvider = Provider<HttpClient>((ref) {
  final url = ref.watch(apiUrlProvider);
  final timeout = ref.watch(timeoutProvider);
  
  return HttpClient()
    ..baseUrl = url
    ..timeout = timeout;
});
Provider 안에서 ref.watch()를 사용해서 다른 Provider의 값을 참조할 수 있어요. 이렇게 하면 Provider들을 조합해서 더 복잡한 객체를 만들 수 있습니다.

실무 예제

API 클라이언트 설정

Dart
// 환경별 API URL
final apiUrlProvider = Provider<String>((ref) {
  return kDebugMode 
    ? 'https://dev-api.example.com'  // 개발 환경
    : 'https://api.example.com';     // 운영 환경
});

// HTTP 클라이언트
final httpClientProvider = Provider<Dio>((ref) {
  final apiUrl = ref.watch(apiUrlProvider);
  
  final dio = Dio(BaseOptions(
    baseUrl: apiUrl,
    connectTimeout: Duration(seconds: 30),
    receiveTimeout: Duration(seconds: 30),
  ));
  
  // 로깅 인터셉터 추가 (개발 환경에서만)
  if (kDebugMode) {
    dio.interceptors.add(LogInterceptor());
  }
  
  return dio;
});

// API 서비스
final userApiProvider = Provider<UserApi>((ref) {
  final httpClient = ref.watch(httpClientProvider);
  return UserApi(httpClient);
});
이렇게 하면 환경에 따라 다른 API URL을 사용하면서도, HTTP 클라이언트 설정을 중앙에서 관리할 수 있어요.

테마 설정

Dart
// 기본 색상 팔레트
final colorPaletteProvider = Provider<ColorPalette>((ref) {
  return ColorPalette(
    primary: Colors.blue,
    secondary: Colors.green,
    background: Colors.white,
    surface: Colors.grey[100]!,
  );
});

// 라이트 테마
final lightThemeProvider = Provider<ThemeData>((ref) {
  final colors = ref.watch(colorPaletteProvider);
  
  return ThemeData(
    brightness: Brightness.light,
    primarySwatch: colors.primary,
    backgroundColor: colors.background,
    cardColor: colors.surface,
  );
});

// 다크 테마
final darkThemeProvider = Provider<ThemeData>((ref) {
  final colors = ref.watch(colorPaletteProvider);
  
  return ThemeData(
    brightness: Brightness.dark,
    primarySwatch: colors.primary,
    backgroundColor: Colors.black,
    cardColor: Colors.grey[800]!,
  );
});

// 앱에서 사용
class MyApp extends ConsumerWidget {
  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final lightTheme = ref.watch(lightThemeProvider);
    final darkTheme = ref.watch(darkThemeProvider);
    
    return MaterialApp(
      theme: lightTheme,
      darkTheme: darkTheme,
      home: HomeScreen(),
    );
  }
}
색상 팔레트를 한 곳에서 정의하고, 그것을 기반으로 라이트/다크 테마를 만드는 방식이에요. 색상을 바꾸고 싶으면 colorPaletteProvider만 수정하면 됩니다.

계산된 값 만들기

Dart
// 사용자 권한
final userRoleProvider = StateProvider<UserRole>((ref) => UserRole.guest);

// 사용자가 관리자인지 확인 (계산된 값)
final isAdminProvider = Provider<bool>((ref) {
  final role = ref.watch(userRoleProvider);
  return role == UserRole.admin;
});

// 사용자가 접근할 수 있는 메뉴 목록 (계산된 값)
final availableMenusProvider = Provider<List<MenuItem>>((ref) {
  final isAdmin = ref.watch(isAdminProvider);
  final role = ref.watch(userRoleProvider);
  
  final menus = <MenuItem>[
    MenuItem('홈', Icons.home),
    MenuItem('프로필', Icons.person),
  ];
  
  if (role != UserRole.guest) {
    menus.add(MenuItem('대시보드', Icons.dashboard));
  }
  
  if (isAdmin) {
    menus.add(MenuItem('관리자 설정', Icons.settings));
    menus.add(MenuItem('사용자 관리', Icons.people));
  }
  
  return menus;
});
사용자 권한에 따라 보여줄 메뉴가 자동으로 계산되는 방식이에요. userRoleProvider가 바뀌면 관련된 모든 계산된 값들이 자동으로 업데이트됩니다.

Provider의 특별한 기능들

지연 초기화 (Lazy Initialization)

Dart
final expensiveServiceProvider = Provider<ExpensiveService>((ref) {
  print('ExpensiveService 생성됨!'); // 실제 사용될 때만 출력됨
  return ExpensiveService();
});
Provider는 실제로 사용될 때까지 생성되지 않아요. 위 예제에서 expensiveServiceProvider를 선언만 하고 아무도 사용하지 않으면 ExpensiveService는 절대 생성되지 않습니다.

자동 의존성 관리

Dart
final databaseProvider = Provider<Database>((ref) => Database());

final userRepositoryProvider = Provider<UserRepository>((ref) {
  final database = ref.watch(databaseProvider);
  return UserRepository(database);
});

final userServiceProvider = Provider<UserService>((ref) {
  final repository = ref.watch(userRepositoryProvider);
  return UserService(repository);
});
Provider들 사이의 의존성이 자동으로 관리돼요. userServiceProvider를 사용하면 Riverpod가 알아서 databaseProvider와 userRepositoryProvider를 먼저 생성해줍니다.

주의사항

상태가 바뀌는 값은 사용하지 마세요

Dart
// ❌ 이렇게 하지 마세요
final currentTimeProvider = Provider<DateTime>((ref) => DateTime.now());

// 위젯에서 사용하면 시간이 업데이트되지 않음
class TimeDisplay extends ConsumerWidget {
  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final time = ref.watch(currentTimeProvider);
    return Text('${time.hour}:${time.minute}'); // 계속 같은 시간만 표시됨
  }
}
Provider는 한 번 생성되면 값이 바뀌지 않기 때문에, 위 예제에서 시간은 앱이 시작될 때의 시간에서 멈춰있을 거예요. 시간처럼 계속 바뀌는 값은 StreamProvider를 사용해야 합니다.

무거운 작업은 Provider에서 하지 마세요

Dart
// ❌ 이렇게 하지 마세요
final heavyComputationProvider = Provider<List<int>>((ref) {
  // 무거운 계산 작업
  final result = <int>[];
  for (int i = 0; i < 1000000; i++) {
    result.add(i * i);
  }
  return result;
});

// ✅ 이렇게 하세요
final heavyComputationProvider = FutureProvider<List<int>>((ref) async {
  // 비동기로 무거운 작업 처리
  return await compute(heavyComputation, null);
});
Provider는 동기적으로 실행되기 때문에 무거운 작업을 하면 앱이 멈출 수 있어요. 시간이 오래 걸리는 작업은 FutureProvider를 사용하세요.

다른 Provider와 비교

vs StateProvider
• Provider: 절대 안 바뀌는 값 (설정, 서비스 객체)
• StateProvider: 앱 실행 중에 바뀔 수 있는 값 (카운터, 토글)

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

vs StreamProvider
• Provider: 한 번 정해지면 안 바뀌는 값
• StreamProvider: 시간에 따라 계속 바뀌는 값들의 흐름

정리

Provider는 Riverpod에서 불변 값을 관리하는 기본 도구입니다. 앱의 설정이나 서비스 객체처럼 한 번 정해지면 바뀌지 않는 값들을 관리할 때 사용하세요.

핵심 포인트:
• 한 번 생성되면 절대 안 바뀌는 값만 사용
• 다른 Provider들을 조합해서 복잡한 객체 생성 가능
• 지연 초기화로 필요할 때만 생성됨
• 의존성이 자동으로 관리됨

실무에서 자주 사용하는 패턴:
• API 클라이언트 설정
• 테마나 스타일 정의
• 환경별 설정 관리
• 계산된 값 캐싱

Provider만 잘 활용해도 앱의 설정과 서비스 객체들을 깔끔하게 관리할 수 있고, 다른 Provider들과 조합해서 복잡한 의존성 구조도 쉽게 만들 수 있습니다.

다음 편 예고: 다음 글에서는 비동기 작업의 결과를 관리하는 FutureProvider에 대해 알아보겠습니다.
#Flutter
#Riverpod
#Provider
#State Management