Overview

브랜디 iOS 파트에서 사용하고 있는 MVVM 패턴에 대하여 소개하고자 합니다.
저희는 VIPER로 시작하였지만 RxSwift를 조금 더 쉽게 적용하기 위해 MVVM으로 전환하였습니다.
MVVM으로 전환하면서 화면 전환 관련된 부분은 Coordinator 패턴을 사용하였습니다.
Coordinator 패턴은 기존에 게재된 글(화면 전환을 해결해 준 Coordinator 패턴)을 참조 부탁드리고, 이 글에서는 MVVM, 특히 ViewModel 사용에 대하여 다루겠습니다.

MVVM +Coordinator + CollectionViewAdapter

mvvm

각 부분에 대하여 간단히 설명하겠습니다.

  • Model: 데이터 모델
  • View: UIViewContoller, UIView의 서브 클래스들
  • ViewModel: Model의 변경을 감지하고, View가 사용할 수 있는 데이터로 변환. UI관련 코드는 없음.
  • CollectionViewAdapter: UICollectionViewDelegate, UICollectionViewDataSource, Layout 관련된 코드를 관리
  • Coordinator: 화면전환 관리

ViewModel과 CollectionViewAdapter

브랜디는 리스트 구현 시 유동적으로 셀을 구성할 수 있는 UICollectionView만 사용하고 있습니다.
UICollectionView 관련 코드는 CollectionViewAdapter 클래스를 만들어 분리하여 관리합니다.
아래 이미지는 ‘찜’ 화면을 구성하는 파일들의 구조입니다.

mvvm

기본적으로 ViewController + ViewModel + CollectionViewAdapter + Coordinator + UICollectionViewCell + Cell의 ViewModel로 구성되어 있습니다.
CollectionViewAdapter는 MVVM에서 분류하자면 View에 해당합니다.
View 하나에 ViewModel을 쌍으로 가지고 있는 것이 기본입니다. 하지만 CollectionViewAdapter는
ViewController의 부담을 줄여주기 위해 Class를 분리한 것 일뿐 따로 ViewModel을 가지고 있지 않습니다. 그러면 어떻게 CollectionViewAdapter에 데이터를 넘겨 주는지 코드를 통해 소개하겠습니다.

protocol BookmarkProductCollectionViewAdapterDataSource: AnyObject {
    var numberOfItems: Int { get }
    func fetchNextItemsIfNeeded(_ indexPath: IndexPath)
    func product(at index: Int) -> ProductModelCV1?
}

final class BookmarkProductCollectionViewAdapter: NSObject {
    private let itemHorizontalMargin: CGFloat = 16
    private let itemHorizontalInset: CGFloat = 8
    private let minimumLineSpacing: CGFloat = 20

    weak var adapterDataSource: BookmarkProductCollectionViewAdapterDataSource?
    weak var delegate: BookmarkProductCollectionViewAdapterDelegate?

    private var sectionTopInset: CGFloat = .zero
    private var sectionInset: UIEdgeInsets { UIEdgeInsets(top: sectionTopInset, left: itemHorizontalMargin, bottom: 0, right: itemHorizontalMargin) }

    init(collectionView: UICollectionView, adapterDataSource: BookmarkProductCollectionViewAdapterDataSource?, delegate: BookmarkProductCollectionViewAdapterDelegate?) {
        super.init()

        let layout = UICollectionViewFlowLayout()
        layout.scrollDirection = .vertical
        layout.minimumInteritemSpacing = itemHorizontalInset
        layout.minimumLineSpacing = minimumLineSpacing
        collectionView.setCollectionViewLayout(layout, animated: false)
        collectionView.delegate = self
        collectionView.dataSource = self
        self.adapterDataSource = adapterDataSource
        self.delegate = delegate
        collectionView.registerCellXib(cellClass: BookmarkProductCollectionViewCell.self)
    }
}

extension BookmarkProductCollectionViewAdapter: UICollectionViewDataSource {
    func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
        return adapterDataSource?.numberOfItems ?? 0
    }

    func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
        guard let cell = collectionView.dequeueReusableCell(withReuseIdentifier: BookmarkProductCollectionViewCell.className(), for: indexPath) as? BookmarkProductCollectionViewCell,
              let product = adapterDataSource?.product(at: indexPath.item) else { return .init() }
        cell.configure(.init(product: product), delegate: delegate)
        return cell
    }
}

extension BookmarkProductCollectionViewAdapter: UICollectionViewDelegate {
    func collectionView(_ collectionView: UICollectionView, willDisplay cell: UICollectionViewCell, forItemAt indexPath: IndexPath) {
        adapterDataSource?.fetchNextItemsIfNeeded(indexPath)
    }

