티스토리 뷰

반응형

간단한 선언적 상태 관리 시스템의 작동 방식을 보여주는 짧은 애니메이션 gif입니다.

 

Flutter를 탐색하다 보면 화면 간에, 앱 전반에 걸쳐 애플리케이션 상태를 공유해야 할 때가 옵니다. 이를 위해 취할 수 있는 여러 접근 방식이 있으며, 고려해야 할 많은 질문들이 있습니다.

 

 


선언형으로 생각하기 시작하세요

만약 Android SDK나 iOS UIKit과 같은 명령형 프레임워크에서 Flutter로 넘어왔다면, 앱 개발을 새로운 시각에서 생각해야 합니다.

기존에 가지고 있던 많은 가정들이 Flutter에서는 적용되지 않습니다. 예를 들어, Flutter에서는 UI의 일부를 수정하는 대신 처음부터 다시 빌드해도 괜찮습니다. Flutter는 매우 빠르기 때문에, 필요하다면 매 프레임마다 다시 빌드해도 문제가 없습니다.

Flutter는 선언형입니다. 이는 Flutter가 현재 앱의 상태를 반영하여 사용자 인터페이스를 빌드한다는 것을 의미합니다:

 


UI = f(state)라는 수학적 공식을 생각해보세요. 여기서 'UI'는 화면 레이아웃을 의미하고, 'f'는 빌드 메서드, 'state'는 애플리케이션 상태입니다.


앱의 상태가 변경되면 (예를 들어, 사용자가 설정 화면에서 스위치를 전환할 때) 상태를 변경하면 사용자 인터페이스가 다시 그려집니다. UI 자체를 명령형(예: widget.setText)으로 변경하는 것이 아니라, 상태를 변경하면 UI가 처음부터 다시 빌드됩니다.

선언형 UI 프로그래밍 스타일은 많은 장점을 가지고 있습니다. 특히, UI의 상태에 대한 코드 경로가 하나만 존재합니다. 주어진 상태에 대해 UI가 어떻게 보여야 하는지 한 번만 설명하면 그것으로 끝입니다.

처음에는 이 프로그래밍 스타일이 명령형 스타일만큼 직관적이지 않을 수 있습니다.

 

 

 

임시 상태와 앱 상태 구분하기

이 문서는 앱 상태, 임시 상태, 그리고 Flutter 앱에서 각각의 상태를 관리하는 방법을 소개합니다.

가장 넓은 의미에서 앱의 상태란 앱이 실행 중일 때 메모리에 존재하는 모든 것을 의미합니다. 여기에는 앱의 자산, Flutter 프레임워크가 유지하는 UI 관련 모든 변수, 애니메이션 상태, 텍스처, 폰트 등이 포함됩니다. 이러한 가장 넓은 의미의 상태 정의는 유효하지만, 앱을 설계하는 데 있어서는 그다지 유용하지 않습니다.

먼저, 일부 상태(예: 텍스처)를 직접 관리하지 않습니다. 프레임워크가 이를 대신 처리해 줍니다. 그래서 더 유용한 상태 정의는 "언제든지 UI를 다시 빌드하기 위해 필요한 모든 데이터"입니다. 두 번째로, 직접 관리하는 상태는 개념적으로 임시 상태와 앱 상태, 이 두 가지 유형으로 나눌 수 있습니다.

 

 

임시 상태

임시 상태(때때로 UI 상태 또는 로컬 상태라고도 함)는 단일 위젯 내에 깔끔하게 포함될 수 있는 상태입니다.

이 정의는 의도적으로 모호하므로 몇 가지 예를 들어보겠습니다.

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

 

위젯 트리의 다른 부분이 이 종류의 상태에 접근할 필요가 거의 없습니다. 이를 직렬화할 필요도 없고, 복잡하게 변경되지도 않습니다.

즉, 이 종류의 상태에는 상태 관리 기법(ScopedModel, Redux 등)을 사용할 필요가 없습니다. StatefulWidget만 있으면 됩니다.

아래에서는 BottomNavigationBar에서 현재 선택된 항목이 _MyHomepageState 클래스의 _index 필드에 어떻게 유지되는지 볼 수 있습니다. 이 예에서 _index는 임시 상태입니다.

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 ...
    );
  }
}


