MVVM: iOS Swift에서 다대다 관계의 View와 Model 에 처리방안

Abacus
18 min readMar 2, 2021

종종 서버 프로토콜과 화면 가이드는 일관성이 존재하지 않는다. N개의 View가 존재한다고 했을 때, 이에 해당되는 데이타는 M개가 있다고 생각해보자.

한개의 View 는 여러개의 모델을 포함할 수 있으며, 한 개의 모델은 여러개의 View에 포함될 수 있다고 했을 때 어떻게 구현하는 것이 좋은 지 생각해 보고자 한다. (아래의 내용은 해당 경우에 대한 개인적인 기술적 견해이며 의견일 뿐이다.)

여기서 간단하게 View는 두개, 데이타는 4개가 있다고 가정하자.

표시되는 View 의 종류

ThumbnailView(그냥 명명한 것임), PosterView

(1) ThumbnailView의 특징

  • 가로화면 이미지
  • 소제목을 가짐
  • 프로그래스바를 표시
  • 선택할 수 있음

(2) PosterView의 특징

  • 세로화면 이미지
  • 아이콘 표시
  • 선택할 수 있음

처리되어야 할 데이타 목록

  • 일반 영화(movie)
  • 시청중 영화(movie)
  • 북마크 된 영화(movie)
  • 취미(hobby)
  • 드라마(tv)

종합해보면 아래의 그래프와 같은 조합이 필요하다.

뷰와 데이타 그래프

일반 포스트 뷰의 구성

  • 이미지를 표시한다(show image)
  • 선택했을 경우 이벤트 처리(select item)
  • 이벤트 태그 표시(bookmark)

썸네일 타입의 뷰의 구성

  • 이미지를 표시한다(show image)
  • 선택했을 경우 이벤트 처리(select item)
  • description(소제목 표시)
  • 프로그래스 바 표시
뷰 타입

이 글은 View들과 Model들을 배타적인 독립성을 유지하면서, 목적하는 모델에 대응되는 화면구현의 다양한 케이스에 대해서 고려해보는 것이 목적이다.

항상 말하는 것이지만 재활용성을 고려했을 때 View는 모델에 종속적이지 않아야 한다. 그렇기 위해서는 모델에 대한 직접적인 참조를 하면 안된다. 그러나 일반적으로 PosterView기반의 화면을 사용하는 구현에서 화면에 표시해야할 데이타의 타입이 다른경우 별도로 구현하게 된다. (나의 경험 상 복사, 붙이기, 찾기, 바꾸기 순으로 작업이 진행된다.)

posterMovie 클래스에서 화면을 표시하기 위해서는

func setImage(view: UIView) { view = movie.image}

posterHobby 클래스에서는

func setImage(view: UIView) { view = hobby.img}

그렇다면 View를 설계하는 입장에서 요구사항을 프로토콜로 정의하여 구현하고자 한다.

먼저 포스트에 대해서 정의 한다면(이택릭체는 공통 부분)

뷰의 구성에 따른 프로토콜 정의

아이콘을 가지는 일반적인 포스트뷰에 대한 정의를 한다면

protocol PosterViewProtocol {
func showImage(view: UIView)
func selectItem(item: Data)

func setBadge(view: UIView)
}

그리고 썸네일양식의 포스트뷰에 대해서 정의 한다면

protocol ThumbnailPoster {
func showImage(view: UIView)
func selectItem(key: String)

func progressBar(view: UIProgressBar)
func description(subTitle: String)
}

여기에 데이타 타입이

struct Movie { // 일반 영화, 시청중 영화 데이타 동일하다.
var movieImage: URL
var movieStream URL
var badgeType: Type1
var progressRate: Int
var movieName: String
}struct Hobby {
var hobbyImage: URL
var detail: Data
}
struct TvDrama {
tvImage URL,
tvType Type2,
drama title,
...
}
....

PosterViewProtocol 프로토콜에 맞게 각 모델별로 PosterView 화면 표시

