SwiftUI에서 ViewModel을 효과적으로 사용하고, DIP를 지키는 방법

서론

  • SwiftUI에서 MVVM을 채택하게 되면 ViewModel을 어떤 방식으로 구현할지 고민을 해봐야합니다.
  • SwiftUI에서 제공해주는 API인 State, ObservableObject은 Source Of Truth라는 중요한 것들이 있습니다. ViewModel은 한 화면마다 하나의 Source Of Truth로 구현되어야 마땅합니다.
  • 이번 글에서는 SwiftUI에서 ViewModel을 어떻게 쉽게 관리할 수 있는지, 또 Clean Architecture를 위한 DIP는 어떤식으로 지켜질 수 있는지 알아보겠습니다.

 

ObservableObject의 ViewModel

  • ViewModel은 Source Of Truth이어야합니다. 즉 여러 값으로 복사되어 여러 객체에서 사용되지 않아야 합니다.
  • 그래서 주로 클래스에서만 사용할 수 있는 ObservableObject를 채택해야합니다.
  • 이 글에서는 ObservableObject의 자세한 설명을 생략하겠습니다. 자세한 내용은 여기에서 확인하실 수 있습니다.
final class HomeViewModel: ObservableObject {
  @Published var products: [Product] = []
  private let service: HomeServiceRepresentable

  init(service: HomeServiceRepresentable) {
    self.service = service
  }

  func fetchProducts() async throws {
    try await products.append(contentsOf: service.fetchProductList())
  }
}
  • 보통 클래스의 직접적으로 ObservableObject를 채택해서 사용합니다.
  • 만약 @Published인 products가 변경된다면, 해당 ViewModel을 참조하고 있는 View들은 모두 업데이트 될 것입니다.

 

아키텍처 흐름의 고민

  • 하지만 이 방식에는 단점이 있습니다.
  • 해당 ViewModel을 가지고 있는 뷰의 하위 뷰들은 products 이외의 또 다른 @State변수를 가질 수도 있습니다.
struct HomeView: View { // 상위 뷰
  var body: some View {
    NavigationStack {
      HomeProductSorterView()
    }
    .environmentObject(viewModel) // ⭐️ ViewModel 주입
    .padding(.horizontal, Metrics.horizontal)
    .navigationBarTitleDisplayMode(.inline)
    .onAppear {
      viewModel.trigger(.fetchProducts)
      viewModel.trigger(.fetchCount)
    }
  }
}

struct HomeProductSorterView: View { // 하위 뷰
  @EnvironmentObject private var viewModel: HomeViewModel
  @State private var count: Int = 0 // Source Of Truth가 두 개 생성
}
  • Home에 관련한 ViewModel이라는 Source Of Truth가 있지만, 하위 뷰에서 count라는 새로운 @State가 생성되었습니다.
  • 따라서 한 화면의 하위뷰까지 모두 커버할 수 있는 ViewModel에게 State를 수정하는 것이 더 나은 방법입니다.
// MARK: - HomeAction

enum HomeAction {
  case fetchProducts
  case loadMoreProducts
  case fetchCount
  case changeOrder
  case changeConvenienceStore(ConvenienceStore)
  case changePromotion(Promotion)
}

// MARK: - HomeState

struct HomeState { ✅
  var products: [Product] = []
  var totalCount: Int = 0
  var productConfiguration: ProductConfiguration = .init()
}

final class HomeViewModel: HomeViewModelRepresentable {
  // MARK: Properties

  private let service: HomeServiceRepresentable

  @Published private(set) var state: HomeState = .init() ✅

  // MARK: Initializations

  init(service: HomeServiceRepresentable) {
    self.service = service
  }

  // MARK: Public Methods

  func trigger(_ action: HomeAction) {
    Task {
	    // action을 통해서 state를 변경해줌
      await handle(action)
    }
  }
}
  • 하위 뷰의 @State가 업데이트되면 ViewModel과 자기 자신의 @State도 업데이트시켜주는 두 가지의 Source Of Truth를 변경하게 되지만,
  • 후자의 방식은 ViewModel에게 이벤트만 전달해서 ViewModel의 state라는 한 가지 변수만 수정해주면 편리합니다.

 

아직 DIP가 지켜지지 않았다.

  • 이것으로 마무리되면 좋겠지만 아직까지 DIP가 지켜지지 않았습니다. 우리는 MockViewModel을 통해 원할한 반응 테스트를 검증하고 싶었기 떄문입니다.
  • 당연하게도 DI를 지키기 위해서는
    • ViewModel에서 구현할 로직들을 프로토콜에 명세하고
    • ViewModel이 해당 프로토콜을 채택한 후
    • View는 기존 콘크리트 타입이 아닌 추상화된 타입으로서 ViewModel을 바라보면 된다.
  • 하지만 문제가 있습니다.
