주요 콘텐츠로 건너뛰기

`ChangeNotifier`에서 마이그레이션하기

Riverpod에서 ChangeNotifierProvider는 pkg:provider에서 매끄럽게 넘어올 수 있도록 제공되는 것입니다.

pkg:riverpod로의 마이그레이션을 막 시작했다면, 전용 가이드를 꼭 읽어 보세요 (빠른 시작 참고). 이 글은 이미 riverpod로 옮겨 왔지만 ChangeNotifier에서 벗어나고 싶은 분들을 위한 것입니다.

결론부터 말하면, ChangeNotifier에서 AsyncNotifier로 마이그레이션하려면 사고방식을 바꿔야 하지만, 그 결과 마이그레이션된 코드는 훨씬 단순해집니다.

다음 (문제가 있는) 예제를 살펴봅시다.

class MyChangeNotifier extends ChangeNotifier {
MyChangeNotifier() {
_init();
}
List<Todo> todos = [];
bool isLoading = true;
bool hasError = false;

Future<void> _init() async {
try {
final json = await http.get('api/todos');
todos = [...json.map(Todo.fromJson)];
} on Exception {
hasError = true;
} finally {
isLoading = false;
notifyListeners();
}
}

Future<void> addTodo(int id) async {
isLoading = true;
notifyListeners();

try {
final json = await http.post('api/todos');
todos = [...json.map(Todo.fromJson)];
hasError = false;
} on Exception {
hasError = true;
} finally {
isLoading = false;
notifyListeners();
}
}
}

final myChangeProvider = ChangeNotifierProvider<MyChangeNotifier>((ref) {
return MyChangeNotifier();
});

이 구현에는 다음과 같은 좋지 않은 설계가 여럿 보입니다.

  • 여러 비동기 상황을 처리하기 위해 isLoading과 hasError를 사용합니다
  • 번거로운 try/catch/finally 구문으로 요청을 조심스럽게 처리해야 합니다
  • 구현이 제대로 동작하려면 적절한 시점에 notifyListeners를 호출해야 합니다
  • 빈 리스트로 초기화하는 것처럼, 일관성이 없거나 바람직하지 않을 수 있는 상태가 존재합니다

이 예제는 ChangeNotifier가 초보 개발자를 잘못된 설계로 이끌 수 있음을 보여 주기 위해 일부러 만든 것입니다. 또 하나 얻을 수 있는 교훈은, 가변 상태가 처음 기대했던 것보다 훨씬 다루기 어려울 수 있다는 점입니다.

Notifier/AsyncNotifier를 불변 상태와 함께 사용하면 더 나은 설계를 할 수 있고 오류도 줄일 수 있습니다.

위 코드를 한 단계씩 최신 API로 마이그레이션하는 방법을 살펴보겠습니다.

마이그레이션 시작하기​

먼저 새 provider / notifier를 선언해야 합니다. 이는 각자의 비즈니스 로직에 따라 어느 정도 고민이 필요한 부분입니다.

위 요구 사항을 정리해 보면 다음과 같습니다.

  • 상태는 매개변수 없는 네트워크 호출로 얻는 List<Todo>로 표현됩니다
  • 상태는 loading, error, data 상태에 대한 정보도 노출해야 합니다
  • 상태는 노출된 몇몇 메서드를 통해 변경될 수 있으므로, 함수만으로는 부족합니다
팁

위의 고민은 결국 다음 질문에 답하는 것으로 정리됩니다.

  1. 부수 효과가 필요한가요?
    • y: riverpod의 클래스 기반 API를 사용합니다
    • n: riverpod의 함수 기반 API를 사용합니다
  2. 상태를 비동기로 불러와야 하나요?
    • y: build가 Future<T>를 반환하게 합니다
    • n: build가 그냥 T를 반환하게 합니다
  3. 매개변수가 필요한가요?
    • y: build(또는 함수)가 매개변수를 받게 합니다
    • n: build(또는 함수)가 추가 매개변수를 받지 않게 합니다
정보

코드 생성을 사용한다면 위의 고민만으로 충분합니다.
알맞은 클래스 이름이나 각 클래스의 고유한 API를 고민할 필요가 없습니다.
@riverpod는 반환 타입과 함께 클래스를 작성하기만 하면 되니, 바로 시작할 수 있습니다.

