Logo
Published on
·18 min read

Flutter 문서 번역 - 상태(State) 관리 (2/2), provider 사용


이전 글에서 배운 상태(State) 관리에 대한 기본 개념을 바탕으로 provider를 사용하는 방법에 대해 알아봅니다.

이전 글(State management: Introduction, Think declaratively, Ephemeral vs app state) 번역은 Flutter 문서 번역 - 상태(State) 관리 (1/2), 기본 개념에서 확인할 수 있습니다.


간단한 app state 관리

이제 선언적 UI 프로그래밍과 ephemeral state와 app state의 차이에 대해 알았으니, 간단한 app state 관리에 대해 배울 준비가 되었습니다.

이 페이지에서는 provider 패키지를 사용합니다. Flutter가 처음이고 다른 접근 방식(Redux, Rx, hooks 등)을 선택할 강력한 이유가 없다면, 아마도 이 접근 방식부터 시작해야 할 것입니다. provider 패키지는 이해하기 쉽고, 많은 코드를 사용하지 않습니다. 또한 다른 모든 접근 방식에 적용 가능한 개념을 사용합니다.

즉, 다른 반응형 프레임워크의 state 관리에 대한 배경 지식이 있다면, options page에 나열된 패키지와 튜토리얼 목록에서 찾을 수 있습니다.

우리의 예제

설명을 위해, 다음과 같은 간단한 앱을 생각해 보세요.

example app

앱에는 catalog와 cart(각각 MyCatalogMyCart 위젯으로 표현됨)라는 두 개의 분리된 화면(screen)이 있습니다. 쇼핑 앱일 수도 있지만, 간단한 소셜 네트워킹 앱에서도 동일한 구조를 상상할 수 있습니다(catalog를 “wall”로, cart를 “favorites”으로 대체).

catalog 화면에는 사용자 정의 app bar(MyAppBar)와 많은 아이템 목록들(MyListItems)의 스크롤 뷰가 포함됩니다.

다음은 위젯 트리로 시각화된 앱입니다.

widget tree

Widget에는 최소 5개의 서브클래스가 있습니다. 이들 중 상당수는 다른 곳에 “속해 있는” state에 액세스해야 합니다. 예를 들어, 각 MyListItem은 자신을 cart에 추가할 수 있어야 합니다. 또한 현재 표시된 항목이 이미 cart에 있는지 확인할 수도 있습니다.

첫 번째 질문입니다: cart의 현재 state를 어디에 두어야 할까요?

state 위로 올리기 (Lifting state up)

Flutter에서는, 이를 사용하는 위젯 상위에 state를 두는 것이 합리적입니다.

이유는? Flutter와 같은 선언적 프레임워크에서 UI를 변경하려면 rebuild해야 합니다. MyCart.updateWith(somethingNew)를 사용할 쉬운 방법은 없습니다. 즉, 외부에서 메서드를 호출하여 위젯을 강제로 변경하는 것은 어렵습니다. 그리고 이 작업을 수행할 수 있다고 해도, 프레임워크의 도움을 받는 대신 프레임워크와 싸우게 될 것입니다.

// 나쁜 예: 이렇게 하지 마세요
void myTapHandler() {
  var cartWidget = somehowGetMyCartWidget();
  cartWidget.updateWith(item);
}

위 코드가 작동하더라도, MyCart 위젯에서 다음과 같은 문제를 해결해야 합니다.

// 나쁜 예: 이렇게 하지 마세요
Widget build(BuildContext context) {
  return SomeWidget(
    // cart의 초기 state
  );
}

void updateWith(Item item) {
  // 어떻게든 여기에서 UI를 변경해야 합니다.
}

UI의 현재 상태(state)를 고려해서 새 데이터를 적용해야 합니다. 이 방법으로는 버그를 피하기 어렵습니다.

Flutter에서는, 콘텐츠가 변경될 때마다 새 위젯을 구성(construct)합니다. MyCart.updateWith(somethingNew)(메서드 호출) 대신 MyCart(contents)(생성자)를 사용합니다. 부모의 build 메서드에서만 새 위젯을 구성할 수 있으므로, 콘텐츠를 변경하려면 MyCart의 부모 또는 그 상위에 있어야 합니다.

