Flutter 튜토리얼 41편: UI 레이어와 Data 레이어 - MVVM 아키텍처 실전 적용
요약
핵심 요지
- 문제 정의: Flutter 앱이 복잡해질수록 UI 코드와 비즈니스 로직이 뒤섞여 유지보수가 어려워진다.
- 핵심 주장: UI 레이어와 Data 레이어를 명확히 분리하면 코드의 재사용성, 테스트 용이성, 유지보수성이 크게 향상된다.
- 주요 근거: Flutter 공식 문서의 MVVM 아키텍처 케이스 스터디에서 제시하는 계층 분리 패턴을 따른다.
- 한계: 소규모 앱에서는 오버엔지니어링이 될 수 있으며, 팀 내 일관된 패턴 적용이 필요하다.
문서가 설명하는 범위
- MVVM 아키텍처의 구조와 역할
- UI 레이어: View와 ViewModel의 관계
- Data 레이어: Repository와 Service의 역할
- 실전 코드 예제와 적용 방법
읽는 시간: 25분 | 난이도: 중급
참고 자료
- App architecture: Case study - Flutter 공식 아키텍처 케이스 스터디
- UI layer - UI 레이어 상세 문서
- Data layer - Data 레이어 상세 문서
문제 상황
앱의 규모가 커지면서 Widget1 안에 데이터 로딩, 에러 처리, 상태 관리 코드가 모두 섞이게 됩니다.
이런 코드는 테스트하기 어렵고, 같은 로직을 여러 화면에서 반복 작성하게 됩니다.
기존 방식의 한계
// 모든 것이 Widget 안에 섞인 코드class BookingScreen extends StatefulWidget { @override State<BookingScreen> createState() => _BookingScreenState();}
class _BookingScreenState extends State<BookingScreen> { List<Booking>? bookings; bool isLoading = true; String? error;
@override void initState() { super.initState(); _loadBookings(); }
Future<void> _loadBookings() async { try { // 직접 API 호출 final response = await http.get(Uri.parse('$baseUrl/bookings')); final data = jsonDecode(response.body); setState(() { bookings = data.map((e) => Booking.fromJson(e)).toList(); isLoading = false; }); } catch (e) { setState(() { error = e.toString(); isLoading = false; }); } }
@override Widget build(BuildContext context) { // UI 빌드 로직 }}문제는 다음과 같습니다.
- UI 로직과 데이터 로딩 로직이 혼재되어 있다
- 같은 데이터를 다른 화면에서 사용하려면 코드를 복사해야 한다
- 단위 테스트를 작성하기 어렵다
Widget을 수정할 때 데이터 로직까지 영향을 받을 수 있다
해결 방법
챕터 1: MVVM 아키텍처 개요
Why
NOTE대규모 앱 개발에서 코드의 관심사를 분리하면 각 부분을 독립적으로 개발하고 테스트할 수 있습니다.
MVVM2 패턴은 이를 위한 검증된 아키텍처입니다.
What
NOTEMVVM은 Model-View-ViewModel의 약자로, 앱을 세 가지 계층으로 분리합니다.
graph TD subgraph UI["UI Layer"] V[View - Widget] VM[ViewModel] end subgraph Data["Data Layer"] R[Repository] S[Service] end V -->|"사용자 입력"| VM VM -->|"상태 변경"| V VM -->|"데이터 요청"| R R -->|"결과 반환"| VM R -->|"API 호출"| S S -->|"응답"| R
계층 역할 예시 View UI 렌더링, 사용자 입력 처리 StatelessWidget,StatefulWidgetViewModel 비즈니스 로직, 상태 관리 ChangeNotifier상속 클래스Repository 데이터 소스 추상화, 캐싱 BookingRepositoryService 외부 API 통신 BookingApiService
How
TIPFlutter에서 MVVM을 구현하는 기본 구조입니다.
// 1. Model - 데이터 구조 정의class Booking {final String id;final String destination;final DateTime date;Booking({required this.id,required this.destination,required this.date,});}// 2. Service - API 통신class BookingApiService {Future<List<Booking>> getBookings() async {// HTTP 요청 로직}}// 3. Repository - 데이터 소스 관리class BookingRepository {final BookingApiService _service;BookingRepository(this._service);Future<List<Booking>> getBookings() {return _service.getBookings();}}// 4. ViewModel - 비즈니스 로직class BookingViewModel extends ChangeNotifier {final BookingRepository _repository;BookingViewModel(this._repository);// 상태와 로직}// 5. View - UIclass BookingScreen extends StatelessWidget {@overrideWidget build(BuildContext context) {// ViewModel을 사용한 UI 구성}}
Watch out
WARNING모든 앱에 MVVM이 필요한 것은 아닙니다. 단순한 앱이나 프로토타입에서는 오히려 복잡성만 증가시킬 수 있습니다. 팀의 규모와 앱의 복잡도를 고려하여 적용 여부를 결정하세요.
결론: MVVM 아키텍처는 UI, 비즈니스 로직, 데이터 접근을 분리하여 유지보수성을 높인다.
챕터 2: UI 레이어 - View 구현
Why
NOTEView는 사용자에게 정보를 표시하고 입력을 받는 역할만 담당합니다. View가 비즈니스 로직을 알지 못하면 UI 변경이 다른 코드에 영향을 주지 않습니다.
What
NOTEFlutter에서 View는
Widget으로 구현됩니다. View는ViewModel3의 상태를 구독하고 화면에 표시합니다.flowchart LR subgraph View W[Widget] LB[ListenableBuilder] end subgraph ViewModel VM[ChangeNotifier] S[State] end LB -->|"구독"| VM VM -->|"notifyListeners"| LB LB -->|"rebuild"| W
컴포넌트 역할 Flutter 구현 View UI 렌더링 StatelessWidgetState 구독 상태 변경 감지 ListenableBuilder입력 처리 사용자 액션 전달 ViewModel메서드 호출
How
TIP
ListenableBuilder4를 사용하여 ViewModel의 상태를 구독하는 View를 만듭니다.class BookingScreen extends StatelessWidget {final BookingViewModel viewModel;const BookingScreen({super.key,required this.viewModel,});@overrideWidget build(BuildContext context) {return Scaffold(appBar: AppBar(title: const Text('내 예약')),body: ListenableBuilder(listenable: viewModel,builder: (context, _) {// ViewModel의 상태에 따라 UI 분기if (viewModel.isLoading) {return const Center(child: CircularProgressIndicator(),);}if (viewModel.error != null) {return Center(child: Column(mainAxisAlignment: MainAxisAlignment.center,children: [Text('오류: ${viewModel.error}'),ElevatedButton(onPressed: viewModel.loadBookings,child: const Text('다시 시도'),),],),);}return ListView.builder(itemCount: viewModel.bookings.length,itemBuilder: (context, index) {final booking = viewModel.bookings[index];return ListTile(title: Text(booking.destination),subtitle: Text(booking.date.toString()),onTap: () => viewModel.selectBooking(booking),);},);},),);}}
Watch out
WARNINGView에서 직접 데이터를 가공하거나 비즈니스 로직을 수행하지 마세요.
// 잘못된 예시: View에서 데이터 가공builder: (context, _) {// View에서 필터링하면 안 됨final activeBookings = viewModel.bookings.where((b) => b.date.isAfter(DateTime.now())).toList();return ListView.builder(itemCount: activeBookings.length,// ...);}// 올바른 예시: ViewModel에서 제공builder: (context, _) {// ViewModel이 이미 필터링된 데이터를 제공return ListView.builder(itemCount: viewModel.activeBookings.length,// ...);}
결론: View는 ViewModel의 상태를 화면에 표시하는 역할만 담당하며, 로직은 ViewModel에 위임한다.
챕터 3: UI 레이어 - ViewModel 구현
Why
NOTEViewModel은 View에 필요한 상태를 관리하고 비즈니스 로직을 처리합니다. View와 Data 레이어 사이의 중재자 역할을 하여 두 계층을 분리합니다.
What
NOTEFlutter에서 ViewModel은
ChangeNotifier5를 상속받아 구현합니다. 상태가 변경되면notifyListeners()를 호출하여 View에 알립니다.stateDiagram-v2 [*] --> Idle Idle --> Loading: loadBookings() Loading --> Success: 데이터 수신 Loading --> Error: 에러 발생 Success --> Loading: refresh() Error --> Loading: retry() Success --> Idle Error --> Idle
상태 설명 UI 표시 Idle 초기 상태 빈 화면 Loading 데이터 로딩 중 로딩 인디케이터 Success 데이터 로드 성공 목록 표시 Error 에러 발생 에러 메시지와 재시도 버튼
How
TIP
ChangeNotifier를 상속받아 ViewModel을 구현합니다.class BookingViewModel extends ChangeNotifier {final BookingRepository _repository;BookingViewModel(this._repository);// 상태 필드List<Booking> _bookings = [];bool _isLoading = false;String? _error;// Getter - View에서 접근List<Booking> get bookings => List.unmodifiable(_bookings);bool get isLoading => _isLoading;String? get error => _error;// 계산된 속성List<Booking> get activeBookings =>_bookings.where((b) => b.date.isAfter(DateTime.now())).toList();// 비즈니스 로직Future<void> loadBookings() async {_isLoading = true;_error = null;notifyListeners();try {_bookings = await _repository.getBookings();} catch (e) {_error = e.toString();} finally {_isLoading = false;notifyListeners();}}void selectBooking(Booking booking) {// 선택 로직notifyListeners();}Future<void> deleteBooking(String id) async {try {await _repository.deleteBooking(id);_bookings.removeWhere((b) => b.id == id);notifyListeners();} catch (e) {_error = '삭제 실패: $e';notifyListeners();}}}
Watch out
WARNINGViewModel에서
BuildContext6를 사용하지 마세요. ViewModel은 UI에 독립적이어야 테스트가 가능합니다.// 잘못된 예시: BuildContext 사용class BookingViewModel extends ChangeNotifier {void showError(BuildContext context) {// ViewModel에서 context 사용 금지ScaffoldMessenger.of(context).showSnackBar(...);}}// 올바른 예시: 상태로 전달class BookingViewModel extends ChangeNotifier {String? _snackBarMessage;String? get snackBarMessage => _snackBarMessage;void clearSnackBarMessage() {_snackBarMessage = null;notifyListeners();}}
결론: ViewModel은 상태 관리와 비즈니스 로직을 담당하며, View와 Data 레이어를 연결한다.
챕터 4: Command 패턴으로 액션 처리
Why
NOTE사용자 액션이 복잡해지면 로딩 상태, 에러 처리, 결과 처리가 반복됩니다.
Command7 패턴을 사용하면 이런 공통 처리를 재사용할 수 있습니다.
What
NOTECommand는 실행 가능한 액션을 캡슐화하는 객체입니다. 실행 상태(running, error, result)를 함께 관리합니다.
flowchart TD subgraph Command E[execute] R[running] ER[error] RS[result] end subgraph ViewModel VM[메서드] C[Command 인스턴스] end VM -->|"생성"| C C -->|"실행"| E E -->|"상태 업데이트"| R E -->|"성공"| RS E -->|"실패"| ER
속성 타입 설명 executeFuture Function()실행할 비동기 함수 runningbool실행 중 여부 errorException?발생한 에러 resultT?실행 결과 completedbool완료 여부
How
TIPFlutter 공식 문서에서 제안하는 Command 클래스 구현입니다.
class Command<T> extends ChangeNotifier {Command(this._action);final Future<T> Function() _action;bool _running = false;Exception? _error;T? _result;bool get running => _running;Exception? get error => _error;T? get result => _result;bool get completed => _result != null;Future<void> execute() async {if (_running) return;_running = true;_error = null;notifyListeners();try {_result = await _action();} on Exception catch (e) {_error = e;} finally {_running = false;notifyListeners();}}void clear() {_result = null;_error = null;notifyListeners();}}ViewModel에서 Command를 사용하는 예시입니다.
class BookingViewModel extends ChangeNotifier {final BookingRepository _repository;late final Command<List<Booking>> loadCommand;late final Command<void> deleteCommand;BookingViewModel(this._repository) {loadCommand = Command(() => _repository.getBookings());deleteCommand = Command(() => _deleteSelectedBooking());}List<Booking> get bookings => loadCommand.result ?? [];Future<void> _deleteSelectedBooking() async {if (_selectedId == null) return;await _repository.deleteBooking(_selectedId!);}}
Watch out
WARNINGCommand의
result는 nullable입니다. View에서 사용할 때 null 체크를 해야 합니다.// View에서 Command 사용ListenableBuilder(listenable: viewModel.loadCommand,builder: (context, _) {final command = viewModel.loadCommand;if (command.running) {return const CircularProgressIndicator();}if (command.error != null) {return Text('Error: ${command.error}');}// result가 null일 수 있음final bookings = command.result ?? [];return ListView.builder(itemCount: bookings.length,// ...);},)
결론: Command 패턴을 사용하면 비동기 액션의 상태 관리를 표준화할 수 있다.
챕터 5: Data 레이어 - Repository 구현
Why
NOTEViewModel이 데이터 소스를 직접 알면 데이터 소스 변경 시 ViewModel도 수정해야 합니다.
Repository8가 데이터 소스를 추상화하면 ViewModel은 데이터가 어디서 오는지 알 필요가 없습니다.
What
NOTERepository는 데이터의 단일 진실 공급원(Single Source of Truth)입니다. 여러 데이터 소스(API, 캐시, 로컬 DB)를 통합하여 일관된 인터페이스를 제공합니다.
flowchart TD VM[ViewModel] subgraph Repository R[BookingRepository] end subgraph Services API[ApiService] Cache[CacheService] DB[LocalDatabase] end VM -->|"getBookings()"| R R -->|"fetchFromApi()"| API R -->|"loadFromCache()"| Cache R -->|"queryLocal()"| DB
역할 설명 데이터 추상화 데이터 소스의 구현 세부사항 숨김 캐싱 전략 데이터 캐싱 및 유효성 관리 데이터 변환 DTO를 도메인 모델로 변환 에러 변환 서비스 에러를 도메인 에러로 변환
How
TIPRepository 클래스 구현 예시입니다.
class BookingRepository {final BookingApiService _apiService;final BookingCacheService _cacheService;BookingRepository({required BookingApiService apiService,required BookingCacheService cacheService,}) : _apiService = apiService,_cacheService = cacheService;// 캐시된 데이터List<Booking>? _cachedBookings;DateTime? _lastFetch;// 캐시 유효 시간static const _cacheValidDuration = Duration(minutes: 5);Future<List<Booking>> getBookings({bool forceRefresh = false}) async {// 캐시가 유효하면 캐시 반환if (!forceRefresh && _isCacheValid()) {return _cachedBookings!;}try {// API에서 데이터 가져오기final bookings = await _apiService.fetchBookings();// 캐시 업데이트_cachedBookings = bookings;_lastFetch = DateTime.now();// 로컬 캐시에도 저장await _cacheService.saveBookings(bookings);return bookings;} catch (e) {// 오프라인이면 로컬 캐시 시도final cached = await _cacheService.loadBookings();if (cached != null) {return cached;}rethrow;}}bool _isCacheValid() {if (_cachedBookings == null || _lastFetch == null) {return false;}return DateTime.now().difference(_lastFetch!) < _cacheValidDuration;}Future<void> deleteBooking(String id) async {await _apiService.deleteBooking(id);_cachedBookings?.removeWhere((b) => b.id == id);await _cacheService.removeBooking(id);}Future<Booking> createBooking(BookingRequest request) async {final booking = await _apiService.createBooking(request);_cachedBookings?.add(booking);return booking;}}
Watch out
WARNINGRepository에서 UI 관련 로직을 처리하지 마세요. Repository는 데이터만 다루고, 표시 형식은 ViewModel에서 결정합니다.
// 잘못된 예시: Repository에서 UI 형식 결정class BookingRepository {Future<String> getFormattedDate(String id) async {final booking = await getBooking(id);// Repository에서 날짜 포맷팅하면 안 됨return DateFormat('yyyy-MM-dd').format(booking.date);}}// 올바른 예시: 원시 데이터 반환class BookingRepository {Future<Booking> getBooking(String id) async {return await _apiService.fetchBooking(id);}}
결론: Repository는 데이터 소스를 추상화하여 ViewModel이 데이터 출처에 의존하지 않게 한다.
챕터 6: Data 레이어 - Service 구현
Why
NOTE
Service9는 외부 시스템과의 통신을 담당합니다. HTTP 요청, 응답 파싱, 에러 처리를 캡슐화하여 Repository의 복잡성을 줄입니다.
What
NOTEService는 상태를 갖지 않는(stateless) 클래스입니다. API 호출과 응답 변환만 담당합니다.
flowchart LR R[Repository] subgraph Service S[ApiService] P[Parser] end subgraph External API[REST API] end R -->|"fetchBookings()"| S S -->|"HTTP GET"| API API -->|"JSON"| S S -->|"parse"| P P -->|"List<Booking>"| R
책임 설명 예시 HTTP 통신 API 엔드포인트 호출 GET, POST, DELETE 인증 처리 토큰 추가, 갱신 Bearer 토큰 응답 파싱 JSON을 모델로 변환 Booking.fromJson()에러 처리 HTTP 에러를 예외로 변환 ApiException
How
TIPHTTP 클라이언트를 사용하는 Service 구현입니다.
class BookingApiService {final http.Client _client;final String _baseUrl;final AuthService _authService;BookingApiService({required http.Client client,required String baseUrl,required AuthService authService,}) : _client = client,_baseUrl = baseUrl,_authService = authService;Future<List<Booking>> fetchBookings() async {final response = await _authenticatedGet('/bookings');final List<dynamic> data = jsonDecode(response.body);return data.map((json) => Booking.fromJson(json)).toList();}Future<Booking> fetchBooking(String id) async {final response = await _authenticatedGet('/bookings/$id');return Booking.fromJson(jsonDecode(response.body));}Future<Booking> createBooking(BookingRequest request) async {final response = await _authenticatedPost('/bookings',body: request.toJson(),);return Booking.fromJson(jsonDecode(response.body));}Future<void> deleteBooking(String id) async {await _authenticatedDelete('/bookings/$id');}// 공통 HTTP 메서드Future<http.Response> _authenticatedGet(String path) async {final token = await _authService.getToken();final response = await _client.get(Uri.parse('$_baseUrl$path'),headers: {'Authorization': 'Bearer $token'},);_checkResponse(response);return response;}Future<http.Response> _authenticatedPost(String path, {required Map<String, dynamic> body,}) async {final token = await _authService.getToken();final response = await _client.post(Uri.parse('$_baseUrl$path'),headers: {'Authorization': 'Bearer $token','Content-Type': 'application/json',},body: jsonEncode(body),);_checkResponse(response);return response;}Future<http.Response> _authenticatedDelete(String path) async {final token = await _authService.getToken();final response = await _client.delete(Uri.parse('$_baseUrl$path'),headers: {'Authorization': 'Bearer $token'},);_checkResponse(response);return response;}void _checkResponse(http.Response response) {if (response.statusCode >= 400) {throw ApiException(statusCode: response.statusCode,message: response.body,);}}}
Watch out
WARNINGService에서 데이터를 캐싱하지 마세요. 캐싱은 Repository의 책임입니다.
// 잘못된 예시: Service에서 캐싱class BookingApiService {List<Booking>? _cache; // Service에서 캐시하면 안 됨Future<List<Booking>> fetchBookings() async {if (_cache != null) return _cache!;// ...}}// 올바른 예시: Service는 항상 API 호출class BookingApiService {Future<List<Booking>> fetchBookings() async {// 항상 새로운 데이터 요청final response = await _authenticatedGet('/bookings');// ...}}
결론: Service는 외부 API와의 통신만 담당하며, 캐싱이나 비즈니스 로직은 포함하지 않는다.
챕터 7: 의존성 주입과 테스트
Why
NOTE각 계층이 인터페이스에 의존하면 테스트 시 Mock 객체로 대체할 수 있습니다.
의존성 주입10을 사용하면 유연한 구조를 만들 수 있습니다.
What
NOTE의존성 주입은 객체가 필요로 하는 의존성을 외부에서 제공하는 패턴입니다.
flowchart TD subgraph DI["의존성 주입 컨테이너"] S[Service] R[Repository] VM[ViewModel] end subgraph App V[View] end DI -->|"주입"| V S -->|"주입"| R R -->|"주입"| VM VM -->|"주입"| V
방식 설명 Flutter 패키지 생성자 주입 생성자 매개변수로 전달 기본 Provider Widget 트리를 통한 주입 providerGetIt 서비스 로케이터 패턴 get_itRiverpod 컴파일 타임 안전성 riverpod
How
TIP생성자 주입과 Provider를 사용한 의존성 주입 예시입니다.
// main.dart - 의존성 설정void main() {// Service 생성final httpClient = http.Client();final authService = AuthService();final apiService = BookingApiService(client: httpClient,baseUrl: 'https://api.example.com',authService: authService,);final cacheService = BookingCacheService();// Repository 생성final repository = BookingRepository(apiService: apiService,cacheService: cacheService,);runApp(MultiProvider(providers: [// Repository를 Provider로 제공Provider<BookingRepository>.value(value: repository),// ViewModel은 ChangeNotifierProvider로ChangeNotifierProvider<BookingViewModel>(create: (context) => BookingViewModel(context.read<BookingRepository>(),),),],child: const MyApp(),),);}// View에서 ViewModel 사용class BookingScreen extends StatelessWidget {@overrideWidget build(BuildContext context) {final viewModel = context.watch<BookingViewModel>();return Scaffold(body: viewModel.isLoading? const CircularProgressIndicator(): ListView.builder(itemCount: viewModel.bookings.length,// ...),);}}테스트 시 Mock 객체 사용 예시입니다.
// ViewModel 단위 테스트void main() {group('BookingViewModel', () {late MockBookingRepository mockRepository;late BookingViewModel viewModel;setUp(() {mockRepository = MockBookingRepository();viewModel = BookingViewModel(mockRepository);});test('loadBookings updates state correctly', () async {// Arrangefinal testBookings = [Booking(id: '1', destination: 'Seoul', date: DateTime.now()),];when(mockRepository.getBookings()).thenAnswer((_) async => testBookings);// Actawait viewModel.loadBookings();// Assertexpect(viewModel.bookings, equals(testBookings));expect(viewModel.isLoading, isFalse);expect(viewModel.error, isNull);});test('loadBookings handles error', () async {// Arrangewhen(mockRepository.getBookings()).thenThrow(Exception('Network error'));// Actawait viewModel.loadBookings();// Assertexpect(viewModel.bookings, isEmpty);expect(viewModel.error, isNotNull);});});}
Watch out
WARNING순환 의존성을 피하세요. A가 B에 의존하고 B가 A에 의존하면 앱이 초기화되지 않습니다.
// 잘못된 예시: 순환 의존성class UserRepository {final BookingRepository bookingRepo; // BookingRepository에 의존UserRepository(this.bookingRepo);}class BookingRepository {final UserRepository userRepo; // UserRepository에 의존 - 순환!BookingRepository(this.userRepo);}// 올바른 예시: 단방향 의존성class UserRepository {final UserApiService apiService;UserRepository(this.apiService);}class BookingRepository {final BookingApiService apiService;BookingRepository(this.apiService);}
결론: 의존성 주입을 통해 각 계층을 독립적으로 테스트하고 교체할 수 있다.
챕터 8: 전체 아키텍처 조합
Why
NOTE개별 컴포넌트를 이해했다면 전체 앱에서 어떻게 조합되는지 파악해야 합니다. 실제 앱에서는 여러 기능이 함께 동작합니다.
What
NOTE전체 아키텍처는 다음과 같이 구성됩니다.
graph TB subgraph Presentation["UI Layer"] V1[BookingScreen] V2[ProfileScreen] VM1[BookingViewModel] VM2[ProfileViewModel] end subgraph Domain["Data Layer"] R1[BookingRepository] R2[UserRepository] end subgraph Infrastructure["Service Layer"] S1[BookingApiService] S2[UserApiService] Cache[CacheService] Auth[AuthService] end V1 --> VM1 V2 --> VM2 VM1 --> R1 VM2 --> R2 R1 --> S1 R1 --> Cache R2 --> S2 S1 --> Auth S2 --> Auth
How
TIP전체 앱 구조 예시입니다.
lib/├── main.dart # 앱 진입점, DI 설정├── app.dart # MaterialApp 설정│├── data/ # Data Layer│ ├── repositories/│ │ ├── booking_repository.dart│ │ └── user_repository.dart│ ├── services/│ │ ├── booking_api_service.dart│ │ ├── user_api_service.dart│ │ └── auth_service.dart│ └── models/│ ├── booking.dart│ └── user.dart│├── ui/ # UI Layer│ ├── booking/│ │ ├── booking_screen.dart│ │ ├── booking_view_model.dart│ │ └── widgets/│ │ └── booking_card.dart│ ├── profile/│ │ ├── profile_screen.dart│ │ └── profile_view_model.dart│ └── shared/│ └── widgets/│ └── loading_indicator.dart│└── core/ # 공통 유틸리티├── command.dart└── exceptions.dartmain.dart에서 전체를 조합합니다.
void main() async {WidgetsFlutterBinding.ensureInitialized();// Infrastructure 계층final httpClient = http.Client();final authService = AuthService();final cacheService = CacheService();// Service 계층final bookingApiService = BookingApiService(client: httpClient,baseUrl: Environment.apiBaseUrl,authService: authService,);final userApiService = UserApiService(client: httpClient,baseUrl: Environment.apiBaseUrl,authService: authService,);// Repository 계층final bookingRepository = BookingRepository(apiService: bookingApiService,cacheService: cacheService,);final userRepository = UserRepository(apiService: userApiService,);// 앱 실행runApp(MultiProvider(providers: [Provider.value(value: authService),Provider.value(value: bookingRepository),Provider.value(value: userRepository),],child: const MyApp(),),);}
Watch out
WARNING기능별로 폴더를 나눌지, 계층별로 폴더를 나눌지는 팀 규모에 따라 결정하세요.
// 소규모 앱: 계층별 구조lib/├── data/├── ui/└── core/// 대규모 앱: 기능별 구조lib/├── features/│ ├── booking/│ │ ├── data/│ │ └── ui/│ └── profile/│ ├── data/│ └── ui/└── core/
결론: 아키텍처 패턴을 일관되게 적용하면 팀원 모두가 코드를 쉽게 이해하고 수정할 수 있다.
한계
- 학습 곡선: MVVM 패턴에 익숙하지 않은 개발자는 초기 학습이 필요하다
- 보일러플레이트: 간단한 기능에도 여러 클래스를 작성해야 한다
- 과도한 추상화: 소규모 앱에서는 불필요한 복잡성을 추가할 수 있다
Footnotes
-
Widget(위젯): Flutter에서 UI를 구성하는 기본 단위이다. ↩
-
MVVM(Model-View-ViewModel): UI와 비즈니스 로직을 분리하는 아키텍처 패턴이다. ↩
-
ViewModel(뷰모델): View에 필요한 데이터와 로직을 제공하는 클래스이다. ↩
-
ListenableBuilder(리스너블빌더): Listenable 객체의 변경을 감지하여 위젯을 재빌드하는 빌더이다. ↩
-
ChangeNotifier(체인지노티파이어): 리스너에게 변경을 알리는 기능을 제공하는 클래스이다. ↩
-
BuildContext(빌드컨텍스트): 위젯 트리에서 현재 위젯의 위치를 나타내는 참조이다. ↩
-
Command(커맨드): 실행 가능한 액션과 그 상태를 캡슐화하는 패턴이다. ↩
-
Repository(레포지토리): 데이터 소스를 추상화하여 단일 진실 공급원을 제공하는 클래스이다. ↩
-
Service(서비스): 외부 시스템과의 통신을 담당하는 stateless 클래스이다. ↩
-
Dependency Injection(의존성 주입): 객체가 필요로 하는 의존성을 외부에서 제공하는 디자인 패턴이다. ↩
공유
이 글이 도움이 되었다면 다른 사람과 공유해주세요!