Do.

SwiftUI 에서 MVVM 을 멈춰야 하는가? 본문

General Dev

SwiftUI 에서 MVVM 을 멈춰야 하는가?

Hey_Hen 2022. 8. 10. 02:11

꽤 자극적인 주제죠?
네 오늘은 SwiftUI에서 MVVM을 멈춰야 하는가에 대한 제 생각을 써보려고 합니다.

결론은 쓰세요 입니다. 반박시 여러분 말이 맞습니다. 👍

본문에 들어가기 앞서, 민감한 주제라고 생각하기 때문에, 누가 그러한 주장을 했는지는 모두 생략하도록 하겠습니다.
본 글에서 작성하는 '여론'의 근거는 주로 iOS Developer Slack 채널 raywenderlich 웨비나, 블로그 글 등을 참고했습니다.

이제 왜 이러한 결론에 도달했는지 설명 드리겠습니다.

SwiftUI의 State와 Binding은 View Model 인가?

최근들어 iOS Developer Slack 채널이나, 온갖 뉴스레터에서 SwiftUI Framework에서는 MVVM을 쓰지 말자는 논지의 아티클을 정말 많이 본 것 같습니다. raywenderlich에서 진행한 웨비나도 있었습니다.

SwiftUI에서 MVVM을 쓰지 말자고 하시는 분들의 의견을 정리하면 아래와 같습니다.

SwiftUI에서는 View에 View Model이 통합되었습니다. (State가 ViewModel이다.) 따라서 View Model을 통한 바인딩이 필요없어졌기 때문에 ViewModel은 불필요한 레이어입니다.

저는 이 의견 말고는 다른 의견은 본적이 없습니다. 있다면 언제든 댓글/이메일 등으로 알려주시면 정정하도록 하겠습니다.

그리고 위 의견에 반대하는 의견도 있습니다.

SwiftUI View에 View Model이 통합되면, Model이 View에 보여지기 위해서는 보여주기 위한 형태로 변환하는 과정이 필요한데, View Model은 그 역할을 겸하고 있다. 그렇다면 이 보여주기 위한(Presentation) 형태로 변환하는 작업 또한 View에 포함되야 하는데, 너무 무거워 진다.

기본적으로 두 의견이 대세인 것으로 보입니다.

두 의견의 핵심을 추려서 다시 파악해 보면
SwiftUI의 StateBinding이 ViewModel로 볼 것이냐 아니냐 에 대한 차이로 보입니다.

State, Binding의 특징은 값이 변경되면 body를 재 호출 해서 body 라는 뷰를 통째로 새로 그리게 됩니다. 즉 값이 변경되면 뷰가 즉시 반응하는 반응형 프로그래밍인 셈이죠.

ViewModel이 뭔데?

MVVM 디자인 패턴은 2005년 Microsoft의 WPF, Sliverlight의 설계자 중 한명인 John Gossman이 개발하고 블로그를 통해 공개했습니다.(1) 해당 패턴은 Martin Fowler의 PM의 아이디어를 차용했다고 밝혔는데요, 해당 블로그 글에 따르면 핵심은 ViewModel과 View의 의존 방향이며, View가 ViewModel의 값을 얻는 특이한 방법입니다.

쉽게 설명하자면, 다들 아시겠지만 MVC의 구조는 유저가 View를 보고 터치 동작과 같은 반응을 수행하면, Controller에 의존해서 이를 수행하고, Controller과 Model과 통신해서 유저 동작 수행 결과를 View에 업데이트 시킵니다. 이 과정에서 View는 Controller에 의존하면서 Controller는 또 View를 알고있는 상태가 됩니다. MVC는 이 방법을 View와 Controller의 상호 참조를 통해서 쉽게 해결합니다. 네 그렇습니다. 양방향 패턴입니다.

그러다 보니 View와 Controller가 너무 의존적이기 때문에 재사용성이 떨어지는 결과를 초래했습니다, 통제 흐름이 양방향이다 보니 리팩터링할 때 코드 찾아다니는 것도 쉽지 않습니다.


MVVM은 이러한 문제를 단방향 참조와 Binding과 같은 개념으로 민첩하게 설계할 수 있게 해줍니다.
View는 유저가 발생하는 어떤 액션을 ViewModel에 보내면, ViewModel은 해당 액션을 분석하고 Model에 의존해서 그 결과를 단순히 방출만 합니다. View를 직접 업데이트 하지 않습니다. 이 덕분에 통제 흐름이 단방향으로 흐르게 됩니다.

