레미도 안 알려주는 5가지 Flutter Riverpod 실전 팁

Ximya(심야)·2025년 2월 16일
9
post-thumbnail

Riverpod(리버팟)을 사용한 지 약 1년이 조금 넘어가네요. 이 기간 동안 총 2개의 프로덕션 앱을 Riverpod으로 구현하거나 전환하였고 이 과정에서 터득한 크고 작은 몇 가지 저만의 실전 팁들이 있습니다.

그리고 이런 팁들은 아래와 같은 고민에서 비롯되었습니다.

  • 전역 Provider가 편리하긴 한데... 어디서나 접근 가능하다는 점이 너무 많은 위험 요소를 불러오는 건 아닐까?
  • 특정 화면에서 전달받은 Argument(인자)를 어떻게 뎁스가 깊은 위젯 구조에서도 효과적으로 전달하고 관리할 수 있을까?
  • WidgetRef에 직접 접근하기 어려운 곳에 provider에 접근해야 한다면 어떻게 해야 할까?
  • Async Provider로부터 값을 참조할 때 requiredValue, value와 같은 extension 메서드를 자주 쓰는데, StateError + null 오류가 자주 발생해...
  • 각 페이지에서 사용되는 ConsumerWidget을 더 쉽고 편리하게 사용하기 위해 유틸리티 클래스 형태로 모듈화할 수는 없을까?

Riverpod을 사용하면서 저와 비슷한 고민들을 해오신 분들께는 꽤 쏠쏠한 인사이트를 발견해 나가실 수 있을 거라 생각됩니다.

아, 참고로 제목에 언급한 레미(Remi Rousselet)는 Riverpod, Provider, Freezed 등등 여러 수만 명이 사용하는 패키지를 만든 유명 컨트리뷰터 입니다.


1. Mixin Class로 전역 Provider를 구조화하기

전에 이 주제로 글을 작성했던 적이 있습니다.

👉 Mixin Class를 활용하여 Riverpod 단점 극복하기

해당 글을 간단히 요약하면, Riverpod의 Provider는 전역 상태(top-level)로 선언되어 어디서든 간단히 import만 하면 접근할 수 있다는 메커니즘은 여러 장점이 있지만, 역설적이게도 특정 페이지에서 어떤 Provider가 사용되는지 파악하기 어렵다는 단점으로 이어짐으로 인해 여러 사이드 이펙트가 발생한다는 것이었죠.

이를 해결하기 위해 각 화면에서 사용되는 상태(state)이벤트(event)Mixin Class로 구조화하는 방안을 제안했었습니다. 아래 예제를 확인해 볼까요.

HomeState