    func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
        guard let productId = adapterDataSource?.product(at: indexPath.item)?.id, let product = adapterDataSource?.product(at: indexPath.item) else { return }
        delegate?.productTapped(productId, product: product)
    }
}

extension BookmarkProductCollectionViewAdapter: UICollectionViewDelegateFlowLayout {
    func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, sizeForItemAt indexPath: IndexPath) -> CGSize {
        return BRBookmarkProductCollectionViewCell.fittingSize(collectionView, itemHorizontalMargin: itemHorizontalMargin, itemHorizontalInset: itemHorizontalInset)
    }

    func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, insetForSectionAt section: Int) -> UIEdgeInsets {
        return sectionInset
    }
}

위 코드가 CollectionViewAdapter의 끝입니다.
물론 셀 종류를 여러 개 사용하는 것 외, 필요에 따라서 코드가 더 추가되겠지만 기본적인 구조는 위의 코드입니다. 제가 CollectionViewAdapter를 개발할 때 고려한 것은 ‘ViewController의 ViewModel 객체나 데이터 Array 혹은 관련 로직을 중복해서 가지고 있지 말자’였습니다. CollectionViewAdapter에는 데이터 Array나 로직이 전혀 없습니다. 필요한 데이터는 CollectionViewAdapterDataSource Protocol을 통해 모두 가져옵니다. 데이터 페이징 처리까지 말이죠.

final class BookmarkProductViewModel {
    // MARK: - BookmarkProductCollectionViewAdapterDataSource
    var numberOfItems: Int { products.count }
    ...
}

extension BookmarkProductViewModel: BookmarkProductCollectionViewAdapterDataSource {
    func fetchNextItemsIfNeeded(_ indexPath: IndexPath) {
        guard !isFinishedPaging, indexPath.row == numberOfItems - 1 else { return }
        fetchBookmarkedProducts(numberOfItems)
    }

    func product(at index: Int) -> ProductModelCV1? {
        return products[safe: index]
    }
}

BookmarkProductCollectionViewAdapterDataSource를 채택한 곳은 ViewController가 가지고 있는 ViewModel입니다. 위와 같이 ViewModel에서 CollectionViewAdapter DataSource 이벤트를 받아 모든 로직을 관리합니다.

final class BookmarkProductViewController: UIViewController {
    private let viewModel: BookmarkProductViewModel =  BookmarkProductViewModel()
    private lazy var adapter: BookmarkProductCollectionViewAdapter = {
        let adapter = BookmarkProductCollectionViewAdapter(collectionView: collectionView, adapterDataSource: viewModel, delegate: self)
        return adapter
    }()
    ...
}

UICollectionView에서 발생하는 이벤트 delegate는 ViewController에서 CollectionViewAdapter를 생성할 때 연결합니다. CollectionViewAdapter는 delegate 이벤트를 직접 받지 않고, ViewController로 바로 넘겨주게 됩니다.

ViewController, CollectionViewAdapter, ViewModel이 역할을 분리함으로써 View의 코드는 상당히 간소화됩니다.
CollectionViewAdapter의 경우 뷰에 종속적이지 않습니다. ViewModel에서 CollectionViewAdapterDataSource Protocol만 채택한다면 여러 개의 뷰에서 사용 가능합니다.

func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
    guard let cell = collectionView.dequeueReusableCell(withReuseIdentifier: BookmarkProductCollectionViewCell.className(), for: indexPath) as? BookmarkProductCollectionViewCell,
          let product = adapterDataSource?.product(at: indexPath.item) else { return .init() }
    cell.configure(.init(product: product), delegate: delegate)
    return cell
}

Cell의 경우 각자 ViewModel을 가지고 있기 때문에 CollectionViewAdapterDataSource에서 Cell의 ViewModel을 생성하여 넘겨줍니다.

Conclusion

View의 코드를 최대한 간소화하고, ViewModel에 집중하여 개발하는 것을 목표로 위와 같은 패턴을 사용하게 되었습니다. 읽기 쉽고, 테스트와 유지 보수에 용이하도록 코드를 줄이는 것은 개발자들의 공통과제입니다. (코드리뷰 시간이 줄어드는 것은 덤이죠~) 앞서 말씀드린 부분은 결국 퀄리티와 이어집니다. 앞으로도 사용자분들께 보다 나은 앱을 제공할 수 있도록 노력하는 브랜디가 되겠습니다.


김중원 | MA팀
kimjw2@brandi.co.kr
브랜디, 오직 예쁜 옷만