티스토리 뷰

오늘은 선언형 UI 개발에서 반필수적으로(?) 사용되는 아키텍처 패턴, MVVM에 대해 알아보겠습니다.
MVVM은 Model, View, ViewModel의 약자입니다.
이름만 들으면 괜히 어려워 보이고, 그래서인지 간단함과 쉬움을 강점으로 내세우는 플러터에서는 단순 MVVM보다 제 생각엔 과대 마케팅이라고 볼 수 있는 클린 아키텍처와 BLoC, Riverpod가 오히려 더 유행하게 된 이유 중 하나이기도 합니다
사실 MVVM 아키텍처 패턴은, 선언형 UI 개발에서 자연스럽게 사용되던 방식들을 체계적으로 정리한 것 뿐입니다. 기존 아키텍처의 관례적 구조에 맞춰 구조화한 뒤, 각 구성 요소를 나타내는 약자를 붙여 MVVM이라는 이름으로 부르게 된 것이죠. 일단 협업하려면 앱 내에서 일관적인 아키텍처 패턴을 사용해야 하거든요.
그래서 막상 입문자들도 어려울게 전혀 없다는거죠. 딱히 특별한게 아니니까요.
일단 간단하게 별도의 아키텍처 사용하지 않고서, 플러터에서 기초적으로 어떻게 데이터를 로드하는지를 알아보도록 합시다.
class _ExampleState extends State<Example> {
String? data; // 서버나 로컬에서 받아올 데이터
Error? error; // 에러 발생 시 정의될 인스턴스
bool isLoading = false;
Future<void> fetchData() async {
// 데이터를 불러오는 중이라고 뷰에 이를 알림.
setState(() => isLoading = false);
// 1초 지연.
await Future.delayed(Duration(seconds: 1));
// 데이터를 모두 불러왔다고 뷰에 이를 알림.
setState(() {
data = "Hello, World!";
isLoading = false;
});
}
@override
void initState() {
super.initState();
// 앱 시작 시 데이터 가져오기.
fetchData();
}
@override
Widget build(BuildContext context) {
// 아직 데이터가 없으면 로딩 인디케이터 표시.
if (isLoading) {
return CircularProgressIndicator();
}
// 에러가 있다면 에러 메시지 출력.
if (error != null) {
return Text(error.toString());
}
// 데이터가 있다면 데이터를 화면에 표시.
if (data != null) {
return Text(data!);
} else {
return Text("데이터 없음");
}
}
}
위 코드와 같이 플러터에서는 MVVM과 같은 별도의 아키텍처 패턴 없이도, 간단한 데이터 처리에서는 매우 직관적이고 깔끔하게 상태를 구현할 수 있습니다. 사실, Flutter 팀이 늘 강조하는 아키텍처 구조가 딱 이런 형태입니다. 추상화 계층이 아니라 동작 위주라서 초보자도 쉽게 구현할 수 있거든요.
하지만 지금 예제처럼 단순한 경우에만 그럴 경우가 크고 오히려 데이터가 많아지고, 로딩, 에러 처리, 다양한 UI 상태까지 고려해야 한다면, 모든 로직이 State, 즉 View 안에 몰리게 되어 복잡도가 크게 증가할 수 있습니다.
또한, 상위 위젯의 상태에 크게 의존하는 하위 위젯이 그 상태를 참조할 때도 매번 파라미터 형태로 값을 넘겨줘야 하기 때문에 불편하고 값을 변경해도 일일히 setState 함수를 호출해야 해서 실수도 많이 발생할 수 있습니다. 항상 데이터가 변화했다는 것을 View에 명시적으로 알려줘야 하거든요.
심지어 하위 위젯에서 상위 위젯의 상태를 변경하려 할 때도 일단 어떻게든 setState를 넘겨줘서 구현은 가능하겠지만(위 예시의 경우에는 _ExampleState를 하위에 넘겨주는 형태가 됩니다.), 이럴 경우 View가 단일 책임을 잘 지킨다고 보기가 훨신 어려워집니다. 마찬가지로 데이터 변화를 View에 알려주는 것을 보장하지 못해서 실수도 많이 발생할 것이며.
결과적으로는 어디서 버그 나는지도 잘 모를겁니다. 어떤 데이터의 변화 사실이 기존 View에 명시적으로 전달되지 않는지, 실제 유지보수하는 제 3자가 추적하기는 매우 어렵거든요.
따라서 MVVM 패턴과 같은 별도의 아키텍처 패턴들이 실제 프로젝트에서 API를 통한 데이터 관리와 View 상태를 함께 관리할 때 유용하게 사용되며, 반대로 기존 플러터의 setState로 데이터 변화를 명시적으로 알리는 것은 애니메이션이나 시각적 효과 등 동작 위주의 UI 작업에서 주로 사용됩니다.
그렇다면 MVVM 패턴은 기존 코드랑 뭐가 다르길래 널리 사용되는걸까요?
class ExampleViewModel extends ChangeNotifier {
String? data; // 서버나 로컬에서 받아올 데이터
Error? error; // 에러 발생 시 정의될 인스턴스
bool isLoading = false;
Future<void> fetchData() async {
isLoading = true;
notifyListeners();
await Future.delayed(Duration(seconds: 1));
data = "Hello, World!";
isLoading = false;
notifyListeners();
}
}
class _ExampleState extends State<Example> {
late final ExampleViewModel viewModel;
@override
void initState() {
super.initState();
// 앱 시작 시 데이터 가져오기.
viewModel = ExampleViewModel()..fetchData();
}
@override
Widget build(BuildContext context) {
return ListenableBuilder(
// View에서는 데이터 변화를 관찰함.
listenable: viewModel,
builder: (context, child) {
if (viewModel.isLoading) {
// 아직 데이터가 없으면 로딩 인디케이터 표시.
return CircularProgressIndicator();
}
// 에러가 있다면 에러 메시지 출력.
if (viewModel.error != null) {
return Text(viewModel.error.toString());
}
// 데이터가 있다면 데이터를 화면에 표시.
if (viewModel.data != null) {
return Text(viewModel.data!);
} else {
return Text("데이터 없음");
}
},
);
}
}
다음과 같이 비즈니스 로직을 위 예시처럼 별도로 분리했을 뿐입니다, 그런데 지금은 View는 오직 화면 그리기와 사용자 이벤트 처리에만 집중하는 것을 볼 수 있습니다.
데이터 로딩, 에러 처리, 상태 관리 등의 로직은 ViewModel 내부에서 모두 수행되며, View는 단지 ViewModel의 상태를 '관찰'하는 역할만 담당하게 됩니다. 여기서는 ListenableBuilder 위젯을 통해 ViewModel에 리스너를 등록하면서 데이터가 변화하면 리빌드시키고 관찰하는 것이죠.
View ↔ ViewModel ↔ Model
일단 이러한 구조는 다음과 같을 것이고 반대로 데이터 흐름은 단방향으로서 흘러갑니다.
즉, 사용자의 입력은 View → ViewModel → Model로 흘러가고,
데이터가 변경되면 Model → ViewModel → View로 다시 전달됩니다.
일단 이런 MVVM 패턴을 사용할 경우 다음과 같은 장점이 생깁니다.
| 단일 책임 원칙(Single Responsibility Principle, 줄여서 SRP) 준수 | View는 오직 UI를 담당하고, ViewModel은 데이터 및 상태 변경 로직을 담당하기 때문에 역할이 명확하게 구분됩니다. 즉, UI를 수정하더라도 데이터 로직에는 영향이 없고, 반대로 API나 비즈니스 로직이 바뀌어도 View 코드를 건드릴 필요가 없습니다. 즉 목적별로 적절히 나눠서 제 3자가 추후 변경할 때도 버그 발생률을 현저히 낮추는 역할을 합니다. |
| 상태 변화의 자동 반영 | ViewModel이 notifyListeners()를 호출하면, 데이터가 변화했다는 것을 의미하므로 리스너를 등록하고 관찰하는 View는 그저 자동으로 UI를 갱신합니다. 따라서 “데이터가 변했는데 화면이 안 바뀌는” 문제를 원천적으로 예방할 수 있습니다. |
| 테스트 용이성 | ViewModel은 Flutter 위젯과 분리되어 있기 때문에, 흔히들 말하는 단위 테스트(Unit Test)가 훨씬 쉬워집니다. 예를 들어 fetchData()가 데이터를 정상적으로 불러오고 isLoading을 올바르게 토글하는지, UI 없이도 확인할 수 있습니다. View에 의존하면 View 자체에서만 테스트 가능하거든요. 위젯을 직접 만들고 Mock 처리하면서 렌더링 해야 합니다. 하지만 이러한 장점 때문에 회귀 테스트를 도입하는 경우는 정말 소수이긴 합니다. |
| 확장성과 재사용성 향상 | 동일한 ViewModel을 여러 View에서 재사용할 수도 있습니다. 예를 들어 동일한 API를 호출하는 다른 페이지가 있다면, ViewModel만 주입받아 그대로 활용할 수 있죠. |
진짜 이렇게 보면 진짜 매번 써야할 것 같지만, 그렇다고 무조건적으로 사용되지는 않습니다, 프로젝트 규모가 작거나 API 사용이 제한적인 특수한 상황 또는 그냥 단순한 경우에는 굳이 MVVM까지 사용할 필요는 없거든요.
다만, 이러한 구조는 API 통신을 통한 데이터 관리가 아니더라도 Model 개념을 제외한 단순 상태 관리(예: TextField 등)에서도 상당히 유용합니다.
MVVM의 핵심은 ‘값의 변화를 명확하게 반영하는 구조’이기 때문에 이런 부분에서 발생할 수 있는 버그를 현저히 줄여주는 이로운 역할을 하거든요.
그리고 흔히들 Riverpod와 같은 라이브러리와 비교를 자꾸 하는데, 아셔야 할 것은 Riverpod는 그저 매우 포괄적인 아키텍처 패턴에서 사용될 수 있는 서브파티 도구일 뿐입니다. 그것도 아주 무거운.
그래서 여전히 제가 항상 여러분들에게 강조하고 싶은것은 필요하지 않는 경우를 개발자가 주체적으로 판단하고 이를 쳐내주는 역할을 해줘야 이러한 아키텍처의 장점들이 극에 달한다는 것입니다.
결국 MVVM은 "모든 프로젝트에 반드시 써야 하는 정답"이 아니라,
상태 관리와 책임 분리를 명확히 하고 싶은 상황에서 탁월한 선택지일 뿐입니다.
가장 중요한 것은 어떤 아키텍처를 쓰느냐보다, 그 구조가 팀의 개발 흐름과 유지보수 방식에 얼마나 잘 맞는가입니다.
MVVM이든 뭐가 됬든 혹은 단순 setState든 — 결국 목적은 “명확하고 버그 없는 코드”죠.
즉, 아키텍처는 코드 품질을 높이기 위한 도구(수단)이지 목표가 절대로 아닙니다.
불필요하게 복잡한 구조를 도입하는 것은 오히려 생산성을 떨어뜨릴 수 있습니다.
그래서 MVVM을 공부할 때 가장 좋은 접근은 “이걸 반드시 써야 한다”가 아니라, “언제 이게 유용하고, 언제는 굳이 필요하지 않은가”를 스스로 구분할 수 있는 안목을 기르는 것입니다.
이 글이 여러분이 선언형 UI와 상태 관리 구조를 설계할 때,
MVVM의 본질을 조금 더 명확히 이해하는 데 도움이 되었길 바랍니다.
그리고 아래는 제가 다트와 플러터의 철학을 명확히 지키면서 여러가지 프로젝트에서 단순 MVVM 패턴을 쉽게 구현하기 위해서 만든 간단한 패키지입니다. 사용하지는 않아도 되는 게 본래 패키지의 특징이지만, 뭐 여러가지로 참고하시면 좋을 듯합니다.
mvvm_service | Flutter package
A Flutter-native service layer inspired by the MVVM pattern, managing services according to the widget lifecycle, without relying on Provider or Riverpod.
pub.dev
- Total
- Today
- Yesterday
- TypeScript
- 조건부 타입
- 타입스크립트
- Flutter
- StretchEffect
- 플러터
- webpack
- 팩토리 메서드
- 플러터 기여
- 최적화
- keystore_signature
- 디자인 패턴
- 객체지향
- 깃허브
- jetpack compose
- Reflow
- omit
- 안드로이드
- 안드로이드 개발
- compose_appbar
- 객체 지향
- pageroute
- svg
- JavaScript
- Factory Method
- github
- android
- pagetransitionsbuilder
- flutter contribution
- web
| 일 | 월 | 화 | 수 | 목 | 금 | 토 |
|---|---|---|---|---|---|---|
| 1 | 2 | 3 | ||||
| 4 | 5 | 6 | 7 | 8 | 9 | 10 |
| 11 | 12 | 13 | 14 | 15 | 16 | 17 |
| 18 | 19 | 20 | 21 | 22 | 23 | 24 |
| 25 | 26 | 27 | 28 | 29 | 30 | 31 |