본문 바로가기
Flutter•Dart

[Riverpod] List 타입의 상태 관리하기

by SAENS 2023. 8. 1.

Riverpod의 Provider가 Mutable Set, List와 같은 집합 타입의 객체를 반환하도록 하는 것은 간단해 보이고, 사실 실제로 간단하지만, 상당히 실수하기 쉽습니다. 이 글에서는 제가 저지른 그 실수를 짚어보고, 발견한 해결책에 대해 설명하려 합니다.

* 아직 Flutter를 공부하고 있는 단계이기 때문에 정확하지 않고 두루뭉술한 표현이 다소 있습니다. 모든 지적은 환영입니다!

 

1. 잘못된 구현

final myListProvider = StateProvider((ref) => <int>[]);

List<int> 객체를 반환하는 myListProvider를 이용해서, 해당 List의 length를 표시해주는 Text, 그리고 List에 원소를 추가해주는 버튼으로 구성된 간단한 위젯을 만들어보면서 알아봅시다.

class MyWidget extends ConsumerWidget {

    @override
    build(BuildContext context, WidgetRef ref) {
    
      final myList = ref.watch(myListProvider);
      
      return Column(
        children: [
          Text('${myList.length}'),
          IconButton(
            onPressed: () => ref.read(myListProvider.notifier).state.add(0),
            icon: Icon(Icons.add),
          ),
        ],
      );
      
    }
  }
}

빌드는 잘 되겠지만, 버튼이 눌려도 아무런 반응이 없을 겁니다. 왜냐하면 build 메서드가 실행이 되어야 하는데 그렇지 못하기 때문입니다.

그 이유는 IDE에서 state에 마우스를 올려보면 짐작할 수 있습니다. 

StateProvider가 동작하는 원리에 대해 간단히 알아봅시다. StateProvider는 StateNotifier 객체를 가지고 있습니다(.notifier). state라는 멤버 property가 있고, state의 setter가 호출될 때, Widget Tree를 탐색하여 해당 provider를 watch하고 있는 Widget들의 build 메서드를 호출합니다.

위의 스크린샷을 보시면, ~.state.add(0) 와 같이 선언한 경우에 state가 setter를 호출하지 않고 getter를 호출했다는 것을 알 수 있습니다. 따라서 당연히 build 메서드가 실행되지 않는 것이죠.

그렇다면 onPressed를 다음과 같이 변경하면 될 것 같네요.

onPressed: () {
  StateController controller = ref.read(myListProvider.notifier);
  var temp = controller.state;
  temp.add(0);
  controller.state = temp;
}

하지만 아닙니다. 그 이유는 add, remove 등을 통해 원소를 조작해도 List 객체 자체에 대한 참조는 변하지 않기 때문입니다. 즉, temp에 state를 대입할 때, temp는 state의 메모리를 참조하고 있다는 얘기입니다.

그리고 StateNotifier 클래스 내부를 살펴보면 다음과 같은 코드들을 발견할 수 있는데,

패키지 내부 파일 : state_notifier.dart
패키지 내부 파일 : state_notifier.dart

변하는 값이 이전과 동일하다면 추가 작업 없이 함수가 끝나도록 되어 있습니다. 최적화를 위해 필요한 경우만 notify하기 위한 구현인 것 같네요. 그래서 temp를 선언해도 어차피 같은 List객체를 참조하고 있어서 값이 변하지 않은 것으로 인식하는 것입니다.

 

2. 동작은 잘 하지만 오버헤드가 발생하는 구현

그래서 temp를 선언할 때 단순히 state를 대입하는 것이 아니라, List.of(~)를 사용하면, 복제본을 만들어서 대입하기 때문에 값이 변한 것으로 인식되고 정상적으로 기능합니다.

onPressed: () {
  StateController controller = ref.read(myListProvider.notifier);
  var temp = List.of(controller.state.add(1));
  controller.state = temp;
}

네, 동작은 하긴 하지만, 문제가 있죠. 겨우 값 하나 추가하는데 리스트 하나를 통째로 복사한다? 꽤 커다란 오버헤드입니다.

 

3. 오버헤드를 없앤 구현

updateShouldNotify가 항상 true를 반환한다면 굳이 복사까지 할 필요는 없겠죠. 그래서 해당 메서드를 재정의하는 StateNotifier의 서브클래스를 만들어 주려 합니다.

보다 일반적인 상황에서도 사용할 수 있도록 state가 Iterable<T>을 상속받는 타입 C임을 명시하는 생성자를 만들어 주고, length, isEmpty, contains 등 필요한 프로퍼티/메서드를 아래와 같이 구현합니다.

class IterableNotifier<T, C extends Iterable<T>> extends StateNotifier<C> {
  IterableNotifier(C state) : super(state);

  int get length => state.length;
  bool get isEmpty => state.isEmpty;
  bool contains(T item) => state.contains(item);

  @override
  bool updateShouldNotify(C old, C current) => true;
}

이제 서브클래스를 하나 더 만들어 List에 대한 Notifier를 만들어줍시다.

class ListNotifier<T> extends IterableNotifier<List<T>> {
  ListNotifier(List<T> state) : super(state);

  void add(T item) {
    state.add(item);
    state = state;
  }

  void remove(T item) {
    state.remove(item);
    state = state;
  }
}

그리고ListNotifier<int>를 반환하는 StateNotifierProvider를 선언하면

final myListProvider =
  StateNotifierProvider<ListNotifier<int>, List<int>> (
    (ref) => ListNotifier(<int>[])
  );

onPressed를 다음과 같이 바꿔줌으로써 오버헤드 없이 잘 작동하는 코드를 작성할 수 있습니다.

onPressed : () => ref.read(myListProvider.notifier).add(0),

이제 List<T> 타입의 상태를 provider를 통해 제대로 관리할 수 있습니다.

List가 아닌 (Iterable를 상속받는)다른 집합 타입의 경우, ListNotifier<T> 클래스에서 List라 써진 것만 다른 타입으로 바꿔주면 되겠습니다. Iterable<T>가 add, remove 등의 메서드까지 공유했다면 더욱 일반적인 코드를 짤 수 있었을텐데 아쉽네요.

댓글