Flutter 튜토리얼 41편: UI 레이어와 Data 레이어 - MVVM 아키텍처 실전 적용

요약#

핵심 요지#

  • 문제 정의: Flutter 앱이 복잡해질수록 UI 코드와 비즈니스 로직이 뒤섞여 유지보수가 어려워진다.
  • 핵심 주장: UI 레이어와 Data 레이어를 명확히 분리하면 코드의 재사용성, 테스트 용이성, 유지보수성이 크게 향상된다.
  • 주요 근거: Flutter 공식 문서의 MVVM 아키텍처 케이스 스터디에서 제시하는 계층 분리 패턴을 따른다.
  • 한계: 소규모 앱에서는 오버엔지니어링이 될 수 있으며, 팀 내 일관된 패턴 적용이 필요하다.

문서가 설명하는 범위#

  • MVVM 아키텍처의 구조와 역할
  • UI 레이어: View와 ViewModel의 관계
  • Data 레이어: Repository와 Service의 역할
  • 실전 코드 예제와 적용 방법

읽는 시간: 25분 | 난이도: 중급


참고 자료#


문제 상황#

앱의 규모가 커지면서 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#

NOTE

MVVM은 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
계층역할예시
ViewUI 렌더링, 사용자 입력 처리StatelessWidget, StatefulWidget
ViewModel비즈니스 로직, 상태 관리ChangeNotifier 상속 클래스
Repository데이터 소스 추상화, 캐싱BookingRepository
Service외부 API 통신BookingApiService

How#

TIP

Flutter에서 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 - UI
class BookingScreen extends StatelessWidget {
@override
Widget build(BuildContext context) {
// ViewModel을 사용한 UI 구성
}
}

Watch out#

WARNING

모든 앱에 MVVM이 필요한 것은 아닙니다. 단순한 앱이나 프로토타입에서는 오히려 복잡성만 증가시킬 수 있습니다. 팀의 규모와 앱의 복잡도를 고려하여 적용 여부를 결정하세요.

결론: MVVM 아키텍처는 UI, 비즈니스 로직, 데이터 접근을 분리하여 유지보수성을 높인다.


챕터 2: UI 레이어 - View 구현#

Why#

NOTE

View는 사용자에게 정보를 표시하고 입력을 받는 역할만 담당합니다. View가 비즈니스 로직을 알지 못하면 UI 변경이 다른 코드에 영향을 주지 않습니다.

What#

NOTE

Flutter에서 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 구현
ViewUI 렌더링StatelessWidget
State 구독상태 변경 감지ListenableBuilder
입력 처리사용자 액션 전달ViewModel 메서드 호출

How#

TIP

ListenableBuilder4를 사용하여 ViewModel의 상태를 구독하는 View를 만듭니다.

class BookingScreen extends StatelessWidget {
final BookingViewModel viewModel;
const BookingScreen({
super.key,
required this.viewModel,
});
@override
Widget 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#

WARNING

View에서 직접 데이터를 가공하거나 비즈니스 로직을 수행하지 마세요.

// 잘못된 예시: 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#

NOTE

ViewModel은 View에 필요한 상태를 관리하고 비즈니스 로직을 처리합니다. View와 Data 레이어 사이의 중재자 역할을 하여 두 계층을 분리합니다.

What#

NOTE

Flutter에서 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#

WARNING

ViewModel에서 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#

NOTE

Command는 실행 가능한 액션을 캡슐화하는 객체입니다. 실행 상태(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#

TIP

Flutter 공식 문서에서 제안하는 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#

WARNING

Command의 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#

NOTE

ViewModel이 데이터 소스를 직접 알면 데이터 소스 변경 시 ViewModel도 수정해야 합니다. Repository8가 데이터 소스를 추상화하면 ViewModel은 데이터가 어디서 오는지 알 필요가 없습니다.

What#

NOTE

Repository는 데이터의 단일 진실 공급원(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#

TIP

Repository 클래스 구현 예시입니다.

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#

WARNING

Repository에서 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#

NOTE

Service는 상태를 갖지 않는(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&lt;Booking&gt;"| R
책임설명예시
HTTP 통신API 엔드포인트 호출GET, POST, DELETE
인증 처리토큰 추가, 갱신Bearer 토큰
응답 파싱JSON을 모델로 변환Booking.fromJson()
에러 처리HTTP 에러를 예외로 변환ApiException

How#

TIP

HTTP 클라이언트를 사용하는 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#

WARNING

Service에서 데이터를 캐싱하지 마세요. 캐싱은 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 패키지
생성자 주입생성자 매개변수로 전달기본
ProviderWidget 트리를 통한 주입provider
GetIt서비스 로케이터 패턴get_it
Riverpod컴파일 타임 안전성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 {
@override
Widget 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 {
// Arrange
final testBookings = [
Booking(id: '1', destination: 'Seoul', date: DateTime.now()),
];
when(mockRepository.getBookings())
.thenAnswer((_) async => testBookings);
// Act
await viewModel.loadBookings();
// Assert
expect(viewModel.bookings, equals(testBookings));
expect(viewModel.isLoading, isFalse);
expect(viewModel.error, isNull);
});
test('loadBookings handles error', () async {
// Arrange
when(mockRepository.getBookings())
.thenThrow(Exception('Network error'));
// Act
await viewModel.loadBookings();
// Assert
expect(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.dart

main.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#

  1. Widget(위젯): Flutter에서 UI를 구성하는 기본 단위이다.

  2. MVVM(Model-View-ViewModel): UI와 비즈니스 로직을 분리하는 아키텍처 패턴이다.

  3. ViewModel(뷰모델): View에 필요한 데이터와 로직을 제공하는 클래스이다.

  4. ListenableBuilder(리스너블빌더): Listenable 객체의 변경을 감지하여 위젯을 재빌드하는 빌더이다.

  5. ChangeNotifier(체인지노티파이어): 리스너에게 변경을 알리는 기능을 제공하는 클래스이다.

  6. BuildContext(빌드컨텍스트): 위젯 트리에서 현재 위젯의 위치를 나타내는 참조이다.

  7. Command(커맨드): 실행 가능한 액션과 그 상태를 캡슐화하는 패턴이다.

  8. Repository(레포지토리): 데이터 소스를 추상화하여 단일 진실 공급원을 제공하는 클래스이다.

  9. Service(서비스): 외부 시스템과의 통신을 담당하는 stateless 클래스이다.

  10. Dependency Injection(의존성 주입): 객체가 필요로 하는 의존성을 외부에서 제공하는 디자인 패턴이다.

공유

이 글이 도움이 되었다면 다른 사람과 공유해주세요!

Flutter 튜토리얼 41편: UI 레이어와 Data 레이어 - MVVM 아키텍처 실전 적용
https://moodturnpost.net/posts/flutter/flutter-ui-data-layer-architecture/
작성자
Moodturn
게시일
2026-01-08
Moodturn

목차