[Flutter] Riverpod - Performing side effect

·

4 min read

본 포스트는 Riverpod 패키지 공식 문서 중 performing side effects 관련 내용을 번역하며 공부한 내용이며, 번역 중 누락된 부분이 존재하며 개인적인 생각이 포함되어있습니다.

Performing side effects

단순한 GET HTTP request의 경우는 사용하기가 쉽고 복잡하지 않다. 하지만 POST request와 같은 side-effects가 발생하는 경우는 어떻게 처리해야할까?

어플리케이션에서는 CRUD를 모두 구현하는 경우가 많으며, 업데이트가 발생하는 경우 local cache 또한 업데이트하여 UI에 새로운 state를 반영해야한다. 즉, POST요청을 통해 특정 데이터에 변화가 발생하면, 새로 수정된 최신의 데이터를 클라이언트에도 동기화 해주는 작업이 필요하다.

consumer 내 provider의 state는 어떻게 업데이트해야할까? Riverpod에서는 provider가 state를 수정하는 방법을 제공하지 않는다.

이러한 처리를 위해서는 단순한 Provider를 사용하지 않고 Notifier를 활용해야한다. Notifier는 provider의 stateful widget이라고 할 수 있다. Notifier를 작성하는 코드는 다음과 같다.

// global class which will create notifier provider automatically
// using code generation
@riverpod
class MyNotifier extends _$MyNotifier {
    @override
    Result build(){
        // your logic here
    }

    // your method (change states) here
}
  • 모든 Notifier는 반드시 build()매서드를 오버라이드해야한다. non-Notifier에서 사용하던 provider 생성 관련 로직을 해당 매서드 내에 작성한다.

본론으로 돌아와서, POST 요청 이후 새로운 데이터를 클라이언트에 적용하기 위해서 기존 state는 어떻게 업데이트를 해야할까? 이를 위해서는 다양한 방법을 사용할 수 있다. 총 세가지의 방법을 알아보며 장단점을 알아보자.

Updating our local cache to match the API response

첫번째 방법은 말그대로 POST 요청에 대한 응답값 자체가 최신 데이터인 경우이다. 만약 서버 로직에서 리턴 값으로 수정된 데이터를 보내준다면 이를 받아서 기존에 있던 provider state에 저장하면 된다.


Future<void> addTodo(Todo todo) async {
    final response = await http.post(
        Uri.https('your_api.com','/todos'),
        headers : {'Content-Type': 'application/json'},
        body: jsonEncode(todo.toJson()),
    )

    List<Todo> newTodos = (jsonDecode(response.body) as List)
        .cast<Map<String,Object?>>()
        .map(Todo.fromJson)
        .toList();

    // 리턴값으로 받은 최신의 데이터를 기존의 state에 저장한다.
    state = AsyncData(newTodos);
}

해당 방식은 추가적인 GET 요청없이도 provider의 state를 업데이트하여 UI에 반영할 수 있다는 장점이 있으나 서버에서 자체적으로 리턴 값이 존재하는 경우에만 활용할 수 있고, 또한 GET request가 복잡한 경우(filtering/sorting)에는 적합하지 않을 수 있다.

Using ref.invalidateSelf() to refresh the provider

또 다른 방법 중 하나는 ref.invalidateSelf()매서드를 활용하는 것이다. POST요청에 대한 리턴 값이 따로 정해져있지 않다면 사용할 수 있는 방법이며, 간단하게 invalidateSelf() 매서드를 통해서 GET 요청을 추가적으로 하는 것이다(Notifier의 build() 매서드가 다시 실행됨)

예제에서는 Todo를 담고 있는 Notifier의 build()매서드 내에서 서버로부터 List<Todo>를 받아오는 것을 가정하고 있다.

