9 분 소요

👀 TL;DR

상태 관리란 데이터가 두 개 이상의 위젯에 공유될 때 이를 관리하기 위한 솔루션을 뜻해요.

  • 일시적(Ephemeral) 상태: 하나의 위젯에서만 관리되는 데이터
  • 앱(App) 상태: 둘 이상의 위젯에서 공유되는 데이터
  • 두 개 이상의 위젯에서 데이터를 공유하려면 많은 콜백함수를 사용해야 되어요. 이를 해소하기 위해 상태관리 솔루션이 등장했어요.
  • 해당 글의 예시에서는 Provider 패키지를 사용하는데 이를 사용하기 위해 3가지 개념만 이해하면 되어요!
    • ChangeNotifier : 이를 상속한 모델(클래스)은 notifyListeners 메서드를 사용해 데이터의 변경을 감지하고 이 모델을 사용하는 위젯에게 변경된 데이터로 UI를 재구축하라고 알림을 주는 역할.
    • ChangeNotifierProvider : ChangeNotifier 을 사용하려면 ChangeNotifierProvider 이 위젯을 사용해 모델(데이터)을 제공해야 해요.
    • Consumer : 변경되는 모델(데이터)을 반영해 ui를 구축하는 부분.

example1

선언적 프로그래밍 개념에 대해 이해해봅시다.

플러터는 선언적이에요. 즉, 앱의 현재 상태를 반영해 UI를 구축한다는 뜻이에요.
선언적 프로그래밍 예시

위 그림을 보면 이해가 더 잘될 거에요! 변경되는 데이터에 따라 build 메서드가 실행되고 해당 값을 반영한 UI가 그려지는 거죠!

선언적 프로그래밍: UI를 어떻게 구성할지를 명시하고, 프레임워크가 알아서 현재 상태를 기반으로 UI를 업데이트하는 것.

// Flutter 예시 코드
class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      home: Scaffold(
        appBar: AppBar(
          title: Text('Declarative 예시'),
        ),
        body: Center(
          child: Text('안녕하세요!'),
        ),
      ),
    );
  }
}

명령-기반(Imperative): UI가 어떻게 업데이트 되어야 하는지를 단계적으로 나열함.

// Imperative 예시 코드 (의사 코드)
void updateUI() {
  // UI 업데이트를 위한 여러 명령들
  appBar.setTitle('Imperative 예시');
  centerText.setText('안녕하세요!');
}

플러터에서는(declarative) 앱의 상태가 변경됐을 때(예를 들어, 유저가 세팅 화면의 스위치를 바꿈), 상태를 변경하고, UI를 다시 그리도록 해요. 즉, 상태가 변경됐을 때 UI가 어떻게 업데이트 되어야 하는 지에 대한 코드를 작성할 필요가 없다는 뜻이죠.
반면에 Imperative 프로그래밍에서는 이렇게 상태가 변경됐을 때 UI를 어떻게 업데이트 해야 하는지에 대해서 코드를 작성해주어야 한다는 게 차이점이에요.(예사: widget.setText)

일시적(ephemeral)state and 앱(application)state의 차이점**

ephemeral state 와 app state와 각 상태를 관리하는 방법을 살펴볼게요.

넓은 의미에서, 앱의 상태라는 것은 앱이 실행 중일 때 메모리에 존재하는 모든 것을 의미하는데요. 이러한 상태에 대한 넓은 의미가 유효하긴 하지만, 앱을 설계하는데 유용하지는 않아요.

두 가지 이유가 있는데요!

  1. 먼저, 텍스쳐 같은 일부 상태를 내가 관리하지 않아요. 즉, 플러터가 이러한 상태를 알아서 관리해주기 때문에 더 유용한 상태의 정의는 “언제든지 UI를 재구축하기 위해 필요한 모든 데이터”에요.
  2. 둘째로는, 개발자가 직접 관리하는 상태는 두 가지 개념적 유형이 있어요: 1)일시적 상태 2)앱 상태

Ephemeral state(일시적 상태)

일시적 상태(UI 상태 또는 local 상태라고도 부름)는 단일 위젯에 깔끔하게 포함할 수 있는 상태를 일컫어요.

예시:

  • PageView의 현재 페이지
  • 복잡한 애니메이션의 현재 진행 상황
  • BottomNavigationBar에서 현재 선택된 탭

위젯 트리의 다른 부분에서 이러한 상태에 거의 접근할 필요가 없고. 직렬화할 필요도, 복잡한 방식으로 변경되지도 않아요.

