Riverpod을 써보자!#
플러터는 상태 관리 패키지가 여러 개 있어요. 그중에서도 저는 Riverpod이라는 패키지를 선호해요. 인기가 많고 사용하기 편리하게 만들어져있고 개인적으로 Remi Rousselet의 팬이어서 쓰고 싶었어요. Bloc은 만들어야 하는 파일이 너무 많아서 불편해 보였어요.
저는 이번에 상태 관리를 적용한 앱 아키텍처를 플러터 프로젝트에 적용하는 법을 알아보고 개인 프로젝트에 적용해 보려고 해요.
앱 아키텍처를 적용하면서 자연스럽게 폴더 구조도 정립해야 하는데 이건 Andrea Bizzotto와 헤비플랜(제가 존경하는 온라인 Flutter 강사님!)님의 방식을 참고하기로 했어요.
앱 아키텍처와 폴더 구조는 Andrea의 방식을 사용할 것이고 상태 관리는 Remi가 만든 Riverpod을 사용하고 헤비플랜님의 Riverpod 강의 내용 중 일부를 참고해서 만드려고 해요. 그리고 저는 Riverpod을 code generation을 통해 만들려고 해요.
저는 상태 관리를 잘 쓰고 싶어서 여러 글을 봤지만 그래도 어떻게 잘 쓸 수 있을지 모르겠더라고요. 그래서 글로 정리하면서 머리와 손에 익히려고 해요.
상태 관리는 특정 상황에 사용되는 문법과 provider들이 있어요. 그래서 어떤 상황에 뭐를 써야 할지 초보자 입장에서 난해한 경우가 많은 것 같아요.
함께 쓰일 패키지들은 다음과 같아요.
- flutter_riverpod
- riverpod_annotation
- riverpod_generator
- build_runner
- custom_lint
- riverpod_lint
- freezed
Provider의 종류와 사용할 providers#
Riverpod 사이트에서 provider마다 오래된 내용이 있을 수 있다는 경고가 있어서 틀린 정보가 있을 수도 있어요.
Provider Type | Provider Create Function | Example Use Case |
---|---|---|
Provider | 특정 유형을 반환 | 서비스 클래스 / 계산된 속성(필터링된 목록) |
StateProvider | 특정 유형을 반환 | 필터 조건 / 간단한 상태 객체 |
FutureProvider | 특정 유형의 Future를 반환 | API 호출 결과 |
StreamProvider | 특정 유형의 Stream을 반환 | API의 결과 stream |
NotifierProvider | (Async)Notifier의 하위 클래스를 반환 | Interface를 통하지 않고는 변경할 수 없는 복잡한 상태 객체 |
StateNotifierProvider | StateNotifier의 하위 클래스를 반환 | Interface를 통하지 않고는 변경할 수 없는 복잡한 상태 객체. NotifierProvider 사용을 추천 |
ChangeNotifierProvider | ChangeNotifier의 하위 클래스를 반환 | 가변성을 요구하는 복잡한 상태 객체 |
Provider의 구체적인 사용 예
- 캐싱 계산
- 다른 공급자(Repository, HttpClient)에게 값을 노출하는 경우
- 테스트나 위젯이 값을 재정의하는 방법을 제공하는 경우
select
를 사용하지 않고도 providers/widgets의 재구축을 줄여야 하는 경우
NotifierProvider/AsyncNotifierProvider의 구체적인 사용 예
- 사용자 정의 이벤트에 반응한 후 시간이 지남에 따라 변경될 수 있는 상태를 노출하는 경우(loading, error, success 등)
- 일부 상태(비즈니스 로직이라고도 해요)를 수정하기 위한 로직을 한 곳에서 중앙 집중화하여 시간이 지남에 따라 유지 관리 가능성을 향상해야 하는 경우
StateNotifierProvider의 구체적인 사용 예
- 사용자 정의 이벤트에 반응한 후 시간이 지남에 따라 변경될 수 있는 불변 상태를 노출하는 경우
- 일부 상태(비즈니스 로직이라고도 해요)를 수정하기 위한 로직을 한 곳에서 중앙 집중화하여 시간이 지남에 따라 유지 관리 가능성을 향상해야 하는 경우
- 이 provider 대신 (Async)NotifierProvider를 사용하는 것이 좋아요
FutureProvider의 구체적인 사용 예
- 비동기 작업 수행 및 캐싱(예: 네트워크 요청)
- 비동기 작업의 오류/로드 상태를 훌륭하게 처리하는 경우
- 여러 비동기 값을 다른 값으로 결합하는 경우
- FutureProvider는 사용자 상호 작용 후 계산을 직접 수정하는 방법을 제공하지 않아요. 간단한 사용 사례를 해결하도록 설계되었어요. AsyncNotifierProvider 사용을 고려하세요.
StreamProvider의 구체적인 사용 예
- Firebase 또는 웹 소켓 듣는 용도로 사용하는 경우
- 몇 초마다 다른 provider를 다시 구축하는 경우
StateProvider의 구체적인 사용 예
- filter type과 같은 enum
- 문자열(일반적으로 텍스트 필드의 원시 콘텐츠)
- 체크박스용 boolean
- Pagination이나 연령 양식 필드용 number
StateProvider는 이런 경우에는 사용하면 안 돼요
- 유효성 검사 논리가 필요한 state인 경우
- 복잡한 객체인 state인 경우(예: 사용자 정의 class, list, map 등)
- count++보다 더 발전된 state를 수정하는 논리인 경우
StateProvider는 주로 UI에 의한 간단한 변수 수정을 허용하기 위해 존재해요.
API를 통하지 않는다면 StateProvider나 NotifierProvider를 사용하는 것이 좋아요. NotifierProvider보다 StateProvider가 더 단순하계 설계되었기 때문에 특정 로직을 처리할 필요가 있는 경우라면 NotifierProvider가 더 좋은 선택일 수 있어요.
NotifierProvider를 사용하는 것이 유지 관리에 용이하고 state의 비즈니스 로직을 한곳에 집중시킬 수 있어요.
ChangeNotifierProvider의 구체적인 사용 예
- provider 패키지에서 ChangeNotifierProvider를 사용하고 이를 riverpod 패키지로 전환하는 경우
- 불변 상태가 되어야 하는 경우지만 가변 상태를 지원해야 하는 경우
Riverpod은 해당 provider 사용을 권장하지 않아요. NotifierProvider를 사용하세요.
변경 가능한 상태를 원한다고 확신하는 경우에만 ChangeNotifierProvider 사용을 고려하세요.
각 provider의 사용 사례들은 살펴봤는데요. 일반적인 API 호출하는 대부분의 경우에는 NotifierProvider나 AsyncNotifierProvider를 사용한다는 걸 알게 되었어요.
그럼 먼저 NotifierProvider/AsyncNotifierProvider로 만들어 놓고 작다면 Provider나 StateProvider를 쓰고 비동기 처리가 되어야 한다면 FutuerProvider나 StreamProvder의 사용을 고려하면 될 것 같아요.
이번에 이렇게 정리를 하니 어떤 상황에 뭘 써야 하는지 알게 되었어요.
그런데 Flutter에선 provider들을 controller, service, repository 클래스를 싱글톤으로 사용하는 경우에도 쓰고 있어요. 그럼 이때는 어떤 provider를 쓰는 것이 좋을까요?
- 간단한 경우라면 Provider를 사용하는 것이 좋아요.
- API를 통해 받아오는 경우 AsyncNotifierProvider를 사용하면 돼요.
- API를 쓰지 않지만 조금 복잡하다면 NotifierProvider를 사용하면 돼요.
프론트엔드나 백엔드 모두 비즈니스 로직 처리가 중요한데요. Riverpod에서 말하는 비즈니스 로직은 네트워크 요청을 수행하는 것을 일반적으로 "비즈니스 로직"이라고 해요. Riverpod에서는 비즈니스 로직이 "provider" 내부에 배치돼요.
이제 riverpod의 providers를 살펴봤으니 프로젝트 폴더 구조를 알아볼게요.
Andrea가 작성한 Flutter App Architecture with Riverpod: An Introduction 글을 보면 하나의 feature는 presentation, application, domain, data라는 폴더를 하나씩 가져요. 폴더 구조를 구성하려면 먼저 앱 아키텍처를 알아야 왜 이런 폴더 구조를 가지는지 알게 되실 거예요. 그리고 이런 아키텍처를 Feature-First Architecture라고 해요.
앱 아키텍처#
이건 Andrea가 만든 여러 아키텍처를 섞거나 가져온 느낌의 아키텍처지만 플러터에서는 따로 정립된 아키텍처가 없기 때문에 이걸 활용해 보려고 해요.
feature#
feature의 정의는 조직마다 다르겠지만 하나의 기능 혹은 영역으로 볼 수 있어요.
feature 폴더는 lib/src/features에 만들어요.
lib/src/features/featrue1..100(특정 이름으로 변경되어야 함)
lib/src/features/feature1을 auth(authentication)라고 가정할게요.
예를 들어 lib/src/features/auth 폴더는 presentation, application, domain, data 폴더를 가져요.
features 폴더에 들어갈 수 있는 예시
‣ lib
‣ src
‣ features
‣ account
‣ admin
‣ checkout
‣ leave_review_page
‣ orders_list
‣ product_page
‣ products_list
‣ shopping_cart
‣ sign_in
presentation#
presentation와 비슷한 개념들 = pages, views, screens, ui, widgets
사용자 인터페이스(UI)와 관련된 코드를 담당해요. 상태에 따라 UI 구성 요소를 렌더링해요.
로그인 화면, 회원가입 화면, 비밀번호 변경 화면 등 인증 기능에 필요한 모든 UI 요소를 여기에 구현해요.
UI 코드는 state 변수를 이용하여 Riverpod Provider를 통해 다른 영역과 통신하고 업데이트돼요.
presentation 폴더에 들어갈 수 있는 예시
# lib/src/features/entries 폴더 예시
‣ entries
‣ presentation
‣ entry_screen
‣ entry_screen.dart
‣ entry_screen_controller.dart
‣ entry_screen_controller.g.dart
‣ entries_screen.dart
Controller에서는 service가 주입돼요. 보통 AsyncValue를 활용해요.
controller와 비슷한 개념들 = state providers, blocs, cubits
아니면 Controller를 riverpod class로 만들기도 해요.
Contoller에는 UI가 들어가면 안 돼요. 또 UI에는 로직이 들어가면 안 돼요.
application#
application과 비슷한 개념들 = services, app logic
비즈니스 로직을 담당해요. 상태를 관리해요.
로그인, 회원가입, 비밀번호 변경 등 인증 기능에 대한 비즈니스 규칙을 여기에 구현해요.
비즈니스 로직 코드는 use cases 함수를 이용하여 Riverpod Provider를 통해 다른 영역과 통신하고 업데이트돼요.
application 폴더에 들어갈 수 있는 예시
# lib/src/features/entries 폴더 예시
‣ entries
‣ application
‣ entries_service.dart
‣ entries_service.g.dart
service에서 필요한 repositories들은 provider에서 생성될 때 주입돼요.
이때 ref.watch로 넘겨줘요. 아니면 ref만 넘겨 함수에서 ref.read로 계속 불러와서 사용해도 괜찮아요.
Application Layer가 모든 걸 중재하는 역할이에요.
- 위젯 상태 관리 및 업데이트는 신경 쓰지 않아요. -> Contoller의 역할
- 데이터 구문 분석과 직렬화 -> Repository의 역할
domain#
엔티티, 도메인 모델, 비즈니스 규칙 등을 정의하는 핵심 영역이에요.
인증 기능과 관련된 개념과 모델을 여기에 정의하여 코드의 추상화 수준을 높이고 재사용성을 향상시켜요.
data 계층에서 나온 데이터를 변환하는 역할을 담당해요.
domain 폴더에 들어갈 수 있는 예시
# lib/src/features/entries 폴더 예시
‣ entries
‣ domain
‣ daily_jobs_details.dart
‣ entries_list_tile_model.dart
‣ entry.dart
‣ entry_job.dart
data#
API와 상호작용해서 데이터 저장 및 로드를 담당해요.
로컬 저장소, API, 네트워크 등을 통해 인증 관련 데이터를 저장하고 불러와요.
데이터 로드 코드는 repositories 함수를 이용하여 Riverpod Provider를 통해 다른 영역과 통신하고 업데이트돼요.
저장될 때 local에 기록할지 remote로 기록할지 상황에 따라 다를 수 있어요.
data 폴더에 들어갈 수 있는 예시
# lib/src/features/entries 폴더 예시
‣ entries
‣ data
‣ entries_repository.dart
그 외 다른 폴더들#
lib/src 폴더에는 features외에도 다양한 폴더들이 있어요.
common_widgets#
공통적으로 사용되는 위젯들을 모아 정의하는 폴더예요.
앱 전체에서 반복적으로 사용되는 버튼, 텍스트 입력 필드, 로딩 표시줄 등의 위젯을 여기에 구현하여 코드 재사용성을 높이고 일관성을 유지해요.
기본적으로 플러터가 제공하는 위젯을 랩핑하거나 조합해 새로운 컴포넌트로 만들어요.
constants#
앱에서 사용되는 다양한 상수들을 정의하는 폴더예요.
컬러 코드, 폰트 스타일, API 주소, 버전 정보 등을 여기에 저장하여 코드 가독성을 높이고 변경 관리를 용이하게 해요.
상수 값을 변경하면 코드 전체에 영향을 미치기 때문에 신중하게 관리해야 해요.
exceptions#
앱 실행 중 발생하는 예외 상황을 처리하는 폴더예요.
예외 발생 시 오류 메시지와 복구 방법을 정의하여 사용자에게 명확한 정보를 제공하고 안정적인 앱 동작을 유지해요.
localization#
앱을 다국어 환경에 지원하기 위한 문자열들을 관리하는 폴더예요.
각 국가 또는 언어별로 문자열 리소스 파일을 생성하고 관리하여 사용자에게 적절한 언어로 앱을 제공해요.
routing#
앱 내 화면 이동을 관리하는 폴더예요.
각 화면 이동 경로를 정의하고 내비게이션 로직을 구현하여 사용자가 앱을 자연스럽게 탐색할 수 있도록 해요.
우리는 프로젝트에서 go_router에 riverpod을 결합한 형태로 사용해요.
utils#
앱 개발에 유용한 다양한 유틸리티 함수들을 모아 정의하는 폴더예요.
위젯이 아닌 함수들이 주로 모여 있어요.
전체 폴더 구조#
위에 내용을 하나씩 설명했어요. 그러면 전체적인 폴더 구조는 아래와 같이 만들 거예요.
‣ lib
‣ src
‣ common_widgets
‣ constants
‣ exceptions
‣ features
‣ address
‣ application
‣ data
‣ domain
‣ presentation
‣ authentication
‣ cart
‣ checkout
‣ orders
‣ products
‣ reviews
‣ localization
‣ routing
‣ utils
꼭 이 구조가 정답은 아니지만 저는 그냥 만들기보다는 Flutter를 잘하는 사람의 폴더 구조를 답습해 보고 싶어서 적용해 보려고 해요. 더 쉽거나 좋은 아키텍처가 나오면 알려주세요! 저도 계속 적용하면서 배워가야 할 것 같아요.
앱 아키텍처 흐름은 이런 식이에요. 모든 건 사용자의 입력과 행동에 의해서 바뀌어요.
화살표를 보면 단일 방향이에요.
- Presentation Layer -> Application Layer -> Data Layer 가는 경우
- Application Layer -> Domain Layer로 가는 경우
이번 글은 여기저기 퍼져있는 걸 단순하게 합쳐서 정리한 글이에요. Andrea 블로그에서 여러 글이 있어요.
다 읽어봐도 예제나 필요한 것들은 유료 코스로 빼놨기 때문에 이 글을 읽고 GPT나 다른 깃허브 코드를 보면서 배울 수밖에 없을 것 같아요. 그래도 읽어보실 분들을 위해 링크를 정리해 놓았어요.
- Flutter App Architecture with Riverpod: An Introduction
- Flutter Project Structure: Feature-first or Layer-first?
- Flutter App Architecture: The Repository Pattern
- Flutter App Architecture: The Domain Model
- Flutter App Architecture: The Presentation Layer
- Flutter App Architecture: The Application Layer
그리고 이번 글에는 코드는 작성하지 않았어요. 이렇게 글로 정리했지만 실제로 잘 다루는 건 아니라 저도 실제 프로젝트에서 적용해 보면서 해봐야 해서 적지 않았어요.
어떤 로직이 Presentation 계층에 속하는지 Application 계층에 속하는지 판단하는 기준이 될 예를 알려드릴게요. 팀이 GUI를 지원하지 않아서 Flutter를 사용하지 않고 CLI로 전환하기로 결정했다고 상상해 보세요. 이 경우 이상적으로 모든 변화를 겪는 건 UI 계층에서 이루어져야 하고, Application 계층은 전혀 변경되지 않아야 해요.
우리는 Presentation에서 Widget과 Controller를 사용하니 Application 계층과 Data 계층만을 가지고 CLI 프로그램을 만들 수 있어야 해요.
The wisdom of the wise, and the experience of ages, may be preserved by quotation.
— Isaac D'Israeli