SwiftUI 개발 환경에서 MVVM이 아닌 TCA 아키텍처를 선택한 이유

GOSARI·2023년 11월 2일
1

Architecture

목록 보기
2/2
post-thumbnail

기존 프로젝트는 RxSwift-UIKit로 진행하면서 자연스레 MVVM 패턴을 선택해왔다.

자연스럽다는 표현을 쓴 이유는, ViewModel이 주는 구조적 이점이나 데이터 플로우 관리의 유용함 뿐만 아니라 다른 개발자들과 쉽게 소통할 수 있다는 장점이 매력적이기 때문이다.

그럼에도 불구하고 이번 Combine-SwiftUI 프로젝트를 처음 진행하면서 MVVM 패턴이 아닌 TCA를 선택한 데에는 우선 몇 가지 불편함이 눈에 띄었기 때문이다. 내가 겪은 건 아래와 같다.

1. SwiftUI의 PropertyWrapper를 활용하기 어렵다.

SwiftUI의 View는 자체적인 PropertyWrapper를 통해 데이터 바인딩 기능을 제공하기 때문에 input과 output을 binding하는 별도 과정이 불필요했다. 실제로 MVVM 구조에서 EnvironmentObject를 사용했다가 머리 부여잡은 사건은 아래 간단히 적겠다.

2. 비즈니스 로직만 책임지는 VM은 동일하게 명명될 수 있을까.

1번의 연장선상으로, SwiftUI 프로젝트에서 VM은 데이터 바인딩 없이 비즈니스 로직만 책임지게 된다. 이러한 책임 축소는 ViewModel 네이밍 이유와 어긋나다고 느껴진다. 단방향 데이터 플로우의 개선된 VM이라고 받아들인다 하더라도 조금 더 나은 이름이 있지 않을까.

3. 의존성 관리와 MVVM은 별도다.

이해도의 부족일 수도 있어서 이걸 적을지 말지 고민했다. 그렇지만 나는 실제로 MVVM을 적용하면서 View를 가볍게 만드는 건 가능했을 지언정 VM을 가볍게 만드는 데에는 실패했다. 의존성 주입의 과정에서 불필요한 정보가 VM과 공유되기 일쑤였다.


이 외에도 커뮤니티에서 개발자들의 여러 글을 보면 더 구체적이고 다양한 근거와 이야기가 있지만...

"SwiftUI에서 MVVM 사용을 멈추자"라고 생각이 들었던 이유

SwiftUI를 위한 클린 아키텍처

Stop using MVVM for SwiftUI

위의 예제에서 등장하는 것처럼 View로만 구조를 꾸리기에는 여전히 몇 가지 우려 지점이 존재했다.

우선 진입점이 많은 View(내지는 비즈니스 규모가 큰 서비스)의 경우, View가 지나치게 무거워질 것이 자명하다. 사실 나는 View에서 API 처리 등을 진행하는 걸 좋아하지 않기 때문에 서비스의 규모와는 무관하게 이를 처리할 객체가 존재해야 한다고 판단했다. View는 말 그대로 UI적 요소를 이용자에게 보여주는 것만이 역할이자 책임이라고 봤다.


MVVM...?

우선적으로 develop 브랜치에서 시도한... VM의 이름만 바뀐 MVVM의 흐름은 아래와 같다.

다이어그램만 봤을 때는 기존의 MVVM 데이터 플로우와 다를 바가 없다.
문제만 산재할 뿐... 코드만 봐도 스트레스네...

1. ViewModel 간 데이터 공유 탓에 의존성이 높아진다.

책임 분리를 위해 VM(ViewHelper)에 input-output을 채택, VM 소통하는 데이터 레이어(클래스)를 만들었다. 클래스를 선택한 이유는 뷰 간 데이터를 공유하기 위해서였는데, 결과적으로 높은 의존성 뿐만 아니라 역할 경계가 모호해지는 문제가 발생했다.

이를 해결하기 위해 일부는 EnvironmentObject 프로퍼티 래퍼를 사용해보았다. 전역적으로 데이터를 공유할 수 있기 때문에 초반에는 대부분을 이 프로퍼티 래퍼로 개발했지만, 앱이 복잡해질수록 상위 뷰가 무거워졌고, 어디서 주입을 누락했는지 추적하기가 번거로웠다.

2. View가 Presenter라는 역할만 가지지 않는다.

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번의 문제가 발생했다.

3. 특정 프로퍼티 래퍼를 View에서 분리하기 까다롭다.

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

TCA(The Composable Architecture)는 단방향 데이터 플로우를 기반한 라이브러리다. ReactorKit과 유사한 플로우를 가지는데, 그 베이스 기반이 TCA는 SwiftUI, ReactorKit은 RxSwift 라는 차이점 정도를 가진다.

추가 정보는 Github Repo, TCA Documentation에서 확인할 수 있다.

공식에서 제공하는 다이어그램도 있지만, 내가 실제 프로젝트에서 구현한 데이터 플로우는 아래와 같다.

Effect를 Stream으로 바꿔 읽는다면 익숙한 모습이다.

  1. View는 이벤트(Action)를 보내고
  2. Store가 받은 이벤트(Action)를 처리법을 아는 Reducer로 받아
  3. 경우에 따라 Effect를 발생시키거나 환경과 상호 작용해서 이벤트를 처리한다.
  4. 이때 변화한 State가 있다면 Store는 값을 방출하고 View는 그것을 구독으로 받아볼 수 있다.

TCA에서 크게 개선되었다고 느낀 강점은 크게 두 가지다.

1. 단방향 아키텍처로 흐름을 추적하기 용이하다.

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()
        }
    }
}

StoreState는 모두 Action을 통해 변화한다. 이때 이 Store와 1대1 대응하는 뷰가 하위 뷰를 가지고 있다면 Action을 통해 State를 공유하거나, 반대로 하위 뷰가 발생시키는 이펙트에 반응하여 State를 업데이트한다.

실제 프로젝트를 진행하면서도 State를 변화시키는 객체가 명확하고, 복잡한 도메인이 얽히지 않고 단방향으로 관리되는 것이 큰 장점으로 느껴졌다.

2. 기능 단위로 Dependency 객체가 모듈화되기 때문에 재사용성이 향상된다.

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곳 등으로 재사용된다.

3. 의존성 관리가 직관적이다.

참고 - 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를 넘겨주고 있다. fetchImageDataasync로 정의하고 있기 때문에 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 사랑해요

TCA 레포지토리의 Discussion

0개의 댓글