Feature-First 아키텍처#
이전에도 개발 방법론과 관련된 글(Flutter에 Riverpod용 아키텍처 끼얹기)을 작성했지만, 예제에 대해서 자세히 만들어보지 않았어요. Feature-First 아키텍처를 어떤 순서로 만들고 Riverpod을 통해 어떻게 만드는지 이번에 정리하고 저도 기억이 안 날 때마다 다시 보기 위해 작성해 보려고 해요! 🥲
Feature-First 아키텍처는 소프트웨어 시스템을 설계하고 개발할 때 Feature를 중심으로 생각하는 접근 방식이에요. 즉, 사용자에게 제공할 기능을 먼저 정의하고, 이를 구현하기 위한 기술적인 부분을 나중에 고려하는 방식이죠. 어떤 책에서도 나오는 내용이지만, 프로젝트 활동에는 계획 -> 분석 -> 설계 -> 코딩 -> 테스트 순서로 진행돼요. 개발 아키텍처는 한 가지 방식이 정답이 아니에요. 팀마다 다르고 사람마다 선호하는 방식이 다를거예요. 저도 이 아키텍처를 사용하려고 하지만 사용하면서 바뀌거나 다른 방식을 적용하게 될 수도 있어요.
실제로 적용해 보면서 느낀 점은 백엔드의 MVC 패턴과 굉장히 유사하지만, presentation 계층의 controller쪽이 조금 달랐어요.
용어 정리#
아키텍처를 기반으로 만들기 전에 알아야 할 것들은 먼저 정리하고 예제를 만들 거예요.
우선 Feature와 Domain에 대해 이해하고 갈게요.
- Feature: 사용자에게 제공되는 특정 기능이나 서비스를 의미해요. 예를 들어, 온라인 쇼핑몰의 '장바구니에 담기' 기능, 소셜 미디어의 '댓글 작성' 기능 등이 있어요. Feature는 사용자 관점에서 바라본 시스템의 기능을 나타내요.
- Domain: 시스템이 다루는 특정 영역 또는 문제 공간을 의미해요. 예를 들어, 온라인 쇼핑몰의 Domain은 '상품', '주문', '결제' 등이 될 수 있어요. Domain은 시스템의 내부적인 논리와 규칙을 담당해요.
간단히 말해서, Feature는 사용자가 직접적으로 보는 기능이고, Domain은 그 기능을 구현하기 위한 내부적인 모델과 규칙이에요.
제 프로젝트 폴더 구조에서는 feature 하나를 기반으로 폴더가 만들어지고 관련된 폴더와 파일을 만들어요. 프로젝트에는 lib/src/features/feature1/domain
폴더가 있어요. 이때 domain 폴더는 위 domain의 정의와 동일하면서 Models이나 Entities의 역할을 해요.
개발 시작 전 정해야 할 것#
항상 어떤 기능을 만들 때는 기획 단계에서 이 서비스에는 어떤 기능이 있다가 정해지고 개발이 시작되어야 해요. 이 기능이 어떤 역할을 하고 요구사항이 정의되어야 해요.
개발자가 요구사항을 정했거나 누군가 정리한 요구사항을 개발자는 유스케이스와 사용자 플로우를 파악해서 어떤 data가 필요하고 어떻게 DB에 저장될지 결정하면 돼요. 그런데 Flutter에서는 일반적으로 DB를 저장하는 역할을 맡고 있진 않으니, 유효성 검사를 마친 data를 APIs를 호출해서 DB에 저장할 수 있도록 전달할 수 있는 형태와 데이터를 APIs를 통해 가져올 때(fromJson 함수 호출 이후) 받아주는 객체를 domain으로 저는 생각하고 있어요.
유스케이스(use case)와 사용자 플로우(user flow)를 조금 더 살펴볼게요. 사실 이 두 가지는 개발자가 정의해야 하는 건 아니긴 해요. 개발자는 이 요구사항을 코드로 구체화하면 돼요.
Use case(사용 사례)#
- 사용자가 서비스(=시스템)를 이용해서 달성하고자 하는 목표를 작업할 수 있도록 수행하기 위한 일련의 정의된 동작이에요.
Use case 정의#
- 주체: 누가 이 기능을 사용하는가? (사용자, 관리자 등)
- 목표: 사용자가 이 기능을 통해 무엇을 달성하고자 하는가?
- 전제 조건: 기능을 사용하기 위한 사전 조건은 무엇인가?
- 수행 단계: 기능을 수행하기 위한 단계는 무엇인가?
- 결과: 기능 수행 후 어떤 결과가 나오는가?
- 예외: 예상되는 예외 상황은 무엇인가?
User flow(사용자 흐름)#
- 사용자가 특정 목표를 달성하기 위해 시스템과 상호작용을 하는 과정을 단계별로 시각화한 것
- 즉, 사용자가 어떤 순서로 어떤 화면을 거쳐 목표를 달성하는지를 보여줘요
user flow 정의#
- 시작점: 사용자가 어디에서 시작하는가?
- 단계: 사용자가 어떤 단계를 거쳐 목표를 달성하는가? 각 단계에서 사용자의 행동과 시스템의 반응을 명시합니다.
- 결과: 사용자가 최종적으로 어떤 결과를 얻는가?
- 다른 경로: 사용자가 다른 선택을 할 경우 어떤 경로를 거치는가?
사용 예시#
use case: 온라인 쇼핑몰에서 상품 구매하기
- 주체: 일반 사용자
- 목표: 원하는 상품을 구매하고 결제하기
- 전제 조건: 회원 가입, 상품 검색
- 수행 단계: 상품 상세 페이지 보기, 장바구니에 담기, 결제 정보 입력, 주문 완료
- 결과: 주문 완료 메시지, 주문 내역 확인 가능
- 예외: 재고 부족, 결제 실패
user flow:
- 사용자가 상품 검색창에 키워드를 입력하고 검색 버튼을 클릭
- 검색 결과 페이지에서 원하는 상품을 선택하고 상세 페이지로 이동
- 상품 상세 페이지에서 상품 정보를 확인하고 장바구니에 담음
- 장바구니 페이지에서 구매할 상품을 확인하고 결제 버튼을 클릭
- 결제 정보를 입력하고 결제를 완료
- 주문 완료 메시지가 표시되고, 주문 내역 페이지로 이동
이런 식으로 어떤 기능에 대해서 완벽하진 않더라도 정의를 하고 디자인 이후 개발하면 돼요. 그런데 Feature-First 아키텍처에서도 domain 영역에 entities와 usecases 폴더를 각각 만드는 방식도 있어요.
그때의 usecase는 비즈니스 로직을 담당해요. 상품 주문, 회원 가입 같은 사용자 작업에 대한 규칙과 알고리즘을 구현해요. 규칙과 알고리즘을 구현한다는 뜻은 사용자 로그인을 처리하거나 특정 게시물을 가져오는 등의 기능을 구현한다고 생각하면돼요. 이는 service 영역에서 하는 것과 비슷한 일이에요.
어떤 예제를 찾아보면 usecases라는 폴더를 만들어서 화면 내에서 담당하는 어떤 함수를 파일 하나씩 만들면서 처리하는데 저는 controller나 service 쪽에서 처리하기로 했어요.
기능 하나를 개발할 순서#
이제 드디어 쓰고 싶었던 부분이에요. 저는 약속 시간을 정하기 위해서 다들 자기가 되는 시간을 투표할 수 있도록 한 명의 사용자가 약속을 위해 정한 여러 메타데이터(제목, 날짜, 시간대)를 가지는 데이터를 생성하는 기능을 만들려고 해요. 그렇다고 하나의 기능으로 볼 수 있지만 앱에서 보면 하나의 기능이 아니에요. 여러 기능의 묶음으로 되어 있어요. 사용자 흐름에선 약속 시간 생성을 누르면 여러 화면을 이동해서 제목을 입력받고 날짜들을 선택한 뒤, 시간대를 선택하고 생성을 누르는 것, 투표를 위한 URL을 공유하는 것, 약속 시간을 투표하는 것, 투표 결과를 보는 것까지 여러 기능이 있어요.
Feature-First 아키텍처에서 기능은 사용자가 서비스를 이용해 달성하고자 하는 완결된 목표를 의미해요.
개발자가 일반적으로 생각하는 기능(이벤트, 함수)과는 달라요.
어떤 feature를 개발하기 위해선 어떻게 시작해야 할까요? 프론트엔드 입장에서 무얼 먼저 만드는 것이 좋은지 항상 고민이었어요. 사실 프론트엔드 입장에서 화면이 나와 있다면 화면을 먼저 만들어보는 것도 좋은데 과연 이게 좋은 방법일까 하는 의문이 있었어요.
사실 이 부분에 대해서는 정답은 없어요. 그렇지만 우아한 형제들의 전 CTO이셨던 김영한님의 답변도 고민의 답이 되는데 도움이 될 것 같아요.
이 부분은 사실 정확한 정답은 없습니다.
어떤 경우에는 화면부터 짜고 들어가는 게 더 좋고, 어떤 경우에는 백단의 로직을 먼저 작성하는 게 더 좋은 경우도 있습니다. 특히 웹 프론트엔드 개발자와 서버 개발자가 완전히 분리된 경우를 생각해 보면 각각 동시에 업무를 진행하고, 붙여야 합니다. 이런 경우를 생각해 보면 화면과 백단의 로직이 동시에 개발 진행될 수 있어야 합니다.
그래서 중요한 것이 사실 요구사항 분석과 설계입니다. 제대로 요구사항을 분석하고, 설계해 두고, 기능별로 어떻게 동작해야 하고, 어떤 데이터가 흐르는지 스펙을 명확하게 해 두면 어느 쪽으로든 진행할 수 있습니다.
저는 핵심 비즈니스 기능은 백단부터 작업하는 것을 선호합니다.
감사합니다^^
저도 비즈니스 기능부터 만들어 보려고 해요. 그리고 UI부터 만들게 되면 이러한 시나리오가 예상돼요.
- UI 화면을 다 만들고 버튼 이벤트나 데이터 목록을 가져오고 화면을 표시해야 할 경우가 생김 <- 나중에 API나 상태 관리 추가하고 다시 확인해야 함
- UI 화면을 다 만든 뒤, 순서를 controller나 service를 만들게 되면 결국 repository나 model을 주입하거나 참조해야 하는 경우가 생겨서 controller를 테스트하기 위해선 나머지가 선행되어야 함
이건 억지로 생각해 본 사례이기 때문에 꼭 이런 문제가 생기는 건 아니지만, UI가 어떤 객체를 의존할 때, 그게 해결되기 전까지 개발하는 과정에서 오류가 떠 있는 상태가 오래 지속될 수 있을 것 같아요. 또한 다른 계층을 만들 때 UI를 고려하게 된다면 UI쪽 코드를 변경하기 싫어서 의존성을 강하게 만들 수 있지 않을까 싶어요.
저는 기능 요구사항이 있으니, domain부터 만들게요. 이번에 만들 사례로 친구들과 약속 시간을 정하기 위한 시간을 투표하는 기능을 예로 들 거예요.
대부분의 Riverpod을 사용하지만, controller 영역을 제외하고는 대부분 화면에서 직접 생성을 피하고 알아서 주입될 수 있는 싱글톤으로 사용할 거예요.
코드에서 import는 지웠으니 그대로 따라 하시면 에러가 나실 거예요. VS Code에서 오류 메시지를 잘 살펴보세요!
Domain 계층 구현#
Entities에 해당하는 주요 엔티티(=모델)를 정의해요. 저는 시간 투표를 위한 거니깐 voting이라는 기능의 이름을 생각했다가 일정과 관련된 서비스라고 생각해서 lib/src/features/calendar
폴더를 만들었어요. 기능을 잘못 만들거나 잘못된 위치에 기능을 만들어도 나중에 파일명만 바꾸면 되니깐 일단은 크게 신경 쓰지 않도록 했어요. 엔티티 이름은 When2meet이라는 서비스가 있어서 features/calendar/domain/when_to_meet.dart 파일을 만들 거예요.
코드를 보면 저는 code generator를 사용하고 있어서 part 부분이 추가되어 있어요.
flutter pub run build_runner watch
를 입력하고 개발해 주세요.
저는 편하게 쓰기 위해서 VS Code에서 Build Runner 확장을 사용하고 있어요.
part 'when_to_meet.freezed.dart';
part 'when_to_meet.g.dart';
typedef WhenToMeetID = String;
/// WhenToMeet은 약속 시간을 정하기 위해 사용되는 클래스입니다.
@freezed
class WhenToMeet with _$WhenToMeet {
const factory WhenToMeet({
required WhenToMeetID id,
required String title,
required UserID creator,
required List<User> invitees,
required List<AnonymousUser> attendees,
required Map<DateTime, Set<TimeSlot>> availableTimes,
required Map<UserID, Map<DateTime, Set<TimeSlot>>> selectedTimes,
}) = _WhenToMeet;
factory WhenToMeet.fromJson(Map<String, dynamic> json) => _WhenToMeet.fromJson(json);
factory WhenToMeet.empty() => WhenToMeet(
id: const UuidV4().generate(),
title: '',
creator: '',
invitees: [],
attendees: [],
availableTimes: {},
selectedTimes: {},
);
factory WhenToMeet.fromCreationState(String title, Map<DateTime, Set<TimeSlot>> availableTimes) => WhenToMeet(
id: const UuidV4().generate(),
title: title,
creator: '',
invitees: [],
attendees: [],
availableTimes: availableTimes,
selectedTimes: {},
);
}
스태틱 함수로 empty는 테스트용으로 만들었고 fromCreationState 함수는 이따 Presentation 계층에서 WhenToMeetCreationController에서 WhenToMeetCreationState를 위한 초기화 함수예요.
Data 계층 구현#
데이터 계층에서는 로컬 or 원격으로부터 데이터 CRUD를 처리해요. 특정 Entity마다 CRUD 중 구현해야 하는 것이 다를 수 있지만 이번에는 CRUD 모두를 추상화할 거예요. 이 부분 역시 VS Code 스니펫을 이용한다면 편리할 거예요.
// features/calendar/data/when_to_meet_repository.dart 파일로 생성하기
abstract class WhenToMeetRepository {
Future<List<WhenToMeet>> getWhenToMeets();
Future<void> addWhenToMeet(WhenToMeet whenToMeet);
Future<void> deleteWhenToMeet(WhenToMeet whenToMeet);
Future<void> updateWhenToMeet(WhenToMeet whenToMeet);
}
이제 구현체를 만들어줄거예요. 저는 firestore를 이용하고 있기 때문에 아래와 비슷한 이름으로 만들어주면 돼요.
- remote_when_to_meet_repository.dart
- firesbase_when_to_meet_repository.dart
Repository 내부에 FirebaseFiresotre를 통해 데이터를 외부에서 조작해요. 그래서 이걸 외부에서 주입해줄거예요. RemoteWhenToMeetRepository을 사용하는 쪽에서 직접 생성하지 않고 provider를 통해 넘겨받을거예요. 이런식으로 Riverpod을 통해 필요한 계층을 담당하는 객체를 넘겨받고 넘겨줄거예요.
part 'remote_when_to_meet_repository.g.dart';
class RemoteWhenToMeetRepository implements WhenToMeetRepository {
const RemoteWhenToMeetRepository(this._fs);
final FirebaseFirestore _fs;
CollectionReference get _whenToMeetCollection => _fs.collection('when_to_meet');
@override
Future<void> addWhenToMeet(WhenToMeet whenToMeet) {
return _whenToMeetCollection.doc(whenToMeet.id).set(whenToMeet.toJson());
}
@override
Future<void> deleteWhenToMeet(WhenToMeet whenToMeet) {
return _whenToMeetCollection.doc(whenToMeet.id).delete();
}
@override
Future<List<WhenToMeet>> getWhenToMeets() {
return _whenToMeetCollection.get().then((snapshot) {
return snapshot.docs.map((doc) {
return WhenToMeet.fromJson(doc.data() as Map<String, dynamic>);
}).toList();
});
}
@override
Future<void> updateWhenToMeet(WhenToMeet whenToMeet) {
return _whenToMeetCollection.doc(whenToMeet.id).update(whenToMeet.toJson());
}
}
@riverpod
WhenToMeetRepository whenToMeetRepository(WhenToMeetRepositoryRef ref) {
return RemoteWhenToMeetRepository(fs: ref.watch(firebaseFirestoreProvider));
}
정말 특별한 상황이 아니라면 whenToMeetRepositoryProvider 같이 provider를 만드는 부분에서 ref.read 대신 ref.watch를 사용해 주세요.
테스트를 위해선 FakeWhenToMeetRepository를 만들고 각 함수에서 임의의 값을 설정해서 반환하면 테스트하기 편할 거예요.
firebaseFirestoreProvider는 lib/src/common/providers/general_providers.dart 파일에 만들어줬어요. 그런데 이건 이미 전역 변수로 사용되기 때문에 꼭 필요하진 않아요.
part 'general_provider.g.dart';
@riverpod
FirebaseFirestore firebaseFirestore(FirebaseFirestoreRef ref) {
return FirebaseFirestore.instance;
}
Application 구현#
이제 WhenToMeetService를 만들 차례가 왔어요. 이 서비스는 비즈니스 로직을 처리해요.
part 'when_to_meet_service.g.dart';
class WhenToMeetService {
WhenToMeetService({
required this.remoteWhenToMeetRepository,
});
final RemoteWhenToMeetRepository remoteWhenToMeetRepository;
Future<void> addWhenToMeet(WhenToMeet whenToMeet) {
return remoteWhenToMeetRepository.addWhenToMeet(whenToMeet);
}
Future<List<WhenToMeet>> fetchWhenToMeets(User user) {
return remoteWhenToMeetRepository.getWhenToMeets(user.id);
}
Future<WhenToMeet?> fetchWhenToMeet(WhenToMeetID id) {
return remoteWhenToMeetRepository.getWhenToMeet(id);
}
Future<void> updateWhenToMeet(WhenToMeet whenToMeet) {
return remoteWhenToMeetRepository.updateWhenToMeet(whenToMeet);
}
}
@riverpod
WhenToMeetService whenToMeetService(WhenToMeetServiceRef ref) {
return WhenToMeetService(
remoteWhenToMeetRepository: ref.watch(remoteWhenToMeetRepositoryProvider),
);
}
서비스에는 데이터를 추가하고 갱신하거나 단건 데이터를 받아와요. 함수 이름을 repository와 비슷하게 만들고 싶은 욕구도 들지만, 최대한 의미를 담아도 좋을 것 같아요. 원래라면 더 많은 함수들이 있어야 할 거예요. 로그인했던 사용자가 생성하거나 참여했던 목록을 가져와야 할 수도 있지만, 지금은 단순하게 만들게요. 지금은 따로 처리를 하진 않았지만, 필요하다면 service 함수 내에서 어떤 처리를 할 수도 있을 거예요.
Presentation 구현#
이제 Screen과 Controller를 만들 거예요. 저는 Screen과 Controller는 플랫폼마다 다르게 만들어야 할 수도 있다고 생각해요. 모바일, 태블릿, 데스크탑 스크린에 따라 표현해야 할 UI가 달라질 텐데, UI가 달라지면 처리하는 데이터는 같더라도 한번에 처리되거나 화면 전환이 되면서 데이터를 하나씩 모으는 경우라면 Screen의 개수와 그 Screen에서 상태 관리를 해야 하는 경우 Controller도 달라질 거라고 생각해요.
이번에는 모바일용 화면만 만들어볼게요. 제목 입력, 날짜 선택, 시간대 선택을 위해 최소 3개의 화면이 필요하지만, 글이 굉장히 길어질 것 같아서 가장 첫번째 화면만 보여드릴게요. _pages 변수에서 제목 입력, 날짜 선택, 시간대 선택를 차례대로 수행해요. 여기서 사용되는 변수들로부터 데이터를 받는 건 Controller에서 해요.
class WhenToMeetCreationPage extends ConsumerWidget {
const WhenToMeetCreationPage({super.key});
final List<Widget> _pages = const [
WhenToMeetCreationTitlePage(),
WhenToMeetCreationDatePage(),
WhenToMeetCreationTimePage(),
];
@override
Widget build(BuildContext context, WidgetRef ref) {
final controller = ref.read(whenToMeetCreationControllerProvider.notifier);
final state = ref.watch(whenToMeetCreationControllerProvider);
return GestureDetector(
onTap: () => FocusScope.of(context).unfocus(),
child: Scaffold(
appBar: AppBar(
title: const Text('약속 만들기'),
leading: controller.isFirstStep()
? null
: IconButton(
icon: const Icon(Icons.arrow_back),
onPressed: () => controller.previousStep(),
),
),
body: SafeArea(
child: Column(
children: [
Padding(
padding: const EdgeInsets.symmetric(horizontal: pg),
child: StepProgressIndicator(stepProgress: state.stepProgress),
),
const SizedBox(height: pg * 2),
Expanded(child: _pages[state.stepProgress.step]),
],
),
),
),
);
}
}
Controller 설명#
Controller에 작성될 함수는 주로 유효성 검증이 끝난 입력 데이터를 받거나, 위젯이 렌더링 되는 시점에 데이터를 전달하거나, 버튼 같은 이벤트를 통해 데이터를 전달하는 경우가 대부분이에요.
Controller가 사실 이 아키텍처에서 가장 난해하고 어떻게 하면 좋을지 고민이 많이 되었던 부분이었어요. 처음에는 riverpod으로 NotifireProvider를 만들 때, 꼭 domain 객체를 사용해야 하는 것 같은 강박을 느꼈어요. 그러나 수정할수록 화면에서 얻어낸 데이터를 꼭 빈 도메인에 하나씩 채워 넣을 필요가 없다는 걸 알았어요. domain이 service로 넘어가기 직전에만 객체로 만들어줘도 충분했어요. service에 넘기기 전까지는 최대한 사용하기 쉬운 형태의 데이터로 하는 것이 저도 더 즐겁게 개발할 수 있었어요.
그래서 Controller를 위한 클래스를 만들었어요. 꼭 freezed를 사용하지 않아도 괜찮을 것 같아요.
WhenToMeetCreationState 파일은 WhenToMeetCreationController의 build 함수의 반환값이에요.
이 클래스가 갖는 변수를 보면 약속 생성 화면에서 사용하는 변수들을 담고 있어요. title, dates,selectedTimes에요. 이 3개의 변수가 사용자 선택을 할 때마다 값이 채워져요. WhenToMeetCreationState는 WhenToMeetCreationController와 같은 파일에다 만들었어요. 두개의 파일로 분리해도 좋겠지만 편하게 하기 위해 저는 하나의 파일에 뒀어요.
part 'when_to_meet_creation_controller.freezed.dart';
part 'when_to_meet_creation_controller.g.dart';
@freezed
class WhenToMeetCreationState with _$WhenToMeetCreationState {
const factory WhenToMeetCreationState({
required String title,
required List<DateTime> dates,
required List<int> selectedTimes,
required List<TimeSlot> availableTimes,
required StepProgress stepProgress,
}) = _WhenToMeetCreationState;
factory WhenToMeetCreationState.empty() {
return WhenToMeetCreationState(
title: '',
dates: [],
selectedTimes: [],
availableTimes: [],
stepProgress: StepProgress(step: 0, initialStep: 0, totalSteps: 3),
);
}
}
extension WhenToMeetCreationStateX on WhenToMeetCreationState {
WhenToMeet toDomain() {
final availableTimesMap =
Map<DateTime, Set<TimeSlot>>.fromEntries(dates.map((date) => MapEntry(date, Set.from(availableTimes))));
return WhenToMeet.fromCreationState(title, availableTimesMap);
}
}
StepProgress는 화면을 이동할 때 몇 번째 순서인지 알려주는 값인데 아키텍처를 설명할 때는 별로 안 중요한 것 같아서 넘어갈게요.
WhenToMeetCreationController는 함수가 여러 개가 있는데요. 제목을 입력받을 때, 날짜를 선택할 때, 시간대를 선택할 때 호출되기도 하고 입력 필드의 값이 바뀔 때마다 호출되는 함수도 있어요. 마지막에 저장할 때는 createWhenToMeet 함수를 호출해 Firestore에 저장해요.
@riverpod
class WhenToMeetCreationController extends _$WhenToMeetCreationController {
@override
WhenToMeetCreationState build() {
ref.onDispose(() => print('WhenToMeetCreationController disposed'));
print('WhenToMeetCreationController created');
// 반환값을 Domain을 그대로 쓰지 않고 State를 만들어서 쓰는게 더 낫다. 그런데 지금 다 만들어놔서 그냥 쓰자.
return WhenToMeetCreationState.empty();
}
Future<WhenToMeet> createWhenToMeet(List<TimeSlot> slots) async {
state = state.copyWith(availableTimes: slots);
final service = ref.read(whenToMeetServiceProvider);
final whenToMeet = state.toDomain();
await service.addWhenToMeet(whenToMeet);
return whenToMeet;
}
void previousStep() {
if (state.stepProgress.isFirstStep()) {
return;
}
state = state.copyWith(stepProgress: state.stepProgress.previousStep());
}
void nextStep() {
if (state.stepProgress.isLastStep()) {
return;
}
state = state.copyWith(stepProgress: state.stepProgress.nextStep());
}
// 제목 입력
void updateTitle(String title) {
state = state.copyWith(title: title);
nextStep();
}
// 날짜 입력
void updateDates(List<DateTime> dates) {
if (state.title.isEmpty) {
assert(false, 'title is empty');
}
state = state.copyWith(dates: dates);
nextStep();
}
// 시간대 입력
void updateAvailableTimes(List<int> selectedTimes) {
if (state.dates.isEmpty) {
assert(false, 'Dates are empty');
}
state = state.copyWith(selectedTimes: selectedTimes);
}
void updateSelectedTimes(List<int> selectedTimes) {
state = state.copyWith(selectedTimes: selectedTimes);
}
bool isFirstStep() => state.stepProgress.isFirstStep();
}
이 아키텍처를 하면서 가장 고민을 많이 했던 부분은 Presentation에서 데이터를 관리할 때 Domain에 집착했었을 때였어요. 백엔드에는 DAO와 DTO라는 개념이 있어요. 이거와 비슷하게 화면에서는 필요한 데이터가 있는데 이게 저장할 때는 가공되거나 필요 없는 데이터일 수 있었어요. 저는 이걸 domain 객체를 쓰면서 보조함수를 만들어서 복잡하게 만들었는데 입력 데이터를 그대로 저장하는 WhenToMeetCreationState 객체를 만드니 코드가 더 간결해졌어요.
아직 완벽하게 이해해서 Feature-First 아키텍처 + Riverpod을 함께 쓰고 있지 못한다고 생각해요.
계속 사용하면서 비슷하게 만들어 본 자료라고 생각해 주세요!
그저 Flutter 개발을 시작하시는 분들에게 조금이나마 가이드가 되었길 바랍니다! 🥹
You can stand tall without standing on someone. You can be a victor without having victims.
— Harriet Woods