기존 프로젝트는 RxSwift-UIKit로 진행하면서 자연스레 MVVM 패턴을 선택해왔다.
자연스럽다는 표현을 쓴 이유는, ViewModel이 주는 구조적 이점이나 데이터 플로우 관리의 유용함 뿐만 아니라 다른 개발자들과 쉽게 소통할 수 있다는 장점이 매력적이기 때문이다.
그럼에도 불구하고 이번 Combine-SwiftUI 프로젝트를 처음 진행하면서 MVVM 패턴이 아닌 TCA를 선택한 데에는 우선 몇 가지 불편함이 눈에 띄었기 때문이다. 내가 겪은 건 아래와 같다.
SwiftUI의 View는 자체적인 PropertyWrapper를 통해 데이터 바인딩 기능을 제공하기 때문에 input과 output을 binding하는 별도 과정이 불필요했다. 실제로 MVVM 구조에서 EnvironmentObject를 사용했다가 머리 부여잡은 사건은 아래 간단히 적겠다.
1번의 연장선상으로, SwiftUI 프로젝트에서 VM은 데이터 바인딩 없이 비즈니스 로직만 책임지게 된다. 이러한 책임 축소는 ViewModel 네이밍 이유와 어긋나다고 느껴진다. 단방향 데이터 플로우의 개선된 VM이라고 받아들인다 하더라도 조금 더 나은 이름이 있지 않을까.
이해도의 부족일 수도 있어서 이걸 적을지 말지 고민했다. 그렇지만 나는 실제로 MVVM을 적용하면서 View를 가볍게 만드는 건 가능했을 지언정 VM을 가볍게 만드는 데에는 실패했다. 의존성 주입의 과정에서 불필요한 정보가 VM과 공유되기 일쑤였다.
이 외에도 커뮤니티에서 개발자들의 여러 글을 보면 더 구체적이고 다양한 근거와 이야기가 있지만...
"SwiftUI에서 MVVM 사용을 멈추자"라고 생각이 들었던 이유
위의 예제에서 등장하는 것처럼 View로만 구조를 꾸리기에는 여전히 몇 가지 우려 지점이 존재했다.
우선 진입점이 많은 View(내지는 비즈니스 규모가 큰 서비스)의 경우, View가 지나치게 무거워질 것이 자명하다. 사실 나는 View에서 API 처리 등을 진행하는 걸 좋아하지 않기 때문에 서비스의 규모와는 무관하게 이를 처리할 객체가 존재해야 한다고 판단했다. View는 말 그대로 UI적 요소를 이용자에게 보여주는 것만이 역할이자 책임이라고 봤다.
우선적으로 develop 브랜치에서 시도한... VM의 이름만 바뀐 MVVM의 흐름은 아래와 같다.
다이어그램만 봤을 때는 기존의 MVVM 데이터 플로우와 다를 바가 없다.
문제만 산재할 뿐... 코드만 봐도 스트레스네...
책임 분리를 위해 VM(ViewHelper)에 input-output을 채택, VM 소통하는 데이터 레이어(클래스)를 만들었다. 클래스를 선택한 이유는 뷰 간 데이터를 공유하기 위해서였는데, 결과적으로 높은 의존성 뿐만 아니라 역할 경계가 모호해지는 문제가 발생했다.
이를 해결하기 위해 일부는 EnvironmentObject 프로퍼티 래퍼를 사용해보았다. 전역적으로 데이터를 공유할 수 있기 때문에 초반에는 대부분을 이 프로퍼티 래퍼로 개발했지만, 앱이 복잡해질수록 상위 뷰가 무거워졌고, 어디서 주입을 누락했는지 추적하기가 번거로웠다.
EnvironmentObject
를 사용했을 때, 변화를 추적하고 일을 하는 건 View가 되어버렸다.
struct LoginView: View {
@EnvironmentObject private var firebaseManager: FirebaseManager
var body: some View {
ZStack {
...
}
.interactiveDismissDisabled()
.onChange(of: self.firebaseManager.state) { state in
if state == .signedIn {
self.showLoginView.toggle()
}
}
}
}
VM에게 변화를 전달하는 방법도 있겠지만, 여전히 1번의 문제가 발생했다.
CoreData를 SwiftUI View에 넣는 과정에서 편의를 위해 FetchRequst 프로퍼티 래퍼를 선택했고, MessiveView가 완성되었다.
// HomeView.swift
@StateObject private var cookieState = CookieState(progress: 0, goal: 0)
@StateObject private var urlState = UrlState(urlChanged: figure.urlChangedDefault)
@EnvironmentObject private var menuManager: MenuManager
@Environment(\.managedObjectContext) private var viewContext
@FetchRequest(
entity: Cookie.entity(),
sortDescriptors: [NSSortDescriptor(
keyPath: \Cookie.date,
ascending: false
)],
animation: .default)
private var cookies: FetchedResults<Cookie>
@FetchRequest(
entity: PersonalVisitGoal.entity(),
sortDescriptors: [NSSortDescriptor(
keyPath: \PersonalVisitGoal.editedDate,
ascending: false)],
animation: .default)
private var goals: FetchedResults<PersonalVisitGoal>
그리고 스포해서 Dependencies로 의존성을 분리한 모습이다.
// CoreDataClient.swift
let context = StorageProvider.shared.container.viewContext
let fetchRequest = PersonalVisitGoal.fetchRequest()
fetchRequest.sortDescriptors = [NSSortDescriptor(
keyPath: \PersonalVisitGoal.editedDate,
ascending: false
)]
do {
guard let goal = try context.fetch(fetchRequest).first else {
let goal = PersonalVisitGoal(context: context)
goal.editedDate = Date()
goal.goal = 10
return goal
}
return goal
} catch {
print(error.localizedDescription)
let goal = PersonalVisitGoal(context: context)
goal.editedDate = Date()
goal.goal = 10
return goal
}
// HomeView.swift
private let store: StoreOf<HomeFeature>
ViewModel 내부에 CoreDataClient의 로직을 위치시키는 것도 방법일 수 있다. 그러나 M과 V를 바인딩하는 목적의 레이어에게 해당 로직을 위치시키는 게 자연스러운 지에 대해서는 의문이 들었다.
View와 Data, VM을 구분짓는 명확한 역할 정의를 위해서는 새로운 아키텍처의 필요성을 느꼈다. 더불어 개발 환경에 따라 변화할 수 있는 비즈니스 로직(API 호출이나 AOuth 등)을 담당할 객체, 그리고 이것의 의존성을 관리할 도구 또한 요구되었다. 그리고 이런 요구를 충족해줄 수 있는 아키텍처로 TCA를 선택하게 되었다.
TCA(The Composable Architecture)는 단방향 데이터 플로우를 기반한 라이브러리다. ReactorKit과 유사한 플로우를 가지는데, 그 베이스 기반이 TCA는 SwiftUI, ReactorKit은 RxSwift 라는 차이점 정도를 가진다.
추가 정보는 Github Repo, TCA Documentation에서 확인할 수 있다.
공식에서 제공하는 다이어그램도 있지만, 내가 실제 프로젝트에서 구현한 데이터 플로우는 아래와 같다.
Effect를 Stream으로 바꿔 읽는다면 익숙한 모습이다.
TCA에서 크게 개선되었다고 느낀 강점은 크게 두 가지다.
struct BlogListFeauture: ReducerProtocol {
struct State: Equatable, Identifiable {
...
}
enum Action: Equatable {
case blog(id: BlogListCellFeature.State.ID, action: BlogListCellFeature.Action)
}
var body: some ReducerProtocol<State,Action> {
Reduce { state, action in
switch action {
case .blog(_, .delegate(.update)):
return .task { .fetchData }
case .blog(_, .loginStateUpdated):
return .send(.loginStateUpdated)
default:
return .none
}
}
.forEach(\.blogList, action: /Action.blog(id:action:)) {
BlogListCellFeature()
}
}
}
한 Store
의 State
는 모두 Action
을 통해 변화한다. 이때 이 Store와 1대1 대응하는 뷰가 하위 뷰를 가지고 있다면 Action을 통해 State를 공유하거나, 반대로 하위 뷰가 발생시키는 이펙트에 반응하여 State를 업데이트한다.
실제 프로젝트를 진행하면서도 State를 변화시키는 객체가 명확하고, 복잡한 도메인이 얽히지 않고 단방향으로 관리되는 것이 큰 장점으로 느껴졌다.
TCA는 기능 단위로 모듈화하고 사용하는 법이 직관적이다.
아래는 CoreData
의 세부 기능을 담당하는 CoreDataClient
를 전역으로 선언하는 모습이다.
// CoreDataClient.swift
extension DependencyValues {
var coreDataClient: CoreDataClient {
get { self[CoreDataClient.self] }
set { self[CoreDataClient.self] = newValue }
}
}
그리고 이렇게 선언된 coreDataClient는 다른 Client(Denedency)나 Store 내부에서 @Dependency(\.coreDataClient) var coreDataClient
로 사용이 가능해진다.
실제로 프로젝트에서는 RecordClient
가 4곳, FirebaseClient
가 6곳 등으로 재사용된다.
참고 - Composable Architecture - Dependencies
TCA 라이브러리에 포함된 Dependency
프로토콜을 채택하면 Store
와 상호 작용할 수 있는 의존성을 주입해줄 수 있다. 이는 기존에 Environment를 App단 혹은 상위 뷰에서 관리해줘야 하는 부담을 줄여줄 수 있기 때문에 강력한 의존성 관리 도구다.
// ImageClient.swift
struct ImageClient {
var fetchImageData: @Sendable (URL) async throws -> Data
}
extension ImageClient: DependencyKey {
static let liveValue = Self(
fetchImageData: { url in
return try await ImageProvider.shared.fetchImageData(with: url)
}
)
}
extension DependencyValues {
var imageClient: ImageClient {
get { self[ImageClient.self] }
set { self[ImageClient.self] = newValue }
}
}
URL 매개변수로 Data를 리턴하는 의존성이다.
실질적으로 데이터를 가져오는 fetchImageData
메소드는 Provider
라는 별도 객체로 관리해주기 때문에 이 의존성 객체에는 Store
에서 요청할 수 있는 input(fetchImageData
), 값 타입(liveValue
)의 분기 익스텐션만 제공할 수 있었다.
여기서는 값 타입으로 liveValue
만 정의했지만, 필요에 따라 preview나 test에 사용할 mockValue
또한 동일하게 DependencyKey
를 채택한 익스텐션에서 정의할 수 있다.
// ImageLoadFeature.swift
struct ImageLoadFeature: ReducerProtocol {
struct State: Equatable {
...
}
enum Action: Equatable {
case fetchImageData(URL)
case fetchedImageData(TaskResult<Data>)
}
@Dependency(\.imageClient) var imageClient
var body: some ReducerProtocol<State, Action> {
Reduce { state, action in
switch action {
case .fetchImageData(let url):
return .task {
await .fetchedImageData(
TaskResult {
try await self.imageClient.fetchImageData(url)
}
)
}
case .fetchedImageData(.failure(let error)):
print(error.localizedDescription)
return .none
case .fetchedImageData(.success(let data)):
state.data = data
return .none
}
}
}
}
실제 사용처인 ImageLoadFeature
다. @Depedency
어트리뷰트를 사용해서 의존성을 주입해주는 것으로 준비는 끝이다. Reduce
내부를 보면 input(fetchImageData
)이 요구하는 Argument를 넘겨주고 있다. fetchImageData
를 async
로 정의하고 있기 때문에 await
로 처리, .task
를 통해 이펙트를 방출한다.
여담으로 task
메소드는 Extension EffectPublisher where failure == Never
에 정의되어 있다.
직관적이기 때문에 가독성이 좋거나 Testable하다는 부분은 이 글에서는 다루지 않을 예정이지만, 결론적으로 서드파티 라이브러리임에도 아키텍처로 채택할 매력이 있다는 점에서 굉장히 동의하고 있다. 그럼에도 TCA도 몇 가지 불편한 점들을 안고 있었는데 그것들은 다른 글에서 다뤄보려고 한다.
(작성될 시 링크가 기재됩니다.)
더불어 더 시간이 된다면 TCA에서 제공하는 테스트 도구도 사용해보고 싶다.
| 공식 참고 문서 |
TCA 깃허브 레포지토리
공식 예제 프로젝트 pointfree- Todos
공식 예제 프로젝트 pointfree - isowords
TCA Documentation
Pointfree video series
| 참고 문서 |
Stop using MVVM for SwiftUI
SwiftUI를 위한 클린 아키텍처
제조업에서 SwiftUI + TCA로 앱 개발하기 - KWDC23
네이버페이 워치앱 SwiftUI TCA 적용기
그리고 Discussion 사랑해요