Future<void> addTodo(Todo todo) async {
  // We don't care about the API response
  await http.post(
    Uri.https('your_api.com', '/todos'),
    headers: {'Content-Type': 'application/json'},
    body: jsonEncode(todo.toJson()),
  );

  // Once the post request is done, we can mark the local cache as dirty.
  // This will cause "build" on our notifier to asynchronously be called again,
  // and will notify listeners when doing so.
  ref.invalidateSelf();

  // (Optional) We can then wait for the new state to be computed.
  // This ensures "addTodo" does not complete until the new state is available.
  await future;
}

이전의 방식과 동일하게 POST 요청을 보낸 뒤 리턴 값을 따로 받아오지 않고 기존에 존재하던 List<Todo>를 담고 있는 provider를 업데이트해주는 방식이다.

해당 방식은 직접적인 GET 요청이 있기 때문에 most up-to-date state를 유지할 수 있게 해준다. (해당 시점에 다른 유저가 todo를 추가하는 것도 반영이 가능함. 최신 상태의 데이터를 유지할 수 있음)

그러나 이러한 방식은 추가적인 GET request가 필요하다는 단점이 존재하며 이는 최적화 관련 이슈를 발생시킬 수 있다.

Updating the local cache manually

마지막으로 local cache를 수동으로 업데이트해주는 방법이 있다. 해당 방식은 백엔드의 동작을 모방하려는 시도가 존재할 수 있다.(앞서 살펴본 방식대로 추가적인 GET요청을 보내지 않고 업데이트를 하기 위해서 백엔드에서 처리하는 로직을 동일하게 프론트 단에서도 처리하도록 하는 것)

Future<void> addTodo(Todo todo) async {
  // We don't care about the API response
  await http.post(
    Uri.https('your_api.com', '/todos'),
    headers: {'Content-Type': 'application/json'},
    body: jsonEncode(todo.toJson()),
  );

  // We can then manually update the local cache. For this, we'll need to
  // obtain the previous state.
  // Caution: The previous state may still be loading or in error state.
  // A graceful way of handling this would be to read `this.future` instead
  // of `this.state`, which would enable awaiting the loading state, and
  // throw an error if the state is in error state.
  final previousState = await future;

  // We can then update the state, by creating a new state object.
  // This will notify all listeners.
  state = AsyncData([...previousState, todo]);
}

이전의 state 값을 따로 변수에 저장해주고, 이를 포함하여 새로운 todo를 뒤에 추가한 List<Todo>값을 state에 할당하여 UI를 업데이트한다. 예제에서는 immutable state를 사용했으나, 이는 필수 사항이 아닌 권장사항이다.

immutability에 대한 상세 내용은 다음의 문서에서 깊게 다룬다.

Riverpod - Why Immutability

local cache를 수동으로 업데이트하는 방식은 단 하나의 요청만 사용한다는 장점이 존재하지만 실제 서버의 데이터와 일치하지 않을 수 있다는 단점이 존재한다. 만약 동일한 시점에 다른 사용자가 데이터를 업데이트한다면(예제에서는 Todo) 이는 local cache에 반영되지 못한다.

또한 실제 백엔드의 로직을 모방하여 작성하려면 더욱 복잡해질 수 있다는 단점 또한 존재한다.

결론

POST 요청 이후에 기존에 존재하던 provider 내 상태를 어떻게 업데이트해야하는지에 대해서 알아봤다. 앱 개발을 하다보면 반드시 마주하게 되는 고민인데, 이를 패키지 문서에서 꽤나 깊게 다뤄준 점이 인상깊었다.

시나리오에 따라서 방법을 적절하게 사용하면 되지만 개인적인 생각으로는 POST 요청 이후 provider를 리프레시해주는 방식이 범용적으로 사용될 수 있는 방법인 것 같다.

개발을 처음 공부하던 시점에서는 이렇게 POST요청을 처리한 이후에 추가적으로 GET 요청을 보내는 것이 서버에 엄청난 과부하를 일으키는 건 아닐지, 너무 말도 안되는 방식은 아닌지 걱정이 많았는데, 이번에 관련 내용을 공부하면서 해당 시나리오도 일반적인 상황이라는 것을 알 수 있었다.

edited by 김동한