주요 콘텐츠로 건너뛰기

부가 작업 수행(Performing side effects)

지금까지는 데이터를 가져오는 방법(일명 GET HTTP 요청 수행)만 살펴봤습니다. 하지만 POST 요청과 같은 부수적 효과(side-effect)는 어떨까요?

애플리케이션은 종종 CRUD(생성, 읽기, 업데이트, 삭제) API를 구현합니다.
이 경우 일반적으로 업데이트 요청(일반적으로 POST)은 로컬 캐시를 업데이트해야 합니다. 로컬 캐시도 업데이트하여 UI에 새 상태가 반영되도록 하는 것이 일반적입니다.

문제는 consumer 내에서 provider의 상태를 어떻게 업데이트할 수 있느냐는 것입니다.
당연히 providers는 자신의 상태(state)를 수정할 수 있는 방법을 노출하지 않습니다. 이는 상태가 통제된 방식으로만 수정되도록 하고 관심사 분리(separation of concerns)를 보장하기 위한 설계입니다.
대신 providers는 자신의 상태를 수정할 수 있는 방법을 명시적으로 노출해야 합니다.

이를 위해 새로운 개념을 사용할 것입니다: Notifiers.
이 새로운 개념을 보여드리기 위해 좀 더 발전된 예를 사용하겠습니다: 할일 목록(to-do list)입니다.

Notifier 정의하기

이 시점에서 우리가 이미 알고 있는 것부터 시작하겠습니다: 간단한 GET 요청입니다. 앞서 첫 번째 provider/네트워크 요청 만들기에서 보았듯이, 글쓰기를 통해 할일 목록을 가져올 수 있습니다:


Future<List<Todo>> todoList(TodoListRef ref) async {
// 네트워크 요청을 시뮬레이션합니다. 이는 일반적으로 실제 API로부터 수신됩니다.
return [
Todo(description: 'Learn Flutter', completed: true),
Todo(description: 'Learn Riverpod'),
];
}

이제 할 일 목록을 가져왔으니 새 할 일을 추가하는 방법을 살펴봅시다.
이를 위해서는 상태(state) 수정을 위한 공개 API를 노출하도록 provider를 수정해야 합니다. 이 작업은 provider를 "notifier"라고 부르는 것으로 변환하여 수행합니다.

Notifiers는 providers의 "상태저장 위젯(stateful widget)"입니다. provider를 정의하는 문법을 약간 수정해야 합니다.
이 새로운 문법은 다음과 같습니다:

@riverpod
class MyNotifier extends _$MyNotifier {
  @override
  Result build() {
    <your logic here>
  }
  <your methods here>
}
어노테이션(annotation)

모든 providers는 @riverpod 또는 @Riverpod()로 어노테이션해야 합니다. 이 어노테이션은 전역 함수나 클래스에 배치할 수 있습니다.
이 어노테이션을 통해 프로바이더를 구성할 수 있습니다.

예를 들어, @Riverpod(keepAlive: true)를 작성하여 "auto-dispose"(나중에 살펴볼 것임)를 비활성화할 수 있습니다.

Notifier

@riverpod 어노테이션이 클래스에 배치되면 해당 클래스를 "Notifier"라고 부릅니다.
클래스는 _$NotifierName을 확장해야 하며, 여기서 NotifierName은 클래스 이름입니다.

Notifiers는 provider의 상태(state)를 수정하는 메서드를 노출할 책임이 있습니다.
이 클래스의 공개 메서드는 ref.read(yourProvider.notifier).yourMethod()를 사용하여 consumer가 액세스할 수 있습니다.

노트

UI에서 상태가 변경되었음을 알 수 있는 수단이 없기 때문에, Notifiers에는 기본 제공 'state' 외에 공개 속성이 없어야 합니다.

The build method

모든 notifiers는 build 메서드를 재정의(override)해야 합니다.
이 메서드는 일반적으로 notifier가 아닌 provider(non-notifier provider)에서 로직을 넣는 위치에 해당합니다.

이 메서드는 직접 호출해서는 안 됩니다.

참고로, 이 새로운 문법을 이전에 보았던 문법과 비교하려면 첫 번째 provider/네트워크 요청 만들기를 확인하면 됩니다.