즉, 이러한 종류의 상태에는 상태 관리 기술을 사용할 필요 없이, 오직 Stateful위젯으로 관리가 가능하죠.

아래는 코드 예시로 MyHomepage 상태 객체에서 _index 프로퍼티가 있는데, 이 index는 BottomNavigationBar의 몇 번째 item인지 트래킹하는 데이터일 뿐, 다른 위젯트리의 위젯이랑 연결되지 않아요. 따라서 _index는 일시적(ephemeral) 상태인거죠.

class MyHomepage extends StatefulWidget {
  const MyHomepage({super.key});

  @override
  State<MyHomepage> createState() => _MyHomepageState();
}

class _MyHomepageState extends State<MyHomepage> {
  int _index = 0;

  @override
  Widget build(BuildContext context) {
    return BottomNavigationBar(
      currentIndex: _index,
      onTap: (newIndex) {
        setState(() {
          _index = newIndex;
        });
      },
      // ... items ...
    );
  }
}

App state

application state(종종 공유된 상태)는 앱의 많은 부분에 걸쳐 공유해야 하는 상태를 뜻해요.

예시:

  • User preferences
  • Login info
  • Notifications in a social networking app
  • The shopping cart in an e-commerce app
  • Read/unread state of articles in a news app

명확한 규칙은 없다!

명확히 말하면, State와 setState( )를 사용해 앱의 모든 상태를 관리할 수 있어요.

따라서 다음 로직을 적용해 데이터가 어떤 관계를 맺고 있느냐에 따라 앱 상태인지일시적 상태인지를 구분하면 된답니다.
ephemeral-vs-app-state

결론

  • 일시적 상태: 단일 위젯 내에서만 존재하는 상태(다른 위젯에서 접근 X)
    • State 객체와 setState( ) 를 사용해 구현 가능.
  • 앱 상태: 하나 이상의 위젯 트리의 위젯에서 접근이 필요한 상태(데이터)

Simple app state management

간단한 앱 상태 관리를 배우기 위해 이 예제에서는 provider 패키지를 사용할 예정이에요.
provider 패키지는 이해하기 쉽고 많은 코드를 사용하지 않아도 되는 장점이 있고 다른 모든 상태 관리 솔루션에 적용할 수 있는 개념을 사용하고 있기 때문에 provider 패키지 예시로 시작합니다.

예시

example

설명을 위해, 위와 같은 간단한 앱을 살펴봅시다.
앱에는 두 개의 화면이 존재해요: catalog 와 cart (각각 MyCatalogMyCart 위젯으로 표현됨.)

이 앱을 위젯 트리로 시각화해보면 다음과 같아요
simple-widget-tree
MyListitem의 경우 장바구니(cart)에 추가될 수 있어야 하고 또한 현재 표시된 항목이 이미 장바구니에 있는지 확인하고 싶을 수도 있겠죠.

그럼 Cart의 현재 상태는 어느 위치에서 관리해야 할까요?

Lifting state up

플러터에서는 상태를 사용하는 위젯 위에 상태를 유지하는 것이 합리적이에요.
왜냐하면 플러터와 같은 선언적 프레임워크에서는 UI를 변경하려면 UI를 Rebuild해야 하기 때문인데요. 명령형처럼 MyCart.updateWith(SomethingNew) 와 같이 상태를 변경할 수 없어요. 즉, 메서드를 호출해 외부에서 위젯을 강제로 변경하지 못하는 거죠.

// BAD: DO NOT DO THIS
void myTapHandler() {
  var cartWidget = somehowGetMyCartWidget();
  cartWidget.updateWith(item);
}


위 코드가 작동하더라도, MyCart 위젯에서 다음을 처리해야 해요.

// BAD: DO NOT DO THIS
Widget build(BuildContext context) {
  return SomeWidget(
    // The initial state of the cart.
  );
}

void updateWith(Item item) {
  // Somehow you need to change the UI from here.
}


UI의 현재 상태를 고려하고 여기에 새 데이터를 적용해야 해요.

플러터에서는 데이터가 변경될 때마다 새로운 위젯을 만들어요. 그래서 MyCart(contents)와 같은 생성자를 사용하는데, 오직 부모의 build 메서드에서 새로운 위젯을 생성하기 때문이죠!
그래서 만약 데이터의 변경에 따라 UI를 Rebuild 하고 싶으면 MyCart의 부모 또는 더 위의 위젯에 데이터가 있어야 해요.

이게 무슨 말이냐면, 지금 이 예시에서 MyListItem 이라는 데이터가 변경됨에 따라 MyCart 위젯에 해당 데이터(MyListItem )의 변경 값이 반영되어야 하는데,

