Flutter에서 MVVM (Model-View-ViewModel) 패턴을 사용하는 것은 앱의 구조를 더 잘 조직하고, 유지보수성을 향상시킬 수 있습니다. MVVM을 사용하면 코드의 가독성, 유지보수성 및 테스트 가능성을 크게 향상시킬 수 있습니다.

clopilot이 마음에 드는 그림 안그려줘서 직접 그려봤습니다.

MVVM 패턴의 구성 요소

Model

Model은 앱에서 사용되는 데이터와 비즈니스 로직을 처리합니다. 네트워크 요청, 데이터베이스 관리 등을 담당하며, 각 데이터를 Dart 객체로 변환하여 ViewModel에 전달합니다. Model 클래스는 데이터를 나타내는 것이죠! Model을 통해 데이터를 인스턴스화하고 나면 해당 데이터를 Flutter 내부에서 쉽게 사용할 수 있게 됩니다. 쉽게 말하면 Model은 ViewModel에 데이터를 전달해주기 위해 데이터를 가져오고 Dart의 객체로 만들어주는 역할을 합니다. 데이터 자체와 관련된 로직만을 담당하는 것이죠. 따라서 모델은 View 및 ViewModel과 완전히 독립적이어야 하며 데이터 자체와 관련된 로직만 포함해야 합니다.

 

Model은 데이터를 표현하고 캡슐화하는 Model, 데이터 액세스를 추상화하고 관리하여 데이터와의 상호 작용을 담당하는 Repository, 비지니스 로직과 외부 리소스와의 통신을 담당하는 Service로 나뉩니다. Model은 서비스의 규모에 따라 Service와 Repository를 한 번에 관리하기도 합니다.

View

사용자 인터페이스(UI)를 담당합니다. View는 ViewModel을 통해 제공된 데이터를 사용자에게 보여주고, 사용자의 입력을 ViewModel로 전달합니다. 그러고나면 ViewModel에서 사용자의 입력에 대한 적절한 처리가 되고 바뀐 상태가 있다면 View는 상태 변화 알림을 받아 화면을 업데이트합니다. View에서는 데이터를 표시할 뿐만 아니라 사용자의 입력에 따라 ViewModel의 어떤 로직을 사용하여야 하는지 통제하는 역할을 합니다. View는 Model 및 ViewModel과 완전히 독립적이어야 하며 데이터 표시와 관련된 요소만 포함해야 합니다.

ViewModel

View와 Model 사이의 중계자 역할을 합니다. ViewModel은 Model로부터 데이터를 받아와서 이를 View가 쉽게 사용할 수 있도록 도와줍니다. ViewModel은 View의 상태 관리를 담당하며, 사용자의 입력을 기반으로 모델을 업데이트합니다. 즉 뷰에서 전해받은 사용자의 입력에 대한 액션을 수행하고 (자신이 소유하고 있는)상태를 업데이트합니다. 그리고나면 상태의 변화를 View에 전파하여 VIew가 화면을 다시 그릴수 있도록 합니다. 따라서 VIewModel은 View에 대한 Model 데이터 변환과 관련된 논리만 포함해야 합니다. 추가적으로 ViewModel은 여러 View에서 사용될 수 있습니다. 바로 여러 스크린 또는 페이지(View)에서 같은 데이터를 사용할 때입니다.

또 다른 규칙

ViewModel과 View 사이의 데이터 바인딩은 ViewModel이 View에 접근하지 않고 서로 다른 ViewModel 간에 데이터를 참조하지 않습니다. 만약 데이터를 공유하여야 한다면 부모 View 혹은 아래의 Model을 이용하여야 하죠. 데이터 바인딩이 잘 구현되었는지 여부는 ViewModel 안에 View에 대한 의존성으로 확인할 수 있습니다. ViewModel은 View에 대한 의존성이 없어야 하죠. ViewModel은 View의 상태와 View를 업데이트하는 로직만을 가지고 있어야합니다. 그렇게 되어 있다면 View가 어떻게 생겼는지와는 관계없이 ViewModel을 재활용할 수 있게 됩니다.