View는 업데이트를 어떻게 하냐 하면, 딱히 특정 ViewModel이 아니더라도 인터페이스를 통해 방출되고 있는 값을 감시하고 하고 있다가 값이 업데이트 되면 뷰 스스로를 변경하면 됩니다.

이 부분이 ViewModel이 존재하는 핵심 이유입니다. 상호 참조로 해결하냐, 값을 방출만 하냐 그 차이지만 이 차이가 생각보다 굉장히 중요합니다. 저는 경험적으로 양방향 아키텍처를 분석하는 것 보다 단방향 아키텍처를 분석하는 것이 훨씬 쉽고, 나중에 분리하기도 쉬웠습니다.
(이 부분이 사바사 일지는 잘 모르겠습니다. 의견 주시면 감사하겠습니다.)

ViewModel의 핵심을 설명하느라 좀 많이 돌아왔는데요!
그래서 State/Binding이 ViewModel을 대체할 수 있을까요?
아니요 그럴 수 없습니다.

저는 두가지 핵심 이유가 있다고 생각합니다.

  1. State와 Binding PropertyWrapper는 모델을 사용하기에 불편하다.
  2. 수명주기가 View에 종속된다.

하나씩 설명 드리겠습니다.

State와 Binding PropertyWrapper는 객체를 사용하기에 불편하다.

우선 State와 Binding은 Class 모델을 사용하기에 조금 번거롭습니다. 무슨 뜻일까요? 애플의 설계는 State, Binding으로 감싼 객체가 변경이 되면 항상 body를 새로 그리기를 원합니다. SwiftUI에서 View는 값의 상태와 동일한 개념입니다. 값이 변경되면 (일반적으로) 항상 뷰가 변경이 됩니다. UIKit과 달리 일일이 업데이트 코드를 사용하지 않아도 되는 것이 SwiftUI의 장점이자 편리한 점이죠.

그런데 State와 Binding으로 감싼 값이 '구조체'가 아니라 '클래스'가 되면 이 규칙은 깨집니다. 구조체일 경우 mutate가 발생하고 body는 그것을 인지하고 뷰를 업데이트 합니다. 그런데 클래스의 경우 멤버가 변경되는 것을 mutate로 보지 않죠 간단합니다.

struct Book {
  var title: String
  var content: String

  init(title: String, content: String) {
    self.title = title
    self.content = content
  }
}

간단하게 Book 클래스를 만들겠습니다.

struct ContentView: View {
  @State var book = Book(title: "곰세마리", content: "")
  var body: some View {
    VStack {
      Image(systemName: "book")
        .imageScale(.large)
        .foregroundColor(.accentColor)
      TextField(
        "내용을 입력하세요",
        text: $book.content,
        axis: .vertical
      )
      .padding()
      .background {
        RoundedRectangle(cornerRadius: 8)
          .foregroundColor(.gray.opacity(0.3))
      }
      .padding()
      RedrawMonitor(count: book.content.count)
    }
  }
}

struct RedrawMonitor: View {
  let count: Int
  var body: some View {
    Text("\(count)")
  }

  init(count: Int) {
    self.count = count
  }
}

그리고 간단하게 Book의 내용을 편집할 수 있는 뷰를 마련했습니다. 여기서 TextField로 Book의 내용을 수정할 수 있고, RedrawMontior를 통해 책 content의 길이를 표시하려고 합니다. 당연히 TextField를 수정하게 되면 Book은 mutate가 발생하게 되고 뷰를 새로 그리면서 RedrawMonitor는 content 길이를 업데이트 하고 표시합니다.

그런데 만약 여기서 Book이 구조체가 아니고 클래스로 변경되면 어떻게 될까요?

final class Book {
  var title: String
  var content: String

  init(title: String, content: String) {
    self.title = title
    self.content = content
  }
}

그닥 놀라운 일은 아닙니다. RedrawMonitor가 업데이트 되지 않습니다.

물론 여기서 왜 굳이 Book이 클래스 이어야 하냐고 반문 하실 수도 있습니다. 하지만 앱을 만들다 보면 꼭 구조체만 사용할 수 없는 상황은 오기 마련입니다.  또 클래스여도 업데이트 할 방법은 있다고 하실 수 있습니다. 의도적으로 mutating을 발생시키는 State를 하나 만들면 됩니다. 하지만 그것은 값의 변화에 따라 뷰 업데이트를 신경쓰지 않겠다는 SwiftUI의 근본 개념 자체를 부정하는 행위입니다.

그래서 SwiftUI는 이러한 불편함을 해소 할 수 있는 도구가 하나 더 있습니다. 바로 ObservableObject 입니다. ObservableObject는 내부에서 objectWillChange가 호출되면 body를 다시 그리도록 하는 특성이 있습니다.
Published Property Wrapper를 사용하면 자동으로 objectWillChange를 호출하죠.