정보

build 이외의 메서드가 없는 Notifier는 앞서 본 문법을 사용하는 것과 동일합니다.

첫 번째 provider/네트워크 요청 만들기에 표시된 문법은 UI에서 수정할 방법이 없는 notifiers에 대한 간략한 표현이라고 볼 수 있습니다.

이제 문법을 살펴봤으니 이전에 정의한 provider를 notifier으로 변환하는 방법을 살펴보겠습니다: Now that we've seen the syntax, let's see how to convert our previously defined provider to a notifier:


class TodoList extends _$TodoList {

Future<List<Todo>> build() async {
// 이전에 FutureProvider에 있던 로직이 이제 빌드 메서드에 있습니다.
return [
Todo(description: 'Learn Flutter', completed: true),
Todo(description: 'Learn Riverpod'),
];
}
}

위젯 내에서 provider를 읽는 방법은 변경되지 않았습니다.
이전 구문과 마찬가지로 ref.watch(todoListProvider)를 계속 사용할 수 있습니다.

주의

notifier의 생성자에 로직을 넣지 마세요.
ref 및 기타 프로퍼티는 아직 사용할 수 없으므로 Notifier에는 생성자가 없어야 합니다. 대신 build 메서드에 로직을 넣으세요.

class MyNotifier extends ... {
MyNotifier() {
// ❌ 이렇게 하지 마세요.
// 이 경우 예외가 발생합니다.
state = AsyncValue.data(42);
}


Result build() {
// ✅ 대신 이렇게 하세요.
state = AsyncValue.data(42);
}
}

POST 요청을 수행하는 메서드 노출하기

이제 Notifier가 생겼으니 부가 작업(side-effect)을 수행할 수 있는 메서드를 추가할 수 있습니다. 그러한 부가 작업 중 하나는 클라이언트가 새 할 일을 POST하도록 하는 것입니다. notifier에 addTodo 메서드를 추가하면 그렇게 할 수 있습니다:


class TodoList extends _$TodoList {

Future<List<Todo>> build() async => [/* ... */];

Future<void> addTodo(Todo todo) async {
await http.post(
Uri.https('your_api.com', '/todos'),
// 할 일 개체를 직렬화하여 서버에 POST합니다.
headers: {'Content-Type': 'application/json'},
body: jsonEncode(todo.toJson()),
);
}
}

그런 다음 첫 번째 provider/네트워크 요청 만들기에서 보았던 것과 동일한 Consumer/ConsumerWidget을 사용하여 UI에서 이 메서드를 호출할 수 있습니다:

class Example extends ConsumerWidget {
const Example({super.key});


Widget build(BuildContext context, WidgetRef ref) {
return ElevatedButton(
onPressed: () {
// "ref.read"를 "myProvider.notifier"와 사용하면
// notifier의 클래스 인스턴스를 얻을 수 있습니다.
// 이를 통해 "addTodo" 메서드를 호출할 수 있습니다.
ref
.read(todoListProvider.notifier)
.addTodo(Todo(description: 'This is a new todo'));
},
child: const Text('Add todo'),
);
}
}
정보

메서드를 호출할 때 ref.watch 대신 ref.read를 사용하고 있는 것을 주목하세요.
ref.watch도 기술적으로는 작동할 수 있지만, "onPressed"와 같은 이벤트 핸들러에서 로직이 수행될 때는 ref.read를 사용하는 것이 좋습니다.

이제 버튼을 누르면 POST 요청을 하는 버튼이 생겼습니다.
그러나 현재로서는 새 할 일 목록을 반영하도록 UI가 업데이트되지 않습니다. 로컬 캐시가 서버의 상태와 일치하기를 원할 것입니다.

장단점이 있는 몇 가지 방법이 있습니다.

API 응답에 맞춰 로컬 캐시 업데이트하기