mixin class HomeState {    
  List<Todo> filteredTodos(WidgetRef ref) => ref.watch(filteredTodosProvider);  

HomeEvent

mixin class HomeEvent {  
  void addTodo(  
    WidgetRef ref, {  
    required TextEditingController textEditingController,  
    required String value,  
  }) {  
    ref.read(todoListProvider.notifier).add(value);  
    textEditingController.clear();  
  }  
}

HomePage

위에 코드에서 확인하실 수 있듯이, 특정 화면에서 Provider의 상태를 참조하는 로직은 State Mixin Class에, 그리고 상태를 변경하거나 참조하여 특정 이벤트를 실행하는 로직을 Event Mixin Class로 구조화하여 적용하는것 입니다.

이 구조의 장점 중 하나는 Provider 사용 범위를 명확히 구분하고 쉽게 추적할 수 있게 도와준다는 점입니다.

특정 페이지에서 어떤 상태를 전달받고 어떤 이벤트가 발생하는지 오직 2개의 Class(Mixin)로만 유추할 수 있다는 점은 작업자와 주변 동료들에게 큰 안정감을 주죠. 이는 우리가 좋은 변수명을 짓는 이유와 비슷합니다.

그리고 코드의 가독성을 높이면서 동시에 복잡도를 낮춰주는 부분에 대해서도 꽤 큰 이점이 있습니다. 예를 들어 ProductDetail 페이지에서 특정 이벤트가 실행될 때, Home 페이지에서 특정 버튼을 클릭했을 때 트리거 되는 이벤트를 동일하게 실행해야 되어야 하고 HomePage에서 버튼을 클릭할 때 실행되는 메서드는 아래와 같이 UI 코드 영역에 정의되어 있다고 가정 해볼게요.

/// HomePage의 하위 위젯중 하나
Button(
  onPress : () {
     ref.read(aProvider).someIntent();
     ref.read(bProvider).someIntent();
     ref.read(aProvider).someIntent();
     ref.read(bProvider).someIntent();
	 ref.read(fProvider).someIntent();
	
  }
)

ProductDetailPage에서 HomePage에서 Button이 클릭되었을 때와 동일한 이벤트를 트리거하기 위해서는 해당 이벤트를 별도로 추출해서 별도의 공통 모듈을 만들거나, 그다지 좋은 방법은 아니지만 저 코드를 그대로 복사하여 ProductDetail 페이지에 위치시킬 수도 있겠습니다.

mixin class HomeEvent {
  void onBtnTapped() {
	 ref.read(aProvider).someIntent();
     ref.read(bProvider).someIntent();
     ref.read(aProvider).someIntent();
     ref.read(bProvider).someIntent();
	 ref.read(fProvider).someIntent();
	}
}

그런데 만약 HomeEvent라는 Event Mixin Class에 버튼을 클릭했을 실행되는 로직을 관리하고 있었다면

mixin class ProductDetailEvent {
  void onBtnTapped() {
     final homeEvent = HomeEvent(); // <-- Mixin Class를 인스턴스화
     homeEvent.onBtnTapped();
  }
}

위와같이 해당 Mixin Class를 인스턴스화해서 필요한 메소드들을 간단하게 실행시킬 수 있겠죠. mixin이 아닌 mixin class를 쓰는 이유가 여기에 있습니다.


2. ProviderScope를 사용하여 페이지 argument(인자)를 효과적으로 전달하고 관리하기

페이지에서 다른 페이지로 Argument(인자)를 전달하는 경우는 굉장히 흔합니다.

예를 들어 커머스 앱이라고 가정해보면, 상품 리스트 페이지에서 특정 상품 상세 페이지로 이동할 때, 상품의 고유 id값을 함께 넘겨 받아 상세 페이지에서는 상품의 상세한 정보를 불러오는 로직이 고려되어야 합니다.

class ProductDetailPage extends ConsumerWidget {  
  const ProductDetailPage({super.key, required this.id});  
  
  final String id;  
  
    
  Widget build(BuildContext context, WidgetRef ref) {  
    return _Scaffold(  
      header: _Header(id),  
      leadingInfoView: _LeadingInfoView(id),  
      imgListView: _ImgListView(id),  
      sizeInfoView: _SizeInfoView(id),  
      descriptionView: _DescriptionView(id),  
      reviewListView: _ReviewListView(id),  
      priceInfoView: _PriceInfoView(id),  
      bottomFixedButton: _BottomFixedButton(id),  
    );  
  }  
}

코드로 확인해보면 ProductDetailPage라는 상세 페이지에서는 상품의 id값을 Argument로 전달받고 있고, 페이지에서 각 섹션별로 위젯의 별도의 클래스로 분리되어 UI가 구조화된 형태입니다. 그리고 각 분리된 위젯에서는 id 값을 기반으로 상품의 상세 정보를 호출하는 로직이 고려되어 있습니다.

이전에 UI 코드의 구조화와 관련해서 자세히 작성한 글이 있으니 참고 바랍니다
👉 내일 바로 써먹는 Flutter Clean UI Code

뎁스가 깊은 UI 코드에서 Route Argument를 참조해야 될 때 발생하는 이슈

가독성을 높이고 상태에 변화에 따른 불필요한 리빌드를 최소화하기 위해 UI를 구조화했지만, 페이지의 자식 위젯중 대부분이 상품의 상세 정보(리뷰, 상세 설명)를 호출하기 위해 부모로 위젯으로부터 상품의 id 값을 계속 전달받아야 하는 구조이기 때문에 일일이 id값을 넘겨받는 과정이 꽤 번거로워 지게 됩니다.

만약 화면에서 전달되는 Argument 타입이 바뀌면, 자식 위젯들에서도 모두 일일이 타입을 변경해줘야 하고, 더 깊은 자식 위젯에서 또 id가 필요하면 계속 내려줘야 하죠. 그리고 이걸 전문적인 용어로 State(Prop) Drilling이라고 부릅니다.

‘그렇다면 자식 위젯을 분리하지 않고, 모든 코드를 한 파일에 몰아넣으면 되지 않나?’ 할 수도 있지만, 그렇게 하면 긴 스파게티 코드로 인해 유지 보수가 매우 어려워집니다.

이 문제를 해결하기 위해, 저는 GetX처럼 정적(static)으로 Argument를 관리하거나, InheritedWidget을 직접 구현하여 Argument를 관리하는 등 여러 시도를 해봤는데요. 각각의 방식마다 장점이 있긴 해도, 명확한 한계점도 존재했습니다. 그래서 도출해낸 방법이 바로 ProviderScope를 이용하는 것입니다.

새로운 ProviderScope에서 Argument Provider 초기화하기

방법은 간단한데요. 먼저 Argument를 저장하고 관리할 Provider를 선언해줍니다. (본 글에서는 해당 Provider를 Argument Provider로 지칭합니다)

/// 일반 provider 선언문
class ProductDetailArgumentNotifier extends StateNotifier<String> {  
  ProductDetailArgumentNotifier(super.state);  
}  
  
final productDetailArgumentProvider = Provider.autoDispose<String>(  
  (ref) => throw Exception('argument를 초기화 시켜 주어야 합니다'),  
);


/// Anotation 적용 provider 선언문
part 'product_detail_page_argument_provider.g.dart';

  
String productDetailArgument(Ref ref) {  
  throw Exception('argument를 초기화 시켜 주어야 합니다');  
}

해당 Provider는 Route Argument 값을 동적으로 받아 초기화 되어야 하므로, state값을 지정하지 않고, Exception을 리턴하도록 합니다. 또한 화면 위젯이 해제될 때 Argument Provider 또한 위젯 트리에서 제거되어야 하기 때문에 꼭 AutoDispose Provider로 선언해주는게 좋습니다.

class ProductDetailPage extends ConsumerWidget {  
  const ProductDetailPage({super.key, required this.id});  
  
  final String id;  
  
    
  Widget build(BuildContext context, WidgetRef ref) {  
    return ProviderScope(  
      overrides: [  
        /// 'overrideWithValue' 메소드를 사용하여 argument를 초기화
        productDetailArgumentProvider.overrideWithValue(id), 
      ],  
      child: Consumer(  
        builder: (context, ref, _) {  
          return _Scaffold(...);  
        },  
      ),  
    );  
  }  
}

그리고 페이지 위젯의 build 메서드 최상단 위치에 ProviderScopeConsumer로 감싸준 뒤 ProviderScopeoverrides 속성에 이전에 선언한 Argument Provider(productDetailArgumentProvider)를 overrideWithValue(id)로 초기화하면 준비는 끝납니다.

⚠️ NOTE
ProviderScope에서 Argument Provider를 override한 뒤에야 해당 값이 초기화되므로, 반드시 ProviderScope 바로 하위에 있는 Consumer 위젯(혹은 HookConsumer)에서 ref를 통해 안전하게 접근해야 합니다.

이제 하위 위젯에서는 아래처럼 WidgetRef만 있으면 바로 Argument Provider를 참조하여 id에 접근할 수 있는 구조가 되었습니다. 이제 상품의 id값을 참조하여 상품의 리뷰 리스트를 불러오는 코드를 확인해볼까요.

/// 상품 리뷰 뷰
class _ReviewListView extends ConsumerWidget {  
  const _ReviewListView({super.key});  
    
  // ⬇️ 이제 부모 위젯에서 직접 id를 전달 받을 필요가 없어요!  
  // const _ReviewListView({super.key, required this.id}); 
  // final String id;  

  Widget build(BuildContext context, WidgetRef ref) {  
    final id = ref.read(productDetailArgumentProvider);  
    final reviewListAsync = ref.watch(reivewListProvider(id));  
    return ...  
  }  
}

이렇게 부모 위젯으로 직접 id를 전달받을 필요 없이 이전에 선언한 Argument ProviderWidgetRef를 통해 위젯의 build 메서드에서 참조해주면 되고,

별도의 파라미터를 계속 넘겨주지 않아도 되므로, 한 페이지에서 다루는 상태가 많거나 자식이 위젯의 뎁스가 깊을수록 굉장히 편리해집니다.

Argument Provider와 State, Event Mixin 활용 방법

앞서 소개한 Mixin Class 방식을 함께 적용해보면, Family Provider처럼 Route Argument를 기반으로 초기화되는 Provider와 관련된 로직이 훨씬 간단해집니다.

mixin class ProductDetailState {  
  /// ✅ GOOD
  AsyncValue<List<Review>> reviewsAsync(WidgetRef ref) {  
    final id = ref.read(productDetailArgumentProvider);  
    return ref.watch(reivewListProvider(id));  
  }  

  /// ❌ BAD
 AsyncValue<List<Review>> reviewsAsync(WidgetRef ref, {required String id}) {  
    return ref.watch(reivewListProvider(id));  
  }  
}  

Mixin Class 내부 메서드에서는 Route Argument Provider를 통해 id를 직접 참조할 수 있기 때문에 Argument를 전달받는 파라미터를 설정해주지 않고 WidgetRef만 넘겨줍니다. 그리고 넘겨받은 WidgetRef를 통해 Route Argument, 즉 id 값을 참조하고 해당 id값을 상품의 리뷰 목록을 불러오는 reivewListProvider라는 Family Provider를 watch하여 반환해 주고요.

이제 위젯단에서 Mixin Class 메서드에 접근하도록 구성해보면 이전과 가독성적인 측면에서 차이를 확인해볼 수 있습니다. 훨씬 더 코드가 간결해지지 않았나요? 이뿐만 아니라 만약 상품 리스트 값을 필요로 하는 위젯이 많아질수록 Mixin Class를 동일하게 참조하면 되기 때문에 중복 코드를 줄이고 유지 보수에도 유리해진다는 이점을 가집니다.

Provider에서 직접 Argument를 직접 참조하는 방식

조금 더 간결한 방법도 있습니다. 만약 특정 Provider가 페이지의 Argument값에 직접 의존하여 초기화되는 Family Provider 형태라면, Argument를 파라미터로 전달받지 않고Argument Provider를 직접 참조하는 방식으로 구성하는 것이죠.

/// 일반 provider 선언문
class Reviews extends AsyncNotifier<List<String>> {  
    
  Future<List<Review>> build() async {  
  final id = ref.read(productDetailArgumentProvider); // <- 여기서 Route Argument를 참조 
return await productRepository.getReviews(id);
  }  
}  
  
final reviewsProvider = AsyncNotifierProvider<ReviewList, List<String>>(  
  dependencies: [productDetailArgumentProvider],  
  () => ReviewList(),  
);


/// Anotation 적용 provider 선언문
part 'review_list_provider.g.dart';  
  
(dependencies: [productDetailArgument])  
class Reviews extends _$ReviewList {  
    
  FutureOr<List<Review>> build() async {  
final id = ref.read(productDetailArgumentProvider);  // <- 여기서 Route Argument를 참조  
return await productRepository.getReviews(id);
  }  
}

그러기 위해서는 해당 Provider의 dependencies에 Argument Provider를 설정해주어야 합니다. 이유는 서로 다른 ProviderScope에 Provider가 초기화되었기 때문입니다. dependencies가 설정되었다면 Notifier의 build 메서드 내부에서 Argument Provider를 문제 없이 참조하여 필요한 동작을 수행할 수 있습니다.

⚠️ NOTE
dependencies가 설정되면 Argument Provider가 초기화되지 않는 다른 화면에서는 해당 Provider를 정상적으로 접근할 수 없으니, 해당 Provider가 특정 화면에서만 명확히 사용되는 형태일 경우에만 이런 방식을 사용하는 것이 권장됩니다.


3. UncontrolledProviderScope, ProviderContainer를 통해 WidgetRef가 없는 곳에서도 provider에 접근하기

앱을 개발하다 보면 WidgetRef에 접근할 수 없는 곳에서 특정 Provider에 접근해야 하는 경우가 있습니다.

FirebaseMessaging.onMessage.listen((RemoteMessage message) {  
  ...  
  if(message.category == 'home') {  
  /// 현재 WidgetRef가 없어서 ref.read(...) 불가능
  ref.read(bottomNavigationIndex.notifier).changeIndex(0);  
  }  
    
});

예를 들어 FCM 푸시 알림을 수신했을 때, Provider의 상태를 변경하여 바텀 네비게이션 인덱스를 변경해야 할 수 있지만, 일반적으로 presentation 레이어가 아닌, 앱 진입 단계에서 등록되는 fcm listen 메서드에서는 WidgetRef에 접근하기 어렵죠. 어떻게 이 문제를 해결할 수 있을까요?

ProviderContainer에 실마리가 있습니다.

final globalContainer = ProviderContainer();

먼저 전역에 ProviderContainer를 하나 선언해둡니다.

  runApp(
    UncontrolledProviderScope(
      container: globalContainer, // <- 여기!
      child: ProviderScope(
        child: MyApp(),
      ),
    ),
  );

그다음 앱 실행의 시작점에 있는 ProviderScope의 상단에 속성에 UncontrolledProviderScope 위젯을 감사주고 container 속성에 선언한 ProviderContainer를 넘겨주어 앱 전체에서 동일한 전역 상태를 참조하고 관리할 수 있도록 해주면 됩니다.

UncontrolledProviderScope는 이미 외부에서 생성된 ProviderContainer를 위젯 트리 하위로 전달할 때 사용됩니다. 보통의 ProviderScope는 내부에서 새로운 ProviderContainer를 생성하고, 위젯 트리와 함께 해당 컨테이너의 생명주기를 관리(예: dispose)하지만, UncontrolledProviderScope는 외부에서 미리 생성한 컨테이너를 그대로 주입하며, 생명주기를 관리하지 않는다는 점이 다릅니다. 결과적으로 앞서 선언한 ProviderContainer는 어떠한 위젯트리의 라이프 사이클에도 영향을 받지 않기 때문에 ProviderScope 대신 UncontrolledProviderScope를 사용하는 것이죠.

비슷하게 ProviderContainer위젯이 아니기 때문에 Flutter 위젯 트리 밖에서도 사용 가능합니다. 이런 특징으로 보통은 테스트를 할 때 주로 사용되지만, 이번에는 WidgetRef가 없는 레이어 또는 모듈에서 특정 Provider를 참조할 수 있도록 설정해줄 수 있습니다.

FirebaseMessaging.onMessage.listen((RemoteMessage message) {  
  ...  
  if(message.category == 'home') {  
  providerContainer.read(bottomNavigationIndex.notifier).changeIndex(0);    
  }  
});

이제 이렇게 WidgetRef를 접근하지 못하는 레이어 또는 모듈에서도 globalContainer로 Provider에 접근하여 상태를 변경할 수 있습니다.

ProviderScope.containerOf(context).read(bottomNavigationIndex.notifier).changeIndex;

만약 WidgetRef는 없지만 BuildContext에 접근할 수 있는 상태라면 ProviderScope.containerOf 메서드를 사용하는 것도 괜찮습니다. 현재 위젯 트리에서 가장 가까운 ProviderScope가 관리하는 ProviderContainer를 가져와 Provider를 참조할 수 있게 됩니다.


4. Async Provider의 상태를 가장 안전하게 참조하는 방법

Async Provider의 비동기 상태를 참조해야 될 때 유의해야 하는 부분이 있습니다.

class CartItems extends _$CartItems {  
    
  Future<List<ProductEntity>> build() async {  
    ...  
  }  
}

위는 유저의 '장바구니' 상품 목록 리스트를 서버로부터 호출하여 관리하는 Async Provider입니다. 유저의 취향이 가장 잘 드러난 데이터이기 때문에 여러 섹션에서 해당 Provider를 참조하는 경우가 빈번하다고 가정해 봅시다.

뭐 예를 들어 특정 상품을 구매한 이후 유저의 장바구니에 담겨 있는 상품을 함께 노출하여 구매 전환율을 높이는 기능이 있을 수도 있겠죠.

Future<void> promptUserToPurchase(WidgetRef ref){
final cardItemsA = ref.read(cartItemsProvider).value;  
final cardItemsB = ref.read(cartItemsProvider).valueOrNull;  
final cardItemsC = ref.read(cartItemsProvider).requireValue;
}

그러려면 먼저 장바구니의 있는 아이템 리스트를 불러와야 하기 때문에 cartItemsProvider를 참조해야 됩니다. 그리고 cartItemsProvider는 비동기적으로 초기화되는 Async Provider이기 때문에 비동기적으로 호출된 값에 접근하기 위해서는 value, requiredValue 같은 extension 메서드를 사용해볼 수 있겠습니다.

그런데 잠깐, promptUserToPurchase라는 메서드를 실행할 때 cartItemsProvider가 비동기적으로 초기화된 것을 보장할 수 있을까요? 만약 Provider가 비동기적으로 초기화되지 않았다면 현재 상태가 AsyncValue(loading 또는 error)일 것이고, 로직이 정상 작동하지 않거나 requireValue를 사용했다면 아래와 같은 오류가 발생할 겁니다.

'Tried to call `requireValue` on an `AsyncValue` that has no value: $this'

그래도 해당 메서드를 실행하는 시점에 비동기 Provider가 초기화된다는 것이 확실히 보장된다고 판단되면 value 또는 requireValue를 사용해도 문제는 없습니다.

하지만 우리의 프로젝트는 수없이 기획 정책이 변경되고, 작업자의 기억이 희미해지기 때문에 해당 메서드가 트리거 되는 시점에 특정 Async Provider가 비동기적으로 초기화되어 있는지 판단하기 힘든 상황이 분명 오게 됩니다. 그 당시에는 해당 이벤트가 트리거 되는 시점에 Async Provider가 이전에 초기화된다는 것이 당연했지만, 시간이 지나면 당연한 것이 그렇지 않게 되는 순간이 분명히 오게 된다는 것이죠.

그렇기 때문에 Async Provider를 참조할 경우에는 비동기적으로 호출이 완료되어 생성된 Provider라는 것이 분명하다고 해도 조금 더 안전하게 상태를 참조해야 합니다.

방법은 간단해요.

Future<void> promptUserToPurchase() async {  
  // cartItemsProvider가 로딩 중이라면 완료될 때까지 기다림  
  // 이미 로딩이 끝났다면 기존 데이터를 바로 리턴  
  final cartItems = await ref.read(cartItemsProvider.future);  
}

.future 프로퍼티를 사용하면, Provider가 아직 로딩 상태일 경우에도 비동기 호출 및 초기화가 완료될 때까지 await하고 이후 상태를 반환합니다. Provider가 이미 초기화되어 있다면 기존 상태를 즉시 반환하니, 어떤 경우에도 안전하게 값을 얻을 수 있죠.

결과적으로 Async Provider 초기화 여부와 상관없이 항상 안전하게 상태를 참조할 수 있게 됩니다.

이렇게 여러 섹션에서 사용되는 Async Provider 상태를 참조해야 될 때에는 습관적으로 .future를 사용하는 것이 우리 개발자들의 정신 건강에 이롭습니다 🥲


5. 궁극의 Riverpod 페이지 유틸리티 클래스

마지막으로, 프로젝트를 진행하면서 저는 공통적으로 사용하는 상태관리 패키지(이 글에서는 Riverpod)에 맞게 '페이지 유틸리티 클래스'를 만들어두곤 합니다.

'궁극의 Riverpod 화면 유틸리티 클래스'라고 조금 거창하게 제목을 지었지만 사실 별거 없습니다. 한 페이지를 구성하는 위젯에서 자주 사용하는 로직을 BasePage(또는 BaseScreen) 형태로 뽑아 모듈화해둔 정도입니다. 그리고 각 페이지가 이 BasePage를 상속받아 필요한 부분만 오버라이드하는 식이죠.

BasePage 코드 자체를 일일이 설명하지는 않겠습니다. 대신 글 하단에 ‘BasePage’ 원본 코드를 첨부했으니 참고 바랍니다.

라이플 사이클 메소드

class ProductDetailPage extends BasePage {  
  const ProductDetailPage({super.key});  

  /// 페이지 위젯이 위젯트리에 생성되었을 때
    
  void onInit(WidgetRef ref) {  
    ...  
  } 

  /// 페이지 위젯이 위젯트리에서 해제될 때
    
  void onDispose(WidgetRef ref) {  
    ...  
  }
  
    
  Widget buildPage(BuildContext context, WidgetRef ref) {  
    return Container();  
  }  
}

보통 각 페이지가 생성되거나 해제될 때, 특정 이벤트를 트리거해야 하는 경우가 있습니다. 그럴 때 BasePage에 정의된 라이프사이클 메서드를 오버라이드하면 됩니다.

Argument Provider 초기화

앞서 ProviderScope를 활용하여 페이지에서 관리하는 라우트 Argument를 효과적으로 관리하는 방법에 대해 다루었습니다. 그러기 위해서는 각 페이지 위젯에서 최상단 부분에서 ConsumerWidgetProviderScope를 차례대로 감싸주고 Argument Provider를 초기화해줘야 했었는데요.

class ProductDetailPage extends BasePage {  
  const ProductDetailPage({super.key});  
    
    
  Override? get argProviderOverrides => productDetailRouteArgProvider;  
    
    
  Widget buildPage(BuildContext context, WidgetRef ref) {  
    return Placeholder();  
  }  
}

해당 로직을 BasePage로 편입시켜 위 코드와 같이 Argument Provider를 넘겨주어 Scope를 지정하고 Provider가 해당 Scope에서 초기화되도록 설계하였습니다. 조금 더 간결해 보이죠?

레이아웃 속성 설정

일반적으로 저희는 Scaffold 위젯을 사용하여 각 페이지의 레이아웃을 구성합니다. 그리고 이 Scaffold 위젯에서는 appBar, body 속성에 화면에 보일 위젯을 설정해주거나, backgroundColor, extendBodyBehindAppBar 같은 속성에는 필요한 여러 레이아웃 값을 설정해주곤 합니다. 그리고 때로는 Scaffold 위에 PopScope 같은 위젯을 감싸 필요한 제스처 설정들을 해주기도 하고요.

  
Widget build(BuildContext context, WidgetRef ref) {  
  return PopScope(  
    onPopInvokedWithResult: (didPop, result) async {...},  
    child: Scaffold(  
      backgroundColor: Colors.white,  
      resizeToAvoidBottomInset: true,  
      extendBodyBehindAppBar: true,  
      appBar: AppBar(),  
      floatingActionButton: FloatingActionButton(),  
      drawer: Drawer(),  
      body: SafeArea(...),  
    ),  
  );  
}

이런 자잘한 레이아웃 속성을 매번 페이지마다 만들다 보면, 정작 중요한 본연의 UI(body)와 섞여서 가독성이 떨어질 수 있죠.

그래서 BasePage를 상속하는 페이지에서는 ‘body가 되는 위젯만’ buildPage에서 작성하고, 나머지 속성(예: AppBar, BackgroundColor 등)은 오버라이드 메서드로 설정하게 만들어줍니다.

class ProductDetailPage extends BasePage {  
  const ProductDetailPage({super.key});  
    
    
  Widget buildPage(BuildContext context, WidgetRef ref) {  
    ...  
  }  
  
    
  PreferredSizeWidget? buildAppBar(BuildContext context, WidgetRef ref) {...}  
  
    
  Widget? buildFloatingActionButton(WidgetRef ref) {...}  
  
    
  Color? get screenBackgroundColor => Colors.white;  
  
    
  bool get wrapWithSafeArea => false;  
  
    
  bool get extendBodyBehindAppBar => false;  
  
    
  void onWillPop(WidgetRef ref) {...}  
}

기능적으로는 별 차이가 없지만, 페이지마다 어떤 레이아웃 옵션을 쓰고 있는지를 한눈에 파악하기 쉬워지고, 자주 쓰는 인터랙션(라이프사이클, WillPop 등)도 쉽게 커스터마이징할 수 있는 장점이 있습니다.

이외에도 여러 다양한 속성 및 기능들이 정의되어 있으니 아래는 원본 BasePage 코드를 참고 부탁드립니다.

abstract class BasePage extends HookConsumerWidget {  
  const BasePage({Key? key}) : super(key: key);  
  
    
  Widget build(BuildContext context, WidgetRef ref) {  
    /// 페이지의 초기화 및 해제를 처리  
    useEffect(  
      () {  
        onInit(ref);  
        return () => onDispose(ref);  
      },  
      [],  
    );  
  
    /// 앱의 라이플 사이클 변화를 처리  
    useOnAppLifecycleStateChange((previousState, state) {  
      switch (state) {  
        case AppLifecycleState.resumed:  
          onResumed(ref);  
          break;  
        case AppLifecycleState.paused:  
          onPaused(ref);  
          break;  
        case AppLifecycleState.inactive:  
          onInactive(ref);  
          break;  
        case AppLifecycleState.detached:  
          onDetached(ref);  
          break;  
        case AppLifecycleState.hidden:  
          onHidden(ref);  
      }  
    });  
  
    return PopScope(  
      canPop: canPop,  
      onPopInvokedWithResult: (didPop, result) async {  
        if (didPop) return;  
        onWillPop(ref);  
      },  
      child: ProviderScope(  
        overrides: argProviderOverrides != null ? [argProviderOverrides!] : [],  
        child: AnnotatedRegion<SystemUiOverlayStyle>(  
          value: SystemUiOverlayStyle(  
            systemNavigationBarColor: Colors.white,  
            systemNavigationBarIconBrightness: Brightness.dark,  
            statusBarColor: Colors.transparent,  
            statusBarBrightness: statusBarBrightness,  
            statusBarIconBrightness: statusBarBrightness,  
          ),  
          child: HookConsumer(  
            builder: (context, ref, child) {  
              return GestureDetector(  
                onTap: !preventAutoUnfocus  
                    ? () => FocusManager.instance.primaryFocus?.unfocus()  
                    : null,  
                child: Container(  
                  color: unSafeAreaColor,  
                  child: wrapWithSafeArea  
                      ? SafeArea(  
                          top: setTopSafeArea,  
                          bottom: setBottomSafeArea,  
                          child: _buildScaffold(context, ref),  
                        )  
                      : _buildScaffold(context, ref),  
                ),  
              );  
            },  
          ),  
        ),  
      ),  
    );  
  }  
  
  Widget _buildScaffold(BuildContext context, WidgetRef ref) {  
    return Scaffold(  
      extendBody: extendBodyBehindAppBar,  
      resizeToAvoidBottomInset: resizeToAvoidBottomInset,  
      appBar: buildAppBar(context, ref),  
      body: buildPage(context, ref),  
      backgroundColor: screenBackgroundColor,  
      bottomNavigationBar: buildBottomNavigationBar(context),  
      bottomSheet: buildBottomSheet(ref),  
      floatingActionButtonLocation: floatingActionButtonLocation,  
      floatingActionButton: buildFloatingActionButton(ref),  
    );  
  }  
  
  /// 하단 네비게이션 바를 구성하는 위젯을 반환  
    
  Widget? buildBottomNavigationBar(BuildContext context) => null;  
  
    
  Widget? buildBottomSheet(WidgetRef ref) => null;  
  
  /// 상단 status bar(노치바 영역) 텍스트 overlay style  /// 값을 설정하여 상단 텍스트 색상을 조정할 수 있음  
  Brightness get statusBarBrightness =>  
      Platform.isIOS ? Brightness.light : Brightness.dark;  
  
  /// 화면 페이지의 본문을 구성하는 위젯을 반환  
    
  Widget buildPage(BuildContext context, WidgetRef ref);  
  
  /// 화면 상단에 표시될 앱 바를 구성하는 위젯을 반환  
    
  PreferredSizeWidget? buildAppBar(BuildContext context, WidgetRef ref) => null;  
  
  /// 화면에 표시될 플로팅 액션 버튼을 구성하는 위젯을 반환  
    
  Widget? buildFloatingActionButton(WidgetRef ref) => null;  
  
  /// 뷰의 안전 영역 밖의 배경색을 설정  
    
  Color? get unSafeAreaColor => AppColor.of.white;  
  
  /// 키보드가 화면 하단에 올라왔을 때 페이지의 크기를 조정하는 여부를 설정  
    
  bool get resizeToAvoidBottomInset => true;  
  
  /// 플로팅 액션 버튼의 위치를 설정  
    
  FloatingActionButtonLocation? get floatingActionButtonLocation => null;  
  
  /// 앱 바 아래의 콘텐츠가 앱 바 뒤로 표시되는지 여부를 설정  
    
  bool get extendBodyBehindAppBar => false;  
  
  /// Swipe Back 제스처 동작을 막는지 여부를 설정  
    
  bool get canPop => true;  
  
  /// 화면의 배경색을 설정  
    
  Color? get screenBackgroundColor => AppColor.of.white;  
  
  /// SafeArea로 감싸는 여부를 설정  
    
  bool get wrapWithSafeArea => true;  
  
  /// 뷰의 안전 영역 아래에 SafeArea를 적용할지 여부를 설정  
    
  bool get setBottomSafeArea => true;  
  
  /// 뷰의 안전 영역 위에 SafeArea를 적용할지 여부를 설정  
    
  bool get setTopSafeArea => true;  
  
  /// 화면 클릭 시 자동으로 포커스를 해제할지 여부를 설정  
    
  bool get preventAutoUnfocus => false;  
  
  /// 앱이 활성화된 상태로 돌아올 때 호출  
    
  void onResumed(WidgetRef ref) {}  
  
  /// 앱이 일시 정지될 때 호출  
    
  void onPaused(WidgetRef ref) {}  
  
  /// 앱이 비활성 상태로 전환될 때 호출  
    
  void onInactive(WidgetRef ref) {}  
  
  /// 앱이 분리되었을 때 호출  
    
  void onDetached(WidgetRef ref) {}  
  
  /// 앱이 hidden 되었을 때 호출  
    
  void onHidden(WidgetRef ref) {}  
  
  /// 페이지 초기화 시 호출  
    
  void onInit(WidgetRef ref) {}  
  
  /// 페이지 해제 시 호출  
    
  void onDispose(WidgetRef ref) {}  
  
  /// will pop시  
    
  void onWillPop(WidgetRef ref) {}  
}

결국 이 BasePage의 목표는 한 페이지에서 자주 필요한 기능들을 모아 간편하게 쓸 수 있도록 하고, 레이아웃 속성들은 명시적으로 오버라이드하여 가독성과 일관성을 높이려는 데 있다고 볼 수 있습니다.

마무리하면서

이번 글에서는 Riverpod을 사용한다면 프로덕션 수준에서 적용해볼 만한 여러 방법에 대해 다루어 보았습니다. 어디까지나 제 경험을 바탕으로 한 팁들이니, 프로젝트 성격과 팀 스타일에 맞춰 적절히 변형해 쓰시면 좋을 것 같네요 :)

긴 글 읽어주셔서 항상 감사합니다 🙇

profile
https://medium.com/@ximya

7개의 댓글

comment-user-thumbnail
2025년 2월 18일

오 정말 좋은팁들이 많네요. 플러터로 앱 개발중인데 고민하던 부분을 해결하는데 도움이 됐습니다!

1개의 답글
comment-user-thumbnail
2025년 2월 19일

존경합니다..

1개의 답글
comment-user-thumbnail
2025년 2월 27일

좋은 내용 감사합니다! 리버팟 사용 중인데 고민했던 내용이 있어서 큰 도움이 되었습니다!

1개의 답글
comment-user-thumbnail
6일 전

이 분 글을 Medium에서 먼저 봤었는데 참 유용하다 생각하고 팔로우 했는데 한국 분이셨다니.. 영광입니다

답글 달기

관련 채용 정보