
이것은 어디까지나 가이드라인일 뿐 확고한 규칙은 아니다. 프로젝트의 요구사항에 맞게 적절히 적용하도록 하자.
관심사 분리는 Flutter 앱을 설계할 때 가장 우선적으로 따라야 하는 핵심 원칙이다. 크게 UI 레이어와 데이터 레이어라는 두 가지 넓은 영역으로 나누고, 각 레이어는 다시 명확한 역할과 책임, 잘 정의된 인터페이스, 경계 및 의존성을 가진 여러 컴포넌트로 세분화된다.
Model-View-ViewModel 아키텍처 패턴은 애플리케이션을 Model, View, ViewModel의 세 개 영역으로 구분한다. View 와 ViewModel 은 애플리케이션의 UI 레이어를 구성하고, Repository 와 서비스가 MVVM의 Model 에 해당하는 데이터를 나타낸다.
이미지 출처: Flutter 공식문서
애플리케이션 기능 구성
View: UI를 표현하는 것 (1개)ViewModel: 로직을 처리하는 것 (1개)Repository: 데이터의 SSOT역할을 하는 것 (1개 이상)Service: 외부 API와 상호작용하는 것 (0개 이상)
복잡한 로직을 가진 앱은 UI 레이어와 데이터 레이어 사이에 별도의 로직 레이어를 두기도 한다. 앞서 살펴본 계층형 아키텍처와 단방향 데이터 흐름에서처럼 말이다. 그런 로직 레이어를 '도메인 레이어'라고 부르기도 한다.
사용자와 상호작용하는 영역. 애플리케이션의 데이터를 사용자에게 보여주고 사용자의 입력을 받는다. 여기서 입력이란 입력 폼에 작성한 것뿐만 아니라 tap event 같은 gesture 입력도 포함한다. UI는 데이터 변화 및 사용자 입력의 영향을 받아 반응한다.
MVVM 패턴에서는 UI 레이어가 두 개의 아키텍처 컴포넌트로 구성된다.
- Views
애플리케이션 데이터를 사용자에게 어떻게 보여줄지 기술하는, 하나의 기능을 구성하는 여러 widget의 조합이다. 사용자 상호작용에 반응하여 View Model에 event를 전달하는 역할도 한다.- View Models
애플리케이션 데이터를 사용자에게 보여줄 형식으로 변형하여 UI State로 변환하는 로직을 담고 있다.이 둘은 일대일 관계를 형성한다. View Model이 UI State를 관리하고 View가 그 State를 화면에 보여주는 거다.
이미지 출처: Flutter 공식문서
View에는 비즈니스 로직이 포함되지 않으며 조건에 따른 widget 보이기/숨기기, 애니메이션 로직, 기기 정보 및 크기에 따른 레이아웃 로직, 단순한 라우팅 로직 정도만 포함된다고 한다. Flutter의 State<StatefulWidget> 에 들어가는 로직 정도인 것 같다.
ViewModel에는 애플리케이션 데이터를 가져와 View에 표시하기 적합한 형태로 변환하는 로직이 포함된다. UI가 rebuild되어도 State가 유지되도록 하는 역할도 수행한다. event handler에 제공할 수 있는 callback function을 View에 제공하기도 하는데, 이런 callback function을 command라고 부르는 모양이다. 이는 command 패턴이라는 디자인 패턴에서 따온 거라나.
command는 View Model class의 method로 작성되어 View class에서 그 내부 구현을 알지 못해도 복잡한 로직을 실행할 수 있도록 한다.
Flutter에서 MVVM을 어떻게 구현하는지 더 알고 싶다면 state 관리에 대한 문서를 살펴보면 좀 더 알 수 있다는 듯.
Repository와 Service로 이루어진 데이터 레이어가 비즈니스 데이터 및 로직을 본격적으로 다룬다. 입출력을 잘 정의해야 재사용성과 테스트 용이성을 높일 수 있다.
이미지 출처: Flutter 공식문서
앞서 Repository class가 SSOT로서 존재한다는 것을 살펴본 바와 같이 Repository는 진실 공급원으로서 작용하는데, Service로부터 데이터를 주기적으로 가져와 raw data를 View Model에서 사용하기 적절한 도메인 모델로 변환한다.
Repository와 View 모델은 N:M 관계를 갖는다. View 모델은 여러 개의 Repository로부터 데이터를 가져올 수 있고 Repository도 여러 개의 View 모델에서 데이터를 쓸 수 있게 한다.
Repository끼리는 서로 알지 못하며, 둘 이상의 Repository의 조합이 필요하다면 그것은 View 모델이나 도메인 레이어에서 수행해야 한다.
애플리케이션의 SSOT인 만큼 Repository는 앱 전반의 lifecycle state를 관리하기 이상적이다. 영구 저장할 필요는 없지만 여러 View Model 간에 공유해야 할 것들을 Repository에 담는 거다.
Service는 앱 가장 밑단에 존재하며, 자체적인 state를 가지지 않는다. API 엔드포인트를 wrap하여 비동기 응답 객체로 외부에 내보낸다. Dart 코드 외부에 존재하는 데이터를 필요로 할 때 유용하다. Service와 Repository도 N:M 관계를 갖는다.
도메인 레이어는 항상 존재하는 건 아니고, 앱 규모에 따라 View 모델을 복잡하게 만드는 로직들을 별도로 추상화하고자 할 때 사용한다. Repository로부터 데이터를 가져와 UI 계층에 적합한 형태로 가공하여 상호작용을 더 단순하고 재사용 가능하게 만든다. 이런 역할을 하는 class는 흔히 interactor 혹은 use case라고 칭한다.
이미지 출처: Flutter 공식문서
주로 여러 Repository의 데이터를 결합행 햐거나 로직이 복잡하거나 서로 다른 View 모델에서 로직이 반복될 때 사용된다. 도메인 레이어를 추가할 땐 여러 장단점이 있기에 필요하다고 판단되었을 경우에만 추가하도록 하자.
장점 단점 View 모델 코드 중복 방지 복잡도 증가 및 인지 부하 상승 로직을 분리하여 테스트 용이성 상승 테스트에 추가적인 mock 필요 View 모델의 가독성 증가 추가적인 boilerplate 코드 필요
도메인 레이어 도입을 고려할 때 View 모델이 Repository에 직접 접근할 수 있게 열어둘 것인지 반드시 use case를 거치도록 규칙을 강제할 것인지도 결정해야 한다. 전자의 경우 중복 코드 등 use case로 추가할 필요가 있는 부분을 발견할 때마다 새로운 use case를 추가하게 된다. 그리고 후자의 경우 단순한 로직이어도 무조건 use case를 추가하게 된다.
후자의 경우 도메인 레이어 도입의 장단점이 극명하게 드러난다. 필요한 순간에만 use case를 추가하는 전자의 방식이 꽤나 합리적인 접근법이다. 대부분의 코드가 use case를 거치고 있다면 그때 가서 나머지 코드도 use case를 거도록 refactor 해도 좋다.
이미지 출처: Flutter 공식문서