
MyWorkoutList 를 마저 작성해 본다. 운동 삭제 기능을 구현하고 운동 요일을 state에 반영해야 한다.
삭제 로직은 어렵지 않다. 운동의 index를 받아 해당 index의 운동을 목록에서 제거하면 된다. 그리고 state가 변경되었음을 알리기 위해 notifyListeners(); 를 해준다. 이를 수행하는 interface를 Provider logic 영역에 작성한다.
lib/logics/my_workout_provider.dart// 앞 부분 생략 Future<void> deleteMyWorkout(int index) async { _workouts.removeAt(index); notifyListeners(); } // 뒷 부분 생략
운동 타일의 삭제 버튼에서 Provider를 통해 앞서 작성한 코드를 사용한다.
lib/widgets/workout_tile.dart// 앞 부분 생략 IconButton( onPressed: () { Provider.of<MyWorkoutProvider>( context, listen: false, ).deleteMyWorkout(index); }, color: colorScheme.outlineVariant, icon: Icon( Icons.delete_outline, size: textTheme.titleLarge?.fontSize, ), ), // 뒷 부분 생략
운동 요일을 state에 기록하기 위해 model에 workoutDays property를 추가한다.
lib/models/my_workout.dartimport 'dart:core'; class MyWorkout { String name; String imageURL; int minutes; List<bool> workoutDays=List.filled(7, false, growable: false); MyWorkout({ required this.name, required this.imageURL, required this.minutes, List<bool>? workoutDays, }): workoutDays=_normalizeDays(workoutDays); static List<bool> _normalizeDays(List<bool>? days) { assert(days == null || days.length == 7, '운동 요일의 Length가 7이어야 합니다'); if (days == null) { return List.filled(7, false, growable: false); } return List<bool>.of(days, growable: false); } }
_normalizeDays method는 형식에 맞지 않는 List가 전달되었을 때에 대한 예외처리다. workouDays 는 null이거나 7개의 bool로 이루어져 있어야 한다. 그 외의 경우는 assert 를 통해 런타임 시점에 오류를 알린다. 사용자가 이 목록을 잘못 전달하는 상황은 발생하지 않고 오직 개발자 실수로 인해서만 발생할 수 있는 문제이기 때문에 이걸로 충분하다.
null 이 전달되면 크기를 바꿀 수 없는 false로 채워진 목록을 return하고, 7개의 bool로 이루어진 목록이 전달되면 List의 크기를 불변으로 만들어 return한다.
WorkoutDaySelector 에서 요일 toggle을 누름에 따라 call할 Provider mehod를 작성한다. 이 또한 notifyListeners(); 를 해주어야 한다.
lib/logics/my_workout_provider.dart// 앞 부분 생략 Future<void> updateMyWorkoutDays({ required List<bool> isSelected, required int workoutIndex, }) async { _workouts[workoutIndex].workoutDays = isSelected; notifyListeners(); } // 뒷 부분 생략
toggle을 누르면 UI상으로만 요일 선택이 되도록 작성되어 있었는데, state에도 반영되도록 코드를 수정한다. 이때, 이것이 어느 운동의 요일 선택기인지 알 수 있도록 WorkoutDaySelector 에 workoutIndex property를 추가하여 constructor로 전달 받는다.
lib/widgets/workout_day_selector.dart// 잎 부분 생략 class WorkoutDaySelector extends StatefulWidget { final int workoutIndex; const WorkoutDaySelector({super.key, required this.workoutIndex}); State<WorkoutDaySelector> createState() => _WorkoutDaySelectorState(); } class _WorkoutDaySelectorState extends State<WorkoutDaySelector> { List<bool> isSelected = List.filled(7, false); void updateIsSelected(int index) { isSelected[index] = !isSelected[index]; Provider.of<MyWorkoutProvider>(context, listen: false).updateMyWorkoutDays( isSelected: List<bool>.from(isSelected), workoutIndex: widget.workoutIndex, ); } // 뒷 부분 생략
기본값으로 모두 false인 List를 사용하도록 구현되어 있는데 state상의 요일 목록을 불러오도록 수정한다. 이 작업은 initState() 에서 해 주어야 한다.
lib/widgets/workout_day_selector.dart// 앞 부분 생략 void initState() { super.initState(); isSelected = List<bool>.from( Provider.of<MyWorkoutProvider>( context, listen: false, ).workouts[widget.workoutIndex].workoutDays, ); } // 뒷 부분 생략
WorkoutDaySelector 는 widget tree에서 상당히 아래에 위치하고 있지만, widget tree를 타고 가지 않아도 Provider를 통해 state를 직접 접근할 수 있다.
ListView로 동일 widget을 여러 개 뿌릴 경우 ListTile 삭제 시 state가 형제 widget에 잘못 붙는 이슈가 발생할 수 있다. 이를 방지하기 위해 state key를 사용하여 각각의 widget을 식별하도록 작성해야 한다.
value key를 사용하면 index 같은 단순한 데이터를 key로 사용할 수 있지만 index가 바뀌었을 때 state가 잘못된 widget에 붙는다. ListTile을 따라 붙는 게 아니라 index를 따라 붙어버린 것이다.
따라서 개별 ListTile의 불변 데이터를 key로 사용하는 편이 좋다. 여기서는 workout 을 object key로 사용하겠다.
// 앞 부분 생략 return WorkoutTile( key: ObjectKey(workout), index: index - 1, name: workout.name, image: workout.imageURL, minutes: workout.minutes, ); // 뒷 부분 생략
이제 [0, 1, 2] 목록에서 1을 삭제한다고 해서 1의 state가 2(였던 것, 이제는 1이 된.)에 붙지 않고 적절한 항목을 찾아갈 수 있다.
데이터베이스를 사용하기 위해 Firebase의 Firestore를 설정하도록 하겠다.
우리 프로젝트의 DB 버전은 Standard로 충분하다. ID는 기본값으로 두고 지역은 서울로 선택한다.
규칙은 Storage랑 동일하게 해주면 되는데 여기서는 다른 방식으로 해보겠다. (결국 같은 말인데 여기서는 함수를 사용해 보았다.)
데이터를 담을 collection을 생성한다.
collection에 들어가는 데이터는 document라고 하며, 각 document 안에는 key-value 형태의 JSON 데이터가 저장된다. document 안에 또 collection을 넣어 sub-collection을 만들 수 있지만 매우 복잡해질 수 있다.
cloud_firestore package를 가져다 사용할 것이다.
pubspec.yamldependencies: flutter: sdk: flutter cupertino_icons: ^1.0.8 audioplayers: ^6.8.1 flex_color_scheme: ^8.4.0 intl: ^0.20.3 go_router: ^17.4.0 shared_preferences: ^2.5.5 firebase_core: ^4.13.0 firebase_auth: ^6.5.7 image_picker: ^1.2.3 firebase_storage: ^13.4.6 provider: ^6.1.5+1 cloud_firestore: ^6.8.0
Firestore를 사용하는 Service의 초안을 작성한다.
lib/services/firestore_service.dartimport 'package:cloud_firestore/cloud_firestore.dart'; import '../models/my_workout.dart'; class FirestoreService { FirebaseFirestore _fs = FirebaseFirestore.instance; Future<void> createMyWorkout(MyWorkout workout) async { try { } catch (e) { throw Exception('db error: $e'); } } Future<MyWorkout?> readMyWorkout(String workoutId) async { try { } catch (e) { throw Exception('db error: $e'); } return null; } Future<void> updateMyWorkout(MyWorkout workout) async { try { } catch (e) { throw Exception('db error: $e'); } } Future<void> deleteMyWorkout(String workoutId) async { try { } catch (e) { throw Exception('db error: $e'); } } }
먼저 Create를 구현해 본다.
lib/services/firestore_service.dart// 앞 부분 생략 Future<void> createMyWorkout(MyWorkout myWorkout) async { try { final myWorkoutCollection = _fs.collection('myworkouts'); Map<String, dynamic> createData = { 'name': myWorkout.name, 'minutes': myWorkout.minutes, 'imageUrl': myWorkout.imageURL, 'workoutDays': myWorkout.workoutDays, }; await myWorkoutCollection.add(createData); } catch (e) { throw Exception('db error: $e'); } } // 뒷 부분 생략
그런데 Map으로 변환해주는 녀석은 Service보다는 Model에 구현되어 있는 편이 낫다.
lib/models/my_workout.dart// 앞 부분 생략 Map<String, dynamic> toMap() { return { 'name': name, 'minutes': minutes, 'imageURL': imageURL, 'workoutDays': workoutDays, }; } // 뒷 부분 생략
우리는 document 하나와 model object 하나를 1대1 매칭하여 설계했다. 이때, document의 JSON 데이터만으로는 ID가 나오지 않아 object를 식별하기 어렵다. 따라서 개발 편의성을 위해 document id를 document 내부에 중복하여 넣는 게 사용하기 편하다.
lib/services/firestore_service.dart// 앞 부분 생략 Future<void> createMyWorkout(MyWorkout myWorkout) async { try { final myWorkoutCollection = _fs.collection('myWorkouts'); final docRef = await myWorkoutCollection.add(myWorkout.toMap()); docRef.update({'id': docRef.id}); } catch (e) { throw Exception('db error: $e'); } } // 뒷 부분 생략
이에 따라 MyWorkout 에도 id property를 추가하고 toMap() method에도 반영해 주어야 한다.
현재 구현으로는 운동을 누가 추가했는지 알 수 없다. 그런데 실제로는 그 운동을 추가한 사용자에게만 해당 운동을 보여주어야 한다. 따라서 각 운동 데이터에 사용자 정보를 담는 uid 값을 추가해 주어야 한다. 하는 김에 생성 일시도 추가해 보자.
lib/models/my_workout.dart// 앞 부분 생략 class MyWorkout { String? id; String name; String imageURL; int minutes; List<bool> workoutDays = List.filled(7, false, growable: false); String? uid; DateTime createdAt; MyWorkout({ this.id, required this.name, required this.imageURL, required this.minutes, this.uid, List<bool>? workoutDays, DateTime? createAt, // }): workoutDays=workoutDays??List.filled(7, false, growable: false); }) : workoutDays = _normalizeDays(workoutDays), createdAt = createAt ?? DateTime.now(); // 뒷 부분 생략
Provider에서 운동을 추가할 때 uid를 전달하게 작성하겠다.
lib/logics/my_workout_provider.dart// 앞 부분 생략 Future<void> addMyWorkout(MyWorkout workout) async { workout.uid = _auth.user?.uid; await _firebaseStore.createMyWorkout(workout); _workouts.add(workout); notifyListeners(); } // 뒷 부분 생략
Firestore Service에서 Firebase Auth Service에 직접 접근하여 uid를 가져오는 것도 가능하다. 동일 레이어끼리의 종속성은 아키텍처 관점에서 충분히 가능한 일이다. 그러나 Service는 ViewModel에서 접근하기로 했으니 그런 접근 방식은 지양한다. 어차피 ViewModel에서 여러 Service를 들고 있으니 ViewModel이 데이터를 넘겨 주도록 한다.
운동을 추가하면 Firestore에 반영된다.
이번 시간에는 Create까지만 하고 나머지는 이후에 이어서 작성하도록 하겠다.
한 수강생이 점심 시간에 강사님께 질문을 하는데 나갈 준비를 하며 들어보니 대충 유추 가능한 영역이었다. 그래서 그냥 식사를 하고 오려 했는데 내가 유추할 수 있던 영역 외에 이슈가 하나 있다고 밑밥을 깔고 설명하기 시작해서 잠시 머물러 보았다. 그리고 결국 다시 자리에 앉아 이야기를 마저 듣다 식사를 하러 갔다.
lib/main.dart 에서 providers property에는 목록을 전달했다. 그러니 여러 개의 Provider가 필요하면 여기에 그것들을 열거하면 되겠지 생각했고 실제로 그랬다. 그런데 사용하는 측에서 이슈가 있었다. Consumer<Generic> 에는 하나의 Provider만 전달할 수 있는 거 아닌가.
이런 경우에 사용할 수 있는 방법이 몇 가지 있다고 한다.
먼저, 한 페이지를 여러 View로 나누고 각각의 View가 state와 1대1로 연결되도록 구현하는 것이다. 이것이 Provider가 지향하는 정석적인 아키텍처이기도 하다. 그런데 이렇게 작성하면 boilerplate 코드로 인한 개발 생산성이 떨어질 수 있다.
다른 방법으로는 여러 state를 묶는 더 큰 state를 생성하는 것이 있다. 가령 workout 과 user 를 동시에 사용해야 한다면 그 둘을 property로 갖는 상위 state를 추가로 구현하는 것이다. 가능은 한데 내 눈에는 별로 좋아보이지 않는다.
그리고 하나의 View가 여러 개의 state와 연결되도록 1대多로 연결하는 방법이 있다. Riverpod 예제에 이런 식으로 작성되어 있는 경우가 많다고 하더라.
명확한 아키텍처와 개발 생산성은 어느 정도 trade-off 관계에 있다. 어느 쪽을 더 중요시할지는 개발자의 판단(혹은 팀의 약속)에 달려 있다. 나는 개인적으로 전자를 더 우선시한다. 개발 생산성은 AI를 통해 얼마든지 높일 수 있지만 아키텍처는 한 번 잡아놓으면 개선하는 게 더 어렵기 때문이다.
그래서 내 프로젝트에서는 Riverpod을 사용하되 기본적으로는 View와 State를 1대1로 구현하고, boilerplate로 인한 비합리성이 커지는 부분에 한해서 1대多로 선택적으로 구현하려고 계획하고 있다.