위에서 배운 것처럼, 플러터는 선언적 방식을 쓰는데, 데이터가 변경됨에 따라 그걸 반영한 UI를 다시 만드는 방식을 취하고 있죠.

선언적 프로그래밍 예시 따라서 새로 변경된 state(MyListItem) 를 반영해 Cart 위젯에 표시를 하려면 새로운 Cart 위젯을 생성해야 하고

이를 생성하려면 Cart 위젯을 담고 있는 build method 를 포함하는 최소한 부모 위젯이어야 한다는 말입니다.

// Cart 위젯을 담고 있는 부모 위젯의 build method 예시
MyApp( ) {
Widget build(BuildContext context) {
  var cartModel = somehowGetMyCartModel(context);
  return SomeWidget(
    // Just construct the UI once, using the current state of the cart.
    // ···
  );
}
 }


위에서 말한 것처럼, 이 예시에서는 MyApp 위젯에 contents (MyListItem) 가 필요해요. 즉, contents가 변경될 때마다 MyCart 위젯을 재구축하는 것이죠.
이러한 이유로 MyCart 위젯은 lifecycle에 대해 걱정할 필요가 없고 - 단지 MyApp 위젯에서 전달하는 contents를 보여주는 역할만 함. 변경이 될 때마다, 이전 MyCart 위젯은 사라지고 완전히 새로운 MyCart 위젯으로 대체되는 것이에요.

simple-widget-tree-with-cart
이제 cart의 상태를 어디에 놓을지 알았으니까, 어떻게 접근하는 지 알아봅시다.

데이터에 접근하기

catalog 에 있는 아이템 중 하나를 유저가 클릭했을 때, 카트에 추가되게 되어요. cart는 위젯 트리에서 MyListItem 위에 있는데, 어떻게 접근할 수 있을까요?

먼저, MyListItem이 클릭 될 때 호출할 콜백함수를 사용할 수 있어요. Dart의 함수는 first class 객체이므로 함수를 원하는 어디든 전달할 수 있어요. 따라서, MyCatalog 내에 다음과 같이 정의할 수 있죠.

@override
Widget build(BuildContext context) {
  return SomeWidget(
    // Construct the widget, passing it a reference to the method above.
    MyListItem(myTapCallback),
  );
}

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


이는 문제 없이 작동하지만 여러 곳에서 수정해야 하는 App 상태인 경우에는 수많은 콜백을 전달해야만 해요. 이러한 문제를 해결하기 위해 Provider 솔루션을 사용하는 거에요.

Provider 를 사용하기 위해 3가지 개념만 이해하면 수많은 콜백을 전달할 필요가 없다!

  • ChangeNotifier
  • ChangeNotifierProvider
  • Consumer

ChangeNotifier

ChangeNotifier 는 구독자에게 변경 알림을 주는 역할이에요. 예를 들어, 무언가가 ChangeNotifier 라면, 그 변경 사항에 대해 구독할 수 있어요.

provider에서 ChangeNotifier 는 App상태를 캡슐화하는 한 가지 방법인데요.

캡슐화(encapsulate)란?

  • 데이터와 해당 데이터를 다루는 메서드(함수)를 하나의 단위로 묶는 것


위 쇼핑 앱 예제에서 ChangeNotifier 를 사용해 cart의 상태를 관리하는 코드 예제를 알아봅시다.

class CartModel extends ChangeNotifier {
  /// Internal, private state of the cart.
  final List<Item> _items = [];

  /// An unmodifiable view of the items in the cart.
  UnmodifiableListView<Item> get items => UnmodifiableListView(_items);

  /// The current total price of all items (assuming all items cost $42).
  int get totalPrice => _items.length * 42;

  /// Adds [item] to cart. This and [removeAll] are the only ways to modify the
  /// cart from the outside.
  void add(Item item) {
    _items.add(item);
    // This call tells the widgets that are listening to this model to rebuild.
    notifyListeners();
  }

  /// Removes all items from the cart.
  void removeAll() {
    _items.clear();
    // This call tells the widgets that are listening to this model to rebuild.
    notifyListeners();
  }
}


여기서 notifyListeners( ) 메서드를 주목해봅시다.
ChangeNotifier 와 관련된 코드는 notifyListeners( ) 뿐인데, notifyListeners( ) 는 만약 add 메서드가 호출되면 이 Cart 모델을 구독하고 있는 모든 위젯에게 변경된 State를 반영한 ui를 재빌드하도록 요청하는 역할이에요.

