Flutter 앱 구조가 복잡해질수록, 구조화를 제대로 하지 않으면 유지보수가 점점 지옥이 됩니다.
이번 글에서는 Riverpod + MVVM 패턴을 실무 스타일로 적용하는 방법을 개념 + 폴더 구조 + 실전 예시로 정리합니다.
| 구성요소 | 설명 |
|---|---|
| Model | 데이터 구조 정의 및 JSON 변환 (fromJson, toJson) |
| Repository | API 또는 Firebase와의 통신 담당 |
| ViewModel | UI 상태 관리 + 기능 처리 (비즈니스 로직) |
| Provider | ViewModel을 앱 전역에서 연결 |
| View(UI) | 화면 구성 (Scaffold, 위젯들) |
| Widgets | 재사용 가능한 UI 컴포넌트 |
| Utils | 날짜 포맷, 색상, 문자열 상수 등 공통 유틸 |
lib/
├── features/
│ └── post/ # 게시글 관련 기능
│ ├── post_page.dart # View - 화면 구성
│ ├── post_view_model.dart # ViewModel - 상태+로직
│ ├── post_model.dart # Model - 데이터 구조
│ ├── post_repository.dart # Repository - Firestore or API 통신
│ └── widgets/
│ └── post_item.dart # 재사용 UI 위젯
├── providers/
│ └── post_provider.dart # Provider - ViewModel 연결
├── utils/
│ ├── app_colors.dart # 공통 색상
│ └── formatters.dart # 포맷 유틸
└── main.dart # 앱 진입점
Model (post_model.dart)
class Post {
final String id;
final String title;
final String content;
Post({required this.id, required this.title, required this.content});
factory Post.fromJson(Map<String, dynamic> json) => Post(
id: json['id'],
title: json['title'],
content: json['content'],
);
Map<String, dynamic> toJson() => {
'id': id,
'title': title,
'content': content,
};
}
Repository (post_repository.dart)
import 'package:cloud_firestore/cloud_firestore.dart';
import 'post_model.dart';
class PostRepository {
final _firestore = FirebaseFirestore.instance;
Future<List<Post>> fetchPosts() async {
final snapshot = await _firestore.collection('posts').get();
return snapshot.docs.map((doc) => Post.fromJson(doc.data())).toList();
}
Future<void> addPost(Post post) async {
await _firestore.collection('posts').add(post.toJson());
}
}
ViewModel (post_view_model.dart)
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'post_repository.dart';
import 'post_model.dart';
class PostViewModel extends StateNotifier<AsyncValue<List<Post>>> {
final PostRepository repository;
PostViewModel(this.repository) : super(const AsyncLoading());
Future<void> loadPosts() async {
try {
final posts = await repository.fetchPosts();
state = AsyncValue.data(posts);
} catch (e, st) {
state = AsyncValue.error(e, st);
}
}
}
Provider (post_provider.dart)
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../features/post/post_view_model.dart';
import '../features/post/post_repository.dart';
final postRepositoryProvider = Provider((ref) => PostRepository());
final postProvider =
StateNotifierProvider<PostViewModel, AsyncValue<List<Post>>>(
(ref) => PostViewModel(ref.watch(postRepositoryProvider)));
View (post_page.dart)
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../providers/post_provider.dart';
class PostPage extends ConsumerWidget {
Widget build(BuildContext context, WidgetRef ref) {
final postState = ref.watch(postProvider);
return Scaffold(
appBar: AppBar(title: Text("게시글 목록")),
body: postState.when(
data: (posts) => ListView.builder(
itemCount: posts.length,
itemBuilder: (_, i) => ListTile(title: Text(posts[i].title)),
),
loading: () => Center(child: CircularProgressIndicator()),
error: (e, _) => Center(child: Text("오류 발생: $e")),
),
floatingActionButton: FloatingActionButton(
onPressed: () => ref.read(postProvider.notifier).loadPosts(),
child: Icon(Icons.refresh),
),
);
}
}