Search

#027 #MVVM 클린 아키텍쳐

 Feature-wise development

맞습니다. 통신으로 데이터를 가져오고 그 데이터를 기반으로 UI를 업데이트하는 경우, 비동기 로직이 개입되므로 상태 관리가 필요합니다. 특히, 데이터 로딩, 성공, 실패와 같은 상태 변화를 처리해야 하는데, 이럴 때는 상태 관리를 통해 UI에 반영할 필요가 있습니다.
하지만 이때도 반드시 StatefulWidget이 필요하지는 않습니다. 대신, ViewModel을 통해 상태를 관리하고, StatelessWidget에서 이를 구독하는 방식이 많이 사용됩니다. Riverpod, Provider 등의 상태 관리 패키지를 사용하면 StatelessWidget에서도 비동기 통신과 상태 변화를 효과적으로 처리할 수 있습니다.

통신 시 상태 관리 패턴

데이터를 로딩할 때, 보통 3가지 상태를 관리하게 됩니다:
1.
로딩 중 (Loading): 통신 중인 상태. UI에서 로딩 스피너 또는 플레이스홀더를 보여줄 때 사용.
2.
성공 (Success): 데이터를 성공적으로 받아왔을 때 UI를 업데이트.
3.
실패 (Error): 통신 중 에러가 발생했을 때 에러 메시지를 보여주는 상태.
이러한 상태들을 ViewModel을 통해 관리하고, StatelessWidget에서 ViewModel을 구독하여 상태에 따라 다른 UI를 보여주면 됩니다.

예시: Riverpod을 사용한 상태 관리