기술적으로 여기서 가장 적합한 선택은 위의 요구 사항을 모두 만족하는 AsyncNotifier<List<Todo>>를 정의하는 것입니다. 먼저 의사 코드를 작성해 봅시다.

class MyNotifier extends AsyncNotifier<List<Todo>> {

FutureOr<List<Todo>> build() {
// TODO ...
return [];
}

Future<void> addTodo(Todo todo) async {
// TODO
}
}

final myNotifierProvider =
AsyncNotifierProvider.autoDispose<MyNotifier, List<Todo>>(MyNotifier.new);
팁

IDE의 스니펫을 활용하면 도움을 받거나 코드를 더 빨리 작성할 수 있습니다. 자세한 내용은 시작하기를 참고하세요.

ChangeNotifier 구현과 비교하면 이제 todos를 선언할 필요가 없습니다. 그 역할은 build로 암묵적으로 불러오는 state가 맡습니다.

실제로 riverpod의 notifier는 한 번에 하나의 엔티티만 노출할 수 있습니다.

팁

Riverpod의 API는 세분화된 사용을 전제로 설계되었습니다. 그래도 마이그레이션하는 동안에는 여러 값을 담는 사용자 정의 엔티티를 정의할 수 있습니다. 처음에는 Dart 3의 레코드를 활용해 마이그레이션을 수월하게 진행하는 것을 고려해 보세요.

초기화​

notifier를 초기화하는 방법은 간단합니다. build 안에 초기화 로직을 작성하기만 하면 됩니다. 이제 기존 _init 함수는 없애도 됩니다.

class MyNotifier extends AsyncNotifier<List<Todo>> {

FutureOr<List<Todo>> build() async {
final json = await http.get('api/todos');
return [...json.map(Todo.fromJson)];
}
}

final myNotifierProvider =
AsyncNotifierProvider.autoDispose<MyNotifier, List<Todo>>(MyNotifier.new);

기존 _init과 비교해도 새 build에서 빠진 것은 없습니다. 이제 isLoading이나 hasError 같은 변수를 초기화할 필요가 없습니다.

Riverpod은 모든 비동기 provider를 AsyncValue<List<Todo>>로 노출해 자동으로 변환하며, 단순한 boolean 플래그 두 개보다 훨씬 더 잘 비동기 상태의 복잡한 부분을 처리합니다.

실제로 AsyncNotifier를 사용하면, 비동기 상태를 처리하기 위해 try/catch/finally를 추가로 작성하는 것은 사실상 안티 패턴이 됩니다.

Mutation과 부수 효과​

초기화와 마찬가지로, 부수 효과를 수행할 때도 hasError 같은 boolean 플래그를 조작하거나 try/catch/finally 블록을 추가로 작성할 필요가 없습니다.

아래는 보일러플레이트를 모두 걷어 내고 위 예제를 완전히 마이그레이션한 결과입니다.

class MyNotifier extends AsyncNotifier<List<Todo>> {

FutureOr<List<Todo>> build() async {
final json = await http.get('api/todos');

return [...json.map(Todo.fromJson)];
}

Future<void> addTodo(Todo todo) async {
// optional: state = const AsyncLoading();
final json = await http.post('api/todos');
final newTodos = [...json.map(Todo.fromJson)];
state = AsyncData(newTodos);
}
}

final myNotifierProvider =
AsyncNotifierProvider.autoDispose<MyNotifier, List<Todo>>(MyNotifier.new);
팁

문법이나 설계 방식은 달라질 수 있지만, 결국 요청을 작성하고 그 뒤에 상태를 갱신하기만 하면 됩니다. 자세한 내용은 Provider를 참고하세요.

마이그레이션 과정 요약​

위에서 진행한 마이그레이션 과정 전체를 작업 관점에서 다시 정리해 봅시다.

  1. 생성자에서 호출하던 사용자 정의 메서드에 있던 초기화 로직을 build로 옮겼습니다
  2. todos, isLoading, hasError 프로퍼티를 제거했습니다. 내부 state로 충분합니다
  3. try-catch-finally 블록을 모두 제거했습니다. future를 반환하는 것으로 충분합니다
  4. 부수 효과(addTodo)에도 같은 단순화를 적용했습니다
  5. state를 다시 할당하는 방식으로 변경을 적용했습니다