여기서 setState()와 StatefulWidget의 State 클래스 내부 필드를 사용하는 것은 매우 자연스럽습니다. 앱의 다른 부분에서는 _index에 접근할 필요가 없습니다. 변수는 MyHomepage 위젯 내부에서만 변경됩니다. 그리고 사용자가 앱을 닫고 다시 시작하더라도 _index가 0으로 초기화되는 것에 대해 신경 쓰지 않아도 됩니다.

 

 

앱 상태

임시 상태가 아닌, 앱의 여러 부분에서 공유하고자 하며, 사용자 세션 간에도 유지하고자 하는 상태를 우리는 애플리케이션 상태(때때로 공유 상태라고도 함)라고 부릅니다.

 

애플리케이션 상태의 예:

  • 사용자 설정
  • 로그인 정보
  • 소셜 네트워킹 앱의 알림
  • 전자 상거래 앱의 장바구니
  • 뉴스 앱에서 읽은/읽지 않은 기사 상태

 

앱 상태를 관리하기 위해서는 다양한 옵션을 조사해야 합니다. 선택은 앱의 복잡성과 성격, 팀의 이전 경험, 기타 여러 측면에 따라 달라집니다.

 

 

명확한 규칙은 없습니다

명확히 말하자면, 앱의 모든 상태를 관리하기 위해 State와 setState()를 사용할 수 있습니다. 사실, Flutter 팀은 많은 간단한 앱 샘플(모든 flutter create에 포함된 시작 앱)을 통해 이를 보여줍니다.

 

반대의 경우도 있습니다. 예를 들어, 특정 앱의 맥락에서는 BottomNavigationBar의 선택된 탭이 임시 상태가 아닐 수 있습니다. 이를 클래스 외부에서 변경해야 하거나, 세션 간에도 유지해야 할 수 있습니다. 이 경우, _index 변수는 앱 상태가 됩니다.

 

특정 변수가 임시 상태인지 앱 상태인지 구분하는 명확하고 보편적인 규칙은 없습니다. 때때로 하나의 상태를 다른 상태로 리팩터링해야 할 때도 있습니다. 예를 들어, 명확히 임시 상태로 시작했지만, 애플리케이션이 기능적으로 성장함에 따라 앱 상태로 이동해야 할 수 있습니다.

 

그 이유로, 아래 다이어그램을 참고할 때 큰 여유를 가지세요:

 

다이어그램: '데이터'로 시작. '누가 필요로 하는가?' 세 가지 옵션: '대부분의 위젯', '일부 위젯', '단일 위젯'. 처음 두 옵션은 '앱 상태'로 이어지고, '단일 위젯' 옵션은 '임시 상태'로 이어짐.

 

Redux의 작성자 Dan Abramov는 React의 setState와 Redux의 store에 대해 질문을 받았을 때 이렇게 답했습니다: "경험의 법칙은 '덜 어색한 것은 무엇이든 하라'이다."

 

요약하면, 모든 Flutter 앱에는 두 가지 개념적 상태 유형이 있습니다. 임시 상태는 State와 setState()를 사용하여 구현할 수 있으며, 종종 단일 위젯에 국한됩니다. 나머지는 앱 상태입니다. 두 가지 유형 모두 Flutter 앱에서 그 역할이 있으며, 둘 간의 구분은 자신의 선호도와 앱의 복잡성에 따라 달라집니다.

 

 

 

간단한 앱 상태 관리

이제 선언형 UI 프로그래밍과 임시 상태 및 앱 상태의 차이점을 알았으니, 간단한 앱 상태 관리에 대해 배울 준비가 되었습니다.

 

이 페이지에서는 provider 패키지를 사용할 것입니다. Flutter를 처음 접하고 다른 접근 방식(Redux, Rx, hooks 등)을 선택할 특별한 이유가 없다면, 이 방법이 시작하기에 좋습니다. provider 패키지는 이해하기 쉽고 코드도 많이 필요하지 않습니다. 또한 다른 모든 접근 방식에서도 적용할 수 있는 개념들을 사용합니다.

 

하지만 다른 반응형 프레임워크에서 상태 관리에 대한 강력한 배경 지식을 가지고 있다면, 옵션 페이지에서 패키지와 튜토리얼 목록을 찾을 수 있습니다.

 

 

예제

Flutter 앱 사용을 보여주는 애니메이션 gif

 

