본 콘텐츠의 이미지 및 내용은 AI로 생성되었습니다.
본 콘텐츠의 이미지 및 내용을 무단으로 복제, 배포, 수정하여 사용할 경우 저작권법에 의해 법적 제재를 받을 수 있습니다.
이미지 로딩 중...
AI Generated
2025. 11. 30. · 13 Views
FutureProvider로 비동기 데이터 처리 완벽 가이드
Flutter Riverpod의 FutureProvider를 활용하여 API 호출, 에러 핸들링, 로딩 상태 관리까지 비동기 데이터 처리의 모든 것을 다룹니다. 초급 개발자도 쉽게 따라할 수 있는 실무 예제와 함께 설명합니다.
목차
1. FutureProvider란?
김개발 씨는 첫 Flutter 프로젝트를 맡게 되었습니다. 서버에서 사용자 정보를 가져와 화면에 표시해야 하는데, 어디서부터 시작해야 할지 막막했습니다.
선배 박시니어 씨가 슬쩍 다가와 말했습니다. "FutureProvider 써봤어요?"
FutureProvider는 비동기 작업의 결과를 관리하는 Riverpod의 특별한 Provider입니다. 마치 식당에서 주문을 넣고 음식이 나올 때까지 기다리는 것처럼, 데이터를 요청하고 결과가 올 때까지의 모든 상태를 자동으로 관리해줍니다.
로딩 중인지, 에러가 났는지, 데이터가 왔는지를 한눈에 알 수 있습니다.
다음 코드를 살펴봅시다.
// FutureProvider 기본 선언
final userProvider = FutureProvider<User>((ref) async {
// 비동기 작업 수행 (API 호출 등)
final response = await http.get(Uri.parse('/api/user'));
// JSON 파싱 후 User 객체 반환
final json = jsonDecode(response.body);
return User.fromJson(json);
});
// 위젯에서 사용하기
class UserProfile extends ConsumerWidget {
Widget build(BuildContext context, WidgetRef ref) {
// watch로 상태 구독
final userAsync = ref.watch(userProvider);
return userAsync.when(
data: (user) => Text(user.name),
loading: () => CircularProgressIndicator(),
error: (err, stack) => Text('오류 발생'),
);
}
}
김개발 씨는 입사 첫 주에 중요한 업무를 맡게 되었습니다. 서버에서 사용자 정보를 가져와 프로필 화면에 표시하는 기능이었습니다.
StatefulWidget에서 initState에 API 호출 코드를 넣고, setState로 화면을 갱신하는 방식으로 구현했습니다. 하지만 코드 리뷰에서 박시니어 씨가 고개를 저었습니다.
"이렇게 하면 위젯이 너무 많은 일을 하게 돼요. 비동기 로직과 UI가 뒤섞여서 나중에 유지보수하기 힘들어집니다." 그렇다면 FutureProvider란 정확히 무엇일까요?
쉽게 비유하자면, FutureProvider는 마치 배달 앱의 주문 추적 시스템과 같습니다. 음식을 주문하면 "주문 접수 중", "조리 중", "배달 중", "배달 완료" 또는 "주문 실패" 같은 상태가 자동으로 업데이트됩니다.
고객은 이 상태만 보면 됩니다. FutureProvider도 마찬가지로 비동기 작업의 "로딩 중", "성공", "실패" 상태를 자동으로 추적해줍니다.
FutureProvider가 없던 시절에는 어땠을까요? 개발자들은 isLoading, hasError, data 같은 변수를 직접 만들어 관리해야 했습니다.
API 호출 전에 isLoading을 true로 바꾸고, 성공하면 data에 값을 넣고 isLoading을 false로, 실패하면 hasError를 true로 바꾸는 식이었습니다. 이런 보일러플레이트 코드가 모든 비동기 작업마다 반복되었습니다.
더 큰 문제는 상태 동기화였습니다. 여러 화면에서 같은 데이터를 사용할 때, 한 곳에서 데이터가 변경되면 다른 곳에서도 반영되어야 합니다.
직접 구현하려면 복잡한 콜백이나 스트림 관리가 필요했습니다. 바로 이런 문제를 해결하기 위해 FutureProvider가 등장했습니다.
FutureProvider를 사용하면 비동기 로직을 위젯에서 완전히 분리할 수 있습니다. Provider 선언부에서 데이터를 어떻게 가져올지만 정의하면 됩니다.
위젯은 그저 데이터를 구독하고 화면에 표시하기만 합니다. 또한 AsyncValue라는 특별한 타입이 로딩, 성공, 에러 상태를 하나의 객체로 감싸줍니다.
위의 코드를 한 줄씩 살펴보겠습니다. 먼저 **FutureProvider<User>**로 Provider를 선언합니다.
제네릭 타입 User는 이 Provider가 최종적으로 제공할 데이터 타입입니다. 콜백 함수 안에서 async/await를 사용해 API를 호출하고, 결과를 파싱해서 반환합니다.
반환된 값은 자동으로 AsyncValue로 감싸집니다. 위젯에서는 **ref.watch(userProvider)**로 이 Provider를 구독합니다.
watch를 사용하면 Provider의 상태가 변경될 때마다 위젯이 자동으로 다시 빌드됩니다. when 메서드는 data, loading, error 세 가지 경우에 대해 각각 다른 위젯을 반환할 수 있게 해줍니다.
실제 현업에서는 어떻게 활용할까요? 예를 들어 쇼핑몰 앱에서 상품 목록을 표시한다고 가정해봅시다.
앱을 켜면 서버에서 상품 데이터를 가져와야 합니다. FutureProvider로 상품 목록 API를 호출하면, 로딩 중에는 스켈레톤 UI를, 성공하면 상품 그리드를, 실패하면 재시도 버튼을 자동으로 표시할 수 있습니다.
하지만 주의할 점도 있습니다. FutureProvider는 한 번만 실행됩니다.
즉, Provider가 처음 읽힐 때 Future가 실행되고, 그 결과가 캐싱됩니다. 데이터를 새로고침하려면 별도의 처리가 필요합니다.
이 부분은 뒤에서 자세히 다루겠습니다. 다시 김개발 씨의 이야기로 돌아가 봅시다.
박시니어 씨의 설명을 들은 김개발 씨는 눈이 번쩍 뜨였습니다. "그러니까 제가 직접 관리하던 로딩 상태, 에러 상태를 FutureProvider가 알아서 해준다는 거네요!" FutureProvider를 제대로 이해하면 비동기 코드가 훨씬 깔끔해집니다.
위젯은 UI에만 집중하고, 데이터 로직은 Provider에게 맡기는 것이 핵심입니다.
실전 팁
💡 - FutureProvider는 읽기 전용 비동기 데이터에 적합합니다. 수정이 필요하면 StateNotifierProvider나 AsyncNotifierProvider를 고려하세요.
- ConsumerWidget 대신 Consumer 위젯을 사용하면 특정 부분만 리빌드할 수 있어 성능이 향상됩니다.
2. API 호출 예제
김개발 씨는 FutureProvider의 기본 개념을 이해했습니다. 이제 실제로 REST API를 호출해서 데이터를 가져오는 코드를 작성해볼 차례입니다.
"백문이 불여일견이죠. 직접 해봅시다." 박시니어 씨가 키보드 앞에 앉으며 말했습니다.
실무에서 FutureProvider는 주로 REST API 호출에 사용됩니다. http 패키지나 dio 패키지로 서버와 통신하고, 응답 데이터를 모델 객체로 변환하는 패턴이 일반적입니다.
Repository 패턴을 함께 사용하면 테스트하기 쉬운 구조를 만들 수 있습니다.
다음 코드를 살펴봅시다.
// 데이터 모델 정의
class Post {
final int id;
final String title;
final String body;
Post({required this.id, required this.title, required this.body});
factory Post.fromJson(Map<String, dynamic> json) {
return Post(
id: json['id'],
title: json['title'],
body: json['body'],
);
}
}
// API 호출 Provider
final postsProvider = FutureProvider<List<Post>>((ref) async {
final response = await http.get(
Uri.parse('https://jsonplaceholder.typicode.com/posts'),
);
if (response.statusCode != 200) {
throw Exception('게시글을 불러오는데 실패했습니다');
}
final List<dynamic> jsonList = jsonDecode(response.body);
return jsonList.map((json) => Post.fromJson(json)).toList();
});
김개발 씨가 첫 번째 과제로 받은 것은 게시판 목록 화면이었습니다. 서버에서 게시글 목록을 가져와 화면에 표시해야 합니다.
"일단 API 명세서부터 확인해볼까요?" 박시니어 씨가 문서를 열었습니다. API는 GET 요청으로 JSON 배열을 반환합니다.
각 게시글에는 id, title, body가 있습니다. 이 데이터를 Flutter 앱에서 사용하려면 먼저 모델 클래스를 정의해야 합니다.
모델 클래스는 마치 택배 상자와 같습니다. 서버에서 오는 데이터는 JSON이라는 "포장지"에 싸여 있습니다.
이 포장을 뜯고 내용물을 정리해서 "Post"라는 상자에 담는 것이 fromJson 팩토리 메서드의 역할입니다. 한번 상자에 담으면 앱 어디서든 깔끔하게 사용할 수 있습니다.
factory Post.fromJson 메서드를 살펴보겠습니다. Map 타입의 JSON 데이터를 받아서 Post 객체를 생성합니다.
필드 이름이 서버 응답의 키와 정확히 일치해야 합니다. 만약 서버에서 "post_title"로 보내는데 코드에서 "title"로 접근하면 null이 됩니다.
이제 postsProvider를 보겠습니다. FutureProvider의 콜백 안에서 http.get으로 API를 호출합니다.
Uri.parse로 문자열 URL을 Uri 객체로 변환해야 합니다. 이것은 http 패키지의 요구사항입니다.
응답을 받으면 가장 먼저 상태 코드를 확인합니다. HTTP 200은 성공을 의미합니다.
200이 아니면 뭔가 잘못된 것이므로 Exception을 던집니다. 이 Exception은 자동으로 AsyncValue의 error 상태로 변환됩니다.
jsonDecode로 응답 본문을 파싱하면 List<dynamic>을 얻습니다. Dart의 JSON 파서는 타입을 정확히 알 수 없으므로 dynamic을 사용합니다.
이 리스트를 map 함수로 순회하면서 각 요소를 Post.fromJson으로 변환합니다. 마지막에 **toList()**를 호출하면 List<Post>가 완성됩니다.
실무에서는 이보다 더 복잡한 구조를 사용합니다. API 호출 로직을 Repository 클래스로 분리하고, Repository를 또 다른 Provider로 만들어 의존성 주입을 합니다.
이렇게 하면 테스트할 때 가짜 Repository를 주입할 수 있습니다. 예를 들어 PostRepository 클래스를 만들고 fetchPosts 메서드를 정의합니다.
그리고 postRepositoryProvider로 이 Repository를 제공합니다. postsProvider에서는 ref.watch(postRepositoryProvider)로 Repository를 가져와 사용합니다.
계층이 나뉘어 있으니 각 부분을 독립적으로 테스트할 수 있습니다. 김개발 씨는 코드를 따라 치면서 질문했습니다.
"그런데 http 패키지 말고 dio를 쓰는 팀도 있던데요?" 박시니어 씨가 고개를 끄덕였습니다. "dio는 인터셉터, 타임아웃, 자동 재시도 같은 고급 기능을 제공해요.
프로젝트 규모에 따라 선택하면 됩니다. 기본 원리는 같아요." 중요한 것은 관심사의 분리입니다.
Provider는 "어떤 데이터를 제공할 것인가"만 알면 됩니다. "어떻게 가져올 것인가"는 Repository가 알면 됩니다.
위젯은 "데이터를 어떻게 표시할 것인가"만 신경 쓰면 됩니다.
실전 팁
💡 - 실무에서는 http 패키지보다 dio 패키지를 많이 사용합니다. 인터셉터로 토큰 갱신, 로깅을 자동화할 수 있습니다.
- JSON 파싱 시 freezed나 json_serializable 패키지를 사용하면 보일러플레이트 코드를 줄일 수 있습니다.
3. 에러 핸들링
테스트 중에 서버가 다운되었습니다. 김개발 씨의 앱은 빈 화면만 보여주고 있었습니다.
"에러 처리를 안 했구나." 박시니어 씨가 웃으며 말했습니다. "사용자는 무슨 일이 일어났는지 알아야 해요."
네트워크 요청은 언제든 실패할 수 있습니다. AsyncValue의 error 상태를 활용하면 에러 발생 시 사용자에게 적절한 피드백을 제공할 수 있습니다.
단순히 에러 메시지를 보여주는 것을 넘어, 재시도 버튼이나 대체 콘텐츠를 제공하는 것이 좋은 UX입니다.
다음 코드를 살펴봅시다.
// 상세한 에러 처리가 포함된 Provider
final userProfileProvider = FutureProvider<UserProfile>((ref) async {
try {
final response = await http.get(Uri.parse('/api/profile'));
if (response.statusCode == 401) {
throw UnauthorizedException('로그인이 필요합니다');
}
if (response.statusCode == 404) {
throw NotFoundException('프로필을 찾을 수 없습니다');
}
if (response.statusCode != 200) {
throw ServerException('서버 오류가 발생했습니다');
}
return UserProfile.fromJson(jsonDecode(response.body));
} on SocketException {
throw NetworkException('인터넷 연결을 확인해주세요');
}
});
// 위젯에서 에러 상태 처리
Widget build(BuildContext context, WidgetRef ref) {
final profileAsync = ref.watch(userProfileProvider);
return profileAsync.when(
data: (profile) => ProfileCard(profile: profile),
loading: () => const ProfileSkeleton(),
error: (error, stackTrace) => ErrorView(
message: error.toString(),
onRetry: () => ref.invalidate(userProfileProvider),
),
);
}
"앱이 죽진 않는데 화면이 하얗게만 보여요." 김개발 씨가 당황한 표정으로 말했습니다. 서버 점검 시간이라 API가 응답하지 않는 상황이었습니다.
에러가 발생했지만 사용자에게 아무런 안내가 없었던 것입니다. 박시니어 씨가 차분하게 설명했습니다.
"에러 핸들링은 두 단계로 생각해야 해요. 첫째는 에러를 잡아서 분류하는 것, 둘째는 사용자에게 적절히 안내하는 것입니다." 에러는 마치 병원의 응급 분류 시스템과 같습니다.
환자가 왔을 때 증상에 따라 응급도를 분류하듯이, 에러도 종류에 따라 다르게 처리해야 합니다. 인터넷 연결 문제인지, 로그인이 필요한 것인지, 서버 문제인지에 따라 사용자에게 다른 안내를 해야 합니다.
코드의 try-catch 블록을 살펴보겠습니다. API 호출 후 상태 코드를 확인합니다.
401은 인증 실패를 의미하므로 로그인 화면으로 안내해야 합니다. 404는 리소스가 없다는 뜻이므로 "찾을 수 없습니다" 메시지가 적절합니다.
500번대 에러는 서버 문제이므로 "잠시 후 다시 시도해주세요"가 맞습니다. SocketException은 네트워크 자체의 문제입니다.
와이파이가 끊겼거나 비행기 모드일 때 발생합니다. 이때는 "인터넷 연결을 확인해주세요"라는 메시지가 사용자에게 가장 도움이 됩니다.
커스텀 Exception 클래스를 만드는 것을 권장합니다. UnauthorizedException, NotFoundException, NetworkException 등으로 분류해두면 위젯에서 에러 타입에 따라 다른 동작을 할 수 있습니다.
예를 들어 UnauthorizedException이면 로그인 화면으로 이동시킬 수 있습니다. 이제 위젯에서의 처리를 보겠습니다.
when 메서드의 세 번째 파라미터인 error 콜백은 두 개의 인자를 받습니다. 첫 번째는 에러 객체, 두 번째는 스택 트레이스입니다.
스택 트레이스는 디버깅할 때 유용하므로 로깅 서비스에 전송하는 것이 좋습니다. ErrorView 위젯에서 중요한 것은 재시도 버튼입니다.
네트워크 문제는 일시적인 경우가 많습니다. 사용자가 다시 시도할 수 있는 방법을 제공해야 합니다.
**ref.invalidate(userProfileProvider)**를 호출하면 Provider가 다시 실행됩니다. 캐시된 데이터가 삭제되고 새로운 API 호출이 시작됩니다.
실무에서는 에러 발생 시 Crashlytics나 Sentry 같은 에러 추적 서비스에 로그를 전송합니다. 이렇게 하면 사용자들이 겪는 에러 패턴을 분석하고 우선순위를 정해 수정할 수 있습니다.
김개발 씨가 물었습니다. "에러 메시지를 그대로 보여줘도 되나요?" 박시니어 씨가 고개를 저었습니다.
"개발자용 에러 메시지와 사용자용 메시지는 달라야 해요. 'SocketException: Connection refused'보다 '인터넷 연결을 확인해주세요'가 훨씬 친절하죠." 좋은 에러 핸들링은 사용자 경험을 크게 향상시킵니다.
앱이 갑자기 죽거나 빈 화면을 보여주는 것보다, 무슨 문제가 있는지 알려주고 해결 방법을 안내하는 것이 훨씬 낫습니다.
실전 팁
💡 - 에러 로깅 서비스(Sentry, Crashlytics)를 연동하여 프로덕션 에러를 추적하세요.
- 사용자에게는 기술적 에러 메시지 대신 친절한 안내 문구를 보여주세요.
- 재시도 가능한 에러와 치명적인 에러를 구분하여 UI를 다르게 구성하세요.
4. 로딩 상태 표시
"로딩 중인데 화면이 멈춘 것 같아요." QA팀에서 버그 리포트가 올라왔습니다. 김개발 씨는 로딩 인디케이터를 추가했지만 뭔가 부족했습니다.
"사용자는 뭔가 일어나고 있다는 걸 느껴야 해요." 박시니어 씨의 조언이 시작되었습니다.
로딩 상태 표시는 단순히 스피너를 보여주는 것 이상입니다. 스켈레톤 UI, 시머 효과, 진행률 표시 등 다양한 방법으로 사용자에게 "앱이 열심히 일하고 있다"는 것을 알려줄 수 있습니다.
AsyncValue의 loading 상태를 활용하면 이런 UI를 쉽게 구현할 수 있습니다.
다음 코드를 살펴봅시다.
// 스켈레톤 UI를 활용한 로딩 상태 처리
class PostListScreen extends ConsumerWidget {
Widget build(BuildContext context, WidgetRef ref) {
final postsAsync = ref.watch(postsProvider);
return postsAsync.when(
data: (posts) => ListView.builder(
itemCount: posts.length,
itemBuilder: (context, index) => PostCard(post: posts[index]),
),
loading: () => ListView.builder(
itemCount: 5, // 예상되는 아이템 수
itemBuilder: (context, index) => const PostCardSkeleton(),
),
error: (e, st) => ErrorWidget(error: e),
);
}
}
// 스켈레톤 컴포넌트
class PostCardSkeleton extends StatelessWidget {
const PostCardSkeleton({super.key});
Widget build(BuildContext context) {
return Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(width: 200, height: 20, color: Colors.grey[300]),
const SizedBox(height: 8),
Container(width: double.infinity, height: 14, color: Colors.grey[200]),
],
),
),
);
}
}
"동그라미가 빙빙 도는 거 말고 다른 방법은 없나요?" 김개발 씨가 기획팀의 피드백을 전달했습니다. 경쟁사 앱들은 로딩 중에도 화면이 예쁘게 보였는데, 우리 앱은 단조롭다는 것이었습니다.
박시니어 씨가 설명했습니다. "스켈레톤 UI라고 들어봤어요?
데이터가 로드되기 전에 콘텐츠의 뼈대를 미리 보여주는 기법이에요." 스켈레톤 UI는 마치 건물 조감도와 같습니다. 건물이 완공되기 전에 조감도를 보면 "아, 여기에 이런 건물이 들어서겠구나"라고 예상할 수 있습니다.
스켈레톤도 마찬가지로 "여기에 제목이, 여기에 본문이 표시되겠구나"라고 사용자가 예상하게 합니다. 단순한 스피너보다 훨씬 정보가 많습니다.
코드를 살펴보겠습니다. postsAsync.when의 loading 콜백에서 단순히 CircularProgressIndicator를 반환하는 대신, ListView.builder로 스켈레톤 카드 여러 개를 보여줍니다.
itemCount를 5로 설정하면 다섯 개의 스켈레톤 카드가 표시됩니다. PostCardSkeleton 위젯을 보면, 실제 PostCard와 비슷한 구조를 가지고 있습니다.
텍스트 대신 회색 상자가 들어갈 자리를 표시합니다. width와 height로 텍스트 영역의 크기를 흉내내고, Colors.grey로 플레이스홀더 색상을 지정합니다.
더 고급 기법으로 시머(Shimmer) 효과가 있습니다. 회색 상자에 빛이 흐르는 듯한 애니메이션을 추가하면 "로딩 중"이라는 느낌이 더 강해집니다.
shimmer 패키지를 사용하면 쉽게 구현할 수 있습니다. 체감 로딩 시간이라는 개념이 있습니다.
실제 로딩 시간이 같아도 사용자가 느끼는 시간은 다를 수 있습니다. 아무것도 안 보이면 길게 느껴지고, 뭔가 움직이면 짧게 느껴집니다.
페이스북, 인스타그램 같은 앱들이 스켈레톤 UI를 적극 활용하는 이유입니다. 로딩 상태에서 고려할 또 다른 점은 부분 로딩입니다.
만약 화면에 여러 섹션이 있고 각각 다른 API를 호출한다면, 먼저 로드된 섹션부터 표시하고 나머지는 스켈레톤으로 남겨둘 수 있습니다. 이렇게 하면 사용자가 기다리는 동안에도 일부 콘텐츠를 소비할 수 있습니다.
김개발 씨가 고개를 끄덕였습니다. "그래서 유튜브도 영상 목록이 하나씩 나타나는 거군요!" 박시니어 씨가 덧붙였습니다.
"맞아요. 그리고 isLoading 상태와 isRefetching 상태를 구분하는 것도 중요해요.
처음 로딩할 때는 스켈레톤을, 새로고침할 때는 기존 데이터 위에 작은 인디케이터를 보여주는 식이죠." AsyncValue에는 isLoading, isRefreshing, hasValue 같은 프로퍼티가 있습니다. 이것들을 조합하면 더 세밀한 로딩 상태 관리가 가능합니다.
예를 들어 hasValue가 true이고 isRefreshing이 true이면 "기존 데이터는 보여주면서 새 데이터를 가져오는 중"입니다.
실전 팁
💡 - shimmer 패키지를 사용하면 스켈레톤에 애니메이션 효과를 쉽게 추가할 수 있습니다.
- 스켈레톤 UI는 실제 콘텐츠와 레이아웃이 비슷해야 데이터 로드 후 화면 점프가 적습니다.
- 로딩 시간이 매우 짧을 것으로 예상되면 스켈레톤 대신 페이드 인 효과만 사용하는 것도 방법입니다.
5. 캐싱과 리프레시
"새로고침을 해도 데이터가 안 바뀌어요!" 사용자 문의가 들어왔습니다. 김개발 씨는 FutureProvider가 결과를 캐싱한다는 것을 뒤늦게 알게 되었습니다.
"캐싱은 양날의 검이에요. 잘 쓰면 성능 향상, 못 쓰면 버그가 되죠." 박시니어 씨가 말했습니다.
FutureProvider는 기본적으로 결과를 캐싱합니다. 한 번 로드된 데이터는 Provider가 dispose되기 전까지 유지됩니다.
데이터를 새로고침하려면 ref.invalidate 또는 ref.refresh를 사용해야 합니다. autoDispose 수정자를 활용하면 메모리 관리도 자동화할 수 있습니다.
다음 코드를 살펴봅시다.
// autoDispose로 메모리 관리 자동화
final userDataProvider = FutureProvider.autoDispose<UserData>((ref) async {
// Provider가 더 이상 사용되지 않으면 자동으로 dispose됨
ref.onDispose(() {
print('userDataProvider disposed');
});
return await fetchUserData();
});
// Pull-to-refresh 구현
class UserProfileScreen extends ConsumerWidget {
Widget build(BuildContext context, WidgetRef ref) {
final userAsync = ref.watch(userDataProvider);
return RefreshIndicator(
onRefresh: () async {
// invalidate: Provider를 무효화하고 다시 fetch
ref.invalidate(userDataProvider);
// 새 데이터가 로드될 때까지 대기
await ref.read(userDataProvider.future);
},
child: userAsync.when(
data: (user) => UserProfileContent(user: user),
loading: () => const LoadingIndicator(),
error: (e, st) => ErrorView(error: e),
),
);
}
}
김개발 씨는 프로필 화면을 만들었습니다. 사용자가 설정에서 이름을 바꾸고 프로필 화면으로 돌아왔는데, 예전 이름이 그대로 표시되었습니다.
화면을 껐다 켜야만 새 이름이 보였습니다. 무엇이 문제였을까요?
캐싱은 마치 냉장고와 같습니다. 마트에서 식재료를 사오면(API 호출) 냉장고에 넣어두고(캐싱) 필요할 때마다 꺼내 씁니다.
매번 마트에 갈 필요가 없으니 편리합니다. 하지만 냉장고 안의 음식이 상하면 문제가 됩니다.
오래된 데이터가 캐시에 남아있으면 사용자에게 잘못된 정보를 보여주게 됩니다. FutureProvider는 기본적으로 영구 캐싱입니다.
Provider가 처음 읽힐 때 Future가 실행되고, 그 결과가 저장됩니다. 이후에 같은 Provider를 읽으면 저장된 결과를 반환합니다.
API를 다시 호출하지 않습니다. 데이터를 새로고침하는 방법은 두 가지입니다.
ref.invalidate와 ref.refresh입니다. invalidate는 Provider를 "무효"로 표시하고, 다음에 읽힐 때 다시 실행되게 합니다.
refresh는 즉시 다시 실행하고 새 값을 반환합니다. 코드의 RefreshIndicator를 보겠습니다.
이것은 Flutter의 기본 위젯으로, 화면을 아래로 당기면 onRefresh 콜백이 실행됩니다. 콜백 안에서 ref.invalidate를 호출하여 Provider를 무효화합니다.
그 다음 **ref.read(userDataProvider.future)**로 새 데이터가 로드될 때까지 기다립니다. await가 완료되면 RefreshIndicator의 스피너가 사라집니다.
autoDispose 수정자도 중요합니다. FutureProvider.autoDispose로 선언하면, 이 Provider를 watch하는 위젯이 하나도 없을 때 자동으로 dispose됩니다.
다음에 다시 읽으면 처음부터 다시 실행됩니다. 메모리 누수를 방지하고 항상 최신 데이터를 보장하는 효과가 있습니다.
하지만 주의할 점이 있습니다. autoDispose를 사용하면 화면을 잠깐 벗어났다 돌아올 때도 데이터를 다시 로드합니다.
네트워크 요청이 많아질 수 있습니다. 이런 경우 cacheTime을 설정하거나, keepAlive를 사용해 일정 시간 동안은 캐시를 유지하도록 할 수 있습니다.
ref.onDispose 콜백은 Provider가 dispose될 때 호출됩니다. 여기서 연결을 끊거나 리소스를 정리하는 작업을 할 수 있습니다.
웹소켓 연결이나 스트림 구독을 해제하는 용도로 많이 사용됩니다. 김개발 씨가 정리했습니다.
"그러니까 데이터가 자주 변하는 화면은 invalidate로 수동 새로고침을 제공하고, 잠깐 보고 나가는 화면은 autoDispose로 자동 정리하면 되겠네요!" 박시니어 씨가 엄지를 들어 보였습니다. "정확해요.
캐싱 전략은 앱의 특성에 따라 달라져요. 실시간성이 중요한 앱과 데이터가 잘 안 변하는 앱은 다르게 접근해야 합니다."
실전 팁
💡 - ref.invalidate는 비동기, ref.refresh는 동기적으로 새 값을 반환합니다. 상황에 맞게 선택하세요.
- autoDispose와 함께 ref.keepAlive()를 사용하면 특정 조건에서만 캐시를 유지할 수 있습니다.
- 너무 공격적인 캐시 무효화는 네트워크 비용을 증가시킵니다. 균형을 찾으세요.
6. family 수정자로 파라미터 전달
"사용자별로 다른 프로필을 보여줘야 하는데, Provider를 여러 개 만들어야 하나요?" 김개발 씨의 질문에 박시니어 씨가 웃었습니다. "family 수정자를 쓰면 하나의 Provider로 해결할 수 있어요."
family 수정자를 사용하면 Provider에 파라미터를 전달할 수 있습니다. 같은 로직이지만 다른 입력값을 받아야 할 때 유용합니다.
예를 들어 사용자 ID를 받아서 해당 사용자의 정보를 가져오는 Provider를 만들 수 있습니다. 각 파라미터 값에 대해 별도의 캐시가 생성됩니다.
다음 코드를 살펴봅시다.
// family로 파라미터를 받는 Provider
final userByIdProvider = FutureProvider.autoDispose.family<User, String>(
(ref, userId) async {
// userId를 사용하여 특정 사용자 정보 fetch
final response = await http.get(
Uri.parse('https://api.example.com/users/$userId'),
);
return User.fromJson(jsonDecode(response.body));
},
);
// 위젯에서 사용
class UserDetailScreen extends ConsumerWidget {
final String userId;
const UserDetailScreen({required this.userId, super.key});
Widget build(BuildContext context, WidgetRef ref) {
// Provider에 파라미터 전달
final userAsync = ref.watch(userByIdProvider(userId));
return userAsync.when(
data: (user) => UserDetailView(user: user),
loading: () => const UserDetailSkeleton(),
error: (e, st) => ErrorView(
onRetry: () => ref.invalidate(userByIdProvider(userId)),
),
);
}
}
김개발 씨는 SNS 앱을 만들고 있었습니다. 친구 목록에서 친구를 탭하면 그 친구의 프로필 화면으로 이동합니다.
문제는 친구가 100명이면 100개의 Provider를 만들어야 하는 것처럼 보였습니다. user1Provider, user2Provider, user3Provider...
이건 미친 짓이었습니다. family 수정자는 마치 자판기와 같습니다.
자판기에는 다양한 음료가 있지만, 버튼을 누르면 원하는 음료가 나옵니다. 자판기 자체는 하나지만, 입력(버튼)에 따라 다른 출력(음료)을 제공합니다.
family Provider도 마찬가지로 하나의 Provider 정의로 다양한 입력값을 처리합니다. 코드의 **FutureProvider.autoDispose.family<User, String>**를 보겠습니다.
제네릭 타입이 두 개입니다. 첫 번째 User는 반환 타입, 두 번째 String은 파라미터 타입입니다.
이 Provider는 String 타입의 userId를 받아서 User를 반환합니다. 콜백 함수도 두 개의 인자를 받습니다.
ref와 userId입니다. userId를 URL에 넣어 API를 호출하고 해당 사용자의 정보를 가져옵니다.
간단하지만 강력합니다. 위젯에서 사용할 때는 userByIdProvider(userId) 형태로 호출합니다.
마치 함수를 호출하는 것처럼 파라미터를 전달합니다. 이렇게 하면 해당 userId에 대한 독립적인 인스턴스가 생성됩니다.
중요한 점은 각 파라미터 값마다 별도의 캐시가 생성된다는 것입니다. userByIdProvider('user1')과 userByIdProvider('user2')는 완전히 다른 캐시를 가집니다.
user1의 데이터를 로드한 후 user2를 로드해도 user1의 캐시는 유지됩니다. invalidate할 때도 파라미터를 함께 전달해야 합니다.
ref.invalidate(userByIdProvider(userId))처럼요. 이렇게 하면 해당 userId의 캐시만 무효화됩니다.
다른 userId의 캐시는 영향받지 않습니다. 복합 파라미터를 전달하고 싶을 때가 있습니다.
예를 들어 카테고리와 정렬 순서 두 가지를 전달하고 싶다면요? 이럴 때는 Record 타입을 사용할 수 있습니다.
FutureProvider.family<List<Product>, ({String category, String sort})>처럼 선언합니다. 주의할 점이 있습니다.
family의 파라미터는 동등성 비교가 가능해야 합니다. 기본 타입(String, int 등)이나 불변 객체를 사용하세요.
매번 새로운 객체를 생성해서 전달하면 캐시가 제대로 동작하지 않습니다. 김개발 씨가 깨달았습니다.
"아, 그래서 freezed로 만든 불변 클래스를 파라미터로 쓰라고 하는 거군요!" 박시니어 씨가 고개를 끄덕였습니다. "맞아요.
freezed는 == 연산자와 hashCode를 자동으로 구현해주니까 family와 궁합이 좋아요." family는 autoDispose와 함께 사용하는 것이 일반적입니다. 사용하지 않는 파라미터의 캐시가 메모리에 계속 남아있으면 문제가 될 수 있기 때문입니다.
autoDispose를 붙이면 해당 파라미터를 watch하는 위젯이 없을 때 자동으로 정리됩니다.
실전 팁
💡 - family 파라미터로는 기본 타입이나 불변 객체를 사용하세요. freezed 패키지가 좋은 선택입니다.
- 복합 파라미터가 필요하면 Dart 3의 Record 타입을 활용하세요.
- autoDispose와 family를 함께 사용하면 메모리 관리가 자동화됩니다.
이상으로 학습을 마칩니다. 위 내용을 직접 코드로 작성해보면서 익혀보세요!
댓글 (0)
함께 보면 좋은 카드 뉴스
LangGraph Fault Tolerance 장애 복구 완벽 가이드
LangGraph 애플리케이션의 장애 복구 메커니즘을 실무 중심으로 배웁니다. Durable Execution부터 Graph Migrations까지 체크포인트 기반 복구 시스템을 스토리텔링으로 쉽게 이해할 수 있습니다.
LangGraph Time Travel 완벽 가이드
LangGraph의 Time Travel 기능으로 특정 시점으로 돌아가고, 상태를 포크하여 분기하고, 대안 경로를 탐색하는 방법을 배웁니다. 실무에서 디버깅과 실험에 활용하는 실전 가이드입니다.
LangGraph Persistence 완벽 가이드
LangGraph의 Persistence 기능을 활용하여 대화 상태를 저장하고 관리하는 방법을 배웁니다. Thread와 Checkpoint를 이해하고, 상태 조회 및 수정 방법을 실무 중심으로 학습합니다.
Riverpod 3.0 쇼핑 앱 종합 프로젝트 완벽 가이드
Flutter와 Riverpod 3.0을 활용한 실무 수준의 쇼핑 앱 개발 과정을 단계별로 학습합니다. 상품 목록, 장바구니, 주문, 인증, 검색 기능까지 모든 핵심 기능을 구현하며 상태 관리의 실전 노하우를 익힙니다.
Riverpod 3.0 Retry 자동 재시도 완벽 가이드
Riverpod 3.0에 새로 추가된 Retry 기능을 활용하여 네트워크 오류나 일시적인 실패 상황에서 자동으로 재시도하는 방법을 배웁니다. 초급 개발자도 쉽게 따라할 수 있도록 실무 예제와 함께 설명합니다.