그렇다면 PosteViewProtocol 에서 Hobby를 표시하기 위해서는

class HobbyEventPosterView: PosteViewProtocol {
var hobby: Hobby
showImage(view: ImageView) {
view = Image(url: hobby.hobbyImage)
}
selectItem(key: String) {
goto detailView(with: detail)
}
setBadge(view: ImageView) {

}

그럼 여기서 Movie를 표시하기 위해서는

class MovieEventPosterView: PosteViewProtocol {
var movie: Movie
showImage(view: ImageView) {
view = Image(url: movie.movieImage)
}
selectItem(key: String) {
detail = movie(key)
goto detailView(with: detail)
}
setBadge(view: ImageView) {
view = Image(url: movie.movieImage)
}

처럼 각각의 PosterView를 PosterViewProtocol에 맞게 Hobby와 Movie 모델데이타를 이용하여 구현해 줄 수 있을 것이다. (그러나 이러한 구현 방법은 PosterViewProtocol을 정의한 장점이 없다고 생각한다. 아래와 같이 PosterViewImpl에서 type에 따라서 PosterViewProtocol을 만족하는 뷰에 대해서 일괄적으로 처리하는 경우가 아니고서는 말이다.)

PosterViewProtocol poster = PosterViewImpl(type: String)
poster.selectItem(key: "1")

ViewModel을 적용하였을 때

우선 PosterViewProtocol프로토콜 API에서 각 데이타 별로 화면처리를 구현할 ViewModel을 정의

여기서 ViewModel이 PosterViewProtocol 프로토콜을 구현하는 이유는, PosterView에서 모델에 상관없이 PosterViewProtocol 인터페이스로 처리하기 위함이다. 실제로 PosterView에서 참조되는 PosterViewProtocol 프로토콜은 PosterViewProtocol를 각 모델별로 구현한 객체일 것이다. 여기서는 Movie모델에 대한 PosterViewProtocol를 구현한 MoviePosterViewModel의 예시이다.

class MoviePosterViewModel: PosterViewProtocol {
var movie: Movie
init(movie: Movie) { // initialize movie
}
showImage(view: ImageView) {
view = Image(url: movie.movieImage)
}
selectItem(key: String) {
detail = movie(key)
goto detailView(with: detail)
}
setBadge(view: ImageView) {
if movie.like == true {
view = Image(image: starIconImage)
}
}
}

위의 ViewModel 을 이용하는 PosterView의 예시를 간단히 표시하다면 아래와 같다. ViewModel에서는 일부의 business logic과 데이타 파싱 및 추가 동작외에도 다양한 예외처리 등이 포함될 것이다

class GeneralPosterView: UIView {
// var movie: Movie
var viewModel: PosterViewProtocol // viewModel은 PosterViewProtocol를 만족해야 한다.
generalShowImage(view: ImageView) {
viewModel.showImage(view: view)
}
generalSelectItem(key: String) {
viewModel.selectItem(key: key)
}
generalSetBadge(view: ImageView) {
viewModel.setBadge(view: view)
}
...

GeneralPosterViewHobbyEventPosterView, MoviePosterViewModel 처럼 동일한 View 이나 내용물(여기서는 데이타 모델)이 다를 경우 부득이 별도로 구성하게 된다. 이는 View의 독립성을 저해하는 요인이 된다. 하여 viewModel에따라서 여러가지 모델에 대한 의존성을 최소화하여 처리할 수 있다

그렇다면 이 ViewModel을 GeneralPosterView에 설정하자.

GeneralPosterView.viewModel = MoviePosterViewModel(movie)

Hobby 에 대해서도 동일한 방법으로 구현하여 설정한다면

GeneralPosterView.viewModel = HobbyPosterViewModel(hobby)

이전과 달리 GeneralPosterView는 모델의 형식과 상관없이 특정 프로토콜에 대한 ViewModel의 참조만 받으면 된다. 만일 ThumbnailView도 구현한다면 동일 한 구조로 가능할 것이다. View 개발자 입장에서는 ViewModel에서 별도의 처리를 모두 해주기 때문에, 정해진 ViewModel이 정해진 프로토콜만 만족한다면 화면을 표시하는 데 문제가 없어야 한다.

ThumbnailView에서 모델에 대한 ViewModel을 구현한다면

class ThumbnailMovieViewModel: ThumbnailPoster {
var movie: Movie
init(movie: Movie) { // initialize movie
}
showImage(view: ImageView) {
view = Image(url: movie.movieImage)
}
selectItem(key: String) {
detail = movie(key)
goto detailView(with: detail)
}
func progressBar(view: UIProgressBar) {
view = movie.rate
}
func description(subTitle: String) {
subTitle = movie.title
}
}

여기서 이택릭 볼드체는 Movie에 대한 별도의 View에서 중복코드가 발생함을 표시한다.

중복코드에 대한 추가적인 방안

위의 코드를 보면 동일한 모델(여기서는 Movie)에 대해서 동일한 처리하는 API에서 중복이 발생한다. 이는 의도하는 API의 형태에 따라 동일한 기능에 API 정의가 다를수도 있으나, 여기서는 일치한다고 보자.

독립적으로 존재하는 ThumbnailView에 viewModel을 설정한다면 아래와 같다

ThumbnailView.viewModel = ThumbNailMovieViewModel(movie)

ViewModel을 구성하여 각각의 View 구현체와 Model의 구현체를 일일이 매칭시켜 따로 구현할 필요는 없어졌다. 단지 ViewModel이 각 케이스별로 늘어나고 중복코드가 발생했다.

상위 View 프로토콜 생성

해당 중복된 부분을 최소화 시킬 수 있는지 검토하기 위해서, 우선 ThumbnailView와 PosterView의 중복된 부분을 아래와 같이 정의하였다.

protocol BasePosterView { 
setImage(view)
selectItem(key: String)
}

BasePosterView를 기준으로 ViewModel을

class BaseMovieViewModel: BasePosterView {
var movie: Movie
init(movie: Movie) { // initialize movie
}
showImage(view: ImageView) {
view = Image(url: movie.movieImage)
}
selectItem(key: String) {
detail = movie(key)
goto detailView(with: detail)
}
}

이를 기준으로 Movie를 참조하는 두가지 종류의 ViewModel을 정의하면 어떻게 될까?

protocol ThumbnailMovieViewModel: BaseMovieViewModel { 
...
func progressBar(view: UIProgressBar) {
view = movie.percent
}
func description(subTitle: String) {
subTitle = movie.name
}
}
protocol PosterMovieViewModel: BaseMovieViewModel {
...
setBadge(view: ImageView) {
view = Image(url: movie.movieImage)
}
}

여기서 PosterMovieViewModel이나 ThumbnailMoveViewModel을 PosterView나 ThumbnailView에 설정을 하기위해서는 해당 ViewModel이 각 View의 인터페이스를 만족해야 한다.

protocol ThumbnailMovieViewModel: BaseMovieViewModel, ThumbnailPoster { 
...
override init(movie: Movie) {
super.init(movie: movie)
}
func progressBar(view: UIProgressBar) {
view = movie.percent
}
func description(subTitle: String) {
subTitle = movie.name
}
}
class PosterMovieViewModel: BaseMovieViewModel, PosterViewProtocol {
...
override init(movie: Movie) {
super.init(movie: movie)
}
func setBadge(view: ImageView) {
view = Image(url: movie.movieImage)
}
}
class PosterHobbyViewModel: BaseHobbyViewModel, PosterViewProtocol {
...
override init(hobby: Hobby) {
super.init(hobby: hobby)
}
func setBadge(view: ImageView) {
view = Image(url: movie.movieImage)
}
}

동일한 모델에 대해서 공통적인 인터페이스를 추출한 후 이를 구현한 ViewModel 클래스를 상속받는다. 이 상태에서 표현하고자 하는 View 의 인터페이스를 만족한다면, ViewModel은 정상적으로 동작될 것이다.

class PosterMovieViewModel: BaseMovieViewModelThumbnailPoster 에 대한 인터페이스를 모두 포함하고 있으면 될 것이다

포함하여 다시 생각하기

중복코드를 피하려고 하니 결과적으로 화면 프로토콜 API와 Movie데이타에 대한 ViewModel 클래스의 API가 중복되지만, 어찌됐던 인터페이스는 만족할 것으로 생각된다.

기본적으로

protocol ThumbnailPoster: BasePosterView

protocol PosterViewProtocol: BasePosterView

를 상속받았을 때 이에 대한 중복 코드를 없애기 위해서는 BasePosterView에 대한 구현이 필요하다. 그러나 구현을 위해서 종속적인 조건하나가 필요하다. 즉 model에 따라서 구현이 달라진다. 즉 BaseMovieViewModel , BaseTVDramaViewModel 이 필요하다는 것이다. 실제 화면을 구성하기 위해선 각 View에서 정의한 인터페이스에 대해서 모델데이타 값을 처리하여 구현되어야 된다. 여기서는 ViewModel이다.

ViewModel = Function(View, Model)

BasePosterView를 포함하는 별도의 화면을 인터페이스로 정의하는 ViewModel을 정의한다고 하자.

우선 각각의 공통인 부분에 대해서 별도의 프로토콜을 가지게 변수로 정의 한다

protocol ThumbnailPoster {
var basePosterView: BasePosterView { get set }

func progressBar(view: UIProgressBar)
func description(subTitle: String)
}
protocol PosterViewProtocol {
var basePosterView: BasePosterView { get set }
func setBadge(view)
}

movie에서 각각의 View에 대한 ViewModel을 구현하면

class ThumbnailMovieViewModel: ThumbnailPoster { 
var movie: Movie
var basePosterView: BasePosterView
init(movie: Movie) {
self.movie = movie
basePosterView = BaseMovieViewModel(movie)
}

func progressBar(view: UIProgressBar) {
view = movie.percent
}
func description(subTitle: String) {
subTitle = movie.name
}
}

또는

class PosterMovieViewModel: PosterViewProtocol { 
var movie: Movie
var basePosterView: BasePosterView
init(movie: Movie) {
self.movie = movie
basePosterView = BaseMovieViewModel(movie)
}

setBadge(view: ImageView) {
view = movie.badgeIcon
}
}

이를 Movie를 표시하는 PosterView에 사용하려면

PosterView.viewModel = PosterMovieViewModel(movie)

이때 PosterView 는 Model과 상관없이 아래와 같이 구현될 수 있다. ThumbnailView 도 유사한 방법으로 처리하면 된다.

class PosterView: UIView {
var viewModel: PosterViewProtocol
showImage(view: ImageView) {
view = viewModel.basePosterView.showImage(view)
}
selectItem(key: String) {
detail = viewModel.basePosterView.goDetailView(key)
}
setBadge(view: ImageView) {
view = viewModel.setBadge(view)
}
}

만일 ThumbnailPoster를 변경없이 최초 정의한 프로토콜을 그대로 사용하고 싶다면

class ThumbnailMovieViewModel: ThumbnailPoster { 
var movie: Movie
var basePosterView: BasePosterView
init(movie: Movie) {
self.movie = movie
basePosterView = BaseMovieViewModel(movie)
}
showImage(view: ImageView) {
basePosterView.showImage(view: view)
}
selectItem(key: String) {
basePosterView.selectItem(key: key)
}

func progressBar(view: UIProgressBar) {
view = movie.percent
}
func description(subTitle: String) {
subTitle = movie.name
}
}

그런데 이렇게 구현하는 것이 최선일까? 의문이 남는다.

인생도 코딩도 후회의 연속.

--

--