사용자가 로그인 화면에 있습니다. 로그인 후 카탈로그 화면으로 이동하여 항목 목록을 봅니다. 여러 항목을 클릭하면 항목이 "추가됨"으로 표시됩니다. 사용자가 버튼을 클릭하면 장바구니 화면으로 이동합니다. 거기서 항목들을 봅니다. 다시 카탈로그로 돌아가도 구매한 항목들이 여전히 "추가됨"으로 표시됩니다.

 

앱에는 카탈로그와 장바구니라는 두 개의 별도 화면이 있습니다(각각 MyCatalog 및 MyCart 위젯으로 표현됩니다). 쇼핑 앱일 수도 있지만, 동일한 구조를 간단한 소셜 네트워킹 앱에서도 상상할 수 있습니다(카탈로그를 "페이지"로, 장바구니를 "즐겨찾기"로 대체).

 

카탈로그 화면에는 커스텀 앱 바(MyAppBar)와 많은 리스트 항목들이 스크롤되는 뷰(MyListItems)가 포함됩니다.

 

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

 

MyApp이 상단에 있고, 그 아래에 MyCatalog와 MyCart가 있습니다. MyCart는 리프 노드이지만, MyCatalog는 MyAppBar와 여러 MyListItems를 자식으로 가지고 있습니다.

 

최소한 5개의 Widget 하위 클래스를 가지고 있습니다. 많은 위젯들이 다른 곳에 "속하는" 상태에 접근해야 합니다. 예를 들어, 각 MyListItem은 자신을 장바구니에 추가할 수 있어야 합니다. 또한 현재 표시된 항목이 이미 장바구니에 있는지 확인하고 싶을 수도 있습니다.

 

이것은 현재 장바구니 상태를 어디에 두어야 하는지에 대한 첫 번째 질문으로 이어집니다.

 

 

상태 끌어올리기

Flutter에서는 상태를 사용하는 위젯 위에 두는 것이 좋습니다.

 

왜일까요? Flutter와 같은 선언형 프레임워크에서는 UI를 변경하려면 다시 빌드해야 합니다. MyCart.updateWith(somethingNew)와 같은 방법으로 UI를 명령적으로 변경하는 것은 어렵습니다. 즉, 외부에서 위젯의 메서드를 호출하여 위젯을 명령적으로 변경하는 것은 어렵습니다. 그리고 설령 이 방법이 작동하더라도 프레임워크와 싸우는 대신 프레임워크를 이용하는 것이 더 낫습니다.

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

 

위의 코드가 작동하려면 MyCart 위젯에서는 다음과 같은 방식으로 문제를 처리해야 합니다:

// BAD: 이렇게 하지 마세요.
Widget build(BuildContext context) {
  return SomeWidget(
    // cart의 초기화 상태
  );
}

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

 

UI의 현재 상태를 고려하고 새로운 데이터를 UI에 적용해야 할 것입니다. 이렇게 하면 버그를 피하기 어렵습니다.

 

Flutter에서는 내용이 변경될 때마다 새 위젯을 구성합니다. MyCart.updateWith(somethingNew)같은 메서드 호출 대신 MyCart(contents) 같은 생성자를 사용합니다. 부모의 빌드 메서드 내에서만 새 위젯을 구성할 수 있기 때문에, 내용을 변경하려면 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 상태를 사용하여 한 번만 UI를 구성합니다
    // ···
  );
}
 
이 예제에서 contents는 MyApp에 있어야 합니다. 변경될 때마다 MyApp에서 MyCart를 다시 빌드합니다(자세한 내용은 이후에 설명). 이로 인해 MyCart는 라이프사이클을 걱정할 필요가 없으며, 주어진 내용에 대해 무엇을 보여줄지 선언하기만 하면 됩니다. 내용이 변경되면 이전 MyCart 위젯은 사라지고 완전히 새로운 것으로 교체됩니다.
 

위와 같은 위젯 트리이지만 이제 MyApp 옆에 작은 'cart' 배지가 표시되고, 두 개의 화살표가 있습니다. 하나는 MyListItems 중 하나에서 'cart'로, 다른 하나는 'cart'에서 MyCart 위젯으로 이어집니다. 이것이 위젯이 불변이라는 의미입니다. 변경되지 않고 교체됩니다.

 

이제 장바구니 상태를 어디에 두어야 할지 알았으니, 이를 어떻게 접근하는지 알아보겠습니다.

 

 

상태 접근

