개발 회고록
[iOS 센츠노트] ViewModel의 분리 본문
MVVM의 단점이라고 하면 화면의 로직이 복잡해질수록 ViewModel 코드의 방대해짐(Massive ViewModel)을 꼽는다.
이 때문에 RIBs와 같이 기능 단위의 로직을 구성하는 아키텍처가 등장하는데 한 몫을 했다고 생각한다.
센츠노트 프로젝트에는 필터링을 하여 향수를 검색하는 뷰가 있다. 이때 계열, 브랜드, 키워드 3가지 카테고리에서 각각 최대 5개의 키워드를 선택해서 검색을 할 수 있다.



뷰의 구성은 FilterVC > Horizontal Scrollview > SeriesView, BrandView, KeywordView로 구성되어 있다.
하나의 ViewController에 하나의 ViewModel라는 원칙을 지키고 있었고 해당 로직 마찬가지로 하나의 ViewModel에 모든 기능을 구현을 하였고 그 ViewModel을 공유하였다.
만약 Chile View의 갯수가 3개를 넘어선다면? 더욱 무거워지고 플로우를 한 눈에 파악하기가 어려워질 것이다.
그래서 해당 ParentViewModel > SeriesViewModel, BrandViewModel, KeywordViewModel 구성으로 바꾸기로 했다.
그리고 각 ViewModel의 역할을 정의하였다.
ParentViewModel -> 클릭된 Series, Brand, Keyword 저장 및 화면 업데이트(탭 및 하단 적용 버튼), 탭 및 적용 버튼 Input & Output 처리
SeriesViewModel -> Series 데이터 Fetch, SeriesView의 Input & Output 처리
BrandViewModel -> Brand 데이터 Fetch, Brand의 Input & Output 처리
KeywordViewModel -> Keyword 데이터 Fetch, Keyword의 Input & Output 처리
Child ViewModel은 Delegate 패턴으로 Parent ViewModel로 값을 전달하게 구현하였다.
기존 ViewModel
import RxSwift
import RxRelay
final class SearchFilterViewModel {
// MARK: - Input & Output
struct Input {
let tabDidTapEvent: Observable<Int>
let doneButtonDidTapEvent: Observable<Void>
let closeButtonDidTapEvent: Observable<Void>
}
struct Output {
let selectedTab = BehaviorRelay<Int>(value: 0)
var hightlightViewTransform = BehaviorRelay<Int>(value: 0)
let tabs = BehaviorRelay<[SearchTab]>(value: SearchTab.default)
let selectedCount = BehaviorRelay<Int>(value: 0)
}
/// Tab
var selectedTab = BehaviorRelay<Int>(value: 0)
/// Series
let seriesDataSource = BehaviorRelay<[FilterSeriesDataSection.Model]>(value: [])
let series = BehaviorRelay<[FilterSeries]>(value: [])
let seriesState = BehaviorRelay<Set<Int>>(value: Set())
let seriesSelected = BehaviorRelay<[SearchKeyword]>(value: [])
/// Brand
var brandInfos: [FilterBrandInfo] = []
let brandInitials = BehaviorRelay<[FilterBrandInitial]>(value: [])
let brandInitialSelected = BehaviorRelay<Int>(value: 1)
let brands = BehaviorRelay<[FilterBrand]>(value: [])
let brandsSelected = BehaviorRelay<[SearchKeyword]>(value: [])
/// Keyword
let keywordDataSource = BehaviorRelay<[FilterKeywordDataSection.Model]>(value: [])
let keywords = BehaviorRelay<[Keyword]>(value: [])
let keywordsSelected = BehaviorRelay<[SearchKeyword]>(value: [])
// MARK: - Binding
func transform(from input: Input, disposeBag: DisposeBag) {}
private func bindInput() {}
private func bindOutput() {}
private func bindTab() {}
private func bindSeries() {}
private func bindBrands() {}
private func bindKeywords() {}
private func bindDoneButton() {}
// MARK: - Network
private func fetchDatas() {}
// MARK: - Action
func clickSeriesMoreButton() {}
func clickSeries() {}
func clickBrandInitial() {}
func clickBrand() {}
func clickKeyword() {}
// MARK: - Update Data
func updateSeries() {}
private func updateBrandInitials() {}
private func updateBrands() {}
private func updateBrandInfo() {}
private func updateKeywords() {}
private func createSearchKeywords() {}
}
내부 구현을 다 생략해도 엄청 길다. 하나의 ViewModel이 모든 역할을 수행하려다 보니 코드가 방대해졌다. 구현 한지 1~2달이 되었음에도 코드의 전체를 파악하는데 꽤 시간이 걸릴정도였다. 그리고 하나의 ViewModel에서의 Unit Test Case도 굉장히 많아질 것이도 차후 유지보수에 치명적일 것이다.
리팩토링 후 ViewModel
- Parent ViewModel
final class SearchFilterViewModel {
enum Tab: Int {
case series
case brand
case keyword
}
// MARK: - Input & Output
struct Input {
let tabDidTapEvent = PublishRelay<Int>()
let doneButtonDidTapEvent = PublishRelay<Void>()
let closeButtonDidTapEvent = PublishRelay<Void>()
}
struct ScrollInput {
let ingredientsDidUpdateEvent = PublishRelay<[SearchKeyword]>()
let brandsDidUpdateEvent = PublishRelay<[SearchKeyword]>()
let keywordsDidUpdateEvent = PublishRelay<[SearchKeyword]>()
}
struct Output {
let tabSelected = BehaviorRelay<Int>(value: 0)
var hightlightViewTransform = BehaviorRelay<Int>(value: 0)
let tabs = BehaviorRelay<[SearchTab]>(value: SearchTab.default)
let selectedCount = BehaviorRelay<Int>(value: 0)
}
// MARK: - Vars & Lets
private weak var coordinator: SearchFilterCoordinator?
private weak var delegate: FilterDelegate?
private let from: CoordinatorType
private let disposeBag = DisposeBag()
let input = Input()
let scrollInput = ScrollInput()
let output = Output()
var ingredients: [SearchKeyword] = []
var brands: [SearchKeyword] = []
var keywords: [SearchKeyword] = []
// MARK: - Life Cycle
init(coordinator: SearchFilterCoordinator,
from: CoordinatorType) {
self.coordinator = coordinator
self.from = from
self.transform(input: self.input, scrollInput: self.scrollInput, output: self.output)
}
// MARK: - Binding
func transform() {}
private func bindInput() {}
private func bindOutput() {}
private func searchKeywords() -> PerfumeSearch {}
}
이제 ParentViewModel은 VC에 있는 탭, 하단 버튼만 신경 써주면 된다. 검색할 시에 필요한 데이터들만 Child View로 부터 받아서 가지고 있으면 된다. Child View에 어떠한 데이터들이 있는지, 어떠한 로직이 들어가 있는지 신경쓸 필요가 없어졌다.
- Delegate
extension SearchFilterViewModel: FilterDelegate {
func updateIngredients(ingredients: [FilterIngredient]) {
let ingredients = ingredients.map { SearchKeyword(idx: $0.idx, name: $0.name, category: .ingredient) }
self.scrollInput.ingredientsDidUpdateEvent.accept(ingredients)
}
func updateBrands(brands: [FilterBrand]) {
let brands = brands.map { SearchKeyword(idx: $0.idx, name: $0.name, category: .brand) }
self.scrollInput.brandsDidUpdateEvent.accept(brands)
}
func updateKeywords(keywords: [Keyword]) {
let brands = keywords.map { SearchKeyword(idx: $0.idx, name: $0.name, category: .keyword) }
self.scrollInput.keywordsDidUpdateEvent.accept(brands)
}
}
protocol FilterDelegate: AnyObject {
func updateIngredients(ingredients: [FilterIngredient])
func updateBrands(brands: [FilterBrand])
func updateKeywords(keywords: [Keyword])
}
Child ViewModel과 통신은 Delegate 패턴을 사용해주고 있다. 어떠한 목적인지를 함수명으로써 명시해줄 수 있는게 큰 장점인 거 같다.
여기서 중요하다고 생각하는 것은 Parent ViewModel에서는 Child ViewModel에서 사용되는 모델이 아닌 실제 검색을 위한 데이터 모델을 가지고 있다는 것이다. 그렇게 하기 위해서 매핑을 해준 후 Update를 해준다. 이로써 책임을 확실히 할 수 있다.
- Child ViewModel
final class SearchFilterSeriesViewModel {
// MARK: - Input & Output
struct Input {
// 0: Series 그룹 번호, 1: 그룹 안에 있는 ingredient 정보
let ingredientDidTapEvent = PublishRelay<(Int, FilterIngredient)>()
let seiresMoreButtonDidTapEvent = PublishRelay<Int>()
}
struct Output {
let seriesDataSource = BehaviorRelay<[FilterSeriesDataSection.Model]>(value: [])
}
// MARK: - Binding
func transform() {}
private func bindInput() {}
private func bindOutput() {}
// MARK: - Network
private func fetchDatas() {}
// MARK: - Update
private func seriesUpdated() -> [FilterSeries] {}
private func updateIngredients() {}
private func seriesStateUpdated() -> Set<Int> {}
}
final class SearchFilterBrandViewModel {
// MARK: - Input & Output
struct Input {
let brandInitialCellDidTapEvent = PublishRelay<Int>()
let brandCellDidTapEvent = PublishRelay<Int>()
}
struct Output {
let brandInitials = BehaviorRelay<[FilterBrandInitial]>(value: [])
let brandInitialSelected = BehaviorRelay<Int>(value: 1)
let brands = BehaviorRelay<[FilterBrand]>(value: [])
}
// MARK: - Binding
func transform(input: Input, output: Output) {}
private func bindInput() {}
private func bindOutput() {}
// MARK: - Network
private func fetchDatas() {}
// MARK: - Update
private func updateBrandInfo(brandIdx: Int) {}
private func brandInitialsUpdated() -> [FilterBrandInitial] {}
private func brandsUpdated() -> [FilterBrand] {}
}
final class SearchFilterKeywordViewModel {
// MARK: - Input & Output
struct Input {
let keywordCellDidTapEvent = PublishRelay<Int>()
}
struct Output {
let keywordDataSource = BehaviorRelay<[FilterKeywordDataSection.Model]>(value: [])
}
// MARK: - Binding
func transform() {}
private func bindInput() {}
private func bindOutput() {}
// MARK: - Network
private func fetchDatas() {}
// MARK: - Update
private func updateKeywords() {}
private func keywordsUpdated() -> [Keyword] {}
}
위와 같이 View의 책임에 맞는 ViewModel을 구현해주었다. 한 눈에 봐도 각자 역할에 맞는 로직만 담겨있다는 것을 알 수 있다. 그래서 유지 보수 측면이나 테스트 시에도 확실히 용이해보인다.
이번 리팩토링으로 느낀점은 3가지가 있다.
1. 리팩토링 또는 협업을 위해서는 네이밍, 주석에 신경을 써야한다.
리팩토링 할 때 코드의 길이가 길고 여러 로직이 섞여있다보니 네이밍만으로 로직을 바로 파악하는데에 한계가 있었다. 사실 로직을 다시 작성하는 게 더 빠를 정도였다. 앞으로 코드를 작성할 시에는 Naming Convension에 더 신경을 쓰고 복잡해보이면 주석을 확실하게 달아야 겠다고 느꼈다.
2. 원칙보다는 융통성있는 코드가 중요하다.
코드를 짜는 데에는 정답은 없다. 하지만 정답에 가까운 코드는 있을 것이다. 언제든 융통성을 가지고 더 나은 코드를 짜는 것은 개발자의 자질이라고 생각한다.
3. 책임이 확실하면 코드를 파악하는데 도움이 된다.
이전 코드는 여러 책임을 담고 있고 코드의 양이 방대해서 파악하는 데 오랜 시간이 걸렸다. 하지만 이번 리팩토링한 코드는 1년이 지나고 다시 리팩토링을 한다 하더라도 한 눈에 파악할 수 있을 거라는 자신이 있다. 그리고 Unit Test도 기존 로직보다 빠르게 작성할 수 있을 거란 확신이 있고 차후에 리팩토링이 있더라도 빠르게 대응할 수 있을 것이다.
'iOS' 카테고리의 다른 글
| [iOS 센츠노트] Unit Test 도입 (0) | 2023.02.17 |
|---|---|
| [iOS 센츠노트] Mult-Type CollectionView 구현 (0) | 2023.01.31 |
| [iOS 센츠노트] ViewModel의 Input Output패턴에 관한 생각 (0) | 2023.01.17 |
| [iOS 센츠노트] MVVM-C & Clean Architecture의 도입 (0) | 2023.01.12 |