ChangeNotifierProvider

이름에서 볼 수 있듯이, ChangeNotifier 인스턴스를 제공하는 위젯이에요.

이 예제에서 Cart와 Catalog사이의 상태를 공유하고 관리하는 것이기 때문에 위에서 봤던 Flutter의 특성(선언형)에 따라 ChangeNotifier를 사용해 상태 변경을 감지해야 하는 위젯의 상위(아래 코드의 MyApp)에 ChangeNotifierProvider를 선언합니다.

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


여기서 create 속성은 CartModel의 새로운 인스턴스를 생성하는 빌더 함수를 정의하고 있어요.

Builder 함수란?

  • 어떤 객체나 값을 생성하는 역할인데, 포인트는 ‘동적’이라는 것이다. 바로 위 코드 예제에서 create 속성에 정의되어있는 builder 함수를 생각해보면, (context) ⇒ CartModel( )로 어떤 매개변수(동적)에 따라 CartModel( ) 인스턴스가 생성되는 것이다.


만약에 여러 클래스를 제공하고 싶다면, MultiProvider 를 사용할 수 있어요.

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

Consumer

이렇게 ChangeNotifierProvider 를 통해 앱의 위젯들에게 CartModel이 제공되었으니 CartModel를 사용할 수 있게 되었습니다.
CartModel을 사용하려면 Consumer 위젯이 필요합니다.

Consumer위젯은 특정 모델(데이터)의 변경을 감지하고 해당 모델이 변경될 때 UI의 일부를 다시 구성하는 데 사용합니다.

사용 방법은요?

  • 먼저 제네릭을 통해 어떤 데이터를 사용할 것인지 명시합니다. Consumer
  • 이 때 Consumer 위젯의 필수 인수는 builder 함수입니다. 이 함수는 ChangeNotifier (이 경우 CartModel)이 변경될 때 마다 호출됩니다. 즉, CartModel의 item이 추가되거나 제거됐을 때 notifyListener가 호출돼서 해당 CartModel을 사용하는 위젯을 rebuild 하는 거에요.

    이렇게 변경된 CartModel을 감지해 동적으로 위젯을 rebuild하기 위해 Consumer 위젯에 builder 함수를 사용하는 거랍니다.

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


코드 예제를 통해 살펴보면 Consumer 위젯의 필수 인수인 builder 함수가 선언되어 있고 인자로 3개가 전달되고 있어요.

  1. context: BuildContext와 동일한 개념으로 더 편리하게 쓰기 위해 줄여쓴다고 보면 됩니다. 즉, 현재 위젯이 어디에 위치하는 지 트리 내에서 위치 정보를 알 수 있는 역할을 해요.
  2. cart: 어떤 모델(데이터)을 쓸 것인지 인자로 선언해요.
  3. child: 성능 최적화와 관련이 있는 매개변수인데, 이 코드 예제에서는 사용하고 있지 않다. 즉, child 매개변수를 활용해 변경되지 않는 부분에 대한 위젯을 선언하여 이 부분(Consumer)이 변경되어도 변경하지 않아도 되는 부분을 child 매개변수를 활용해 선언함으로써 성능을 최적화할 수 있는 것이다.

Best Practice

Consumer 위젯을 사용할 때는 변경이 필요한 부분만 제어할 수 있는 범위에 사용하는 것이 좋습니다. 왜냐하면 위에서 볼 수 있듯이, ui변경이 일어나는 부분을 최소화하기 위함입니다.

나쁜 코드 예시

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

좋은 코드 예시

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

Provider.of

모델의 데이터 UI를 변경할 필요는 없지만 해당 모델의 데이터에 접근해야 하는 경우가 있습니다. 예를 들어, ClearCart 버튼은 사용자가 장바구니에서 모든 항목을 제거할 수 있도록 허용하려고 합니다. 이 버튼은 장바구니 내용을 표시할 필요가 없으며, 단순히 clear( )메서드를 호출해야 합니다.
이 경우에는 Consumer 위젯이 아닌 listen 매개 변수를 false로 설정한 Provider.of을 사용할 수 있습니다.

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

이 코드를 build methods에서 사용해도 notifyListeners가 호출될 때 이 위젯은 rebuild 되지 않습니다!

Putting it all together

해당 예제를 통해 전체 코드를 보며 위 내용들을 더 잘 이해할 수 있을 거에요.
프로바이더 전체 예제 - 간단한 카운터 앱

참고 문헌

플러터 상태관리 공식 문서

댓글남기기