protocol HomeViewModelRepresentable: ObservableObject {
  var state: HomeState { get }
  func trigger(_ action: HomeAction)
}

final class HomeViewModel: TestViewModelRepresentable { /* ... */ }

struct HomeView: View {
  @StateObject var viewModel: TestViewModelRepresentable // ❌ 오류
  // ...
}
  • 오류가 발생합니다.
  • 왜냐하면 ObservableObject에는 associatedType이 존재하기 때문이죠
public protocol ObservableObject : AnyObject {
  /// The type of publisher that emits before the object has changed.
  associatedtype ObjectWillChangePublisher : Publisher = ObservableObjectPublisher where Self.ObjectWillChangePublisher.Failure == Never
}
  • View에서는 컴파일 시점에 ObservableObject의 실제 구현체가 어떤 타입인지 알 수 없기 때문에 오류가 발생합니다.

 

DIP를 지키는 방법

  • 해결 방법은 간단합니다. 실제 구현체의 타입을 제네릭으로 선언하기만 하면 됩니다.
// 성공 ✅
struct HomeView<ViewModel>: View where ViewModel: HomeViewModelRepresentable {
  @StateObject private var viewModel: ViewModel
}

StateObject에 private 접근제어 사용하기

  • View에 들어가는 ViewModel은 기본적으로 StateObject가 가장 적합합니다.
  • 하지만 StateObject가 private로 선언되었기 때문에 기본적으로 Memberwise Initializer가 제공되지 않습니다.
  • StateObject는 기본적으로 View의 생명 주기를 공유하는 ObservedObject입니다. 이 생명주기를 공유하는 프로퍼티이기 떄문에 문제가 발생합니다.
  • 만약 ViewModel을 바로 전달해주면 어떤 일이 발생할까요? 로그를 출력해서 알아보면,
final class HomeViewModel: TestViewModelRepresentable {
  //...

  init() {
    Log.make(with: .viewModel)?.debug("\\(Self.self) Initialized")
  }
  deinit {
    Log.make(with: .viewModel)?.debug("\\(Self.self) Deinitialized")
  }
}

struct HomeView<ViewModel>: View where ViewModel: HomeViewModelRepresentable {
  @StateObject private var viewModel: ViewModel  
  
  init(viewModel: HomeViewModelRepresentable) {
    _viewModel = StateObject(wrappedValue: viewModel)
    Log.make(with: .view)?.debug("\\(Self.self) Initialized")
  }
}
HomeViewModel Initialized
HomView Initialized
HomeViewModel Initialized
HomView Initialized
HomeViewModel Initialized
HomView Initialized
HomeViewModel Deinitialized
HomeViewModel Initialized
HomView Initialized
HomeViewModel Deinitialized
  • 이런 식으로 View와 ViewModel의 인스턴스가 끊임없이 다시 생성되고 해제되는 것을 볼 수 있습니다. 왜냐하면 생성 로직에서 viewModel 또한 생성되고 있기 떄문입니다.
  • 하지만 앞서 말했듯이 StateObject는 View의 인스턴스가 계속 생겨남과 상관없이 하나의 인스턴스를 공유해야합니다.
  • 그래서 이 방법과 달리,
struct HomeView<ViewModel>: View where ViewModel: HomeViewModelRepresentable {
  @StateObject private var viewModel: ViewModel
  
  init(viewModel: @autoclosure @escaping () -> ViewModel) {
    _viewModel = StateObject(wrappedValue: viewModel())
    Log.make(with: .view)?.debug("\\(Self.self) Initialized")
  }
}
HomeView Initialized
HomeViewModel Initialized // 오직 한 번 실행 됨!
HomeView Initialized
HomeView Initialized
HomeView Initialized
  • 이렇게 클로저로 전달하게 되면 성공적으로 View의 생명주기와 같이 ViewModel의 인스턴스가 생성되고 해제될 수 있습니다.
  • 이렇게 @escaping 클로저를 생성자에 배치함으로서 현재 View와 생명 주기의 관계를 따져서 init()의 로직을 실행할지 안 할지 판단하는 것 같습니다.

 

마치며

  • 이번 글에서는 SwiftUI에서 ViewModel을 효율적으로 관리하는 방법과 DIP를 지키는 방법을 알아보았습니다. 뿐만 아니라 그 ObservedObject가 StateObject라면 private 접근 제어를 어떻게 사용할 수 있는지도 알아보았습니다.
  • 그냥 쉽게 TCA같은 라이브러리를 사용할 수도 있었지만, 외부 라이브러리 사용을 최대한 지양하면서 Clean Architecture를 지키는 보편적인 MVVM을 SwiftUI에는 어떻게 적용할 수 있는지 고민할 수 있는 유익한 고민거리였습니다.

 

Reference