Architecture 완벽 마스터
Architecture의 핵심 개념과 실전 활용법
학습 항목
이미지 로딩 중...
Flutter MVVM 패턴 완벽 가이드
Flutter 앱 개발에서 MVVM 패턴을 활용하여 비즈니스 로직과 UI를 효과적으로 분리하는 방법을 배웁니다. 실전 예제와 함께 ViewModel, Model, View의 역할과 구현 방법을 상세히 알아봅니다.
목차
- MVVM 패턴 기본 개념
- ViewModel 구현하기
- View와 ViewModel 연결하기
- Repository 패턴과 함께 사용하기
- ViewModel에서 Repository 사용하기
- 상태 관리를 위한 Enum 활용
- Sealed Class로 고급 상태 관리
- 폼 입력과 유효성 검사
- 리스트 데이터 관리와 무한 스크롤
- 에러 처리와 사용자 피드백
1. MVVM 패턴 기본 개념
시작하며
여러분이 Flutter 앱을 개발할 때 화면이 복잡해질수록 비즈니스 로직과 UI 코드가 뒤섞여서 유지보수가 어려워진 경험이 있나요? 예를 들어, 사용자 로그인 화면에서 입력 검증, API 호출, 로딩 상태 관리, 에러 처리 등이 모두 하나의 Widget에 섞여 있어서 코드를 수정할 때마다 어디를 고쳐야 할지 헷갈리는 상황입니다.
이런 문제는 실제 개발 현장에서 자주 발생합니다. 특히 여러 개발자가 협업하는 프로젝트에서는 코드의 책임이 명확하지 않으면 버그가 발생하기 쉽고, 테스트 코드를 작성하기도 어렵습니다.
또한 UI를 변경할 때마다 비즈니스 로직에 영향을 줄 수 있어 리팩토링이 두려워집니다. 바로 이럴 때 필요한 것이 MVVM 패턴입니다.
MVVM은 Model-View-ViewModel의 약자로, 앱의 각 레이어를 명확히 분리하여 코드의 재사용성을 높이고 유지보수를 쉽게 만들어줍니다.
개요
간단히 말해서, MVVM 패턴은 앱을 Model(데이터), View(UI), ViewModel(비즈니스 로직)이라는 세 가지 레이어로 분리하는 아키텍처 패턴입니다. 이 패턴이 필요한 이유는 각 레이어가 독립적으로 동작하면서도 명확한 책임을 가지기 때문입니다.
View는 오직 UI 렌더링에만 집중하고, ViewModel은 비즈니스 로직과 상태 관리를 담당하며, Model은 데이터 구조를 정의합니다. 예를 들어, 쇼핑몰 앱에서 상품 목록을 보여주는 경우, View는 그리드 레이아웃만 신경 쓰고, ViewModel이 API에서 데이터를 가져오고 필터링하는 로직을 처리합니다.
기존에는 StatefulWidget 안에 모든 로직을 작성했다면, 이제는 ViewModel로 로직을 분리하여 여러 화면에서 재사용할 수 있습니다. MVVM의 핵심 특징은 첫째, 관심사의 분리(Separation of Concerns)로 각 레이어가 하나의 책임만 가집니다.
둘째, 테스트 용이성으로 ViewModel은 UI 없이 단위 테스트가 가능합니다. 셋째, 데이터 바인딩으로 ViewModel의 상태 변경이 자동으로 View에 반영됩니다.
이러한 특징들이 중요한 이유는 실무에서 코드의 품질과 생산성을 크게 향상시키기 때문입니다.
코드 예제
// Model: 사용자 데이터 구조
class User {
final String id;
final String name;
final String email;
User({required this.id, required this.name, required this.email});
// JSON 변환 메서드
factory User.fromJson(Map<String, dynamic> json) {
return User(
id: json['id'],
name: json['name'],
email: json['email'],
);
}
}
설명
이것이 하는 일: Model은 앱에서 사용하는 데이터의 구조를 정의하는 클래스입니다. UI 로직이나 비즈니스 로직을 포함하지 않고, 순수하게 데이터만 표현합니다.
첫 번째로, User 클래스는 사용자의 기본 정보인 id, name, email을 필드로 가집니다. final 키워드를 사용하여 불변성을 보장하는데, 이렇게 하는 이유는 데이터의 일관성을 유지하고 예기치 않은 변경을 방지하기 위함입니다.
그 다음으로, 생성자에서 required 키워드를 사용하여 모든 필드가 반드시 초기화되도록 강제합니다. 이렇게 하면 null 관련 버그를 컴파일 타임에 방지할 수 있습니다.
마지막으로, fromJson 팩토리 생성자를 통해 API 응답이나 데이터베이스에서 가져온 JSON 데이터를 User 객체로 쉽게 변환할 수 있습니다. 이는 실무에서 네트워크 통신이나 로컬 저장소를 다룰 때 필수적인 패턴입니다.
여러분이 이 코드를 사용하면 데이터 구조가 명확해지고, 타입 안정성이 보장되며, 코드의 가독성이 크게 향상됩니다. 또한 여러 화면에서 동일한 User 모델을 재사용할 수 있어 중복 코드를 줄일 수 있고, 데이터 형식이 변경되어도 한 곳만 수정하면 됩니다.
실전 팁
💡 Model 클래스는 항상 불변(immutable)으로 만드세요. final 키워드를 사용하면 데이터의 예측 가능성이 높아지고 버그가 줄어듭니다.
💡 JSON 직렬화/역직렬화를 자주 사용한다면 json_serializable 패키지를 활용하여 보일러플레이트 코드를 자동 생성하세요.
💡 복잡한 데이터 구조는 freezed 패키지를 사용하여 copyWith, equality 비교 등의 기능을 자동으로 생성할 수 있습니다.
💡 API 응답 파싱 시 예외 처리를 추가하여 잘못된 데이터 형식으로 인한 런타임 에러를 방지하세요.
2. ViewModel 구현하기
시작하며
여러분이 Flutter 앱에서 상태 관리를 할 때 setState()를 남발하다가 어디서 상태가 변경되는지 추적하기 어려워진 적이 있나요? 예를 들어, 사용자 프로필 화면에서 프로필 로딩, 데이터 갱신, 에러 처리 등의 상태가 여러 곳에 흩어져 있어서 디버깅할 때 머리가 아픈 상황입니다.
이런 문제는 상태 관리의 책임이 분산되어 있을 때 발생합니다. 특히 비동기 작업이 많은 앱에서는 로딩 상태, 에러 상태, 성공 상태를 일관되게 관리하지 않으면 UI가 예상치 못하게 동작할 수 있습니다.
또한 비즈니스 로직이 Widget에 섞여 있으면 테스트 코드를 작성할 수 없습니다. 바로 이럴 때 필요한 것이 ViewModel입니다.
ViewModel은 UI와 완전히 분리된 상태 관리 레이어로, 모든 비즈니스 로직과 상태를 한 곳에서 관리하여 코드의 명확성을 높여줍니다.
개요
간단히 말해서, ViewModel은 View와 Model 사이의 중재자 역할을 하며, UI에 필요한 모든 데이터와 로직을 제공하는 클래스입니다. ViewModel이 필요한 이유는 비즈니스 로직을 UI로부터 완전히 분리하여 테스트와 유지보수를 쉽게 만들기 때문입니다.
View는 ViewModel을 관찰(observe)하다가 상태가 변경되면 자동으로 UI를 업데이트합니다. 예를 들어, 뉴스 앱에서 기사 목록을 불러오는 경우, ViewModel이 API 호출, 에러 처리, 캐싱 등을 담당하고 View는 단순히 데이터를 화면에 표시하기만 합니다.
기존에는 StatefulWidget에서 initState()나 버튼 클릭 핸들러에 모든 로직을 작성했다면, 이제는 ViewModel의 메서드를 호출하기만 하면 됩니다. ViewModel의 핵심 특징은 첫째, ChangeNotifier나 Stream을 사용한 반응형 프로그래밍으로 상태 변경을 자동으로 View에 알립니다.
둘째, UI 의존성이 없어서 BuildContext 없이도 테스트할 수 있습니다. 셋째, 생명주기 관리로 리소스를 적절히 해제하여 메모리 누수를 방지합니다.
이러한 특징들이 중요한 이유는 확장 가능하고 유지보수하기 쉬운 앱을 만들 수 있기 때문입니다.
코드 예제
import 'package:flutter/foundation.dart';
// ViewModel: 사용자 관리 로직
class UserViewModel extends ChangeNotifier {
User? _user;
bool _isLoading = false;
String? _errorMessage;
// Getter로 상태 노출
User? get user => _user;
bool get isLoading => _isLoading;
String? get errorMessage => _errorMessage;
// 사용자 정보 로드
Future<void> loadUser(String userId) async {
_isLoading = true;
_errorMessage = null;
notifyListeners(); // UI에 로딩 상태 알림
try {
// API 호출 시뮬레이션
await Future.delayed(Duration(seconds: 2));
_user = User(id: userId, name: '홍길동', email: 'hong@example.com');
} catch (e) {
_errorMessage = '사용자 정보를 불러올 수 없습니다';
} finally {
_isLoading = false;
notifyListeners(); // UI에 완료 상태 알림
}
}
}
설명
이것이 하는 일: UserViewModel은 사용자 관련 모든 비즈니스 로직과 상태를 캡슐화하며, ChangeNotifier를 통해 상태 변경을 View에 전파합니다. 첫 번째로, private 변수(_user, _isLoading, _errorMessage)로 내부 상태를 숨기고 public getter로만 노출합니다.
이렇게 하는 이유는 View가 직접 상태를 변경하지 못하도록 막아서 데이터의 무결성을 보장하기 위함입니다. 모든 상태 변경은 반드시 ViewModel의 메서드를 통해서만 이루어져야 합니다.
그 다음으로, loadUser 메서드에서 비동기 작업을 수행하면서 각 단계마다 적절한 상태로 업데이트합니다. 먼저 _isLoading을 true로 설정하고 notifyListeners()를 호출하여 View가 로딩 인디케이터를 표시하도록 합니다.
try-catch 블록으로 에러를 안전하게 처리하고, finally 블록에서 반드시 로딩 상태를 해제합니다. 마지막으로, notifyListeners()를 호출할 때마다 이 ViewModel을 구독하는 모든 위젯이 자동으로 rebuild됩니다.
이는 Flutter의 반응형 프로그래밍의 핵심으로, 수동으로 setState()를 호출할 필요가 없어집니다. 여러분이 이 코드를 사용하면 비즈니스 로직을 UI와 완전히 분리할 수 있고, 단위 테스트를 작성하여 로직의 정확성을 검증할 수 있습니다.
또한 동일한 ViewModel을 여러 화면에서 공유하여 일관된 사용자 경험을 제공할 수 있으며, 상태 관리가 중앙 집중화되어 디버깅이 훨씬 쉬워집니다.
실전 팁
💡 ViewModel은 반드시 dispose() 메서드를 구현하여 리소스를 정리하세요. 특히 Stream 구독이나 Timer가 있다면 메모리 누수를 방지해야 합니다.
💡 로딩, 에러, 성공 상태를 enum으로 관리하면 더 명확한 상태 관리가 가능합니다. 예: enum LoadingState { idle, loading, success, error }
💡 ViewModel에서는 절대 BuildContext를 사용하지 마세요. 네비게이션이나 스낵바 표시는 View에서 ViewModel의 상태를 감지하여 처리해야 합니다.
💡 복잡한 상태 관리가 필요하다면 Riverpod이나 Bloc 같은 전문 상태 관리 라이브러리와 MVVM 패턴을 결합하세요.
💡 API 호출 로직은 별도의 Repository 레이어로 분리하면 ViewModel이 더 간결해지고 테스트하기 쉬워집니다.
3. View와 ViewModel 연결하기
시작하며
여러분이 ViewModel을 만들었는데 실제 화면과 어떻게 연결해야 할지 막막했던 경험이 있나요? 예를 들어, ChangeNotifier를 사용하는 ViewModel을 만들었지만 위젯에서 상태 변경을 감지하는 방법을 몰라서 결국 다시 StatefulWidget으로 돌아간 경험 말입니다.
이런 문제는 Flutter의 Provider 패턴에 익숙하지 않을 때 발생합니다. ViewModel을 만드는 것까지는 좋았지만, 실제로 위젯 트리에 제공하고 구독하는 메커니즘을 이해하지 못하면 MVVM 패턴의 장점을 활용할 수 없습니다.
또한 불필요한 위젯 rebuild를 방지하는 최적화 방법도 알아야 합니다. 바로 이럴 때 필요한 것이 Provider 패키지입니다.
Provider는 ViewModel을 위젯 트리에 제공하고, 변경 사항을 효율적으로 감지하여 필요한 부분만 업데이트하는 강력한 도구입니다.
개요
간단히 말해서, Provider는 InheritedWidget을 간편하게 사용할 수 있게 해주는 패키지로, ViewModel을 위젯 트리의 상위에 제공하고 하위 위젯에서 쉽게 접근할 수 있게 합니다. Provider가 필요한 이유는 의존성 주입(Dependency Injection)과 상태 감지를 동시에 해결하기 때문입니다.
ChangeNotifierProvider를 사용하면 ViewModel의 생명주기를 자동으로 관리하고, Consumer나 watch를 통해 상태 변경을 감지할 수 있습니다. 예를 들어, 장바구니 앱에서 장바구니 ViewModel을 최상위에 제공하면 상품 목록 화면, 장바구니 화면, 결제 화면 모두에서 동일한 인스턴스를 공유할 수 있습니다.
기존에는 위젯 간 데이터 전달을 위해 생성자로 계속 넘겨주거나 전역 변수를 사용했다면, 이제는 Provider로 필요한 곳에서 바로 접근할 수 있습니다. Provider의 핵심 특징은 첫째, 자동 dispose로 화면을 벗어나면 ViewModel이 자동으로 정리됩니다.
둘째, 선택적 rebuild로 Consumer를 사용하면 필요한 위젯만 업데이트됩니다. 셋째, 여러 Provider를 중첩하여 복잡한 의존성 관계를 관리할 수 있습니다.
이러한 특징들이 중요한 이유는 성능과 메모리 효율성을 동시에 달성할 수 있기 때문입니다.
코드 예제
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
// View: 사용자 프로필 화면
class UserProfileScreen extends StatelessWidget {
final String userId;
const UserProfileScreen({required this.userId});
@override
Widget build(BuildContext context) {
// ViewModel을 위젯 트리에 제공
return ChangeNotifierProvider(
create: (_) => UserViewModel()..loadUser(userId),
child: Scaffold(
appBar: AppBar(title: Text('프로필')),
body: Consumer<UserViewModel>(
builder: (context, viewModel, child) {
// 로딩 상태 처리
if (viewModel.isLoading) {
return Center(child: CircularProgressIndicator());
}
// 에러 상태 처리
if (viewModel.errorMessage != null) {
return Center(child: Text(viewModel.errorMessage!));
}
// 데이터 표시
final user = viewModel.user!;
return Padding(
padding: EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('이름: ${user.name}', style: TextStyle(fontSize: 18)),
SizedBox(height: 8),
Text('이메일: ${user.email}', style: TextStyle(fontSize: 18)),
],
),
);
},
),
),
);
}
}
설명
이것이 하는 일: 이 View는 Provider 패턴을 사용하여 ViewModel과 완전히 분리되면서도 상태 변경에 반응하는 반응형 UI를 구현합니다. 첫 번째로, ChangeNotifierProvider의 create 콜백에서 UserViewModel을 생성하고 즉시 loadUser를 호출합니다.
cascade 연산자(..)를 사용하여 한 줄로 표현했습니다. ChangeNotifierProvider는 화면이 dispose될 때 자동으로 ViewModel의 dispose()도 호출하여 메모리 누수를 방지합니다.
그 다음으로, Consumer<UserViewModel> 위젯이 ViewModel의 변경 사항을 구독합니다. notifyListeners()가 호출될 때마다 Consumer의 builder 함수가 재실행되어 UI가 업데이트됩니다.
builder 함수는 context, viewModel, child 세 가지 매개변수를 받는데, viewModel을 통해 현재 상태에 접근할 수 있습니다. 마지막으로, 조건부 렌더링으로 로딩, 에러, 성공 상태에 따라 다른 UI를 표시합니다.
isLoading이 true면 로딩 인디케이터를, errorMessage가 null이 아니면 에러 메시지를, 그 외에는 실제 사용자 정보를 보여줍니다. 이렇게 명확한 상태 분기를 통해 사용자에게 항상 적절한 피드백을 제공할 수 있습니다.
여러분이 이 코드를 사용하면 UI 코드가 매우 깔끔해지고, 비즈니스 로직은 전혀 포함되지 않아 디자이너나 다른 개발자가 UI를 수정하기 쉬워집니다. 또한 ViewModel을 교체하거나 테스트용 Mock ViewModel로 바꾸기도 간단하며, 동일한 패턴을 모든 화면에 일관되게 적용할 수 있어 코드베이스의 일관성이 높아집니다.
실전 팁
💡 Consumer는 필요한 최소 범위에만 사용하세요. 전체 Scaffold를 감싸면 불필요한 rebuild가 발생할 수 있습니다.
💡 상태를 읽기만 하고 rebuild가 필요 없는 경우 Provider.of<T>(context, listen: false)나 context.read<T>()를 사용하세요.
💡 여러 ViewModel을 사용할 때는 MultiProvider를 활용하여 코드를 깔끔하게 유지하세요.
💡 Selector를 사용하면 ViewModel의 특정 필드만 감지하여 더욱 세밀한 최적화가 가능합니다.
💡 Provider는 위젯 트리의 상위에서 제공해야 하위 어디서든 접근할 수 있습니다. main.dart에서 전역 Provider를 설정하는 것도 좋은 방법입니다.
4. Repository 패턴과 함께 사용하기
시작하며
여러분이 ViewModel에서 직접 API를 호출하다 보니 ViewModel이 너무 비대해지고, 네트워크 로직과 비즈니스 로직이 섞여서 테스트하기 어려워진 경험이 있나요? 예를 들어, 여러 화면에서 동일한 사용자 API를 호출하는데 각 ViewModel마다 중복된 HTTP 코드를 작성하고 있는 상황입니다.
이런 문제는 데이터 소스와 비즈니스 로직이 분리되지 않았을 때 발생합니다. ViewModel이 HTTP 클라이언트를 직접 사용하면 Mock 테스트가 어렵고, API 엔드포인트가 변경되면 모든 ViewModel을 수정해야 합니다.
또한 로컬 캐싱이나 데이터베이스 접근 같은 추가 요구사항이 생기면 코드가 더욱 복잡해집니다. 바로 이럴 때 필요한 것이 Repository 패턴입니다.
Repository는 데이터 소스(API, 데이터베이스, 캐시)를 추상화하여 ViewModel이 데이터의 출처를 몰라도 되게 만들어줍니다.
개요
간단히 말해서, Repository는 데이터 액세스 로직을 캡슐화한 레이어로, ViewModel과 실제 데이터 소스 사이의 중재자 역할을 합니다. Repository 패턴이 필요한 이유는 데이터 출처를 추상화하여 ViewModel이 "어디서" 데이터를 가져오는지가 아니라 "무엇을" 가져오는지에만 집중할 수 있게 하기 때문입니다.
Repository는 내부적으로 API 호출, 로컬 데이터베이스 조회, 메모리 캐시 확인 등 복잡한 로직을 처리하지만, ViewModel에는 간단한 인터페이스만 제공합니다. 예를 들어, 뉴스 앱에서 네트워크가 없을 때는 로컬 DB에서, 있을 때는 API에서 데이터를 가져오는 로직을 Repository가 담당합니다.
기존에는 ViewModel이 http 패키지를 직접 import하여 API를 호출했다면, 이제는 Repository 인터페이스만 의존하여 테스트와 유지보수가 쉬워집니다. Repository 패턴의 핵심 특징은 첫째, 단일 책임 원칙으로 데이터 액세스만 담당합니다.
둘째, 의존성 역전으로 ViewModel이 구체적인 구현이 아닌 추상 인터페이스에 의존합니다. 셋째, 캐싱 전략이나 에러 처리를 중앙에서 일관되게 관리할 수 있습니다.
이러한 특징들이 중요한 이유는 앱의 확장성과 유지보수성을 크게 향상시키기 때문입니다.
코드 예제
import 'package:http/http.dart' as http;
import 'dart:convert';
// Repository 인터페이스
abstract class UserRepository {
Future<User> getUser(String userId);
Future<void> updateUser(User user);
}
// Repository 구현체
class UserRepositoryImpl implements UserRepository {
final String baseUrl;
final http.Client client;
UserRepositoryImpl({
required this.baseUrl,
http.Client? client,
}) : client = client ?? http.Client();
@override
Future<User> getUser(String userId) async {
try {
final response = await client.get(
Uri.parse('$baseUrl/users/$userId'),
);
if (response.statusCode == 200) {
return User.fromJson(jsonDecode(response.body));
} else {
throw Exception('사용자를 찾을 수 없습니다');
}
} catch (e) {
throw Exception('네트워크 오류: $e');
}
}
@override
Future<void> updateUser(User user) async {
// 업데이트 로직 구현
final response = await client.put(
Uri.parse('$baseUrl/users/${user.id}'),
body: jsonEncode({'name': user.name, 'email': user.email}),
);
if (response.statusCode != 200) {
throw Exception('업데이트 실패');
}
}
}
설명
이것이 하는 일: Repository 패턴은 인터페이스와 구현을 분리하여 데이터 액세스 로직을 캡슐화하고, ViewModel이 테스트 가능하고 유연한 구조를 갖게 합니다. 첫 번째로, UserRepository 추상 클래스는 데이터 액세스 계약(contract)을 정의합니다.
getUser와 updateUser 메서드의 시그니처만 선언하고 구현은 하지 않습니다. 이렇게 하는 이유는 ViewModel이 이 인터페이스에만 의존하게 하여, 나중에 구현을 교체하거나 Mock 객체로 대체할 수 있게 하기 위함입니다.
그 다음으로, UserRepositoryImpl은 실제 HTTP 통신을 구현합니다. http.Client를 생성자에서 주입받아 테스트 시 Mock Client를 사용할 수 있게 합니다.
getUser 메서드는 API를 호출하고, 응답 상태 코드를 확인하여 성공 시 User 객체를 반환하고 실패 시 의미 있는 예외를 던집니다. 마지막으로, try-catch 블록으로 네트워크 오류를 안전하게 처리합니다.
ViewModel은 Repository가 던진 예외를 받아서 사용자에게 적절한 에러 메시지를 표시할 수 있습니다. 이렇게 에러 처리 책임도 명확히 분리됩니다.
여러분이 이 코드를 사용하면 ViewModel에서 HTTP 로직이 완전히 사라지고, 단순히 repository.getUser()만 호출하면 됩니다. 또한 테스트 시 UserRepository를 Mock으로 교체하여 네트워크 없이도 ViewModel을 테스트할 수 있으며, API 엔드포인트가 변경되어도 Repository만 수정하면 되고, 나중에 로컬 DB를 추가하더라도 Repository 구현만 변경하면 ViewModel은 전혀 영향을 받지 않습니다.
실전 팁
💡 Repository는 항상 추상 클래스나 인터페이스로 정의하여 의존성 역전 원칙을 따르세요. ViewModel은 구체 클래스가 아닌 추상화에 의존해야 합니다.
💡 테스트를 위해 MockUserRepository를 만들 때 Mockito나 Mocktail 같은 Mock 라이브러리를 활용하세요.
💡 Repository에서 캐싱 전략을 구현하면 불필요한 API 호출을 줄여 성능을 크게 향상시킬 수 있습니다.
💡 에러 처리는 Repository에서 일관되게 처리하고, 비즈니스 로직에 맞는 커스텀 Exception을 정의하세요.
💡 여러 데이터 소스를 조합해야 한다면 Repository 내부에서 조합 로직을 처리하여 ViewModel을 단순하게 유지하세요.
5. ViewModel에서 Repository 사용하기
시작하며
여러분이 Repository를 만들었는데 ViewModel에서 어떻게 효과적으로 사용해야 할지 고민해본 적이 있나요? 예를 들어, Repository를 직접 생성하다 보니 테스트할 때 Mock으로 교체하기 어렵고, 여러 ViewModel에서 동일한 Repository 인스턴스를 공유해야 하는데 어떻게 관리해야 할지 막막한 상황입니다.
이런 문제는 의존성 주입(Dependency Injection)을 제대로 이해하지 못했을 때 발생합니다. ViewModel 내부에서 Repository를 직접 생성(new)하면 결합도가 높아져서 테스트가 어렵고, 설정을 변경하기도 힘듭니다.
또한 메모리 효율성 측면에서도 불필요하게 여러 인스턴스가 생성될 수 있습니다. 바로 이럴 때 필요한 것이 생성자 주입 방식의 의존성 주입입니다.
ViewModel은 생성자를 통해 Repository를 받아서 사용하기만 하고, 실제 인스턴스는 외부에서 제공받는 방식입니다.
개요
간단히 말해서, 의존성 주입은 객체가 필요한 의존성을 스스로 생성하지 않고 외부로부터 받는 디자인 패턴입니다. 의존성 주입이 필요한 이유는 코드의 결합도를 낮추고 테스트 가능성을 높이기 때문입니다.
ViewModel이 Repository의 구체적인 생성 방법을 모르고 인터페이스만 알면, 프로덕션에서는 실제 Repository를, 테스트에서는 Mock Repository를 주입할 수 있습니다. 예를 들어, 전자상거래 앱에서 ProductViewModel은 ProductRepository를 생성자로 받아서 사용하고, 단위 테스트에서는 가짜 상품 데이터를 반환하는 MockProductRepository를 주입합니다.
기존에는 ViewModel 내부에서 repository = UserRepositoryImpl()처럼 직접 생성했다면, 이제는 생성자 매개변수로 받아서 유연성을 확보합니다. 의존성 주입의 핵심 특징은 첫째, 제어의 역전(IoC)으로 객체 생성 책임이 외부로 이동합니다.
둘째, 느슨한 결합으로 구현을 쉽게 교체할 수 있습니다. 셋째, 테스트 용이성으로 Mock 객체를 자유롭게 주입할 수 있습니다.
이러한 특징들이 중요한 이유는 SOLID 원칙을 따르는 깔끔한 아키텍처를 구축할 수 있기 때문입니다.
코드 예제
import 'package:flutter/foundation.dart';
// ViewModel with Dependency Injection
class UserViewModel extends ChangeNotifier {
final UserRepository _repository;
User? _user;
bool _isLoading = false;
String? _errorMessage;
// 생성자를 통한 의존성 주입
UserViewModel({required UserRepository repository})
: _repository = repository;
User? get user => _user;
bool get isLoading => _isLoading;
String? get errorMessage => _errorMessage;
Future<void> loadUser(String userId) async {
_isLoading = true;
_errorMessage = null;
notifyListeners();
try {
// Repository를 통한 데이터 로드
_user = await _repository.getUser(userId);
} catch (e) {
_errorMessage = e.toString();
} finally {
_isLoading = false;
notifyListeners();
}
}
Future<void> updateUserName(String newName) async {
if (_user == null) return;
try {
final updatedUser = User(
id: _user!.id,
name: newName,
email: _user!.email,
);
await _repository.updateUser(updatedUser);
_user = updatedUser;
notifyListeners();
} catch (e) {
_errorMessage = '업데이트 실패: $e';
notifyListeners();
}
}
}
설명
이것이 하는 일: 이 ViewModel은 생성자 주입을 통해 Repository에 의존하며, 모든 데이터 액세스를 Repository에 위임하여 단일 책임 원칙을 따릅니다. 첫 번째로, 생성자에서 required UserRepository repository 매개변수를 받아 private 필드 _repository에 저장합니다.
타입을 추상 클래스 UserRepository로 선언하여 구체적인 구현에 의존하지 않습니다. 이렇게 하는 이유는 나중에 다른 구현체(예: 캐싱 Repository, Mock Repository)로 쉽게 교체할 수 있게 하기 위함입니다.
그 다음으로, loadUser 메서드는 단순히 _repository.getUser()를 호출하기만 합니다. HTTP 요청 구성, JSON 파싱, 에러 매핑 등의 복잡한 로직은 Repository가 담당하므로 ViewModel 코드가 매우 깔끔해집니다.
ViewModel은 오직 비즈니스 로직(로딩 상태 관리, UI 상태 업데이트)에만 집중합니다. 마지막으로, updateUserName 메서드는 불변 객체 패턴을 사용하여 새로운 User 인스턴스를 생성한 후 Repository를 통해 업데이트합니다.
업데이트 성공 시에만 로컬 상태를 변경하여 데이터 일관성을 보장합니다. 여러분이 이 코드를 사용하면 ViewModel을 단위 테스트할 때 실제 네트워크 없이 테스트할 수 있습니다.
MockUserRepository를 만들어서 주입하면 되기 때문입니다. 또한 Repository의 구현이 변경되어도 ViewModel 코드는 전혀 수정할 필요가 없으며, 여러 ViewModel이 동일한 Repository 인스턴스를 공유하여 메모리를 효율적으로 사용할 수 있고, 코드의 가독성이 높아져서 새로운 팀원도 빠르게 이해할 수 있습니다.
실전 팁
💡 Provider와 함께 사용할 때는 ProxyProvider나 Provider의 create에서 Repository를 주입하세요. 예: Provider(create: (context) => UserViewModel(repository: context.read<UserRepository>()))
💡 여러 Repository를 사용하는 경우 각각 생성자 매개변수로 받아서 명시적으로 의존성을 표현하세요.
💡 get_it 같은 서비스 로케이터 패키지를 사용하면 복잡한 의존성 그래프를 쉽게 관리할 수 있습니다.
💡 ViewModel 테스트 시 setUp() 메서드에서 MockRepository를 생성하고 모든 테스트에서 재사용하세요.
💡 Repository가 null일 수 있는 경우는 거의 없으므로 nullable 타입 대신 required로 강제하여 null 체크를 줄이세요.
6. 상태 관리를 위한 Enum 활용
시작하며
여러분이 ViewModel에서 로딩, 에러, 성공 상태를 bool 변수 여러 개로 관리하다가 서로 모순된 상태에 빠진 경험이 있나요? 예를 들어, isLoading이 true인데 errorMessage도 null이 아닌 상황처럼, 논리적으로 불가능한 상태 조합이 발생하는 경우입니다.
이런 문제는 상태를 독립적인 변수들로 관리할 때 발생합니다. bool 타입의 isLoading, isError, isSuccess를 따로 관리하면 16가지(2^4) 조합이 가능하지만 실제로 유효한 상태는 4-5가지뿐입니다.
이렇게 되면 버그가 생기기 쉽고, 새로운 상태를 추가할 때도 모든 조건문을 수정해야 합니다. 바로 이럴 때 필요한 것이 Enum을 사용한 상태 관리입니다.
Enum으로 상태를 정의하면 한 번에 하나의 상태만 가질 수 있어 모순된 상태를 원천적으로 차단할 수 있습니다.
개요
간단히 말해서, Enum(열거형)은 가능한 값들의 집합을 미리 정의하여 타입 안정성과 코드 명확성을 높이는 Dart의 기능입니다. Enum으로 상태를 관리하는 것이 필요한 이유는 상태 전이(State Transition)를 명확히 하고, 불가능한 상태 조합을 컴파일 타임에 방지하기 때문입니다.
하나의 Enum 변수로 idle, loading, success, error 상태를 표현하면 동시에 loading과 error 상태가 될 수 없습니다. 예를 들어, 결제 화면에서 '결제 대기', '처리 중', '완료', '실패' 상태를 Enum으로 관리하면 각 상태에 대한 UI를 정확히 매핑할 수 있습니다.
기존에는 bool isLoading, bool hasError, String? errorMessage 같은 여러 변수를 조합했다면, 이제는 LoadState state 하나로 모든 상태를 표현할 수 있습니다.
Enum 상태 관리의 핵심 특징은 첫째, 상호 배타적 상태로 한 번에 하나의 상태만 가집니다. 둘째, 패턴 매칭으로 switch 문에서 모든 경우를 강제하여 빠진 케이스를 컴파일러가 잡아줍니다.
셋째, 가독성 향상으로 if (state == LoadState.loading)은 if (isLoading && !hasError)보다 명확합니다. 이러한 특징들이 중요한 이유는 상태 관리 버그를 줄이고 코드의 안정성을 높이기 때문입니다.
코드 예제
// 상태를 나타내는 Enum
enum LoadState {
idle, // 초기 상태
loading, // 로딩 중
success, // 성공
error // 에러
}
// Enum을 사용한 ViewModel
class UserViewModel extends ChangeNotifier {
final UserRepository _repository;
User? _user;
LoadState _state = LoadState.idle;
String? _errorMessage;
UserViewModel({required UserRepository repository})
: _repository = repository;
User? get user => _user;
LoadState get state => _state;
String? get errorMessage => _errorMessage;
// 편의 getter들
bool get isLoading => _state == LoadState.loading;
bool get hasError => _state == LoadState.error;
Future<void> loadUser(String userId) async {
_state = LoadState.loading;
_errorMessage = null;
notifyListeners();
try {
_user = await _repository.getUser(userId);
_state = LoadState.success;
} catch (e) {
_state = LoadState.error;
_errorMessage = e.toString();
}
notifyListeners();
}
}
설명
이것이 하는 일: LoadState Enum은 ViewModel이 가질 수 있는 모든 상태를 명시적으로 정의하여 상태 관리를 단순하고 안전하게 만듭니다. 첫 번째로, LoadState Enum은 네 가지 상태를 정의합니다.
idle은 아직 데이터를 로드하지 않은 초기 상태, loading은 현재 데이터를 가져오는 중, success는 데이터 로드 완료, error는 실패 상태를 의미합니다. 이렇게 하는 이유는 앱의 모든 가능한 상태를 한눈에 파악할 수 있고, 새로운 상태를 추가할 때도 Enum에 항목 하나만 추가하면 되기 때문입니다.
그 다음으로, ViewModel에서 _state 하나로 모든 상태를 관리합니다. loadUser 메서드를 보면 작업 시작 시 LoadState.loading으로 설정하고, 성공하면 LoadState.success, 실패하면 LoadState.error로 변경합니다.
각 시점에 명확히 하나의 상태만 가지므로 "로딩 중인데 에러도 있는" 같은 모순된 상황이 발생하지 않습니다. 마지막으로, 편의를 위해 isLoading, hasError 같은 getter를 제공합니다.
이렇게 하면 View에서 간단한 조건 체크는 쉽게 할 수 있으면서도, 복잡한 상태 분기는 switch 문으로 안전하게 처리할 수 있습니다. 여러분이 이 코드를 사용하면 상태 관리 버그가 크게 줄어들고, View에서 switch (_viewModel.state)로 모든 경우를 빠짐없이 처리할 수 있습니다.
컴파일러가 모든 case를 강제하므로 새로운 상태를 추가해도 어디를 수정해야 할지 명확하며, 코드 리뷰 시 상태 전이 로직을 쉽게 파악할 수 있고, 디버깅할 때도 현재 상태를 로그로 찍으면 문제를 빠르게 찾을 수 있습니다.
실전 팁
💡 더 복잡한 상태가 필요하다면 sealed class와 패턴 매칭을 사용하여 각 상태에 데이터를 포함시킬 수 있습니다. 예: sealed class LoadState { class Success<T> extends LoadState { final T data; } }
💡 Enum에 extension을 추가하여 각 상태에 대한 헬퍼 메서드를 구현할 수 있습니다. 예: extension LoadStateExt on LoadState { bool get canRetry => this == LoadState.error; }
💡 여러 비동기 작업을 동시에 관리해야 한다면 각각 별도의 상태 변수를 가지는 것보다 Map<String, LoadState>를 사용하세요.
💡 상태 변경 로직을 별도의 메서드로 분리하면 상태 전이를 추적하기 쉽습니다. 예: void _setState(LoadState newState) { _state = newState; notifyListeners(); }
💡 freezed 패키지를 사용하면 상태와 데이터를 함께 관리하는 불변 객체를 쉽게 만들 수 있습니다.
7. Sealed Class로 고급 상태 관리
시작하며
여러분이 Enum으로 상태를 관리하다가 각 상태마다 다른 데이터를 함께 저장해야 하는 상황에 직면한 적이 있나요? 예를 들어, Success 상태일 때는 실제 데이터를, Error 상태일 때는 에러 메시지와 에러 코드를, Loading 상태일 때는 진행률을 함께 저장해야 하는데 Enum만으로는 불가능한 경우입니다.
이런 문제는 Enum이 단순히 값만 표현할 뿐 추가 데이터를 가질 수 없기 때문에 발생합니다. 결국 Enum과 별도의 변수들을 조합하게 되면서 다시 상태 관리가 복잡해지고, 타입 안정성도 떨어집니다.
또한 Success 상태에서 errorMessage를 참조하는 실수를 컴파일러가 잡아주지 못합니다. 바로 이럴 때 필요한 것이 Dart 3.0의 Sealed Class입니다.
Sealed Class는 각 상태를 독립적인 클래스로 정의하면서도 패턴 매칭을 통해 타입 안전하게 처리할 수 있게 해줍니다.
개요
간단히 말해서, Sealed Class는 제한된 클래스 계층 구조를 만들어 모든 하위 타입이 동일 파일에 정의되어야 하며, 패턴 매칭 시 모든 경우를 강제하는 Dart 3.0의 기능입니다. Sealed Class가 필요한 이유는 상태와 그에 관련된 데이터를 하나의 타입으로 캡슐화하여 타입 안정성을 극대화하기 때문입니다.
각 상태가 독립적인 클래스이므로 Success 상태는 User 데이터를, Error 상태는 Exception을 가질 수 있습니다. 예를 들어, 파일 다운로드 기능에서 Idle(준비), Downloading(진행률), Completed(파일 경로), Failed(에러) 상태를 각각 필요한 데이터와 함께 정의할 수 있습니다.
기존에는 Enum + 여러 nullable 변수를 조합했다면, 이제는 하나의 Sealed Class 변수로 모든 상태와 데이터를 타입 안전하게 관리할 수 있습니다. Sealed Class의 핵심 특징은 첫째, 완전한 패턴 매칭으로 switch 표현식에서 모든 하위 타입을 처리하지 않으면 컴파일 에러가 발생합니다.
둘째, 타입 안전성으로 각 상태에서만 의미 있는 데이터에 접근할 수 있습니다. 셋째, 불변성과 데이터 클래스의 장점을 모두 가집니다.
이러한 특징들이 중요한 이유는 런타임 에러를 컴파일 타임으로 앞당겨 앱의 안정성을 크게 높이기 때문입니다.
코드 예제
// Sealed Class로 상태 정의
sealed class UserState {}
class UserInitial extends UserState {}
class UserLoading extends UserState {
final double? progress;
UserLoading({this.progress});
}
class UserSuccess extends UserState {
final User user;
UserSuccess(this.user);
}
class UserError extends UserState {
final String message;
final Exception? exception;
UserError(this.message, {this.exception});
}
// ViewModel with Sealed Class
class UserViewModel extends ChangeNotifier {
final UserRepository _repository;
UserState _state = UserInitial();
UserViewModel({required UserRepository repository})
: _repository = repository;
UserState get state => _state;
Future<void> loadUser(String userId) async {
_state = UserLoading();
notifyListeners();
try {
final user = await _repository.getUser(userId);
_state = UserSuccess(user);
} catch (e, stackTrace) {
_state = UserError(
'사용자 정보를 불러올 수 없습니다',
exception: e as Exception,
);
print('Error: $e\n$stackTrace');
}
notifyListeners();
}
}
설명
이것이 하는 일: Sealed Class는 상태 패턴을 타입 시스템 레벨에서 구현하여 각 상태가 필요한 데이터를 캡슐화하고, 모든 경우를 처리하도록 강제합니다. 첫 번째로, sealed class UserState {}는 기본 클래스로, 이 파일에서만 상속 가능합니다.
네 가지 구체적인 상태 클래스(UserInitial, UserLoading, UserSuccess, UserError)가 각각 필요한 데이터를 필드로 가집니다. 예를 들어 UserSuccess는 User 객체를, UserError는 메시지와 예외 객체를 가지므로 각 상태에서 필요한 정보를 정확히 표현합니다.
그 다음으로, ViewModel에서 _state의 타입은 UserState이지만 실제로는 네 가지 구체 타입 중 하나입니다. loadUser 메서드를 보면 작업 단계에 따라 적절한 상태 객체를 생성하여 할당합니다.
UserLoading()은 매개변수 없이, UserSuccess는 User 객체와 함께, UserError는 메시지와 예외와 함께 생성됩니다. 마지막으로, View에서는 switch 표현식으로 안전하게 상태를 처리할 수 있습니다.
예를 들어 switch (state) { UserInitial() => Text('대기 중'), UserLoading() => CircularProgressIndicator(), UserSuccess(:final user) => Text(user.name), UserError(:final message) => Text(message) }처럼 패턴 매칭으로 각 타입을 구별하고 데이터를 추출합니다. 여러분이 이 코드를 사용하면 각 상태에서만 의미 있는 데이터에만 접근할 수 있어 null 체크가 불필요하고, 새로운 상태를 추가하면 컴파일러가 모든 switch 문에서 해당 케이스를 처리하도록 강제하며, 타입 추론이 강력하게 작동하여 명시적 캐스팅이 필요 없고, 코드의 의도가 명확하여 유지보수가 쉬워집니다.
실전 팁
💡 freezed 패키지를 사용하면 Sealed Class와 copyWith, equality 비교 등을 자동으로 생성할 수 있어 보일러플레이트를 크게 줄일 수 있습니다.
💡 패턴 매칭에서 변수 추출 구문(:final user)을 활용하면 더 간결한 코드를 작성할 수 있습니다.
💡 공통 동작이 필요하다면 Sealed Class에 추상 메서드를 정의하고 각 하위 클래스에서 구현하세요.
💡 복잡한 상태 전이가 있다면 상태 기계(State Machine) 패턴과 결합하여 허용된 전이만 가능하도록 제한할 수 있습니다.
💡 제네릭을 사용하여 재사용 가능한 상태 클래스를 만들 수 있습니다. 예: sealed class DataState<T> { class Loading<T> extends DataState<T> {} class Success<T> extends DataState<T> { final T data; } }
8. 폼 입력과 유효성 검사
시작하며
여러분이 로그인이나 회원가입 폼을 만들 때 입력값 검증 로직이 Widget 코드와 뒤섞여서 지저분해진 경험이 있나요? 예를 들어, 이메일 형식 체크, 비밀번호 길이 검증, 실시간 에러 메시지 표시 등의 로직이 TextField의 onChanged 콜백 안에 모두 들어가 있어서 코드를 읽기 어려운 상황입니다.
이런 문제는 폼 상태와 검증 로직이 View에 밀집되어 있을 때 발생합니다. Widget은 UI 렌더링에만 집중해야 하는데 비즈니스 로직까지 담당하게 되면 테스트도 어렵고, 동일한 검증 로직을 다른 화면에서 재사용하기도 힘듭니다.
또한 여러 필드 간의 의존 관계(예: 비밀번호 확인)를 관리하기도 복잡해집니다. 바로 이럴 때 필요한 것이 ViewModel에서 폼 상태를 관리하는 패턴입니다.
모든 입력값과 검증 로직을 ViewModel에 두고, View는 단순히 데이터를 바인딩하기만 하면 됩니다.
개요
간단히 말해서, 폼 관리 ViewModel은 사용자 입력을 저장하고, 실시간으로 유효성을 검사하며, 제출 가능 여부를 판단하는 책임을 가집니다. 폼 관리를 ViewModel에서 하는 것이 필요한 이유는 검증 로직을 재사용 가능하고 테스트 가능한 형태로 캡슐화하기 때문입니다.
ViewModel은 각 필드의 값과 에러 메시지를 상태로 관리하며, 입력이 변경될 때마다 검증을 수행합니다. 예를 들어, 회원가입 폼에서 이메일, 비밀번호, 비밀번호 확인, 이름 필드를 모두 검증하고, 모든 필드가 유효할 때만 회원가입 버튼을 활성화할 수 있습니다.
기존에는 각 TextField마다 validator를 작성하고 GlobalKey<FormState>를 사용했다면, 이제는 ViewModel의 메서드를 호출하여 더 유연하고 강력한 검증을 수행할 수 있습니다. 폼 관리 ViewModel의 핵심 특징은 첫째, 반응형 검증으로 입력이 변경되면 즉시 에러를 표시하거나 숨깁니다.
둘째, 중앙 집중식 상태 관리로 모든 필드 상태를 한 곳에서 파악할 수 있습니다. 셋째, 제출 가능 여부를 자동으로 계산하여 UI 로직을 단순화합니다.
이러한 특징들이 중요한 이유는 사용자 경험을 개선하고 개발 생산성을 높이기 때문입니다.
코드 예제
import 'package:flutter/foundation.dart';
// 폼 관리 ViewModel
class LoginViewModel extends ChangeNotifier {
String _email = '';
String _password = '';
String? _emailError;
String? _passwordError;
bool _isSubmitting = false;
String get email => _email;
String get password => _password;
String? get emailError => _emailError;
String? get passwordError => _passwordError;
bool get isSubmitting => _isSubmitting;
// 모든 필드가 유효한지 확인
bool get isValid =>
_email.isNotEmpty &&
_password.isNotEmpty &&
_emailError == null &&
_passwordError == null;
// 이메일 변경 및 검증
void updateEmail(String value) {
_email = value;
_validateEmail();
notifyListeners();
}
void _validateEmail() {
if (_email.isEmpty) {
_emailError = '이메일을 입력하세요';
} else if (!_email.contains('@')) {
_emailError = '올바른 이메일 형식이 아닙니다';
} else {
_emailError = null;
}
}
// 비밀번호 변경 및 검증
void updatePassword(String value) {
_password = value;
_validatePassword();
notifyListeners();
}
void _validatePassword() {
if (_password.isEmpty) {
_passwordError = '비밀번호를 입력하세요';
} else if (_password.length < 6) {
_passwordError = '비밀번호는 최소 6자 이상이어야 합니다';
} else {
_passwordError = null;
}
}
// 로그인 제출
Future<bool> submit() async {
if (!isValid) return false;
_isSubmitting = true;
notifyListeners();
try {
// 로그인 API 호출
await Future.delayed(Duration(seconds: 2));
return true;
} finally {
_isSubmitting = false;
notifyListeners();
}
}
}
설명
이것이 하는 일: 이 LoginViewModel은 로그인 폼의 모든 상태와 로직을 캡슐화하여 View가 단순히 데이터를 표시하고 사용자 입력을 전달하는 역할만 하도록 합니다. 첫 번째로, 각 필드마다 값(_email, _password)과 에러 메시지(_emailError, _passwordError)를 별도로 관리합니다.
이렇게 분리하는 이유는 사용자가 입력하는 동안 실시간으로 에러를 표시하거나 숨길 수 있기 때문입니다. isValid getter는 모든 조건을 확인하여 제출 버튼의 활성화 여부를 결정합니다.
그 다음으로, updateEmail과 updatePassword 메서드는 입력값을 저장한 후 즉시 검증 메서드를 호출합니다. _validateEmail은 빈 값 체크와 간단한 형식 검증을 수행하며, 유효하지 않으면 적절한 에러 메시지를 설정합니다.
이렇게 private 검증 메서드로 분리하면 로직을 재사용하고 테스트하기 쉬워집니다. 마지막으로, submit 메서드는 최종 제출 전에 isValid를 한 번 더 확인하고, 제출 중에는 _isSubmitting을 true로 설정하여 View가 로딩 인디케이터를 표시하거나 버튼을 비활성화할 수 있게 합니다.
finally 블록으로 항상 상태를 원복하여 다음 시도가 가능하도록 합니다. 여러분이 이 코드를 사용하면 View의 TextField에서는 onChanged: viewModel.updateEmail만 연결하면 되므로 코드가 매우 간결해지고, 검증 로직을 단위 테스트로 쉽게 검증할 수 있으며, 동일한 검증 로직을 다른 화면(예: 회원가입)에서도 재사용할 수 있고, 폼의 복잡도가 증가해도 ViewModel만 수정하면 되므로 유지보수가 쉬워집니다.
실전 팁
💡 정규표현식을 사용하여 더 정교한 검증을 구현하세요. 예: RegExp(r'^[\w-.]+@([\w-]+.)+[\w-]{2,4}$').hasMatch(email)
💡 debounce를 적용하여 사용자가 타이핑을 멈춘 후에만 검증하면 불필요한 검증 호출을 줄일 수 있습니다.
💡 복잡한 폼은 별도의 FormField 클래스를 만들어 값, 에러, 검증 로직을 캡슐화하세요.
💡 서버 사이드 검증(예: 중복 이메일 체크)은 비동기 검증으로 구현하고 로딩 상태를 표시하세요.
💡 FormBuilder나 reactive_forms 같은 패키지를 사용하면 더 선언적인 폼 관리가 가능합니다.
9. 리스트 데이터 관리와 무한 스크롤
시작하며
여러분이 뉴스 피드나 상품 목록 같은 긴 리스트를 구현할 때 페이지네이션 로직과 스크롤 감지 코드가 복잡하게 얽혀있던 경험이 있나요? 예를 들어, 스크롤이 끝에 도달했을 때 다음 페이지를 로드하고, 로딩 중에는 중복 요청을 방지하며, 에러 발생 시 재시도 버튼을 보여주는 등의 로직을 Widget에 모두 작성해야 하는 상황입니다.
이런 문제는 리스트 상태 관리가 복잡하기 때문에 발생합니다. 현재 페이지 번호, 로딩 상태, 에러 상태, 전체 데이터 리스트, 더 불러올 데이터가 있는지 여부 등을 추적해야 하고, ScrollController를 사용한 스크롤 감지 로직도 필요합니다.
이 모든 것이 Widget에 들어가면 코드가 매우 복잡해집니다. 바로 이럴 때 필요한 것이 페이지네이션 전용 ViewModel입니다.
리스트 데이터와 페이지네이션 로직을 ViewModel로 분리하면 무한 스크롤 구현이 훨씬 간단해집니다.
개요
간단히 말해서, 페이지네이션 ViewModel은 리스트 데이터를 청크 단위로 로드하고, 추가 로딩을 관리하며, 에러 처리와 재시도 로직을 제공합니다. 페이지네이션 ViewModel이 필요한 이유는 대량의 데이터를 효율적으로 로드하고, 사용자 경험을 개선하기 위한 복잡한 로직을 캡슐화하기 때문입니다.
초기 로드, 추가 로드, 새로고침, 에러 처리 등 다양한 시나리오를 일관되게 관리할 수 있습니다. 예를 들어, 소셜 미디어 앱의 피드에서 처음 20개 게시물을 로드하고, 스크롤하면서 계속 추가로 불러오며, 당겨서 새로고침도 지원하는 기능을 구현할 수 있습니다.
기존에는 ScrollController에 리스너를 추가하고 수동으로 페이지 번호를 관리했다면, 이제는 ViewModel의 loadMore() 메서드만 호출하면 됩니다. 페이지네이션 ViewModel의 핵심 특징은 첫째, 점진적 로딩으로 필요한 만큼만 데이터를 가져와 메모리와 네트워크를 절약합니다.
둘째, 중복 요청 방지로 동시에 여러 로드 요청이 발생하지 않도록 합니다. 셋째, 세분화된 상태 관리로 초기 로딩, 추가 로딩, 에러를 구별합니다.
이러한 특징들이 중요한 이유는 대규모 데이터를 다루는 앱에서 필수적이기 때문입니다.
코드 예제
import 'package:flutter/foundation.dart';
// 페이지네이션 ViewModel
class NewsListViewModel extends ChangeNotifier {
final NewsRepository _repository;
List<News> _items = [];
bool _isLoading = false;
bool _isLoadingMore = false;
String? _error;
int _currentPage = 1;
bool _hasMore = true;
NewsListViewModel({required NewsRepository repository})
: _repository = repository;
List<News> get items => _items;
bool get isLoading => _isLoading;
bool get isLoadingMore => _isLoadingMore;
String? get error => _error;
bool get hasMore => _hasMore;
// 초기 로드
Future<void> loadInitial() async {
if (_isLoading) return;
_isLoading = true;
_error = null;
_currentPage = 1;
notifyListeners();
try {
_items = await _repository.getNews(page: _currentPage);
_hasMore = _items.length >= 20; // 한 페이지당 20개
} catch (e) {
_error = e.toString();
_items = [];
} finally {
_isLoading = false;
notifyListeners();
}
}
// 추가 로드 (무한 스크롤)
Future<void> loadMore() async {
if (_isLoadingMore || !_hasMore || _isLoading) return;
_isLoadingMore = true;
notifyListeners();
try {
_currentPage++;
final newItems = await _repository.getNews(page: _currentPage);
_items.addAll(newItems);
_hasMore = newItems.length >= 20;
} catch (e) {
_error = e.toString();
_currentPage--; // 실패 시 페이지 복원
} finally {
_isLoadingMore = false;
notifyListeners();
}
}
// 새로고침
Future<void> refresh() async {
_items.clear();
await loadInitial();
}
}
설명
이것이 하는 일: 이 NewsListViewModel은 뉴스 리스트의 페이지네이션 로직을 완전히 캡슐화하여 View가 단순히 리스트를 표시하고 스크롤 끝에서 loadMore()를 호출하기만 하면 되도록 합니다. 첫 번째로, _isLoading과 _isLoadingMore를 분리하여 초기 로딩과 추가 로딩을 구별합니다.
이렇게 하는 이유는 View에서 다른 UI를 표시하기 위함입니다. 초기 로딩에는 전체 화면 로딩 인디케이터를, 추가 로딩에는 리스트 하단의 작은 인디케이터를 표시합니다.
_hasMore 플래그는 더 이상 불러올 데이터가 없을 때 불필요한 API 호출을 방지합니다. 그 다음으로, loadInitial은 첫 페이지를 로드하고 기존 데이터를 모두 지웁니다.
메서드 시작 시 _isLoading 체크로 중복 호출을 방지하며, _currentPage를 1로 초기화합니다. loadMore는 다음 페이지를 로드하여 기존 리스트에 추가하며, 실패 시 페이지 번호를 복원하여 재시도가 가능하도록 합니다.
마지막으로, refresh 메서드는 당겨서 새로고침(pull-to-refresh) 기능을 위한 것으로, 기존 데이터를 지우고 처음부터 다시 로드합니다. 이렇게 명확히 분리된 메서드로 각 시나리오를 쉽게 처리할 수 있습니다.
여러분이 이 코드를 사용하면 ListView.builder에서 itemCount와 itemBuilder만 설정하고, 스크롤 끝에 도달했을 때 viewModel.loadMore()를 호출하면 자동으로 다음 페이지가 로드되며, RefreshIndicator를 추가하여 간단히 새로고침 기능을 구현할 수 있고, 모든 페이지네이션 로직이 테스트 가능한 형태로 분리되어 있어 안정적인 무한 스크롤을 구현할 수 있습니다.
실전 팁
💡 NotificationListener<ScrollNotification>을 사용하여 스크롤이 80% 지점에 도달하면 자동으로 loadMore()를 호출하여 더 부드러운 UX를 제공하세요.
💡 에러 발생 시 재시도 로직을 추가하고, 지수 백오프(exponential backoff)를 적용하여 서버 부하를 줄이세요.
💡 로컬 캐싱을 구현하여 이미 로드한 데이터를 재사용하고, 네트워크 없이도 데이터를 표시할 수 있게 하세요.
💡 infinite_scroll_pagination 패키지를 사용하면 페이지네이션 로직을 더 쉽게 구현할 수 있습니다.
💡 성능 최적화를 위해 ListView.builder 대신 ListView.separated나 CustomScrollView를 사용하세요.
10. 에러 처리와 사용자 피드백
시작하며
여러분이 ViewModel에서 에러를 처리할 때 스낵바나 다이얼로그를 직접 표시하려다가 BuildContext 문제로 막힌 경험이 있나요? 예를 들어, API 호출이 실패했을 때 사용자에게 에러 메시지를 보여주고 싶은데, ViewModel에서는 BuildContext에 접근할 수 없어서 어떻게 해야 할지 모르는 상황입니다.
이런 문제는 ViewModel이 UI 레이어에 의존하려고 할 때 발생합니다. MVVM 패턴의 핵심 원칙 중 하나는 ViewModel이 UI를 전혀 모르는 것인데, 직접 다이얼로그를 띄우려 하면 이 원칙이 깨집니다.
또한 ViewModel을 단위 테스트할 수 없게 되고, 재사용성도 떨어집니다. 바로 이럴 때 필요한 것이 이벤트 스트림 패턴입니다.
ViewModel은 에러를 상태로만 노출하고, View가 상태를 감지하여 적절한 UI 피드백을 표시하는 방식입니다.
개요
간단히 말해서, 이벤트 스트림 패턴은 ViewModel이 일회성 이벤트(에러, 성공 메시지, 네비게이션)를 Stream으로 발행하고, View가 이를 구독하여 적절한 UI 액션을 수행하는 방식입니다. 이벤트 스트림이 필요한 이유는 ViewModel과 View의 책임을 명확히 분리하면서도 일회성 UI 이벤트를 효과적으로 처리하기 위함입니다.
상태(State)는 지속적인 데이터(로딩 중, 사용자 정보)를 나타내지만, 이벤트(Event)는 한 번만 발생하는 액션(에러 알림, 성공 토스트, 화면 전환)을 나타냅니다. 예를 들어, 결제 완료 후 "결제가 완료되었습니다" 메시지를 한 번만 표시하고 주문 상세 화면으로 이동하는 경우, 이를 상태가 아닌 이벤트로 처리해야 합니다.
기존에는 ViewModel에서 직접 showDialog()를 호출하려고 했다면, 이제는 이벤트를 발행하고 View가 이를 감지하여 다이얼로그를 표시합니다. 이벤트 스트림 패턴의 핵심 특징은 첫째, UI 독립성으로 ViewModel이 BuildContext를 전혀 모릅니다.
둘째, 일회성 처리로 이벤트는 한 번만 소비되고 사라집니다. 셋째, 타입 안전성으로 sealed class로 이벤트 타입을 정의하여 모든 경우를 처리합니다.
이러한 특징들이 중요한 이유는 테스트 가능하고 유지보수하기 쉬운 코드를 만들기 때문입니다.
코드 예제
import 'dart:async';
import 'package:flutter/foundation.dart';
// 이벤트 타입 정의
sealed class UserEvent {}
class ShowError extends UserEvent {
final String message;
ShowError(this.message);
}
class ShowSuccess extends UserEvent {
final String message;
ShowSuccess(this.message);
}
class NavigateToHome extends UserEvent {}
// 이벤트 스트림을 가진 ViewModel
class UserViewModel extends ChangeNotifier {
final UserRepository _repository;
final StreamController<UserEvent> _eventController =
StreamController<UserEvent>.broadcast();
User? _user;
bool _isLoading = false;
UserViewModel({required UserRepository repository})
: _repository = repository;
User? get user => _user;
bool get isLoading => _isLoading;
Stream<UserEvent> get events => _eventController.stream;
Future<void> login(String email, String password) async {
_isLoading = true;
notifyListeners();
try {
_user = await _repository.login(email, password);
_eventController.add(ShowSuccess('로그인 성공!'));
_eventController.add(NavigateToHome());
} catch (e) {
_eventController.add(ShowError('로그인 실패: ${e.toString()}'));
} finally {
_isLoading = false;
notifyListeners();
}
}
@override
void dispose() {
_eventController.close();
super.dispose();
}
}
설명
이것이 하는 일: 이 패턴은 ViewModel이 UI를 모르면서도 일회성 이벤트를 View에 전달하여, View가 적절한 시점에 적절한 UI 피드백을 표시할 수 있게 합니다. 첫 번째로, sealed class UserEvent로 가능한 모든 이벤트 타입을 정의합니다.
ShowError는 에러 메시지를, ShowSuccess는 성공 메시지를, NavigateToHome은 화면 전환을 의미합니다. 이렇게 타입으로 정의하는 이유는 View에서 패턴 매칭으로 안전하게 처리할 수 있기 때문입니다.
그 다음으로, StreamController<UserEvent>를 사용하여 이벤트 스트림을 관리합니다. broadcast()로 생성하여 여러 리스너가 동시에 구독할 수 있게 합니다.
login 메서드에서 성공하면 두 개의 이벤트를 순차적으로 발행합니다. 먼저 성공 메시지를 보여주고, 그 다음 홈 화면으로 이동하는 이벤트를 발행합니다.
마지막으로, dispose()에서 _eventController.close()를 호출하여 리소스를 정리합니다. View에서는 StreamBuilder나 listen()으로 이벤트를 구독하고, switch 표현식으로 각 이벤트 타입에 따라 다른 액션을 수행합니다.
여러분이 이 코드를 사용하면 ViewModel을 단위 테스트할 때 이벤트 스트림을 구독하여 올바른 이벤트가 발행되는지 검증할 수 있고, View 코드는 UI 로직만 포함하여 명확해지며, 동일한 ViewModel을 다른 플랫폼(웹, 데스크톱)에서 재사용하면서 각 플랫폼에 맞는 UI 피드백을 제공할 수 있고, 에러 처리 로직이 중앙 집중화되어 일관된 사용자 경험을 제공할 수 있습니다.
실전 팁
💡 에러 이벤트에 에러 타입이나 코드를 포함시켜 View에서 다른 UI를 표시할 수 있게 하세요. 예: 네트워크 에러는 재시도 버튼, 인증 에러는 로그인 화면 이동
💡 Stream 대신 ValueNotifier<UserEvent?>를 사용하는 방법도 있지만, Stream이 더 명확하게 이벤트 의미를 전달합니다.
💡 복잡한 이벤트 처리가 필요하다면 bloc 패키지의 이벤트 시스템을 고려하세요.
💡 View에서 이벤트를 구독할 때는 반드시 dispose에서 구독을 취소하여 메모리 누수를 방지하세요.
💡 네비게이션 이벤트는 Navigator를 직접 호출하지 말고 GoRouter 같은 라우팅 패키지와 통합하세요.