사용자가 카탈로그의 항목(MyListItem) 중 하나를 클릭하면 장바구니(cart)에 추가됩니다. 하지만 장바구니는 MyListItem 위에 있기 때문에 어떻게 해야 할까요?

 

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

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

void myTapCallback(Item item) {
  print('user tapped on $item');
}
 
이 방법도 괜찮지만, 많은 곳에서 앱 상태를 수정해야 한다면 많은 콜백을 전달해야 하므로 금방 지루해질 것입니다.
 

다행히 Flutter에는 위젯이 하위 위젯(즉, 자식뿐만 아니라 그 아래의 모든 위젯)에 데이터와 서비스를 제공할 수 있는 메커니즘이 있습니다. 모든 것이 위젯™인 Flutter에서 기대할 수 있듯이, 이러한 메커니즘은 특별한 종류의 위젯, 즉 InheritedWidget, InheritedNotifier, InheritedModel 등입니다. 여기서는 이러한 저수준의 메커니즘을 다루지 않을 것입니다.

 

대신 저수준의 위젯과 함께 작동하지만 사용하기 쉬운 패키지를 사용할 것입니다. 그것이 바로 provider입니다.

 

provider를 사용하기 전에, pubspec.yaml에 의존성을 추가하는 것을 잊지 마세요. provider 패키지를 의존성으로 추가하려면 flutter pub add 명령어를 실행하세요:

flutter pub add provider
이제 'package/provider.dart'를 임포트하고 빌드를 시작할 수 있습니다.
 

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

  • ChangeNotifier
  • ChangeNotifierProvider
  • Consumer

 

 

ChangeNotifier

ChangeNotifier는 Flutter SDK에 포함된 간단한 클래스로, 리스너에게 변경 사항을 알릴 수 있습니다. 즉, ChangeNotifier는 변화에 구독(Observable)할 수 있는 형태입니다. (Observable이라는 용어에 익숙한 분들은 그것의 한 형태로 이해할 수 있습니다.)

 

provider에서 ChangeNotifier는 애플리케이션 상태를 캡슐화하는 한 방법입니다. 아주 간단한 앱에서는 하나의 ChangeNotifier로 충분합니다. 복잡한 앱에서는 여러 모델과 따라서 여러 ChangeNotifier가 필요합니다. (provider를 사용할 때 반드시 ChangeNotifier를 사용할 필요는 없지만, 작업하기 쉬운 클래스입니다.)

 

쇼핑 앱 예제에서는 ChangeNotifier에서 장바구니 상태를 관리하려고 합니다. 다음과 같이 이를 확장하는 새로운 클래스를 만듭니다:

class CartModel extends ChangeNotifier {
  /// 장바구니의 내부 비공개 상태.
  final List<Item> _items = [];

  /// 장바구니 항목의 변경 불가능한 뷰.
  UnmodifiableListView<Item> get items => UnmodifiableListView(_items);

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

  /// [item]을 장바구니에 추가합니다. 외부에서 장바구니를 수정할 수 있는 유일한 방법입니다.
  void add(Item item) {
    _items.add(item);
    // This call tells the widgets that are listening to this model to rebuild.
    // 이 호출은 이 모델을 듣고 있는 위젯에게 다시 빌드하라고 알립니다. 
    notifyListeners();
  }

  /// 장바구니에서 모든 항목을 제거합니다.
  void removeAll() {
    _items.clear();
    // 이 호출은 이 모델을 듣고 있는 위젯에게 다시 빌드하라고 알립니다.
    notifyListeners();
  }
}
ChangeNotifier와 관련된 유일한 코드는 notifyListeners() 호출입니다. 모델이 앱의 UI를 변경할 수 있는 방식으로 변경될 때마다 이 메서드를 호출하세요. CartModel의 나머지 부분은 모델 자체와 그 비즈니스 로직입니다.
 

ChangeNotifier는 flutter의 일부로, Flutter의 상위 클래스에 의존하지 않습니다. 쉽게 테스트할 수 있습니다(위젯 테스트를 사용할 필요도 없습니다). 예를 들어, 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의 경우 MyCart와 MyCatalog 위에 어느 정도 위치시키면 됩니다.

 

필요 이상으로 ChangeNotifierProvider를 높게 배치하고 싶지 않습니다(범위를 오염시키고 싶지 않기 때문). 하지만 우리의 경우 MyCart와 MyCatalog의 상위에 있는 유일한 위젯은 MyApp입니다.