// GOOD
void myTapHandler(BuildContext context) {
  var cartModel = somehowGetMyCartModel(context);
  cartModel.add(item);
}

이제 MyCart에는 UI의 모든 버전을 만드는 하나의 코드 경로만 있습니다.

// GOOD
Widget build(BuildContext context) {
  var cartModel = somehowGetMyCartModel(context);
  return SomeWidget(
    // cart의 현재 state를 사용해서, UI를 한 번만 구성합니다.
    // (Just construct the UI once, using the current state of the cart.)
    // ···
  );
}

이 예제에서는, 콘텐츠MyApp에 있어야 합니다. 그것이 변경될 때마다, 상위의 MyCart가 다시 구성(rebuild)됩니다(이후에 더 설명하겠습니다). 이 때문에 MyCart는 라이프사이클을 걱정할 필요가 없습니다. 그냥 콘텐츠를 어떻게 보여줄지 선언합니다. 콘텐츠가 변경되면, 이전 MyCart 위젯은 사라지고 새로운 것으로 완전히 대체됩니다.

widget tree

이것이 위젯이 불변(immutable)하다는 것의 의미입니다. 변경되지 않습니다—교체됩니다.

이제 cart의 state를 어디에 두어야 할지 알았으니, 어떻게 액세스하는지 알아보겠습니다.

state에 액세스하기 (Accessing the state)

사용자가 카탈로그의 항목 중 하나를 클릭하면, cart에 추가됩니다. 그러나 cart가 MyListItem 상위에 있으니, 어떻게 해야 할까요?

간단한 옵션은, MyListItem이 클릭되었을 때 호출할 수 있는 콜백을 제공하는 것입니다. Dart의 함수는 일급 클래스 객체(first class object)이므로, 원하는 방식으로 전달할 수 있습니다. 따라서, MyCatalog 내에서 다음과 같이 정의할 수 있습니다:


Widget build(BuildContext context) {
  return SomeWidget(
    // 위젯을 구성하고 위 메서드에 대한 참조를 전달합니다.
    MyListItem(myTapCallback),
  );
}

void myTapCallback(Item item) {
  print('user tapped on $item');
}

이 방법은 괜찮지만, 다른 많은 곳에서 수정이 필요한 app state의 경우, 많은 콜백을 전달해야 합니다. 이것은 꽤 지겨운 일입니다.

다행히도, Flutter에는 위젯이 자손들에게 데이터와 서비스를 제공할 수 있는 메커니즘이 있습니다 (즉, 그들의 자식뿐만 아니라 그 하위의 모든 위젯들에게도 해당됩니다). 모든 것이 Widget™인 Flutter에서 예상할 수 있듯이, 이러한 메커니즘들은 특별한 종류의 위젯일 뿐입니다—InheritedWidget, InheritedNotifier, InheritedModel 등이 있습니다. 여기에서는 그것들을 다루지 않을 것입니다. 왜냐하면 우리가 하려는 것에 비해 다소 low-level이기 때문입니다.

대신, low-level 위젯으로 작동되지만 사용하기는 간단한 패키지를 사용할 것입니다. provider입니다.

provider로 작업하기 전, pubspec.yaml 파일에 해당 패키지의 dependency 추가를 잊지 마세요.

provider 패키지를 추가하려면, flutter pub add를 실행하세요:

flutter pub add provider

이제 import 'package:provider/provider.dart';와 작업을 시작할 수 있습니다.

provider를 사용하면, 콜백이나 InheritedWidget에 대해 걱정할 필요가 없습니다. 그러나 3가지 개념을 이해해야 합니다.

  • ChangeNotifier
  • ChangeNotifierProvider
  • Consumer

ChangeNotifier

ChangeNotifier는 Flutter SDK에 포함된 간단한 클래스로, 리스너에게 변경 알림을 제공합니다. 다시 말해, 어떤 것이 ChangeNotifier인 경우, 그것의 변경 사항을 구독할 수 있습니다(Observable의 한 형태입니다.).

