혹시 Rivperod 좋아하시나요?
저는 최근에 새롭게 시작한 Flutter 프로젝트에서 Riverpod 패키지를 사용하여 상태관리를 하고 있습니다. 제가 주로 사용해 온 패키지는 Provider
나 GetX
였기 때문에 Riverpod과는 친숙하지 않았습니다. 다만, 근래 Flutter 유저들이 Riverpod에 열광하는 이유가 궁금했죠. 그래서 과감하게 Riverpod을 선택하게 되었습니다.
그리고 얼마 지나지 않아 저 또한 Riverpod의 매력에 푹 빠져들게 되었습니다.
Riverpod을 사용하여 프로젝트를 구현하면서 패키지가 제시하는 리액티브한 메커니즘과 각종 기능에 대부분 만족했지만, 단 한 가지 부분에서는 불만족스러운 경험이 있었습니다.
전역 변수
로 선언된 provider는 무조건 나쁘다는 것을 말하고 싶은 것은 아닙니다. (또한, provider의 상태는 ProviderContainer
안에서 관리되고 있어 완전한 전역 변수로 보기도 어렵습니다.)
Riverpod 공식문서에도 기재되었듯이 전역으로 선언된 provider는 immutable
한 성격을 띄고 있기 때문에 앱의 라이프사이클을 방해하거나 테스트 코드를 작성하는데 문제가 발생하지는 않겠죠.
그러나 어디에서든 import만으로 provider에 접근할 수 있다는 점은, 특정 페이지에서 어떤 provider가 사용되는지 파악하기 어렵다는 단점으로 이어집니다.
이런 단점은 여러 문제를 동반합니다.
예를 들어, 당신이 Riverpod을 사용한 Flutter 프로젝트에 참여하게 되고, 상사가 다음과 같은 Task를 던져줬다고 가정해 봅시다.
"홈 페이지에 사용되는 Provider들을 기반으로 Unit Test 코드를 작성해주세요."
이제 막 프로젝트에 투입되었기 때문에 홈 페이지에서 어떤 provider의 상태값
과 이벤트 메소드
가 사용되는지 파악하는 것이 어려울 것이고, 이로인해 테스트 코드의 범위를 파악하고 작성하는 데 많은 시간 소요하게 됩니다.
또한, 여러 명이 협업하는 프로젝트에서는 특정 페이지에서 사용되는 provider들을 파악하는 것이 중요한데요. provider의 사용 범위를 명확히 이해하지 못하면 이미 만들어진 provider를 재사용할 기회를 놓칠 수 있고, 불필요한 추가적인 provider를 만들어 사이드 이펙트
가 발생할 수 있겠죠.
이러한 문제 외에도 앱이 규모가 크고 복잡할수록 provider의 사용 범위를 파악하기 어려워진다는 점은 프로젝트의 유지보수
를 어렵게하는 원인이 됩니다.
위와 같은 문제를 방지하기 위해서는 provider의 사용 범위를 구조화
하는 것이 중요합니다. 즉, 특정 섹션에서 어떤 provider가 사용되는지 쉽게 파악할 수 있어야 합니다. provider 사용 범위를 어떻게 구조화할 수 있을지 고민을 하던 중 Randal L. Schwartz의 'The Riverpod "Global" Myth'라는 유튜브 영상을 보게 되었습니다.
해당 동영상에서는 Riverpod가 오해되는 전역 변수만
을 사용한다는 내용과 함께, provider 사용 범위를 구조화
하는 다양한 방법에 대해 자세히 설명하고 있습니다.
본 포스팅에서는 이 영상에서 다루고 있는 두 가지 방법을 간단하게 소개합니다.
더불어, provider의 사용 범위를 구조화한다는 면에서 앞서 Randal이 소개한 방법과 비슷한 매커니즘을 가지고 있지만, Dart 3.0에서 추가된 mixin class
를 이용하여 조금 더 명시적이
고 테스트가 용이한 형태
로 provider의 사용 범위를 구조화하는 방법에 대해서도 설명 드리니, 참고하시면 좋겠습니다.
본 포스팅에서는 다루고 있는 예제는 Riverpod 공식 문서에 있는 'Todo app'를 기반으로 합니다.
먼저, 특정 페이지 섹션에서 사용되는 provider를 private
으로 선언하여 provider 자체를 로컬화
시키는 방법을 살펴보겠습니다.
final _uncompletedTodosCount = Provider<int>((ref) {
return ref.watch(todoListProvider).where((todo) => !todo.completed).length;
});
class Toolbar extends HookConsumerWidget {
const Toolbar({
Key? key,
}) : super(key: key);
Widget build(BuildContext context, WidgetRef ref) {
return Material(
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
'${ref.watch(_uncompletedTodosCount)}', // <- 로컬화된 provider에 접근
),
...
}
}
위에 코드 예제처럼 특정 소스 파일에서만 사용될 수 있도록 provider의 접근 범위를 private으로 제한하면, 해당 소스 파일에서만 해당 provider에 접근이 가능하게 됩니다. 이를 통해 provider의 사용 범위를 명시적으로 관리할 수 있게 되죠.
다만 class로 분리된 여러 자식 위젯에서 해당 provider에 접근해야 하는 경우 코드가 조금 복잡해질 수 있습니다.
final _uncompletedTodosCount = Provider<int>((ref) {
return ref.watch(todoListProvider).where((todo) => !todo.completed).length;
});
part 'tool_bar3.dart'; // <- part 파일로분리
class HomePage extends HookConsumerWidget with HomeEvent, HomeState {
const HomePage({Key? key}) : super(key: key);
Widget build(BuildContext context, WidgetRef ref) {
return Scaffold(
body: ListView(
children: [
Toolbar1(ref.watch(_uncompletedTodosCount)),
Toolbar2(ref.watch(_uncompletedTodosCount)),
const _Toolbar3(),
...
}
}
예를 들어, HomePage에 Toolbar1
, Toolbar2
, Toolbar3
라는 자식 위젯이 있고 모두 _uncompletedTodosCount
provider에 접근해야 하는 구조라면, 매번 로컬화된 provider의 상태 값을 인자로 넘겨주거나 자식 위젯을 part 파일
로 분리해야 하는 번거로움이 발생할 수 있습니다.
앞서 언급한 문제를 해결할 수 있는 방법은 클래스의 정적 변수
에 provider를 할당하는 것 입니다.
abstract class HomeProviders {
HomeProviders._();
static final todoListFilter = StateProvider((_) => TodoListFilter.all);
static final uncompletedTodosCount = Provider<int>((ref) {
return ref.watch(todoListProvider).where((todo) => !todo.completed).length;
});
...
}
위에 코드처럼 Home 섹션에서 사용되는 모든 provider들을 class 내부에 정적 변수로 할당합니다.
class Toolbar extends ConsumwerWidget {
const Toolbar({
Key? key,
}) : super(key: key);
Widget build(BuildContext context, WidgetRef ref) {
return Material(
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
'${ref.watch(HomeProviders.uncompletedTodosCount)}',
// 정적 변수를 통해 provider에 접근
),
...
}
}
그다음 위젯에서 필요한 provider를 해당 class를 통해 참조하게 하면 됩니다. provider가 class의 정적 변수로 provider가 할당되었기 때문에 불필요한 인스턴스가 생성되거나 provider의 라이프 사이클을 방해하지 않으면서 동시에 provider의 사용 범위를 구조화할 수 있게 됩니다.
이미 소개한 두 가지 방법으로도 충분히 provider의 사용 범위를 명시할 수 있지만, Mixin Class를 활용하여 조금 더 명시적이고 테스트가 용이한 방법을 제안드려 볼까 합니다.
먼저 State Mixin Class
입니다. 해당 클래스에는 특정 페이지에서 사용되는 모든 provider의 상태 값
을 리턴하는 메소드들
이 구성되어 있습니다.
mixin class HomeState {
int uncompletedTodosCount(WidgetRef ref) => ref.watch(uncompletedTodosCountProvider);
List<Todo> filteredTodos(WidgetRef ref) => ref.watch(filteredTodosProvider);
...
}
위의 HomeState
Mixin Class는 HomePage
섹션에서 사용되는 provider들의 상태값을 관리하고 있습니다. 각 메소드는 WidgetRef
을 인자로 받으며, WidgetRef의 확장 메소드인 watch
를 사용하여 상태를 전달합니다.
AsyncValue<Todo> todoAsync(WidgetRef ref) => ref.watch(todoProvider);
만약 비동기 데이터 Future
타입을 반환해야한다면, 해당 값을 AsyncValue
타입으로 감싸주면 됩니다.
앞서 소개한 방식과는 달리, provider 자체를 변수로 관리하는 것이 아닌, WidgetRef를 통해 provider의 state 값을 메소드로 리턴하고 있다는 차이가 있습니다.
위와 같이 구성된 State Mixin Class는 위젯 클래스에 mixin
되어 위젯에서 provider의 상태 값
에 접근할 수 있도록 합니다.
이어서 Event Mixin Class
에 대해 살펴보겠습니다. Event Mixin Class에서는 특정 섹션에서 사용되는 모든 이벤트 로직
을 효율적으로 관리합니다. State Mixin Class와 마찬가지로 WidgetRef
를 인자로 받아서 provider의 메소드에 손쉽게 접근할 수 있습니다.
mixin class HomeEvent {
void addTodo(
WidgetRef ref, {
required TextEditingController textEditingController,
required String value,
}) {
ref.read(todoListProvider.notifier).add(value);
textEditingController.clear();
}
void requestTextFieldsFocus(
{required FocusNode textFieldFocusNode,
required FocusNode itemFocusNode}) {
itemFocusNode.requestFocus();
textFieldFocusNode.requestFocus();
}
...
}
예를 들어, 위의 addTodo
메소드는 WidgetRef
객체를 통해 todoListProvider
라는 Notifier Provider에 접근하여 새로운 아이템을 현재 리스트에 추가하는 메소드를 실행시킵니다.
NOTE
provider와 관련된 로직뿐만 아니라, 기타 사용자 인터랙션으로 인한 이벤트 값들도 해당 Mixin Class에서 관리하는 것이 권장됩니다. 특정 섹션에서 사용되는 event 값들을 한 곳에서 관리하면 UI 코드와 event 로직을 완벽하게 분리하여 가독성을 향상시킬 수 있으며, 코드 추적이 용이해집니다.
마찬가지로, 위와 같이 Event Mixin Class는 Event 메소드가 필요한 위젯에 mixin
되어, 간편하게 event 메소드를 전달할 수 있습니다.
조금 복잡해 보일 수 있지만 개념은 아주 간단합니다.
핵심은 widget에서 provider에 바로 접근하게 하지 않고 State 및 Event Mixin Class라는 새로운 통로
를 통해 provider에 접근
할 수 있도록 하는 것입니다.
BLoC을 사용하신 경험이 있으신분들이랑면 state과 event를 분리한다는 면에서 일부 유사하다는 점을 눈치채셨을 겁니다. 이렇게 provider의 state과 event 로직을 분리함으로써 생기는 장점과 관련해서는 뒤에 내용에서 확인하실 수 있습니다.
그럼 이렇게 Rvierpod provider의 state 값과 event메소드들은 Mixin Class에서 관리하면 어떤 이점
이 있을까요? 크게 5가지 이점을 살펴보겠습니다.
특정 페이지 섹션에서 사용되는 provider들의 로직은 하나의 Mixin Class에서 중앙 집중적
으로 관리되어 유지보수가 용이한 구조가 됩니다.
mixin class HomeState {
List<Todo> todos(WidgetRef ref) => ref.watch(todoListFromRemoteProvider).value;
}
예를 들어, 위 코드에서는 todoListFromRemoteProvider
provider를 통해 원격 데이터를 받아오고 있고, 특정 페이지 내 여러 위젯에서 이 값을 참조하고 있다고 가정해 봅시다.
mixin class HomeState {
List<Todo> todos(WidgetRef ref) => ref.watch(todoListFromLocal);
}
만약 원격 데이터를 호출하는 기존 provider에서 로컬 데이터를 불러오는 todoListFromLocal
provider로 변경해야 한다면, 간단히 HomeState class에서 provider만 교체해 주면 됩니다.
그러나 Mixin State Class를 사용하지 않고 각 위젯에서 직접 provider를 사용하는 구조라면, 각 위젯에서 사용 중인 기존 provider를 새로운 provider로 일일이 변경해주 어야 하는 번거로움이 발생할 수 있습니다.
Mixin Class에서 provider 리소스를 관리하면 특정 페이지에서 어떤 provider 상태 값과 이벤트 로직이 사용되는지 한눈에 파악할 수 있습니다. Mixin Class가 부모 페이지 위젯 또는 자식 위젯에 mixin되어 명확한 종속성
이 설정되기 때문입니다.
또한, Event Mixin Class에서 event 로직을 관리함으로써 UI 코드와 event 메소드를 완벽하게 분리하여 가독성을 높일 수 있습니다.
State 및 Event Mixin Class를 활용하면 유닛 테스트
코드 작성이 더욱 편리해집니다.
유닛 테스트 코드를 작성하기 전에 앱의 규모가 클수록 테스트 범위
를 설정하기가 까다롭습니다. 대체 어디까지 테스트를 해야되는지 항상 고민이 되죠.
mixin class HomeEvent {
void addTodo(
WidgetRef ref, {
required TextEditingController textEditingController,
required String value,
}) { ... }
void removeTodo(WidgetRef ref, {required Todo selectedTodo}) { ... }
void changeFilterCategory(WidgetRef ref, {required TodoListFilter filter}) { ...
}
void toggleTodoState(WidgetRef ref, {required String todoId}) { ... }
void editTodoDesc(WidgetRef ref,
{required bool isFocused,
required TextEditingController textEditingController,
required Todo selectedTodo}) { ... }
}
하지만 Event Mixin Class에 특정 페이지에서 사용되는 이벤트 로직들을 한 눈에 파악할 수 있다면 테스트 범위를 설정하고 테스트 시나리오를 구성하는데 꽤 도움이 될 수 있습니다.
기존에 작성된 State 및 Event Mixin 모듈을 활용하면 유닛 테스트 코드가 훨씬 간결해집니다.
mixin class HomeEventTest {
void addTodo(
ProviderContainer container, {
required TextEditingController textEditingController,
required String value,
}) {
container.read(todoListProvider.notifier).add(value);
textEditingController.clear();
}
void removeTodo(ProviderContainer container, {required Todo selectedTodo}) {
container.read(todoListProvider.notifier).remove(selectedTodo);
}
...
}
mixin class HomeStateTest {
List<Todo> filteredTodos(ProviderContainer container) =>
container.read(filteredTodosProvider);
int uncompletedTodosCount(ProviderContainer container) =>
container.read(uncompletedTodosCountProvider);
...
}
먼저 기존 State 및 Event Mixin 모듈의 코드를 복사
해서 새로운 테스트용 Mixin Class
를 만들어 줍니다. 이때 기존 메소드에서는 WidgetRef
를 인자로 받았지만, 테스트 코드를 실행시키기 위해 ProviderContainer
타입으로 변경하고, 기존의 .watch
메소드를 .read
로 변경해 주어야 합니다.
void main() {
final homeEvent = HomeEventTest();
final homeState = HomeStateTest();
test('Add todo', () {
final container = createContainer();
const String todoDescription = 'Write Riverpod Test Code';
homeEvent.onTodoSubmitted(container,
textEditingController: TextEditingController(), value: todoDescription);
expect(
homeState.filteredTodos(container).last.description, todoDescription);
});
}
그다음에는 테스트 main 메소드 안에서 각각 State 및 Event Mixin Class 인스턴스
를 생성한 뒤, 해당 인스턴스들을 활용하여 테스트 코드를 작성해 줍니다.
단계별로 설명드리면 아래와 같습니다.
Event Mixin Class 내부에는 테스트하려는 이벤트 메소드
가 정의되어 있으며, State Mixin Class에는 예상되는 테스트 결과 값
이 구성되어 있기 때문에, 이를 활용하여 복잡한 시나리오를 포함한 유닛 테스트 코드를 간편하게 작성할 수 있게 됩니다.
현업에서 프로젝트를 진행할 때 기능 명세와 API 스펙은 모두 개발자에게 전달되었지만, 디자인이 완성되지 않은 상황이 종종 발생합니다. 이때, 디자인이 완성되기를 기다리지 않고 미리 State 및 Event Mixin Class 모듈을 작성하는 작업을 수행할 수 있습니다. 이렇게 미리 만들어진 Mixin Class를 활용하면 완성된 디자인을 받은 후 UI 위젯을 구현하고, 만들어둔 Mixin Class를 연동하여 공백없이 프로젝트를 진행할 수 있게 됩니다.
앞서 살짝 언급했지만, 여려명이 협업하는 프로젝트일수록 특정 페이지에서 어떤 provider들이 사용되고 있는지 파악하는건 중요합니다. 이런 부분을 간과할 경우 기능은 동일하지만 이름만 다른 provider가 하나 더 생겨 사이드 이펙트가 발생하는 불상사가 발생할 수도 있습니다. (제 경험담입니다)
이번 포스팅에서는 Mixin Class를 활용하여 Riverpod에서 provider의 사용범위를 구조화하는 방법에 대해 알아보았습니다. 앱의 규모가 작다면 이런 접근 방법은 오버지니어링으로 비춰질 수 있지만, 앱의 규모가 커지고 다루는 provider가 많아질수록 더 많은 이점이 있습니다. 무엇보다도 유닛 테스트 코드
를 아주 쉽게 작성할 수 있다는 점이 개인적으로 마음에 듭니다.
또한 Mixin Class를 활용하여 Provider의 사용 범위를 구조화하는 방법은 제가 이전에 작성한 '내일 바로 써먹는 Flutter Clean UI Code'라는 포스팅에서 제시한 접근방법과 함께 사용했을 때 더 빛을 발휘합니다. 이 포스팅에서는 Flutter의 UI코드를 섹션별로 Class Widget으로 구조화하는 방법에 대해 다루고 있는데, Mixin Class는 구조화된 UI 위젯에 유연하게 mixin되어 적용할 수 있기 때문에 함께 적용했을 때 시너지가 발생합니다.
참고로, 본 포스팅에서 다룬 Todo App 예제 프로젝트가 궁금하신다면 제 깃허브 레포를 참고해주세요. 기존 Riverpod 공식문서에 있는 예제 코드에서 State 및 Event Mixin Class 적용 로직과 간다한 테스트 코드만 추가해 두었습니다.
최근에 Riverpod을 너무 만족하며 사용하고 있어서 Riverpod과 관련된 다양한 포스팅을 추가로 업로드할 계획이 있습니다. 혹시 관련된 내용이 궁금하시다면 팔로우를 해주세요:)
읽어주셔서 감사합니다!
레포 살펴 보는것만으로도 엄청나게 도움이 많이 되네요. 보면서 스스로 많이 반성하게 됩니다.