void main() {
  runApp(
    ChangeNotifierProvider(
      create: (context) => CartModel(),
      child: const MyApp(),
    ),
  );
}
 
CartModel의 새 인스턴스를 생성하는 빌더를 정의하고 있습니다. ChangeNotifierProvider는 필요하지 않으면 CartModel을 다시 빌드하지 않도록 충분히 스마트합니다. 또한 인스턴스가 더 이상 필요하지 않을 때 자동으로 CartModel의 dispose()를 호출합니다.
 

여러 클래스를 제공하려면 MultiProvider를 사용할 수 있습니다:

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

Consumer

CartModel이 앱의 위젯에 ChangeNotifierProvider 선언을 통해 제공되므로 이를 사용하기 시작할 수 있습니다.

이 작업은 Consumer 위젯을 통해 수행됩니다.

return Consumer<CartModel>(
  builder: (context, cart, child) {
    return Text('Total price: ${cart.totalPrice}');
  },
);
접근하려는 모델의 유형을 명시해야 합니다. 이 경우 CartModel을 원하므로 Consumer<CartModel>이라고 씁니다. 제네릭(<CartModel>)을 명시하지 않으면 provider 패키지가 도와줄 수 없습니다. provider는 타입 기반이므로 타입이 없으면 무엇을 원하는지 알 수 없습니다.
 

Consumer 위젯의 유일한 필수 인수는 builder입니다. 빌더는 ChangeNotifier가 변경될 때마다 호출되는 함수입니다. (즉, 모델에서 notifyListeners()를 호출할 때 해당 Consumer 위젯의 모든 빌더 메서드가 호출됩니다.)

 

빌더는 세 가지 인수를 가지고 호출됩니다. 첫 번째는 모든 빌드 메서드에서 얻는 context입니다.

 

빌더 함수의 두 번째 인수는 ChangeNotifier의 인스턴스입니다. 이것이 우리가 처음에 요청한 것입니다. 모델의 데이터를 사용하여 주어진 시점에 UI가 어떻게 보여야 하는지 정의할 수 있습니다.

 

세 번째 인수는 child로, 최적화를 위해 있습니다. 모델이 변경될 때 변경되지 않는 큰 위젯 하위 트리가 있다면, 한 번만 구성하고 빌더를 통해 가져올 수 있습니다.

return Consumer<CartModel>(
  builder: (context, cart, child) => Stack(
    children: [
      // 매번 다시 빌드하지 않고 child 위젯을 사용합니다.
      if (child != null) child,
      Text('Total price: ${cart.totalPrice}'),
    ],
  ),
  // 재사용할 위젯을 여기서 빌드합니다.
  child: const SomeExpensiveWidget(),
);
 
Consumer 위젯을 트리에서 가능한 한 깊숙이 배치하는 것이 최선의 방법입니다. 일부 세부 사항이 변경되었다고 해서 UI의 큰 부분을 다시 빌드하게 하지 마세요.
// [X] 이렇게 하지 마세요.
return Consumer<CartModel>(
  builder: (context, cart, child) {
    return HumongousWidget(
      // ...
      child: AnotherMonstrousWidget(
        // ...
        child: Text('Total price: ${cart.totalPrice}'),
      ),
    );
  },
);

 

대신 이렇게 하세요.

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

 

 

Provider.of

때로는 모델의 데이터가 변경되어도 UI를 변경할 필요가 없으나 접근이 필요할 때가 있습니다. 예를 들어, ClearCart 버튼은 사용자가 장바구니의 모든 항목을 제거할 수 있게 하기를 원합니다. 장바구니의 내용을 표시할 필요는 없고, 단지 clear() 메서드를 호출하기만 하면 됩니다.

 

이 경우 Consumer<CartModel>을 사용할 수 있지만, 이는 낭비입니다. CartModel 데이터가 변경이 되면 다시 빌드할 필요가 없는 위젯을 다시 빌드하라고 프레임워크에 요청하는 것입니다.

 

이 경우에는 listen 매개변수를 false로 설정하여 Provider.of를 사용할 수 있습니다.

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

 

이 줄을 빌드 메서드에 사용해도 notifyListeners가 호출될 때 이 위젯이 다시 빌드되지 않습니다.

반응형
댓글
공지사항