물론 Published에 클래스를 쓰게되면 자동으로 objectWillChange를 발생시키지 않습니다. State와 같은 이유 이지요.
하지만 강제적으로 뷰를 업데이트 하기 위해 State은 더미를 만들어야 하지만 ObservableObject는 의도적으로 통제할 수 있죠.

결론은 State와 Binding에는 구조체만 넣는것이 합당? 해 보이고 이는 State와 Binding을 ViewModel로 보기에는 수많은 제약이 따릅니다.

수명주기가 View에 종속된다.

SwiftUI에서 View는 Data의 파생입니다. 데이터가 변경이 되면 View는 자동으로 상태를 따라갑니다. 그런데 WWDC 내용에 따르면 View는 지역적 특성을 가집니다, View에 종속되죠. 실제로 SwiftUI 뷰를 구성하다 보면 State, Binding은 값이라는 느낌보다는 UILabel, UIButton 같은 느낌입니다. 값 보다는 뷰에 가까운 것이죠. API통신과 같은 외부의 값을 가져오는데는 많은 불편함이 있습니다.
View쪽 코드에서 어떤 값을 가져와 State에 대입해야 하죠.
코드로 보면

final class Repository {
  private let cached = Book(title: "Swift", content: "어렵다~")

  func loadBook() -> Book {
    cached
  }
}

어떤 Repository 모델이 있다고 가정 합니다. 아주 심플하게요

struct ContentView: View {
  let repository: Repository
  @State var update = false
  @State var book = Book(title: "곰세마리", content: "")
  var body: some View {
    VStack { ... } 
    .onAppear {
      let initBook = repository.loadBook()
      self.book = initBook
    }
  }
}

외부에서 가져온 값을 @State book으로 전달할려면 View의 특정 시점이나 트리거에서 가져와서 대입해야 합니다. 그런데 여기에 한술 더떠 지금은 Entity를 날것 그대로 가져오고 있는데, 만약 여기서 데이터를 가공해서 보여줘야 할때는 어떻게 해야 하나요? 데이터 가공 코드가 뷰에 들어가야 하나요? 그러지 않길 바랍니다.

해결책은 많다.

앞서 말한 케이스의 해결방법은 엄청 많습니다. Realm에서 밀고가는 MVI도 있고, TCA도 있습니다. 그런데 그거아세요?
Realm에서 말한 MVI는 Realm에 의존합니다. 웨비나에 따르면 다른 Persistent Data Base에 대한 질문이 나왔을 때 Realm 개발자 분께서는 답변을 주시지 않으셨습니다.
TCA는? TCA 그 자체라는 거대한 라이브러리에 의존합니다.

그런데 ObservableObject를 ViewModel로 쓰면 별도의 프레임워크, 라이브러리 없이 너무나 쉽게 해결 가능합니다.
물론 SwiftUI에 알맞게 사용하기 위해 많은 코드를 연구하다 보면 이건 더이상 ViewModel이 아닌 것 같은데~ 그런 생각이 들기도 하고, 어쨌든 정답은 없으니 기존 UIKit 프레임워크 위에서 쓰던 MVVM을 그대로 가져오면 또 SwiftUI의 특성을 100% 살리지 못하는 것도 사실입니다.

하지만 애초에 MVVM은 ViewModel과 View의 통신방법 (바인딩)과 의존방향(단방향) 이 핵심이라는 것을 잊지 마세요! ObservableObject는 바인딩을 하기에 매우 유용한 도구입니다.

결론

SwiftUI라고 해서 MVVM 개념이 부적절 하다는 생각은 저는 별로 좋지 않다고 생각합니다. 그렇다고 MVVM을 맹목적으로 사용하는 것도 주의해야 합니다. 하지만 MVVM을 포기하고(정확히는 MVVM의 개념)을 포기하고 굳이 다른 서드파티 프레임워크 라는 길로 돌아가지 않았으면 합니다. 중요한 것은 민첩하고 유연한 코드를 구성하는 개념 그 자체이지 어떤 디자인 패턴, 아키텍처 패턴을 선택하는 것이 아니라는 것입니다.

References

(1) https://docs.microsoft.com/en-us/archive/msdn-magazine/2009/february/patterns-wpf-apps-with-the-model-view-viewmodel-design-pattern
(2) 클린 아키텍처, 로버트 마틴
(3) raywenderlich Realm 웨비나
(4) SwiftUI 관련 WWDC

Comments