본 콘텐츠의 이미지 및 내용은 AI로 생성되었습니다.
본 콘텐츠의 이미지 및 내용을 무단으로 복제, 배포, 수정하여 사용할 경우 저작권법에 의해 법적 제재를 받을 수 있습니다.
이미지 로딩 중...
AI Generated
2025. 12. 11. · 15 Views
Riverpod Family 완벽 가이드 파라미터로 동적 Provider 만들기
Riverpod의 family 수정자를 사용하면 같은 로직을 여러 파라미터로 재사용할 수 있습니다. 사용자별 프로필, 상품별 상세정보 등 실무에서 자주 쓰이는 패턴을 이북처럼 술술 읽히는 스타일로 배워봅시다.
목차
1. family 수정자란?
어느 날 김플러터 씨는 회사에서 쇼핑몰 앱을 만들고 있었습니다. 상품이 100개인데, 각 상품마다 Provider를 만들어야 할까요?
선배 박리버 씨가 웃으며 말했습니다. "family 수정자를 쓰면 하나로 끝나는데요?"
family 수정자는 Provider에 파라미터를 전달하여 동적으로 다른 데이터를 가져올 수 있게 해주는 기능입니다. 마치 함수에 인자를 넘기는 것처럼, Provider에도 값을 전달할 수 있습니다.
이를 통해 같은 로직을 여러 상황에 재사용할 수 있어 코드 중복을 크게 줄일 수 있습니다.
다음 코드를 살펴봅시다.
// 일반 Provider - 파라미터 없음
final userProvider = Provider<User>((ref) {
return User(id: 1, name: '고정된 사용자');
});
// Family Provider - 파라미터 있음
final userByIdProvider = Provider.family<User, int>((ref, userId) {
// userId를 받아서 동적으로 사용자 정보 반환
return fetchUserById(userId);
});
// 사용할 때
final user1 = ref.watch(userByIdProvider(1)); // 1번 사용자
final user2 = ref.watch(userByIdProvider(2)); // 2번 사용자
김플러터 씨는 입사 6개월 차 모바일 개발자입니다. 오늘은 쇼핑몰 앱의 상품 상세 페이지를 만들어야 합니다.
상품이 100개나 되는데, 각 상품마다 Provider를 만들어야 할까요? 선배 개발자 박리버 씨가 화면을 보더니 고개를 저었습니다.
"그렇게 하면 Provider가 100개나 생겨요. family를 쓰면 하나로 해결됩니다." family란 정확히 무엇일까요? 쉽게 비유하자면, family는 마치 자판기와 같습니다.
자판기는 하나지만 버튼 번호에 따라 다른 음료수가 나오죠. family도 하나의 Provider이지만 전달하는 파라미터에 따라 다른 데이터를 제공합니다.
이처럼 family는 동적으로 파라미터를 받아 다양한 데이터를 생성하는 역할을 합니다. family가 없던 시절에는 어땠을까요? 개발자들은 비슷한 로직의 Provider를 여러 개 만들어야 했습니다.
user1Provider, user2Provider, user3Provider... 끝도 없이 복사 붙여넣기를 해야 했죠.
코드가 길어지고, 수정할 때도 모든 Provider를 일일이 찾아서 고쳐야 했습니다. 더 큰 문제는 파라미터가 동적으로 결정되는 경우였습니다.
사용자가 선택한 상품 ID에 따라 데이터를 가져와야 하는데, 미리 Provider를 만들어둘 수도 없었죠. 바로 이런 문제를 해결하기 위해 family가 등장했습니다. family를 사용하면 하나의 Provider로 무한한 경우의 수를 처리할 수 있습니다.
또한 코드 중복이 사라져 유지보수가 쉬워집니다. 무엇보다 런타임에 동적으로 결정되는 값을 Provider에 전달할 수 있다는 큰 이점이 있습니다.
위의 코드를 한 줄씩 살펴보겠습니다. 먼저 일반 Provider를 보면 파라미터 없이 항상 같은 User 객체를 반환합니다. 하지만 userByIdProvider는 Provider.family<반환타입, 파라미터타입> 형태로 선언되어 있습니다.
이 부분이 핵심입니다. 두 번째 제네릭 타입인 int가 바로 파라미터의 타입을 의미합니다.
다음으로 함수의 시그니처를 보면 (ref, userId)처럼 두 개의 파라미터를 받습니다. 첫 번째는 항상 ref이고, 두 번째가 우리가 전달하는 파라미터입니다.
마지막으로 사용할 때는 userByIdProvider(1)처럼 함수 호출 형태로 파라미터를 넘깁니다. 실제 현업에서는 어떻게 활용할까요? 예를 들어 SNS 앱을 개발한다고 가정해봅시다.
피드에서 여러 사용자의 프로필을 표시해야 할 때, family를 활용하면 userProfileProvider(userId)로 각 사용자의 프로필을 간단히 가져올 수 있습니다. 인스타그램, 페이스북 같은 대형 서비스에서도 이런 패턴을 적극적으로 사용하고 있습니다.
하지만 주의할 점도 있습니다. 초보 개발자들이 흔히 하는 실수 중 하나는 파라미터로 복잡한 객체를 전달하는 것입니다. 이렇게 하면 캐싱이 제대로 동작하지 않아 성능 문제가 발생할 수 있습니다.
따라서 int, String 같은 기본 타입이나 간단한 값 객체를 파라미터로 사용해야 합니다. 다시 김플러터 씨의 이야기로 돌아가 봅시다. 박리버 씨의 설명을 들은 김플러터 씨는 눈이 반짝였습니다.
"아, 그래서 100개의 상품을 하나의 Provider로 처리할 수 있군요!" family를 제대로 이해하면 더 깔끔하고 유지보수하기 쉬운 코드를 작성할 수 있습니다. 여러분도 오늘 배운 내용을 실제 프로젝트에 적용해 보세요.
실전 팁
💡 - 파라미터는 int, String 같은 기본 타입을 사용하세요
- 복잡한 객체를 전달하면 캐싱 문제가 생길 수 있습니다
2. 사용자 프로필 Provider
김플러터 씨가 다음으로 맡은 작업은 채팅 앱의 사용자 프로필 기능입니다. 채팅방에 10명이 있으면 10명의 프로필을 각각 표시해야 하는데, 어떻게 해야 할까요?
사용자 프로필 Provider는 family를 활용한 가장 대표적인 예시입니다. 사용자 ID를 파라미터로 받아 해당 사용자의 프로필 정보를 제공합니다.
실무에서 가장 자주 마주치는 패턴이며, API 호출과 결합하여 실제 서버에서 데이터를 가져오는 방식으로 구현됩니다.
다음 코드를 살펴봅시다.
// 사용자 모델
class UserProfile {
final int id;
final String name;
final String avatarUrl;
UserProfile({required this.id, required this.name, required this.avatarUrl});
}
// Family Provider로 사용자 프로필 제공
final userProfileProvider = FutureProvider.family<UserProfile, int>((ref, userId) async {
// 실제로는 API 호출
final response = await http.get('/api/users/$userId');
return UserProfile.fromJson(response.data);
});
// 위젯에서 사용
class ProfileCard extends ConsumerWidget {
final int userId;
@override
Widget build(BuildContext context, WidgetRef ref) {
final profile = ref.watch(userProfileProvider(userId));
return profile.when(
data: (user) => Text(user.name),
loading: () => CircularProgressIndicator(),
error: (err, stack) => Text('오류 발생'),
);
}
}
김플러터 씨는 이제 family의 개념을 이해했습니다. 하지만 실제 프로젝트에 어떻게 적용해야 할지는 아직 막연합니다.
채팅 앱에서 각 사용자의 프로필을 표시해야 하는데, 코드로 어떻게 구현할까요? 박리버 씨가 실제 예제를 보여주기 시작했습니다.
"가장 많이 쓰이는 패턴이 바로 사용자 프로필이에요." 사용자 프로필 Provider란 정확히 무엇일까요? 쉽게 비유하자면, 사용자 프로필 Provider는 마치 학교 앨범과 같습니다. 학번을 말하면 해당 학생의 사진과 정보가 나오죠.
여기서 학번이 userId이고, 앨범이 Provider인 셈입니다. 이처럼 사용자 프로필 Provider는 ID를 받아 해당 사용자의 정보를 조회하는 역할을 담당합니다.
왜 이런 패턴이 필요할까요? 채팅 앱을 생각해봅시다. 채팅방에 들어가면 여러 사용자의 메시지가 보입니다.
각 메시지 옆에는 보낸 사람의 프로필 사진과 이름이 표시되어야 하죠. 만약 family가 없다면 각 사용자마다 별도의 Provider를 만들어야 합니다.
하지만 사용자가 몇 명일지는 미리 알 수 없습니다. 새로운 사람이 채팅방에 입장할 수도 있고, 나갈 수도 있죠.
바로 이런 상황에서 family가 빛을 발합니다. userProfileProvider(userId)처럼 사용하면 런타임에 동적으로 사용자 정보를 가져올 수 있습니다. 또한 Riverpod의 자동 캐싱 기능 덕분에 같은 userId로 두 번 요청하면 서버에 다시 요청하지 않고 캐시된 데이터를 반환합니다.
무엇보다 코드가 간결하고 이해하기 쉽다는 장점이 있습니다. 위의 코드를 한 줄씩 살펴보겠습니다. 먼저 UserProfile 클래스는 사용자 정보를 담는 모델입니다.
실무에서는 보통 이런 모델 클래스를 먼저 정의하죠. 다음으로 FutureProvider.family를 사용했다는 점이 중요합니다.
Provider가 아니라 FutureProvider인 이유는 API 호출이 비동기 작업이기 때문입니다. 제네릭 타입을 보면 **<UserProfile, int>**로 선언되어 있습니다.
첫 번째는 반환 타입(UserProfile), 두 번째는 파라미터 타입(int)입니다. 함수 본문에서는 userId를 사용해 API를 호출하고, 결과를 UserProfile 객체로 변환하여 반환합니다.
위젯에서 사용할 때는 profile.when을 사용합니다. FutureProvider는 loading, data, error 세 가지 상태를 가지므로, 각 상태에 맞는 UI를 표시할 수 있습니다.
데이터가 로딩 중일 때는 로딩 인디케이터를, 성공하면 사용자 이름을, 실패하면 에러 메시지를 보여주는 것이죠. 실제 현업에서는 어떻게 활용할까요? 카카오톡 같은 메신저 앱을 예로 들어봅시다.
채팅방 목록에서 각 방의 상대방 프로필을 표시할 때, 채팅 내용에서 보낸 사람의 아바타를 표시할 때, 친구 목록을 스크롤할 때 모두 이 패턴을 사용합니다. 심지어 같은 사용자가 여러 곳에 표시되더라도 API는 한 번만 호출됩니다.
Riverpod가 자동으로 캐싱해주기 때문이죠. 하지만 주의할 점도 있습니다. 초보 개발자들이 흔히 하는 실수는 파라미터로 UserProfile 객체 전체를 전달하는 것입니다.
"이미 UserProfile이 있는데 왜 userId만 전달하죠?"라고 물어보곤 하죠. 하지만 family는 **파라미터로 동등성 비교(equality check)**를 합니다.
객체는 참조가 다르면 같은 내용이어도 다르다고 판단하여 캐싱이 깨집니다. 따라서 ID 같은 기본 타입을 사용해야 합니다.
다시 김플러터 씨의 이야기로 돌아가 봅시다. 박리버 씨의 코드를 본 김플러터 씨는 감탄했습니다. "이렇게 간단한데 API 호출, 로딩 처리, 캐싱까지 다 되는군요!" 사용자 프로필 Provider 패턴을 익히면 실무에서 가장 자주 쓰이는 기능을 쉽게 구현할 수 있습니다.
여러분도 지금 당장 프로젝트에 적용해 보세요.
실전 팁
💡 - FutureProvider.family를 사용하면 비동기 데이터를 쉽게 처리할 수 있습니다
- 파라미터는 항상 ID 같은 기본 타입을 사용하세요
3. 상품 상세 Provider
다음 날 김플러터 씨는 쇼핑몰 앱의 상품 상세 페이지를 만들기 시작했습니다. 사용자가 상품을 클릭하면 해당 상품의 정보를 서버에서 가져와야 하는데, 어떻게 해야 할까요?
어제 배운 family를 써먹을 때가 온 것 같습니다.
상품 상세 Provider는 productId를 파라미터로 받아 해당 상품의 상세 정보를 제공합니다. 이커머스 앱에서 가장 핵심적인 기능이며, 사용자 프로필과 비슷한 패턴이지만 상품 특성상 가격, 재고, 옵션 등 더 복잡한 데이터를 다룬다는 차이가 있습니다.
다음 코드를 살펴봅시다.
// 상품 모델
class Product {
final String id;
final String name;
final int price;
final String imageUrl;
final int stock;
Product({
required this.id,
required this.name,
required this.price,
required this.imageUrl,
required this.stock,
});
}
// 상품 상세 Provider
final productDetailProvider = FutureProvider.family<Product, String>((ref, productId) async {
// 실제 API 호출
final response = await http.get('/api/products/$productId');
return Product.fromJson(response.data);
});
// 상품 상세 페이지
class ProductDetailPage extends ConsumerWidget {
final String productId;
const ProductDetailPage({required this.productId});
@override
Widget build(BuildContext context, WidgetRef ref) {
final productAsync = ref.watch(productDetailProvider(productId));
return productAsync.when(
data: (product) => Column(
children: [
Image.network(product.imageUrl),
Text(product.name),
Text('${product.price}원'),
Text('재고: ${product.stock}개'),
],
),
loading: () => Center(child: CircularProgressIndicator()),
error: (err, stack) => Text('상품을 불러올 수 없습니다'),
);
}
}
김플러터 씨는 어제 배운 사용자 프로필 패턴을 떠올렸습니다. "그렇다면 상품도 똑같이 하면 되겠네!" 자신감이 생긴 김플러터 씨는 바로 코드를 작성하기 시작했습니다.
박리버 씨가 옆에서 지켜보다가 고개를 끄덕였습니다. "맞아요, 패턴은 같아요.
다만 상품은 데이터가 좀 더 복잡하죠." 상품 상세 Provider란 정확히 무엇일까요? 쉽게 비유하자면, 상품 상세 Provider는 마치 백화점 안내 데스크와 같습니다. 상품 번호를 말하면 그 상품이 어디에 있고, 가격이 얼마이고, 재고가 몇 개 남았는지 알려주죠.
여기서 상품 번호가 productId이고, 안내 데스크가 Provider인 셈입니다. 이처럼 상품 상세 Provider는 상품 ID로 해당 상품의 모든 정보를 조회하는 역할을 담당합니다.
실제 쇼핑몰에서는 어떻게 동작할까요? 사용자가 쿠팡이나 네이버쇼핑에서 상품 목록을 보다가 마음에 드는 상품을 클릭합니다. 그러면 상품 상세 페이지로 이동하면서 해당 상품의 ID가 URL에 포함됩니다.
예를 들어 /product/ABC123 같은 형태죠. 이 ID를 가지고 서버에 상품 정보를 요청해야 합니다.
만약 family가 없다면 라우팅할 때마다 복잡한 상태 관리 코드를 작성해야 했을 겁니다. 하지만 family를 사용하면 정말 간단해집니다. 페이지가 빌드될 때 productDetailProvider(productId)를 호출하면 끝입니다.
Riverpod가 자동으로 데이터를 가져오고, 로딩 상태를 관리하고, 에러도 처리해줍니다. 개발자는 UI만 신경 쓰면 되죠.
위의 코드를 한 줄씩 살펴보겠습니다. 먼저 Product 모델을 보면 사용자 프로필보다 필드가 많습니다. 실무에서는 여기에 설명, 리뷰 점수, 배송 정보 등 훨씬 더 많은 필드가 추가됩니다.
하지만 핵심은 같습니다. ID로 조회할 수 있는 엔티티라는 점이죠.
productDetailProvider를 보면 **FutureProvider.family<Product, String>**으로 선언되어 있습니다. 사용자 프로필에서는 int를 썼지만, 상품 ID는 보통 문자열이므로 String을 사용했습니다.
이처럼 비즈니스 요구사항에 맞게 타입을 선택할 수 있습니다. 위젯에서는 productAsync.when을 사용해 세 가지 상태를 처리합니다.
데이터가 로딩 중일 때는 로딩 인디케이터를 중앙에 표시하고, 성공하면 상품 이미지, 이름, 가격, 재고를 세로로 나열합니다. 만약 네트워크 에러나 서버 에러가 발생하면 친절한 에러 메시지를 보여주죠.
실제 현업에서는 더 복잡한 기능도 추가됩니다. 예를 들어 옵션 선택 기능을 생각해봅시다. 옷이라면 색상과 사이즈를 선택해야 하죠.
이때는 또 다른 family Provider를 만들어서 (productId, option)을 파라미터로 받을 수도 있습니다. 혹은 장바구니에 담기, 좋아요 같은 액션도 이 Provider를 참조하여 구현합니다.
쿠팡의 로켓배송, 네이버페이 포인트 같은 복잡한 기능도 결국 이 기본 패턴에서 시작합니다. 먼저 상품 정보를 가져오고, 그 위에 기능을 쌓아가는 것이죠.
주의할 점도 있습니다. 초보 개발자들이 흔히 하는 실수는 상품 목록과 상품 상세를 같은 Provider로 처리하려는 것입니다. "어차피 같은 상품인데 왜 나눠야 하죠?" 하지만 목록에서는 간단한 정보만 필요하고, 상세에서는 리뷰, 상세 설명, 연관 상품 등 무거운 데이터가 필요합니다.
용도에 맞게 Provider를 분리하는 것이 성능에 유리합니다. 다시 김플러터 씨의 이야기로 돌아가 봅시다. 김플러터 씨는 코드를 작성하고 테스트해봤습니다.
상품을 클릭하면 로딩이 잠깐 나타났다가 상품 정보가 화면에 뜹니다. 뒤로가기를 눌렀다가 다시 들어가면 로딩 없이 바로 나타납니다.
"캐싱이 되는군요!" 박리버 씨가 웃으며 말했습니다. "이제 진짜 실무에서 쓸 수 있는 수준이에요." 상품 상세 Provider 패턴은 이커머스뿐 아니라 모든 앱에서 응용할 수 있습니다.
뉴스 상세, 게시글 상세, 동영상 상세 모두 같은 패턴이니까요. 여러분도 자신의 프로젝트에 맞게 변형해서 사용해 보세요.
실전 팁
💡 - 상품 ID 타입은 실제 API에 맞게 String 또는 int로 선택하세요
- 목록용 Provider와 상세용 Provider는 분리하는 것이 좋습니다
4. @riverpod에서 family 사용
김플러터 씨는 이제 family를 자유자재로 쓸 수 있게 되었습니다. 그런데 박리버 씨가 새로운 방법을 알려줬습니다.
"요즘은 @riverpod 어노테이션을 많이 쓰는데, 훨씬 간결해요." 코드 생성 방식이라니, 궁금해진 김플러터 씨가 귀를 기울였습니다.
riverpod_generator를 사용하면 어노테이션 기반으로 더 간결하게 family Provider를 작성할 수 있습니다. @riverpod 어노테이션을 붙인 함수에 파라미터를 추가하면 자동으로 family Provider가 생성됩니다.
타입 안정성이 높아지고 보일러플레이트 코드가 줄어드는 장점이 있습니다.
다음 코드를 살펴봅시다.
// riverpod_annotation 패키지 임포트
import 'package:riverpod_annotation/riverpod_annotation.dart';
part 'product_provider.g.dart';
// @riverpod 어노테이션으로 간단하게 선언
@riverpod
Future<Product> productDetail(ProductDetailRef ref, String productId) async {
// productId 파라미터를 받으면 자동으로 family가 됨
final response = await http.get('/api/products/$productId');
return Product.fromJson(response.data);
}
// 사용할 때는 동일
class ProductPage extends ConsumerWidget {
final String productId;
@override
Widget build(BuildContext context, WidgetRef ref) {
// 생성된 Provider 사용
final product = ref.watch(productDetailProvider(productId));
return product.when(
data: (p) => Text(p.name),
loading: () => CircularProgressIndicator(),
error: (e, s) => Text('에러'),
);
}
}
김플러터 씨는 지금까지 배운 방식에 익숙해졌습니다. Provider.family, FutureProvider.family를 직접 선언하는 방식이죠.
그런데 박리버 씨가 새로운 파일을 열어 보여줬습니다. 코드가 훨씬 짧고 깔끔했습니다.
"이게 뭐예요?" 김플러터 씨가 물었습니다. "코드 생성 방식이에요.
더 안전하고 편해요." @riverpod 어노테이션이란 정확히 무엇일까요? 쉽게 비유하자면, @riverpod는 마치 요리사의 레시피와 같습니다. 레시피대로 재료와 조리법을 적으면 자동으로 완성된 요리가 나오죠.
@riverpod도 함수만 작성하면 build_runner가 자동으로 Provider 코드를 생성해줍니다. 개발자는 핵심 로직만 신경 쓰면 되고, 반복적인 보일러플레이트는 자동으로 처리되는 것이죠.
기존 방식과 어떻게 다를까요? 기존 방식에서는 FutureProvider.family<Product, String>처럼 제네릭 타입을 명시하고, (ref, productId)처럼 파라미터를 받는 함수를 작성해야 했습니다. 타입을 두 번 적어야 하는 불편함도 있었죠.
반환 타입은 Future<Product>인데 제네릭에도 Product를 적어야 하니까요. @riverpod 방식은 훨씬 간단합니다. 그냥 일반 함수를 작성하듯이 Future<Product> productDetail(ref, productId) 형태로 쓰면 끝입니다.
함수 이름이 productDetail이면 자동으로 productDetailProvider가 생성됩니다. 파라미터가 있으면 자동으로 family가 됩니다.
타입 추론도 자동으로 되니 실수할 여지가 줄어듭니다. 위의 코드를 한 줄씩 살펴보겠습니다. 먼저 import 부분을 보면 riverpod_annotation 패키지를 사용합니다.
기존의 flutter_riverpod만으로는 코드 생성이 안 되므로 이 패키지를 추가해야 합니다. 다음으로 part 지시문이 있습니다.
product_provider.g.dart는 build_runner가 생성할 파일명입니다. 이 파일에 실제 Provider 코드가 들어갑니다.
@riverpod 어노테이션을 함수 위에 붙이는 것이 핵심입니다. 함수 시그니처를 보면 첫 번째 파라미터는 항상 ref이고, 그 다음부터가 family 파라미터입니다.
여기서는 String productId 하나만 받으므로 **family<Product, String>**이 자동으로 생성됩니다. 사용할 때는 기존과 완전히 동일합니다.
ref.watch(productDetailProvider(productId))처럼 함수 호출 형태로 쓰면 됩니다. 차이점은 이 Provider가 수동으로 작성한 것이 아니라 자동 생성된 것이라는 점뿐입니다.
실제 프로젝트에서 코드 생성을 어떻게 실행할까요? 터미널에서 dart run build_runner watch 명령어를 실행하면 됩니다. 그러면 파일을 저장할 때마다 자동으로 .g.dart 파일이 업데이트됩니다.
처음 한 번만 설정하면 되고, 그 다음부터는 신경 쓸 필요가 없습니다. 많은 대형 프로젝트에서 @riverpod 방식을 채택하고 있습니다.
코드 리뷰할 때도 함수 로직만 보면 되니 더 직관적이고, 리팩토링할 때도 타입 에러를 컴파일 타임에 잡을 수 있어 안전합니다. 주의할 점도 있습니다. 초보 개발자들이 흔히 하는 실수는 part 지시문을 빼먹는 것입니다.
part 'xxx.g.dart'를 작성하지 않으면 코드 생성은 되지만 현재 파일에서 사용할 수 없습니다. 또한 파일명과 part 지시문의 이름이 일치해야 합니다.
product_provider.dart 파일이면 part 'product_provider.g.dart'여야 합니다. 다시 김플러터 씨의 이야기로 돌아가 봅시다. 김플러터 씨는 build_runner를 실행해보고 .g.dart 파일이 생성되는 것을 확인했습니다.
"와, 이렇게 긴 코드가 자동으로 만들어지네요!" 박리버 씨가 웃으며 말했습니다. "그래서 우리는 핵심 로직만 신경 쓰면 되는 거예요." @riverpod 방식은 처음에는 낯설 수 있지만, 익숙해지면 훨씬 생산성이 높아집니다.
최신 Riverpod 공식 문서에서도 이 방식을 권장하고 있으니 꼭 익혀두세요.
실전 팁
💡 - build_runner watch를 켜두면 파일 저장 시 자동으로 코드가 생성됩니다
- part 지시문을 빼먹지 않도록 주의하세요
5. 복수 파라미터 전달
김플러터 씨가 만들던 기능이 점점 복잡해졌습니다. 이제는 사용자 ID와 날짜를 동시에 받아서 특정 사용자의 특정 날짜 활동 기록을 가져와야 합니다.
"파라미터가 두 개인데 어떻게 하죠?" 박리버 씨가 답했습니다. "간단해요, 묶어서 보내면 돼요."
복수 파라미터를 전달하려면 여러 값을 하나의 객체로 묶어야 합니다. Dart의 Record 타입을 사용하면 간단하게 처리할 수 있습니다.
클래스를 만들어도 되지만, Record를 쓰면 보일러플레이트가 줄어들고 타입 안정성도 유지됩니다.
다음 코드를 살펴봅시다.
// Record 타입으로 복수 파라미터 전달
@riverpod
Future<List<Activity>> userActivityByDate(
UserActivityByDateRef ref,
// Record로 userId와 date를 함께 받음
({int userId, DateTime date}) params,
) async {
final response = await http.get(
'/api/users/${params.userId}/activities?date=${params.date}',
);
return parseActivities(response.data);
}
// 사용할 때
class ActivityList extends ConsumerWidget {
final int userId;
final DateTime selectedDate;
@override
Widget build(BuildContext context, WidgetRef ref) {
// Record로 파라미터 전달
final activities = ref.watch(
userActivityByDateProvider((userId: userId, date: selectedDate)),
);
return activities.when(
data: (list) => ListView.builder(
itemCount: list.length,
itemBuilder: (context, index) => Text(list[index].title),
),
loading: () => CircularProgressIndicator(),
error: (e, s) => Text('에러'),
);
}
}
김플러터 씨는 새로운 요구사항을 받았습니다. 사용자가 달력에서 날짜를 선택하면, 그날 그 사용자가 한 활동 목록을 보여줘야 합니다.
즉, userId와 date 두 개의 값이 필요한 상황입니다. "지금까지는 파라미터가 하나였는데, 두 개는 어떻게 하죠?" 김플러터 씨가 고민에 빠졌습니다.
박리버 씨가 힌트를 줬습니다. "family는 파라미터를 하나만 받아요.
그럼 두 개를 묶으면 되겠죠?" 복수 파라미터란 정확히 무엇일까요? 쉽게 비유하자면, 복수 파라미터는 마치 택배 상자와 같습니다. 물건 하나만 보낼 수도 있지만, 여러 개를 상자에 담아서 보낼 수도 있죠.
Record는 바로 이 상자 역할을 합니다. userId와 date를 Record라는 상자에 담아서 한꺼번에 전달하는 것입니다.
왜 Record를 사용할까요? 예전에는 별도의 클래스를 만들어야 했습니다. UserActivityParams 같은 이름으로 클래스를 정의하고, userId와 date 필드를 넣고, 생성자를 만들고, == 연산자와 hashCode를 오버라이드하고...
정말 귀찮은 작업이었죠. Dart 3.0부터 도입된 Record 타입은 이 모든 것을 한 줄로 해결합니다.
Record는 정말 강력합니다. ({int userId, DateTime date}) 이렇게 타입을 선언하면 끝입니다. 컴파일러가 자동으로 동등성 비교를 구현해주므로 캐싱도 완벽하게 동작합니다.
또한 이름이 있는 필드(named fields)를 사용하므로 params.userId, params.date처럼 명확하게 접근할 수 있습니다. 위의 코드를 한 줄씩 살펴보겠습니다. 함수 시그니처를 보면 세 번째 파라미터로 ({int userId, DateTime date}) params를 받습니다.
이것이 Record 타입입니다. 중괄호 안에 필드를 나열하고, 전체를 params라는 이름으로 받는 것이죠.
함수 본문에서는 params.userId와 params.date로 각 값에 접근합니다. 사용할 때를 보면 userActivityByDateProvider((userId: userId, date: selectedDate)) 형태로 호출합니다.
괄호가 두 겹인 것이 처음엔 낯설 수 있습니다. 바깥 괄호는 함수 호출, 안쪽 괄호는 Record 생성입니다.
userId: userId 부분에서 왼쪽은 Record의 필드명, 오른쪽은 변수명입니다. 실제 현업에서는 어떻게 활용할까요? 건강 관리 앱을 예로 들어봅시다.
사용자가 달력에서 날짜를 선택하면 그날의 운동 기록, 식단 기록, 수면 기록을 보여줘야 합니다. 이때 userExerciseProvider((userId: id, date: selectedDate)), userMealProvider((userId: id, date: selectedDate)) 처럼 여러 Provider에서 같은 패턴을 재사용할 수 있습니다.
또 다른 예로 게시판 앱을 생각해봅시다. 특정 카테고리의 특정 페이지 게시글 목록을 가져와야 할 때 postListProvider((category: 'tech', page: 2)) 형태로 사용하면 됩니다.
주의할 점도 있습니다. 초보 개발자들이 흔히 하는 실수는 Record 대신 List나 Map을 사용하는 것입니다. [userId, date] 형태로 전달하면 안 될까요?
기술적으로는 가능하지만 타입 안정성이 떨어집니다. params[0]이 userId인지 date인지 헷갈릴 수 있죠.
Map은 더 심각합니다. params['userId']처럼 문자열 키를 사용하면 오타가 나도 컴파일 타임에 잡히지 않습니다.
Record를 사용하면 타입 안정성이 보장됩니다. params.userId라고 쓰면 IDE가 자동완성도 해주고, 오타가 나면 바로 빨간 줄이 뜹니다. 컴파일러가 타입도 정확히 추론하므로 런타임 에러 걱정이 없습니다.
다시 김플러터 씨의 이야기로 돌아가 봅시다. 김플러터 씨는 Record를 처음 써봤지만 금방 익숙해졌습니다. "클래스를 만들 필요가 없으니 정말 편하네요!" 박리버 씨가 덧붙였습니다.
"파라미터가 3개, 4개로 늘어나도 같은 방식으로 하면 돼요." 복수 파라미터 패턴을 익히면 훨씬 복잡한 요구사항도 간단하게 처리할 수 있습니다. 여러분의 프로젝트에서도 활용해 보세요.
실전 팁
💡 - Record 타입을 사용하면 별도 클래스 없이 복수 파라미터를 전달할 수 있습니다
- List나 Map보다 Record가 타입 안정성이 높습니다
6. 캐싱 동작 이해
김플러터 씨는 앱을 테스트하다가 신기한 현상을 발견했습니다. 같은 상품을 여러 번 클릭해도 로딩이 한 번만 나타납니다.
"캐싱이 되는 건가요?" 박리버 씨가 고개를 끄덕였습니다. "Riverpod의 핵심 기능 중 하나죠.
제대로 이해하면 성능을 크게 높일 수 있어요."
Riverpod의 캐싱은 같은 파라미터로 요청하면 이전 결과를 재사용하는 기능입니다. family Provider는 파라미터별로 별도의 인스턴스를 생성하고 캐싱합니다.
이를 통해 불필요한 API 호출을 줄이고 사용자 경험을 향상시킬 수 있습니다.
다음 코드를 살펴봅시다.
// 캐싱 동작 예제
@riverpod
Future<Product> product(ProductRef ref, String id) async {
print('API 호출: $id'); // 실제로 호출되는 시점 확인
final response = await http.get('/api/products/$id');
return Product.fromJson(response.data);
}
// 캐시 무효화
class ProductDetailPage extends ConsumerWidget {
final String productId;
@override
Widget build(BuildContext context, WidgetRef ref) {
final productAsync = ref.watch(productProvider(productId));
return Column(
children: [
// 새로고침 버튼
IconButton(
icon: Icon(Icons.refresh),
onPressed: () {
// 특정 파라미터의 캐시만 무효화
ref.invalidate(productProvider(productId));
},
),
// 전체 캐시 무효화
IconButton(
icon: Icon(Icons.refresh_outlined),
onPressed: () {
// 모든 product Provider 캐시 무효화
ref.invalidate(productProvider);
},
),
productAsync.when(
data: (p) => Text(p.name),
loading: () => CircularProgressIndicator(),
error: (e, s) => Text('에러'),
),
],
);
}
}
김플러터 씨는 앱을 테스트하면서 이상한 점을 발견했습니다. 상품 상세 페이지에 들어갔다가 뒤로가기를 누르고 다시 들어가면, 로딩 없이 바로 내용이 나타납니다.
"분명히 API를 호출하는 코드인데 왜 안 느리지?" 박리버 씨가 설명을 시작했습니다. "Riverpod가 자동으로 캐싱을 해줘서 그래요.
똑똑하죠?" 캐싱이란 정확히 무엇일까요? 쉽게 비유하자면, 캐싱은 마치 메모장과 같습니다. 누군가 "ABC123 상품 정보 알려줘"라고 물어보면 서버에 가서 찾아온 뒤 메모장에 적어둡니다.
나중에 또 같은 질문을 하면 서버에 가지 않고 메모장을 보고 바로 답해주는 것이죠. 이처럼 캐싱은 한 번 가져온 데이터를 저장해뒀다가 재사용하는 기능입니다.
Riverpod의 캐싱은 어떻게 동작할까요? productProvider('ABC123')을 처음 호출하면 실제로 API를 호출합니다. 그리고 결과를 메모리에 저장합니다.
중요한 점은 파라미터별로 따로 저장한다는 것입니다. productProvider('ABC123')과 productProvider('XYZ789')는 완전히 별개의 캐시를 갖습니다.
다시 productProvider('ABC123')을 호출하면 API를 호출하지 않고 저장된 결과를 즉시 반환합니다. 사용자는 로딩 없이 화면을 바로 볼 수 있습니다.
이것이 Riverpod의 핵심 강점입니다. 실무에서 왜 중요할까요? 사용자가 상품 목록을 스크롤하다가 마음에 드는 상품을 클릭합니다.
상세 페이지를 보다가 "음, 다른 것도 볼까?" 하고 뒤로가기를 누릅니다. 그리고 다시 같은 상품을 클릭할 수도 있습니다.
만약 캐싱이 없다면 매번 API를 호출해서 데이터 비용도 낭비되고 사용자는 로딩을 여러 번 봐야 합니다. 하지만 캐싱 덕분에 사용자는 부드러운 경험을 할 수 있습니다.
뒤로가기와 앞으로가기를 반복해도 즉시 화면이 나타나니까요. 특히 모바일 환경에서 데이터 절약은 정말 중요합니다.
위의 코드를 한 줄씩 살펴보겠습니다. print 문을 추가한 것을 주목하세요. 이렇게 하면 실제로 API가 호출되는 시점을 콘솔에서 확인할 수 있습니다.
같은 상품을 여러 번 클릭해도 print가 한 번만 찍히는 것을 볼 수 있을 겁니다. 새로고침 버튼을 보면 ref.invalidate(productProvider(productId)) 코드가 있습니다.
invalidate는 캐시를 무효화하는 메서드입니다. 이것을 호출하면 해당 파라미터의 캐시가 삭제되고, 다음에 다시 호출할 때 API가 실행됩니다.
사용자가 "최신 정보로 업데이트"를 원할 때 유용합니다. 전체 캐시 무효화 버튼은 ref.invalidate(productProvider) 형태로 파라미터 없이 호출합니다.
이렇게 하면 모든 파라미터의 캐시가 한꺼번에 삭제됩니다. 예를 들어 로그아웃할 때나 전체 데이터를 리프레시할 때 사용합니다.
캐싱의 생명주기는 어떻게 될까요? 기본적으로 Riverpod는 아무도 참조하지 않는 Provider를 자동으로 dispose 합니다. 예를 들어 상품 상세 페이지에서 나가면 해당 Provider를 감시하는 위젯이 없어지므로, 잠시 후 자동으로 정리됩니다.
다만 바로 정리되는 것은 아니고 약간의 유예 시간이 있어서, 사용자가 빠르게 뒤로갔다 앞으로 가면 캐시가 유지됩니다. keepAlive를 사용하면 이 동작을 제어할 수 있습니다.
keepAlive를 true로 설정하면 아무도 참조하지 않아도 캐시가 유지됩니다. 자주 쓰이는 데이터에 유용하지만, 메모리를 계속 차지하므로 신중하게 사용해야 합니다.
실제 프로젝트에서는 어떤 전략을 쓸까요? 일반적으로 사용자 프로필, 설정 정보 같은 것은 keepAlive를 켜서 앱이 실행되는 동안 계속 유지합니다. 반면 상품 목록, 검색 결과 같은 것은 자동 정리를 활용합니다.
장바구니, 좋아요 같은 것은 상태가 변경될 때마다 invalidate를 호출하여 최신 데이터를 보장합니다. 주의할 점도 있습니다. 초보 개발자들이 흔히 하는 실수는 캐시가 영원히 유지된다고 생각하는 것입니다.
앱을 종료하면 메모리가 초기화되므로 캐시도 사라집니다. 영구 저장이 필요하면 SharedPreferences나 Hive 같은 로컬 DB를 함께 사용해야 합니다.
또한 파라미터 객체의 동등성도 중요합니다. Record나 기본 타입은 값이 같으면 같다고 판단하지만, 일반 클래스는 참조가 다르면 다르다고 판단합니다.
그래서 앞에서 Record를 권장했던 것이죠. 다시 김플러터 씨의 이야기로 돌아가 봅시다. 김플러터 씨는 콘솔을 보며 신기해했습니다.
"정말로 첫 번째만 API가 호출되네요!" 박리버 씨가 미소를 지었습니다. "이제 Riverpod를 제대로 이해한 거예요.
캐싱까지 신경 쓰면 진짜 프로죠." 캐싱을 이해하면 성능 최적화와 사용자 경험 개선을 동시에 달성할 수 있습니다. 여러분도 앱을 만들 때 캐싱 전략을 꼭 고려해 보세요.
실전 팁
💡 - ref.invalidate()로 캐시를 수동으로 제어할 수 있습니다
- keepAlive를 사용하면 캐시 생명주기를 조절할 수 있습니다
이상으로 학습을 마칩니다. 위 내용을 직접 코드로 작성해보면서 익혀보세요!
댓글 (0)
함께 보면 좋은 카드 뉴스
Riverpod 3.0 쇼핑 앱 종합 프로젝트 완벽 가이드
Flutter와 Riverpod 3.0을 활용한 실무 수준의 쇼핑 앱 개발 과정을 단계별로 학습합니다. 상품 목록, 장바구니, 주문, 인증, 검색 기능까지 모든 핵심 기능을 구현하며 상태 관리의 실전 노하우를 익힙니다.
Riverpod 3.0 Retry 자동 재시도 완벽 가이드
Riverpod 3.0에 새로 추가된 Retry 기능을 활용하여 네트워크 오류나 일시적인 실패 상황에서 자동으로 재시도하는 방법을 배웁니다. 초급 개발자도 쉽게 따라할 수 있도록 실무 예제와 함께 설명합니다.
Riverpod 3.0 requireValue로 Provider 결합하기
Riverpod 3.0에 새로 추가된 requireValue를 활용하여 여러 Provider의 데이터를 효율적으로 결합하는 방법을 배웁니다. 비동기 데이터를 마치 동기 데이터처럼 다루는 실전 패턴을 소개합니다.
Flutter 3.0 Offline 데이터 영속화 완벽 가이드
Flutter 3.0에서 새롭게 추가된 Offline 데이터 영속화 기능을 배웁니다. Storage 인터페이스부터 SharedPreferences 활용, 실전 예제까지 실무에서 바로 사용할 수 있는 패턴을 배워봅시다.
Riverpod 3.0 Mutation으로 폼 제출 완벽 가이드
Riverpod 3.0의 새로운 Mutation 기능으로 로그인과 회원가입 폼을 우아하게 처리하는 방법을 배웁니다. 로딩 상태, 에러 처리, 성공 처리까지 실무에서 바로 쓸 수 있는 패턴을 익혀보세요.