일반적인 백엔드 관행은 POST 요청이 리소스의 새 상태를 반환하도록 하는 것입니다.
특히, 저희 API는 새 할 일을 추가한 후 새 할 일 목록을 반환합니다. 이를 수행하는 한 가지 방법은 state = AsyncData(response)를 작성하는 것입니다:

  Future<void> addTodo(Todo todo) async {
// POST 요청은 새 애플리케이션 상태와 일치하는 List<Todo>를 반환합니다.
final response = await http.post(
Uri.https('your_api.com', '/todos'),
headers: {'Content-Type': 'application/json'},
body: jsonEncode(todo.toJson()),
);

// API 응답을 디코딩하여 List<Todo>로 변환합니다.
List<Todo> newTodos = (jsonDecode(response.body) as List)
.cast<Map<String, Object?>>()
.map(Todo.fromJson)
.toList();

// 로컬 캐시를 새 상태와 일치하도록 업데이트합니다.
// 그러면 모든 리스너에게 알림이 전송됩니다.
state = AsyncData(newTodos);
}
장점
  • UI는 가능한 가장 최신 상태로 유지됩니다. 다른 사용자가 할 일을 추가하면 우리도 볼 수 있습니다.
  • 서버가 진실의 원천입니다. 이 접근 방식을 사용하면 클라이언트는 할 일 목록에서 새 할 일을 어디에 삽입해야 하는지 알 필요가 없습니다.
  • 단 한 번의 네트워크 요청만 필요합니다.
단점
  • 이 접근 방식은 서버가 특정 방식으로 구현된 경우에만 작동합니다. 서버가 새 상태를 반환하지 않으면 이 접근 방식은 작동하지 않습니다.
  • 필터/소팅이 있는 경우와 같이 연결된 GET 요청이 더 복잡한 경우에는 여전히 작동하지 않을 수 있습니다.