MVVM 패턴의 장점

MVVM 패턴을 사용한다는 것은 데이터 바인딩을 통한 UI와 비즈니스 로직의 분리를 의미합니다. 이는 개발 과정에서의 오류를 줄이고 더 깔끔한 구조를 유지할 수 있게 합니다. ViewModel을 통한 상태 관리는 애플리케이션의 다양한 UI 컴포넌트 간의 일관된 데이터 흐름을 보장하며, 테스트와 확장성 측면에서도 이점을 제공합니다.

 

MVVM의 느슨하게 결합된 아키텍처를 촉진하여 코드의 유연성과 재사용성을 향상시킵니다. MVVM을 사용함으로써 Model 또는 ViewModel을 변경해도 View에 영향을 주지 않으며, View를 변경해도 Model 또는 ViewModel에 영향을 주지 않습니다. 즉 다른 구성 요소에 영향을 주지 않고 하나의 구성 요소를 쉽게 변경할 수 있으며, 동일한 Model 및 ViewModel에 대해 서로 다른 View를 사용할 수 있습니다. 다른 크기의 화면에 대해 동일한 Model과 ViewModel을 사용하고 다른 VIew를 사용하는 방삭으로 말이죠.

 

MVVM의 또 다른 장점은 앱의 다양한 구성 요소를 더 쉽게 테스트할 수 있다는 것입니다. Model, View, ViewModel이 분리되어 있어 독립적으로 테스트가 가능합니다. 즉, 모델은 단위 테스트로 테스트할 수 있고, View 및 ViewModel은 통합 테스트로 테스트할 수 있습니다. ViewModel은 모의 개체를 사용하여 테스트할 수 있으므로 실제 View 없이도 ViewModel의 논리를 테스트할 수 있습니다.

MVVM 패턴의 구현

Flutter에서 MVVM을 구현하기 위해서 Provider를 사용하거나 큰 프로젝트인 경우 BLoC를 사용하는 것 같습니다. 그런데 저는 Provider의 개선된 버전인 riverpod을 공부하기 위해 이번 프로젝트에서는 riverpod을 사용해보았습니다. 사실 처음으로 Flutter 프로젝트를 진행하면서 Provider보다 riverPod이 더 사용하기 쉬워보였기 때문에 선택한 것입니다. 하지만 riverpod은 provider와 달리 상태를 전역으로 관리하기 때문에 MVVM패턴을 적용할 때 메모리에 더 신경을 써야했습니다. 그래서 autoDispose를 활용해 더 이상 참조되지 않는 Provider는 메모리에서 해제되게 하였습니다.

 

Flutter를 처음 사용하는 입장에서 완전하게 구현된 MVVM패턴은 아니지만 riverpod을 이용해 UI와 로직을 분리하는 데 참고가 되었으면 합니다. 랜덤한 여우 사진을 보내주는 api를 활용하였습니다. + 버튼을 누르면 랜덤한 여우 사진을 불러와 화면에 추가하는 아주 간단한 화면입니다.

 

ps. dio와 riverpod이 필요합니다.

View

ViewModel로 부터 데이터를 받아 화면에 표시하는 역할만을 합니다.

// view.dart

import 'package:flutter/material.dart';
import 'package:flutter_application_1/src/MVVM/viewModel.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';