1.
ViewModel에서 상태 관리:
import 'package:flutter_riverpod/flutter_riverpod.dart'; enum DataStatus { loading, success, error } class DataState { final DataStatus status; final List<String>? data; final String? error; DataState({required this.status, this.data, this.error}); } class DataViewModel extends StateNotifier<DataState> { DataViewModel() : super(DataState(status: DataStatus.loading)); Future<void> fetchData() async { state = DataState(status: DataStatus.loading); try { // 가정: 데이터를 비동기 통신으로 가져옴 await Future.delayed(Duration(seconds: 2)); // Mocking a network delay final data = ["Item1", "Item2", "Item3"]; state = DataState(status: DataStatus.success, data: data); } catch (e) { state = DataState(status: DataStatus.error, error: e.toString()); } } } final dataViewModelProvider = StateNotifierProvider<DataViewModel, DataState>((ref) { return DataViewModel(); });
Dart
복사
1.
StatelessWidget에서 상태 구독 및 UI 업데이트:
import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; class DataScreen extends ConsumerWidget { Widget build(BuildContext context, WidgetRef ref) { final dataState = ref.watch(dataViewModelProvider); return Scaffold( appBar: AppBar(title: Text('Data Screen')), body: Center( child: dataState.status == DataStatus.loading ? CircularProgressIndicator() // 로딩 중일 때 : dataState.status == DataStatus.success ? ListView.builder( // 데이터를 성공적으로 받아왔을 때 itemCount: dataState.data!.length, itemBuilder: (context, index) { return ListTile( title: Text(dataState.data![index]), ); }, ) : Text('Error: ${dataState.error}'), // 에러 발생 시 ), floatingActionButton: FloatingActionButton( onPressed: () { ref.read(dataViewModelProvider.notifier).fetchData(); }, child: Icon(Icons.refresh), ), ); } }
Dart
복사

StatelessWidget이 적합한가?

1.
상태 관리 외부화: StatelessWidget은 자체적으로 상태를 관리하지 않지만, ViewModel이 상태를 관리하므로 UI는 그 상태를 구독하고 적절히 리빌드됩니다. UI의 상태 관리 로직이 ViewModel에 있으므로, 위젯 자체는 단순히 ViewModel의 상태 변화를 반영하기만 하면 됩니다.
2.
Riverpod/Provider와 같은 상태 관리 패키지의 활용: 이러한 상태 관리 도구들은 상태를 외부에서 관리하고 구독하는 컴포넌트만 리빌드하는 최적화된 방법을 제공합니다. 따라서 StatelessWidget도 비동기 상태를 처리하는 데 전혀 문제가 없습니다.

언제 StatefulWidget이 필요할까?

StatefulWidget이 필요한 경우는 로컬 상태가 필요한 경우입니다. 예를 들어:
애니메이션 컨트롤: 화면 전환 시 애니메이션을 제어하거나 복잡한 애니메이션을 수행할 때.
폼 데이터의 임시 상태: 사용자가 입력한 폼 데이터의 임시 상태를 관리할 때.
스와이프, 드래그, 제스처: 사용자 상호작용에 따라 변하는 UI 상태를 관리할 때.
이런 경우 StatefulWidget을 사용하지만, 통신을 통해 데이터를 가져오는 것과 같은 비즈니스 로직 관련 상태 관리는 보통 StatefulWidget보다는 ViewModel을 사용하는 것이 더 깔끔합니다.

결론

통신으로 데이터를 가져오고 이를 UI에 반영할 때는 상태 관리를 반드시 해야 하지만, 반드시 StatefulWidget을 써야 하는 것은 아닙니다. ViewModel을 통해 상태를 관리하고, StatelessWidget에서 상태를 구독하여 UI를 업데이트하는 것이 더 클린하고 유지보수하기 쉬운 방법입니다.
Flutter에서 기능별로 나누는 대신, 트리 구조로 계층을 따라 폴더를 나누는 방식도 매우 유효합니다. 특히 클린 아키텍처와 같이 책임 분리가 명확히 이루어지는 경우, UI 위젯과 관련된 모든 의존성을 해당 UI 컴포넌트 가까이에 위치시키는 방식은 관리가 수월할 수 있습니다. 이는 코드 작성자 입장에서 탐색 경로가 더 짧아지고, 각 UI 컴포넌트가 필요로 하는 리소스(모델, 뷰모델, 서비스 등)가 한눈에 들어오기 때문입니다.
아래는 트리 구조로 기능을 나누지 않고, 각 UI 위젯을 중심으로 폴더를 구성하는 방식입니다.

트리 구조로 폴더 구성 (계층에 따른 구조)

lib/ │ ├── common/ # 재사용 가능한 유틸리티, 공통적인 위젯, 스타일, 테마 │ ├── widgets/ # 공통 위젯 (예: Button, Card 등) │ ├── styles/ # 테마, 스타일 관련 │ └── utils/ # 공통적으로 사용할 유틸리티 │ ├── pages/ │ └── [page_name]/ # 각 화면별로 폴더를 나눔 │ ├── models/ # 해당 페이지에서 사용하는 데이터 모델 │ ├── subpages/ # 하위구조 페이지들 │ ├── widgets/ # 화면을 구성하는 세부 위젯들 │ ├── [page_name]_page.dart # 페이지의 메인 화면 위젯 │ ├── [page_name]_repo.dart # (API 호출, 로컬 데이터베이스 등) │ └── [page_name]_viewmodel.dart # 상태관리 담당 │ ├── main.dart # 앱 엔트리 포인트 └── app.dart # MaterialApp, Route, Provider 초기화
Plain Text
복사

예시 구조

lib/ ├── common/ │ ├── widgets/ │ │ ├── custom_button.dart │ │ └── custom_card.dart │ ├── styles/ │ │ └── theme.dart │ └── utils/ │ └── validators.dart │ ├── pages/ │ └── home/ # 홈 화면에 대한 폴더 구조 │ ├── models/ │ │ └── home_model.dart │ ├── services/ │ │ └── home_service.dart │ ├── viewmodels/ │ │ └── home_viewmodel.dart │ ├── widgets/ │ │ ├── home_header.dart │ │ └── home_list_item.dart │ └── home_view.dart # 홈 페이지의 메인 위젯 │ └── main.dart
Plain Text
복사

이 구조의 장점

1.
탐색 경로 단축: 페이지별로 관련 파일들이 한 곳에 모여 있어, 특정 페이지의 변경 사항을 쉽게 관리할 수 있습니다. 특히 뷰모델(ViewModel), 서비스(Service), 모델(Model) 모두 해당 페이지의 폴더 내에 위치하므로 한 번에 보기 쉽습니다.
2.
의존성 명확화: 어떤 페이지가 어떤 서비스를 사용하는지, 어떤 모델을 참조하는지 명확하게 알 수 있습니다. 파일 의존성이 페이지 내에서 자연스럽게 관리됩니다.
3.
재사용 가능한 코드 분리: 공통적으로 사용될 유틸리티나 위젯들은 common/ 디렉토리에 배치하여 다른 페이지에서 재사용할 수 있습니다.

코드 예시 (HomeViewModel과 HomeService)

// home_viewmodel.dart import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:your_app_name/pages/home/services/home_service.dart'; import 'package:your_app_name/pages/home/models/home_model.dart'; final homeViewModelProvider = StateNotifierProvider<HomeViewModel, HomeState>((ref) { final homeService = ref.read(homeServiceProvider); return HomeViewModel(homeService); }); class HomeViewModel extends StateNotifier<HomeState> { final HomeService homeService; HomeViewModel(this.homeService) : super(HomeInitialState()); Future<void> loadHomeData() async { try { final data = await homeService.fetchHomeData(); state = HomeLoadedState(data); } catch (error) { state = HomeErrorState(error.toString()); } } } // home_service.dart import 'package:your_app_name/pages/home/models/home_model.dart'; class HomeService { Future<HomeModel> fetchHomeData() async { // API 호출 또는 데이터베이스 접근 로직 return HomeModel(data: 'example data'); } }
Dart
복사

고려할 사항

1.
위젯 간 재사용: 페이지 내에서만 사용되는 위젯이라면 해당 페이지의 widgets/ 폴더에 두는 것이 적절하지만, 여러 페이지에서 공통으로 사용하는 위젯은 common/widgets/에 두어야 합니다.
2.
폴더 깊이: UI 컴포넌트에 가까운 모든 파일들을 한 폴더 내에서 관리하면 트리의 깊이가 깊어지지 않아, 직관적인 탐색이 가능합니다.

결론

이 방식은 UI 중심으로 구조를 나누는 방식으로, 각 페이지가 독립적으로 관리되며 관련된 코드가 하나의 폴더 내에 모여 있는 구조입니다. 모듈화된 관리탐색 경로의 직관성이 중요한 프로젝트에 적합하며, 기능 간의 복잡한 의존성이 적을 때 특히 효과적입니다.

트리 구조로 폴더 구성 (계층에 따른 구조)

lib/ │ ├── common/ # 재사용 가능한 유틸리티, 공통적인 위젯, 스타일, 테마 │ ├── widgets/ # 공통 위젯 (예: Button, Card 등) │ ├── styles/ # 테마, 스타일 관련 │ └── utils/ # 공통적으로 사용할 유틸리티 │ ├── pages/ │ └── [page_name]/ # 각 화면별로 폴더를 나눔 │ ├── models/ # 해당 페이지에서 사용하는 데이터 모델 │ ├── subpages/ # 하위구조 페이지들 │ ├── widgets/ # 화면을 구성하는 세부 위젯들 │ ├── [page_name]_page.dart # 페이지의 메인 화면 위젯 │ ├── [page_name]_repo.dart # (API 호출, 로컬 데이터베이스 등) │ └── [page_name]_viewmodel.dart # 상태관리 담당 │ ├── main.dart # 앱 엔트리 포인트 └── app.dart # MaterialApp, Route, Provider 초기화
Plain Text
복사