ref.invalidateSelf()`를 사용하여 provider를 새로고침

한 가지 옵션은 provider가 GET 요청을 다시 실행하도록 하는 것입니다.
이는 POST 요청 뒤에 ref.invalidateSelf()를 호출하여 수행할 수 있습니다:

  Future<void> addTodo(Todo todo) async {
// API 응답은 신경 쓰지 않습니다.
await http.post(
Uri.https('your_api.com', '/todos'),
headers: {'Content-Type': 'application/json'},
body: jsonEncode(todo.toJson()),
);

// 포스트 요청이 완료되면 로컬 캐시를 더티(dirty)로 표시할 수 있습니다.
// 이렇게 하면 notifier의 "build"가 비동기적으로 다시 호출되고, 이 때 리스너(listener)에게 알림이 전송됩니다.

ref.invalidateSelf();

// (선택 사항) 그런 다음 새 상태가 계산될 때까지 기다릴 수 있습니다.
// 이렇게 하면 새 상태를 사용할 수 있을 때까지 "addTodo"가 완료되지 않습니다.
await future;
}
장점
  • UI는 가능한 가장 최신 상태로 유지됩니다. 다른 사용자가 할 일을 추가하면 우리도 볼 수 있습니다.
  • 서버가 진실의 원천입니다. 이 접근 방식을 사용하면 클라이언트는 할 일 목록에서 새 할 일을 어디에 삽입해야 하는지 알 필요가 없습니다.
  • 이 접근 방식은 서버 구현에 관계없이 작동합니다. 필터/소팅이 포함된 경우와 같이 GET 요청이 더 복잡한 경우에 특히 유용할 수 있습니다.
단점
  • 이 접근 방식은 비효율적일 수 있는 추가 GET 요청을 수행합니다.

로컬 캐시 수동 업데이트

또 다른 옵션은 로컬 캐시를 수동으로 업데이트하는 것입니다.
여기에는 백엔드의 동작을 모방하는 작업이 포함됩니다. 예를 들어, 백엔드가 새 항목을 처음에 삽입하는지 아니면 마지막에 삽입하는지 알아야 합니다.

  Future<void> addTodo(Todo todo) async {
// API 응답은 신경 쓰지 않습니다.
await http.post(
Uri.https('your_api.com', '/todos'),
headers: {'Content-Type': 'application/json'},
body: jsonEncode(todo.toJson()),
);

// 그런 다음 로컬 캐시를 수동으로 업데이트할 수 있습니다. 이를 위해서는 이전 상태를 가져와야 합니다.
// 주의: 이전 상태가 여전히 로딩 중이거나 오류 상태일 수 있습니다.
// 이 문제를 우아하게 처리하는 방법은 `this.state` 대신 `this.future`를 읽어서 로딩 상태를 기다리게 하고
// 상태가 오류 상태인 경우 오류를 발생시키는 것입니다.
final previousState = await future;

// 그런 다음 새 상태 객체를 생성하여 상태를 업데이트할 수 있습니다.
// 그러면 모든 리스너에게 알림이 전송됩니다.
state = AsyncData([...previousState, todo]);
}
정보

이 예제에서는 불변(immutable) 상태를 사용합니다. 이는 필수는 아니지만 권장 사항입니다. 자세한 내용은 Why Immutability를 참조하세요.
대신 변경 가능한 상태를 사용하려는 경우 다른 방법을 사용할 수 있습니다:

    final previousState = await future;
// 이전 할 일 목록을 변경합니다. (Mutable)
previousState.add(todo);
// 리스너에게 수동으로 알림을 전송합니다.
ref.notifyListeners();
장점
  • 이 접근 방식은 서버 구현에 관계없이 작동합니다.
  • 네트워크 요청은 단 한 번만 필요합니다.
단점
  • 로컬 캐시가 서버의 상태와 일치하지 않을 수 있습니다. 다른 사용자가 할 일을 추가한 경우 이를 볼 수 없습니다.
  • 이 접근 방식은 백엔드의 로직을 효과적으로 복제하고 구현하기가 더 복잡할 수 있습니다.

더 알아보기: 스피너(spinner) 표시 및 오류 처리(error handling)

지금까지 살펴본 바에 따르면 버튼을 누르면 POST 요청을 하고 요청이 완료되면 변경 사항을 반영하여 UI가 업데이트되는 버튼이 있습니다.
하지만 현재로서는 요청이 수행되고 있다는 표시도 없고, 요청이 실패할 경우 어떤 정보도 표시되지 않습니다.

한 가지 방법은 로컬 위젯 상태에 addTodo가 반환한 Future를 저장한 다음 해당 Future를 수신하여 스피너 또는 오류 메시지를 표시하는 것입니다.
이 시나리오에서는 flutter_hooks가 유용합니다. 물론 StatefulWidget을 대신 사용할 수도 있습니다.

다음 스니펫은 작업이 보류 중인 동안 진행률 표시기를 보여줍니다. 그리고 실패하면 버튼을 빨간색으로 렌더링합니다:

A button which turns red when the operation failed

class Example extends ConsumerStatefulWidget {
const Example({super.key});


ConsumerState<ConsumerStatefulWidget> createState() => _ExampleState();
}

class _ExampleState extends ConsumerState<Example> {
// pending중인 addTodo 작업입니다. 또는 pending중인 작업이 없으면 null입니다.
Future<void>? _pendingAddTodo;


Widget build(BuildContext context) {
return FutureBuilder(
// pending중인 작업을 수신하여 그에 따라 UI를 업데이트합니다.
future: _pendingAddTodo,
builder: (context, snapshot) {
// 오류 상태가 있는지 여부를 계산합니다.
// 연결 상태(connectionState) 확인은 연산을 다시 시도할 때 처리합니다.
final isErrored = snapshot.hasError && snapshot.connectionState != ConnectionState.waiting;

return Row(
children: [
ElevatedButton(
style: ButtonStyle(
// 오류가 있는 경우 버튼이 빨간색으로 표시됩니다.
backgroundColor: MaterialStateProperty.all(
isErrored ? Colors.red : null,
),
),
onPressed: () {
// addTodo가 반환한 future를 변수에 보관합니다.
final future = ref.read(todoListProvider.notifier).addTodo(Todo(description: 'This is a new todo'));

// 해당 future를 로컬 상태에 저장합니다.
setState(() {
_pendingAddTodo = future;
});
},
child: const Text('Add todo'),
),
// 작업이 pending중이므로 진행률 표시기(progress indicator)를 표시합니다.
if (snapshot.connectionState == ConnectionState.waiting) ...[
const SizedBox(width: 8),
const CircularProgressIndicator(),
]
],
);
},
);
}
}