class MyView extends ConsumerWidget {
  const MyView({super.key});

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    var viewModel = ref.watch(foxViewModelProvider);
    return SafeArea(
      child: Container(
        color: Colors.white,
        alignment: Alignment.center,
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            Text(
              '여우 사진의 개수: ${viewModel.foxImagesCount}',
              style: const TextStyle(
                color: Colors.black,
                decoration: TextDecoration.none,
                fontSize: 28,
              ),
            ),
            IconButton(
                onPressed: () {
                  viewModel.fetchingMoreCatImage();
                },
                icon: const Icon(Icons.add)),
            Expanded(
              child: ListView.builder(
                itemBuilder: (BuildContext context, int index) {
                  return Image.network(
                    viewModel.getFoxImage(index),
                    height: 300,
                    fit: BoxFit.contain,
                  );
                },
                itemCount: viewModel.foxImagesCount,
              ),
            )
          ],
        ),
      ),
    );
  }
}

ViewModel

repository를 만들어 해당 레포지토리에서 데이터를 가져옵니다. 

// viewModel.dart

import 'package:flutter/material.dart';
import 'package:flutter_application_1/src/MVVM/repository.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';

final foxViewModelProvider = ChangeNotifierProvider((ref) => FoxViewModel());

class FoxViewModel with ChangeNotifier {
  var foxRepository = FoxRepository();

  int get foxImagesCount => foxRepository.foxImageUrlList.length;

  String getFoxImage(int index) => foxRepository.foxImageUrlList[index].image;

  void fetchingMoreCatImage() async {
    await foxRepository.addFoxImage();
    // 이미지를 추가하고 구독(watch)하고 있는 View에게 변화를 알려줍니다.
    notifyListeners();
  }
}

Model

Model

받은 데이터를 Dart 코드 상에서 쉽게 사용할 수 있도록 인스턴스화합니다. 데이터의 구조와 속성을 정의하고 관리합니다.

// model.dart

class Fox {
  final String image;

  Fox(this.image);

  factory Fox.fromJson(Map<String, dynamic> json) {
    return Fox(json['image']);
  }
}

Repository

데이터 저장과 접근에 대한 로직을 추상화합니다. 데이터를 로컬 저장소나 원격 서버에서 가져오거나 저장하는 등의 작업을 수행합니다. 다음 예시 코드와 같이 작은 규모에서는 Service가 따로 분리되지 않고 Repository에 속할 수도 있습니다. 데이터를 조회하고 클라이언트가 사용할 수 있도록 저장할 때 일관된 접근 방식을 정하는 역할을 합니다.

// repository.dart

import 'package:dio/dio.dart';
import 'package:flutter/material.dart';
import 'package:flutter_application_1/src/MVVM/model.dart';

class FoxRepository {
  List<Fox> foxImageUrlList = [];

  Dio dio = Dio();

  Future<void> addFoxImage() async {
    try {
      var response = await dio.get('https://randomfox.ca/floof/');
      foxImageUrlList.add(Fox.fromJson(response.data));
    } catch (err, stackTrace) {
      debugPrint('에러 $err, $stackTrace');
    }
  }
}

Service

비지니스 로직, 외부와의 연결을 담당을 담당하는 계층입니다. Repository에서 가져온 데이터를 처리하고 규칙을 적용합니다. 사용자 인증, 복잡한 계산, 여러 소스에서 온 데이터의 통합 등이 이루어집니다. 일반적으로 비지니스 로직 또는 앱의 전역 상태등을 담당합니다. 애플리케이션의 규모가 큰 경우 데이터를 관리하고 처리하는 로직을 캡슐화하여 ViewModel과 Model 간의 연결 고리 역할을 하게 됩니다. Repository가 백엔드와 통신하고 Service가 Repository에 접근하여 ViewModel에 데이터를 가져다 주는 것이죠.

 

예시 코드는 규모가 작아 굳이 Service를 추가하지 않았지만 만약 Service가 추가된다면 viewModel이 Service를 통해 Respository에 접근하게 됩니다.

MVVM의 보일러 플레이트

위의 코드에서 viewModel이 ChangeNotifier 클래스를 믹스인하고 ChangeNotifierProvider를 이용해 해당 클래스의 provider를 전역으로 생성한 뒤, view에서 ref.watch(viewModel)을 사용하는 것이 MVVM의 보일러 플레이트가 될겁니다. 해당 패턴의 자동생성을 위한 base코드를 만들지는 못했지만 간단한 사용으로 MVVM 패턴을 구현할 수 있습니다.