provider에서 ChangeNotifier는 application state를 캡슐화하는 한 가지 방법입니다. 매우 간단한 앱의 경우, 단일 ChangeNotifier로 충분합니다. 복잡한 앱의 경우, 여러 모델과, 여러 ChangeNotifier가 있습니다.(provider와 함께 ChangeNotifier를 사용할 필요는 전혀 없지만, 사용하기 쉬운 클래스입니다.).

우리의 쇼핑 앱 예제에서, ChangeNotifier에서 cart의 state를 관리하려고 합니다. 다음과 같이, ChangeNotifier를 확장하는 새 클래스를 만듭니다:

class CartModel extends ChangeNotifier {
  /// 내부용, cart의 private state
  final List<Item> _items = [];

  /// cart 항목들의 변경 불가능한 뷰
  UnmodifiableListView<Item> get items => UnmodifiableListView(_items);

  /// 현재 모든 항목의 총 가격(모든 항목의 가격이 $42라고 가정).
  int get totalPrice => _items.length * 42;

  /// cart에 [item]을 추가. 이것과 [removeAll]은 외부에서 cart를 수정하는 유일한 방법.
  void add(Item item) {
    _items.add(item);
    // 이 호출은 해당 모델을 listen하는 위젯들에게 다시 빌드하라고 지시합니다.
    notifyListeners();
  }

  /// cart의 모든 항목을 제거.
  void removeAll() {
    _items.clear();
    // 이 호출은 해당 모델을 listen하는 위젯들에게 다시 빌드하라고 지시합니다.
    notifyListeners();
  }
}

ChangeNotifier를 특별하게 하는 유일한 코드는 notifyListeners() 호출입니다. 앱의 UI를 변경할 수 있는 방식으로 모델이 변경될 때마다 이 메서드를 호출하세요. CartModel의 다른 부분들은 모델 자체와 비즈니스 로직입니다.

ChangeNotifierflutter:foundation의 일부이며, Flutter의 higher-level 클래스에 의존하지 않습니다. 이것은 쉽게 테스트할 수 있으며 (이를 위한 widget testing를 사용할 필요가 없습니다). 예를 들어 다음은 CartModel의 간단한 유닛 테스트입니다:

test('adding item increases total cost', () {
  final cart = CartModel();
  final startingPrice = cart.totalPrice;
  var i = 0;
  cart.addListener(() {
    expect(cart.totalPrice, greaterThan(startingPrice));
    i++;
  });
  cart.add(Item('Dash'));
  expect(i, 1);
});

ChangeNotifierProvider

ChangeNotifierProvider는 자손들에게 ChangeNotifier의 인스턴스를 제공하는 위젯입니다. 이것은 provider 패키지에서 제공됩니다.

우리는 이미 ChangeNotifierProvider를 액세스가 필요한 위젯들의 상위에 놓아야 하는 것을 알고 있습니다. CartModel의 경우, MyCartMyCatalog 두 위젯보다 위에 놓여져야 합니다.

ChangeNotifierProvider를 필요 이상으로 높게 두고 싶지는 않습니다 (scope를 오염시키고 싶지 않기 때문에). 그러나 여기서는, MyCartMyCatalog 두 위젯의 상위에 위치한 유일한 위젯이 MyApp입니다.

void main() {
  runApp(
    ChangeNotifierProvider(
      create: (context) => CartModel(),
      child: const MyApp(),
    ),
  );
}

CartModel의 새 인스턴스를 생성하는 builder를 정의하는 것에 유의하세요.
ChangeNotifierProvider는 꼭 필요한 경우가 아니면 CartModel을 rebuild하지 않을 만큼 영리합니다. 또한 해당 인스턴스가 더 이상 필요하지 않을 때 자동으로 CartModeldispose()를 호출합니다.

하나 이상의 클래스를 제공한다면, MultiProvider를 사용할 수 있습니다:

void main() {
  runApp(
    MultiProvider(
      providers: [
        ChangeNotifierProvider(create: (context) => CartModel()),
        Provider(create: (context) => SomeOtherClass()),
      ],
      child: const MyApp(),
    ),
  );
}

Consumer

이제 상위에서 ChangeNotifierProvider을 선언해 앱의 위젯에 CartModel이 제공되었으므로, 사용할 수 있습니다.

