Flutter 튜토리얼 43편: 아키텍처 권장사항과 디자인 패턴
요약
핵심 요지
- 관심사의 분리와 단방향 데이터 흐름은 강력히 권장되는 핵심 원칙입니다.
- Repository 패턴으로 데이터 접근을 추상화하고, MVVM으로 UI 로직을 분리합니다.
- 불변 데이터 모델과 Command 패턴으로 상태 관리의 복잡성을 줄입니다.
- Optimistic State, Result 객체 등 실용적인 패턴으로 사용자 경험을 향상시킵니다.
문서가 설명하는 범위
Flutter 공식 문서의 Recommendations와 Design patterns를 기반으로 Flutter 앱 개발에서 권장하는 아키텍처 원칙과 디자인 패턴을 설명합니다.
참고 자료
핵심 아키텍처 원칙
Why - 왜 아키텍처 원칙이 필요한가요?
앱이 커지면 코드의 복잡성도 증가합니다. 명확한 원칙 없이 개발하면 코드가 뒤엉키고, 수정할 때마다 예상치 못한 버그가 발생합니다. 아키텍처 원칙은 이런 혼란을 방지하는 가이드라인입니다.
What - Flutter가 권장하는 원칙은 무엇인가요?
Flutter 팀이 강력히 권장하는(Strongly Recommended) 원칙과 조건부로 권장하는(Conditional) 원칙이 있습니다.
| 권장 수준 | 원칙 | 설명 |
|---|---|---|
| 강력히 권장 | 관심사의 분리 | UI와 비즈니스 로직을 분리 |
| 강력히 권장 | 단방향 데이터 흐름 | 상태는 아래→위, 이벤트는 위→아래 |
| 강력히 권장 | Repository 패턴 | 데이터 접근을 추상화 |
| 강력히 권장 | MVVM 패턴 | ViewModel로 UI 로직 분리 |
| 강력히 권장 | 의존성 주입 | 전역 객체 대신 명시적 주입 |
| 강력히 권장 | 테스트 작성 | Unit, Widget, Integration 테스트 |
| 조건부 권장 | 불변 데이터 모델 | freezed, built_value 활용 |
| 조건부 권장 | Domain 레이어 | 복잡한 비즈니스 로직이 있을 때만 |
How - 원칙을 적용하는 방법
관심사의 분리 적용
// 나쁜 예: 위젯에 모든 로직이 혼재class BadUserScreen extends StatefulWidget { @override State<BadUserScreen> createState() => _BadUserScreenState();}
class _BadUserScreenState extends State<BadUserScreen> { User? user;
@override void initState() { super.initState(); _loadUser(); }
Future<void> _loadUser() async { // HTTP 호출이 위젯에 직접 있음 final response = await http.get(Uri.parse('https://api.example.com/user')); setState(() { user = User.fromJson(jsonDecode(response.body)); }); }
@override Widget build(BuildContext context) { return Text(user?.name ?? 'Loading...'); }}// 좋은 예: 각 계층이 명확히 분리됨
// Data Layerclass UserRepository { final ApiClient _client; UserRepository(this._client);
Future<User> getUser() async { final response = await _client.get('/user'); return User.fromJson(response); }}
// Logic Layerclass UserViewModel extends ChangeNotifier { final UserRepository _repository; User? user;
UserViewModel(this._repository);
Future<void> loadUser() async { user = await _repository.getUser(); notifyListeners(); }}
// UI Layerclass GoodUserScreen extends StatelessWidget { final UserViewModel viewModel; const GoodUserScreen({required this.viewModel, super.key});
@override Widget build(BuildContext context) { return ListenableBuilder( listenable: viewModel, builder: (context, _) => Text(viewModel.user?.name ?? 'Loading...'), ); }}Watch out - 주의사항
- Domain 레이어는 복잡한 비즈니스 로직이 있을 때만 추가하세요.
- 단순한 CRUD 앱에서는 UI 레이어와 Data 레이어만으로 충분합니다.
- 과도한 추상화는 오히려 복잡성을 증가시킵니다.
데이터 레이어 패턴
Why - 왜 데이터 레이어가 중요한가요?
데이터 레이어는 앱의 모든 데이터 흐름을 담당합니다. API, 데이터베이스, 파일 시스템 등 다양한 데이터 소스와 상호작용하며, 이를 앱의 나머지 부분에서 추상화합니다.
What - Repository 패턴이란 무엇인가요?
Repository 패턴1은 데이터 접근 로직을 비즈니스 로직에서 분리하는 패턴입니다. Repository는 데이터의 단일 진실 원천(Single Source of Truth)으로서, 데이터의 일관성을 보장합니다.
How - Repository 구현하기
추상 Repository 정의
// 추상 인터페이스로 정의abstract class BookingRepository { Future<Result<List<Booking>>> getBookings(); Future<Result<Booking>> getBooking(String id); Future<Result<void>> createBooking(Booking booking); Future<Result<void>> deleteBooking(String id);}실제 구현체
// 원격 API를 사용하는 구현체class BookingRepositoryRemote implements BookingRepository { final ApiClient _apiClient; final CacheService _cache;
BookingRepositoryRemote({ required ApiClient apiClient, required CacheService cache, }) : _apiClient = apiClient, _cache = cache;
@override Future<Result<List<Booking>>> getBookings() async { try { // 캐시 확인 final cached = await _cache.get<List<Booking>>('bookings'); if (cached != null) return Result.ok(cached);
// API 호출 final response = await _apiClient.get('/bookings'); final bookings = (response as List) .map((e) => Booking.fromJson(e)) .toList();
// 캐시 저장 await _cache.set('bookings', bookings);
return Result.ok(bookings); } catch (e) { return Result.error(e as Exception); } }
// ... 나머지 메서드 구현}환경별 구현체 분리
// 개발용 Mock 구현체class BookingRepositoryMock implements BookingRepository { final List<Booking> _mockData = [ Booking(id: '1', destination: '서울', date: DateTime.now()), Booking(id: '2', destination: '부산', date: DateTime.now()), ];
@override Future<Result<List<Booking>>> getBookings() async { await Future.delayed(const Duration(milliseconds: 500)); return Result.ok(_mockData); }
// ... 나머지 메서드 구현}
// main.dart에서 환경에 따라 다른 구현체 사용void main() { final repository = kDebugMode ? BookingRepositoryMock() : BookingRepositoryRemote(apiClient: ApiClient(), cache: CacheService());
runApp(MyApp(bookingRepository: repository));}Watch out - 주의사항
- Repository는 데이터 타입별로 하나씩 만드세요 (UserRepository, BookingRepository 등).
- Service는 단일 데이터 소스를 담당합니다 (ApiService, DatabaseService 등).
- 추상 클래스를 사용하면 테스트와 환경 분리가 쉬워집니다.
UI 레이어 패턴
Why - 왜 MVVM 패턴을 사용하나요?
위젯에 비즈니스 로직이 섞이면 테스트가 어렵고 재사용성이 떨어집니다. MVVM(Model-View-ViewModel)2 패턴은 UI 로직을 ViewModel로 분리하여 이 문제를 해결합니다.
What - MVVM의 구성 요소
| 구성 요소 | 역할 | 포함 내용 |
|---|---|---|
| View | 화면 표시 | 위젯, 레이아웃, 애니메이션 |
| ViewModel | UI 로직 | 상태 관리, 데이터 변환, 이벤트 처리 |
| Model | 데이터 | 불변 데이터 클래스 |
How - ViewModel 구현하기
기본 ViewModel 구조
class HomeViewModel extends ChangeNotifier { final BookingRepository _bookingRepository; final UserRepository _userRepository;
HomeViewModel({ required BookingRepository bookingRepository, required UserRepository userRepository, }) : _bookingRepository = bookingRepository, _userRepository = userRepository;
// 상태 List<Booking> _bookings = []; User? _user; bool _isLoading = false; String? _error;
// Getter List<Booking> get bookings => _bookings; User? get user => _user; bool get isLoading => _isLoading; String? get error => _error;
// 메서드 Future<void> load() async { _isLoading = true; _error = null; notifyListeners();
try { final results = await Future.wait([ _bookingRepository.getBookings(), _userRepository.getCurrentUser(), ]);
_bookings = (results[0] as Result<List<Booking>>).value; _user = (results[1] as Result<User?>).value; } catch (e) { _error = e.toString(); } finally { _isLoading = false; notifyListeners(); } }}View에서 ViewModel 사용
class HomeScreen extends StatelessWidget { final HomeViewModel viewModel;
const HomeScreen({required this.viewModel, super.key});
@override Widget build(BuildContext context) { return ListenableBuilder( listenable: viewModel, builder: (context, _) { if (viewModel.isLoading) { return const Center(child: CircularProgressIndicator()); }
if (viewModel.error != null) { return Center(child: Text('오류: ${viewModel.error}')); }
return Column( children: [ // 사용자 정보 if (viewModel.user != null) Text('안녕하세요, ${viewModel.user!.name}님'),
// 예약 목록 Expanded( child: ListView.builder( itemCount: viewModel.bookings.length, itemBuilder: (context, index) { final booking = viewModel.bookings[index]; return ListTile( title: Text(booking.destination), subtitle: Text(booking.formattedDate), ); }, ), ), ], ); }, ); }}Watch out - 주의사항
View에는 최소한의 로직만 포함해야 합니다. 다음 항목만 View에 두세요.
- 조건부 위젯 표시를 위한 간단한 if문
- 애니메이션 로직
- 디바이스 정보 기반 레이아웃 결정
- 기본적인 라우팅 로직
복잡한 로직은 모두 ViewModel로 이동하세요.
불변 데이터 모델
Why - 왜 불변 모델을 사용하나요?
가변 데이터 모델은 예상치 못한 곳에서 데이터가 변경될 수 있습니다. 불변 모델은 데이터 변경을 명시적으로 만들어 버그를 줄이고 단방향 데이터 흐름을 강화합니다.
What - 불변 데이터 모델이란?
불변(Immutable) 객체는 생성 후 상태를 변경할 수 없습니다. 데이터를 수정하려면 새 객체를 생성해야 합니다.
// 불변 데이터 모델class User { final String id; final String name; final String email;
const User({ required this.id, required this.name, required this.email, });
// 일부 필드만 변경한 새 객체 생성 User copyWith({ String? id, String? name, String? email, }) { return User( id: id ?? this.id, name: name ?? this.name, email: email ?? this.email, ); }
// JSON 직렬화 factory User.fromJson(Map<String, dynamic> json) { return User( id: json['id'] as String, name: json['name'] as String, email: json['email'] as String, ); }
Map<String, dynamic> toJson() => { 'id': id, 'name': name, 'email': email, };
// 동등성 비교 @override bool operator ==(Object other) => identical(this, other) || other is User && runtimeType == other.runtimeType && id == other.id && name == other.name && email == other.email;
@override int get hashCode => id.hashCode ^ name.hashCode ^ email.hashCode;}How - freezed로 불변 모델 생성하기
freezed3 패키지를 사용하면 보일러플레이트 코드를 자동 생성할 수 있습니다.
import 'package:freezed_annotation/freezed_annotation.dart';
part 'user.freezed.dart';part 'user.g.dart';
@freezedclass User with _$User { const factory User({ required String id, required String name, required String email, @Default(false) bool isVerified, }) = _User;
factory User.fromJson(Map<String, dynamic> json) => _$UserFromJson(json);}
// 사용 예시final updatedUser = user.copyWith(name: '김철수');Watch out - 주의사항
- 불변 모델은 조건부 권장사항입니다. 작은 앱에서는 오버헤드일 수 있습니다.
- freezed는 코드 생성 도구이므로
dart run build_runner build를 실행해야 합니다. - API 모델과 도메인 모델을 분리하면 복잡성이 줄어듭니다.
실용적인 디자인 패턴
Command 패턴
Why - 왜 Command 패턴을 사용하나요?
ViewModel의 메서드는 실행 중, 성공, 실패 등 다양한 상태를 가집니다. 각 메서드마다 이 상태를 관리하면 코드가 복잡해집니다.
What - Command 패턴이란?
Command4 패턴은 메서드 실행과 상태를 캡슐화합니다. 실행 중, 완료, 에러 상태를 자동으로 관리합니다.
class Command<T> extends ChangeNotifier { Command(this._action);
final Future<T> Function() _action;
bool _running = false; T? _result; Exception? _error;
bool get running => _running; T? get result => _result; Exception? get error => _error; bool get completed => _result != null;
Future<void> execute() async { if (_running) return;
_running = true; _error = null; notifyListeners();
try { _result = await _action(); } catch (e) { _error = e as Exception; } finally { _running = false; notifyListeners(); } }}How - Command 사용하기
class BookingViewModel extends ChangeNotifier { final BookingRepository _repository;
late final Command<List<Booking>> loadBookings; late final Command<void> createBooking;
BookingViewModel(this._repository) { loadBookings = Command(() => _repository.getBookings()); createBooking = Command(() => _repository.createBooking(_newBooking!)); }
Booking? _newBooking;
void setNewBooking(Booking booking) { _newBooking = booking; notifyListeners(); }}
// View에서 사용ListenableBuilder( listenable: viewModel.loadBookings, builder: (context, _) { if (viewModel.loadBookings.running) { return const CircularProgressIndicator(); } if (viewModel.loadBookings.error != null) { return Text('에러: ${viewModel.loadBookings.error}'); } return BookingList(bookings: viewModel.loadBookings.result ?? []); },)Result 패턴
Why - 왜 Result 패턴을 사용하나요?
Dart의 예외는 선언이나 catch가 강제되지 않습니다. 에러 처리를 잊기 쉽고, 어떤 예외가 발생하는지 문서화되지 않습니다.
What - Result 패턴이란?
Result 패턴은 성공과 실패를 명시적인 타입으로 표현합니다.
sealed class Result<T> { const Result();
factory Result.ok(T value) = Ok<T>; factory Result.error(Exception error) = Error<T>;
bool get isOk => this is Ok<T>; bool get isError => this is Error<T>;
T get value => (this as Ok<T>).value; Exception get error => (this as Error<T>).error;}
class Ok<T> extends Result<T> { final T value; const Ok(this.value);}
class Error<T> extends Result<T> { final Exception error; const Error(this.error);}How - Result 사용하기
// Repository에서 Result 반환class UserRepository { Future<Result<User>> getUser(String id) async { try { final response = await _client.get('/users/$id'); return Result.ok(User.fromJson(response)); } on NotFoundException { return Result.error(UserNotFoundException(id)); } on NetworkException catch (e) { return Result.error(e); } }}
// ViewModel에서 Result 처리Future<void> loadUser(String id) async { final result = await _repository.getUser(id);
switch (result) { case Ok(value: final user): _user = user; case Error(error: final e): _error = e.toString(); } notifyListeners();}Optimistic State 패턴
Why - 왜 Optimistic State를 사용하나요?
네트워크 요청은 시간이 걸립니다. 응답을 기다리는 동안 UI가 멈춰 있으면 사용자 경험이 나빠집니다.
What - Optimistic State란?
Optimistic State5는 서버 응답 전에 UI를 먼저 업데이트하는 패턴입니다. 대부분의 요청이 성공한다고 가정하고, 실패하면 롤백합니다.
// 좋아요 버튼 예시class PostViewModel extends ChangeNotifier { final PostRepository _repository; Post _post;
PostViewModel(this._repository, this._post);
Post get post => _post;
Future<void> toggleLike() async { // 1. 낙관적 업데이트: 즉시 UI 변경 final previousState = _post; _post = _post.copyWith( isLiked: !_post.isLiked, likeCount: _post.isLiked ? _post.likeCount - 1 : _post.likeCount + 1, ); notifyListeners();
// 2. 서버 요청 final result = await _repository.toggleLike(_post.id);
// 3. 실패 시 롤백 if (result.isError) { _post = previousState; notifyListeners(); // 에러 메시지 표시 } }}Watch out - 주의사항
- Optimistic State는 되돌릴 수 있는 작업에만 사용하세요.
- 결제, 삭제 등 중요한 작업은 서버 응답을 기다려야 합니다.
- 실패 시 사용자에게 명확한 피드백을 제공하세요.
오프라인 우선 지원
Why - 왜 오프라인 지원이 필요한가요?
모바일 앱은 네트워크 연결이 불안정한 환경에서도 동작해야 합니다. 오프라인 지원이 있으면 사용자가 언제 어디서든 앱을 사용할 수 있습니다.
What - 오프라인 우선 아키텍처
오프라인 우선(Offline-First)은 로컬 데이터를 기본으로 사용하고, 가능할 때 서버와 동기화하는 전략입니다.
How - 오프라인 우선 구현
class BookingRepository { final LocalDatabase _localDb; final ApiClient _apiClient; final ConnectivityService _connectivity;
BookingRepository({ required LocalDatabase localDb, required ApiClient apiClient, required ConnectivityService connectivity, }) : _localDb = localDb, _apiClient = apiClient, _connectivity = connectivity;
Future<Result<List<Booking>>> getBookings() async { // 1. 로컬 데이터 먼저 반환 final localBookings = await _localDb.getBookings();
// 2. 온라인이면 서버와 동기화 if (await _connectivity.isOnline) { try { final remoteBookings = await _apiClient.get('/bookings'); await _localDb.saveBookings(remoteBookings); return Result.ok(remoteBookings); } catch (e) { // 서버 실패해도 로컬 데이터 반환 } }
return Result.ok(localBookings); }
Future<Result<void>> createBooking(Booking booking) async { // 1. 로컬에 먼저 저장 (pending 상태로) await _localDb.saveBooking(booking.copyWith(syncStatus: SyncStatus.pending));
// 2. 온라인이면 즉시 동기화, 아니면 나중에 if (await _connectivity.isOnline) { await _syncPendingBookings(); }
return Result.ok(null); }
Future<void> _syncPendingBookings() async { final pending = await _localDb.getPendingBookings(); for (final booking in pending) { try { await _apiClient.post('/bookings', booking.toJson()); await _localDb.updateSyncStatus(booking.id, SyncStatus.synced); } catch (e) { // 실패한 항목은 나중에 재시도 } } }}Watch out - 주의사항
- 오프라인 데이터임을 사용자에게 알려주세요.
- 동기화 충돌 해결 전략을 미리 정의하세요.
- 대용량 데이터는 선택적 동기화를 고려하세요.
앱 구조와 네이밍
권장하는 폴더 구조
lib/├── main.dart├── app.dart├── config/│ └── routes.dart├── data/│ ├── models/│ │ └── booking.dart│ ├── repositories/│ │ └── booking_repository.dart│ └── services/│ └── api_client.dart├── domain/│ └── use_cases/│ └── create_booking_use_case.dart└── ui/ ├── core/ │ ├── themes/ │ └── widgets/ └── booking/ ├── booking_screen.dart └── booking_view_model.dart네이밍 컨벤션
| 컴포넌트 | 네이밍 예시 | 설명 |
|---|---|---|
| Screen/View | HomeScreen, BookingDetailScreen | 전체 화면 위젯 |
| ViewModel | HomeViewModel, BookingDetailViewModel | UI 로직 클래스 |
| Repository | UserRepository, BookingRepository | 데이터 접근 클래스 |
| Service | ApiClient, AuthService | 외부 서비스 클래스 |
| Model | User, Booking | 데이터 클래스 |
Watch out - 주의사항
- Flutter SDK 클래스와 충돌하는 이름을 피하세요.
/widgets폴더 대신/ui/core/widgets를 사용하세요.- 기능별로 폴더를 구성하면 관련 코드를 쉽게 찾을 수 있습니다.
마무리
아키텍처 권장사항과 디자인 패턴의 핵심을 정리하면 다음과 같습니다.
- 핵심 원칙: 관심사의 분리, 단방향 데이터 흐름, MVVM, Repository 패턴
- 불변 데이터: freezed로 불변 모델을 생성하여 예측 가능한 상태 관리
- Command 패턴: 메서드 실행 상태를 캡슐화하여 복잡성 감소
- Result 패턴: 에러 처리를 명시적으로 만들어 안정성 향상
- Optimistic State: 즉각적인 UI 피드백으로 사용자 경험 개선
다음 튜토리얼에서는 Flutter의 유닛 테스트를 자세히 살펴보겠습니다.
Footnotes
-
Repository 패턴: 데이터 접근 로직을 캡슐화하여 비즈니스 로직과 분리하는 디자인 패턴입니다. 데이터 소스(API, DB)의 변경이 비즈니스 로직에 영향을 주지 않게 합니다. ↩
-
MVVM(Model-View-ViewModel): UI 개발을 위한 아키텍처 패턴으로, View(UI), ViewModel(UI 로직), Model(데이터)을 분리합니다. Microsoft의 WPF에서 시작되어 다양한 플랫폼에서 사용됩니다. ↩
-
freezed: Dart에서 불변 클래스, 유니온 타입, 패턴 매칭을 지원하는 코드 생성 패키지입니다. copyWith, ==, hashCode, toJson 등의 보일러플레이트를 자동 생성합니다. ↩
-
Command 패턴: 요청을 객체로 캡슐화하는 행동 디자인 패턴입니다. Flutter에서는 비동기 메서드의 실행 상태(로딩, 성공, 에러)를 관리하는 데 활용합니다. ↩
-
Optimistic State(낙관적 상태): 서버 응답을 기다리지 않고 UI를 먼저 업데이트하는 패턴입니다. 대부분의 요청이 성공한다고 가정하여 응답성을 높이고, 실패 시 롤백합니다. ↩
공유
이 글이 도움이 되었다면 다른 사람과 공유해주세요!