여러 viewModel이 같은 상태를 보아야하고 상태가 계속 유지되어야 하는 경우

Singleton 패턴을 사용하여 Repository 인스턴스를 만들어서 여러 ViewModel이 같은 인스턴스를 참조하도록 구성할 수 있습니다.

// Singleton 패턴을 적용한 Share 클래스
class Share {
  Share._();  // 생성자를 private으로 설정
  
  // instance 생성
  // 프라이빗으로 만들어 getter에 의해서만 수정 가능
  static final Share _instance = Share._();

  static Share get instance => _instance;
}

// Share 인스턴스를 제공하는 Provider
final shareProvider = Provider<Share>((ref) {
  return Share.instance;
});

final viewModelProvider = ChangeNotifierProvider<ViewModel>((ref) {
  var share = ref.watch(shareProvider);
  return ViewModel(share);
});

_instance 변수는 static final 키워드로 선언되어 클래스 자체의 인스턴스를 나타내며 프라이빗 생성자이기 떄문에 외부에서 직접 Share 객체를 생성하는 것을 방지합니다. factory 생성자는 _instance 인스턴스를 반환합니다. 즉, 항상 같은 인스턴스를 제공합니다.

 

싱글톤 패턴을 사용하면 shareProvider가 오랫동안 사용되지 않더라도 가비지 컬렉터에 의해 메모리에서 해제되지 않아 더욱 안정적일 수 있습니다. 해당 인스턴스는 애플리케이션의 종료 전까지 유지됩니다. 다만 그만큼 자원을 오랜 시간 동안 점유하고 있어야 한다는 의미이기도 합니다. 따라서, 메모리 누수, 데이터 오염과 같은 문제가 발생할 수있어 사용 시에 주의가 필요합니다.

여러 viewModel이 같은 상태를 보아야 하지만 참조되지 않을 때 삭제되어야 하는 경우

만약 여러 viewModel이 같은 상태의 reposiroy를 보는 등 공유상태가 필요하다면 다음과 같이 사용할 수 있습니다.

// autoDispose를 사용하면 해당 provider를 참조하는 View가 없을 때
// 메모리에서 삭제됩니다.
final shareProvider = Provider.autoDispose((ref) {
  // 새로운 Provider를 watch하게 되면 인스턴스가 생성된다.
  final shareRepository = Share();
  return shareRepository;
});


class Share {
  // 공유하는 데이터
}


// ViewModelProvider1과 ViewModelProvider2는 shareProvider가 같기때문에
// 같은 Share 인스턴스를 보게됩니다.
// 단 ViewModelProvider1과 ViewModelProvider2로 연결되는 사이에
// Share 인스턴스에 대한 참조가 유지되지 않으면 새로운 인스턴스를 보게 됩니다.

final viewModelProvider1 =
    ChangeNotifierProvider.autoDispose<viewModel1>((ref) {
  var share = ref.watch(shareProvider); // 
  return ViewModel1(share);
});

final viewModelProvider2 =
    ChangeNotifierProvider.autoDispose<viewModel2>((ref) {
  var share = ref.watch(shareProvider); // 
  return ViewModel2(share);
});

Provider를 전역에 생성하거나 인스턴스를 전역에 생성할 수도 있지만 위와 같은 방법으로 생성한다면 사용하지 않을 때는 메모리에서 해제된다는 장점이 있습니다. 또한 싱글톤 패턴보다 테스트가 쉽다는 장점이 있습니다.

참고 자료

LINE Engineering, Flutter 인기 아키텍처 라이브러리 3종 비교 분석 - GetX vs BLoC vs Provider
Build a Flutter App using MVVM
Flutter: MVVM Architecture

+ Recent posts