이는 Consumer 위젯을 통해 수행됩니다.

return Consumer<CartModel>(
  builder: (context, cart, child) {
    return Text('Total price: ${cart.totalPrice}');
  },
);

액세스하려는 모델의 유형(type)을 지정해야 합니다. 여기서는, CartModel을 원하므로, Consumer<CartModel>을 씁니다. generic(<CartModel>)을 지정하지 않으면, provider 패키지는 도움을 줄 수 없습니다. provider는 유형에 기반하며, 유형이 없으면 원하는 것을 알 수 없습니다.

Consumer 위젯의 유일한 필수 argument는 builder입니다. Builder는 ChangeNotifier가 변경될 때마다 호출되는 함수입니다. (다시 말해, 모델에서 notifyListeners()를 호출하면, 모든 해당 Consumer 위젯의 builder 메서드가 호출됩니다.)

builder는 세 개의 argument로 호출됩니다. 첫 번째는 context로, 모든 build 메서드에서 얻을 수 있습니다.

builder 함수의 두 번째 argument는 ChangeNotifier의 인스턴스입니다. 우리가 애초에 요구한 것입니다. 모델의 데이터를 사용해 UI가 어떻게 보여야 하는지를 정의할 수 있습니다.

세 번째 argument는 최적화를 위한 child입니다. 모델이 변경되어도 바뀌지 않는 Consumer 하위의 큰 위젯 서브트리가 있는 경우, 한 번 구성한 후 builder를 통해 가져올 수 있습니다.

return Consumer<CartModel>(
  builder: (context, cart, child) => Stack(
    children: [
      // 매번 rebuild하지 않고, 여기에 SomeExpensiveWidget 사용
      if (child != null) child,
      Text('Total price: ${cart.totalPrice}'),
    ],
  ),
  // 비용이 많이 드는 위젯을 여기에 빌드
  child: const SomeExpensiveWidget(),
);

가능한 Consumer 위젯을 트리의 깊은 곳에 두는 것이 가장 좋습니다. 어딘가의 세부 사항이 변경되었다고 해서 UI의 큰 부분을 rebuild하고 싶지는 않습니다.

// 이렇게 하지 마세요
return Consumer<CartModel>(
  builder: (context, cart, child) {
    return HumongousWidget(
      // ...
      child: AnotherMonstrousWidget(
        // ...
        child: Text('Total price: ${cart.totalPrice}'),
      ),
    );
  },
);

대신:

// 이렇게 하세요
return HumongousWidget(
  // ...
  child: AnotherMonstrousWidget(
    // ...
    child: Consumer<CartModel>(
      builder: (context, cart, child) {
        return Text('Total price: ${cart.totalPrice}');
      },
    ),
  ),
);

Provider.of

가끔, UI 변경을 위한 모델 데이터는 필요하지 않지만, 액세스는 필요한 경우가 있습니다. 예를 들어, ClearCart 버튼은 사용자가 cart에서 모든 것을 제거할 수 있도록 합니다. cart의 내용을 표시할 필요가 없고, clear() 메서드만 호출하면 됩니다.

이를 위해 Consumer<CartModel>을 사용할 수 있지만, 이는 비효율적입니다. rebuild할 필요가 없는 위젯을 rebuild하도록 프레임워크에 요청하는 것입니다.

이런 경우, listen 매개변수(parameter)를 false로 설정해 Provider.of를 사용할 수 있습니다.

Provider.of<CartModel>(context, listen: false).removeAll();

build 메소드에서 위 코드를 사용하면, notifyListeners가 호출될 때 이 위젯이 rebuild 되지 않습니다.

종합하면

이 글에서 다룬 예제를 확인할 수 있습니다. 더 간단한 것을 원한다면, provider로 구축한 simple Counter app을 확인하세요.

이 글을 따라 하다 보면 상태-기반 애플리케이션을 만드는 능력이 크게 향상될 것입니다. 이러한 기술을 익히기 위해 provider로 애플리케이션을 직접 만들어 보세요.

state 관리 접근 방식 목록 (List of state management approaches)

원문, List of state management approaches에서 확인할 수 있습니다.